diff options
| author | nexxeln <[email protected]> | 2025-12-02 18:37:24 +0000 |
|---|---|---|
| committer | nexxeln <[email protected]> | 2025-12-02 18:37:24 +0000 |
| commit | dfb0c05ab33cb20537002eaeb896e6b2ab35af25 (patch) | |
| tree | 49ecaa46903671d96f2f9ebc5af688ab2ea2c7bd /packages/memory-graph | |
| parent | Fix: Update discord links in README.md and CONTRIBUTING.md (#598) (diff) | |
| download | supermemory-dfb0c05ab33cb20537002eaeb896e6b2ab35af25.tar.xz supermemory-dfb0c05ab33cb20537002eaeb896e6b2ab35af25.zip | |
add spaces selector with search (#600)update-memory-graph
relevant files to review:
\- memory-graph.tsx
\- spaces-dropdown.tsx
\- spaces-dropdown.css.ts
Diffstat (limited to 'packages/memory-graph')
41 files changed, 2088 insertions, 1672 deletions
diff --git a/packages/memory-graph/README.md b/packages/memory-graph/README.md index 049613bb..eae06940 100644 --- a/packages/memory-graph/README.md +++ b/packages/memory-graph/README.md @@ -10,6 +10,9 @@ - **WebGL-powered rendering** - Smooth performance with hundreds of nodes - **Interactive exploration** - Pan, zoom, drag nodes, and explore connections - **Semantic connections** - Visualizes relationships based on content similarity +- **Space filtering with search** - Dynamically search and filter by spaces/tags +- **Memory limit control** - Limit memories per document (50-3000) for performance +- **Controlled/uncontrolled modes** - Flexible state management for integration - **Responsive design** - Works seamlessly on mobile and desktop - **Zero configuration** - Works out of the box with automatic CSS injection - **Lightweight** - Tree-shakeable and optimized bundle @@ -115,6 +118,17 @@ export async function GET(request: Request) { | `highlightDocumentIds` | `string[]` | `[]` | Document IDs to highlight | | `highlightsVisible` | `boolean` | `true` | Whether highlights are visible | +### Space & Memory Control Props (Optional) + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `selectedSpace` | `string` | `"all"` | Currently selected space (controlled) | +| `onSpaceChange` | `(spaceId: string) => void` | - | Callback when space changes | +| `onSearchSpaces` | `(query: string) => Promise<string[]>` | - | Async space search function | +| `memoryLimit` | `number` | `500` | Max memories per document when space selected | +| `onMemoryLimitChange` | `(limit: number) => void` | - | Callback when limit changes | +| `isExperimental` | `boolean` | `false` | Enable experimental features | + ### Pagination Props (Optional) For large datasets, you can implement pagination: @@ -133,8 +147,16 @@ import type { DocumentWithMemories, MemoryEntry, DocumentsResponse, - MemoryGraphProps + MemoryGraphProps, + MemoryLimit, + MemoryCountSelectorProps, + GraphNode, + GraphEdge, + MemoryRelation } from '@supermemory/memory-graph' + +// Memory limit can be one of these values +type MemoryLimit = 50 | 100 | 250 | 500 | 1000 | 2000 | 3000 ``` ## Advanced Usage @@ -184,6 +206,107 @@ function GraphWithPagination() { </MemoryGraph> ``` +### Controlled Space Selection & Memory Limiting + +Control the selected space and memory limit externally for integration with your app's state management: + +```tsx +import { MemoryGraph } from '@supermemory/memory-graph' + +function ControlledGraph() { + const [selectedSpace, setSelectedSpace] = useState("all") + const [memoryLimit, setMemoryLimit] = useState(500) + const [searchResults, setSearchResults] = useState([]) + + // Handle space search via your API + const handleSpaceSearch = async (query: string) => { + const response = await fetch(`/api/spaces/search?q=${query}`) + const spaces = await response.json() + setSearchResults(spaces) + return spaces + } + + return ( + <div> + {/* Display current state */} + <div className="controls"> + <p>Selected Space: {selectedSpace}</p> + <p>Memory Limit: {memoryLimit}</p> + <button onClick={() => { + setSelectedSpace("all") + setMemoryLimit(500) + }}> + Reset Filters + </button> + </div> + + {/* Controlled graph */} + <MemoryGraph + documents={documents} + selectedSpace={selectedSpace} + onSpaceChange={setSelectedSpace} + onSearchSpaces={handleSpaceSearch} + memoryLimit={memoryLimit} + onMemoryLimitChange={setMemoryLimit} + variant="console" + showSpacesSelector={true} + /> + </div> + ) +} +``` + +### Uncontrolled Mode (Automatic) + +If you don't provide `selectedSpace` or `memoryLimit` props, the component manages its own state: + +```tsx +<MemoryGraph + documents={documents} + // Component manages space selection and memory limit internally + onSearchSpaces={handleSpaceSearch} // Still can provide search function + showSpacesSelector={true} +/> +``` + +### Space Search Integration + +Implement server-side space search for dynamic filtering: + +```tsx +// Frontend +const handleSpaceSearch = async (query: string): Promise<string[]> => { + const response = await fetch('/api/spaces/search', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query }) + }) + return response.json() +} + +<MemoryGraph + documents={documents} + onSearchSpaces={handleSpaceSearch} + showSpacesSelector={true} +/> + +// Backend (Next.js example) +// app/api/spaces/search/route.ts +export async function POST(request: Request) { + const { query } = await request.json() + + const response = await fetch('https://api.supermemory.ai/v3/search/spaces', { + method: 'GET', + headers: { + 'Authorization': `Bearer ${process.env.SUPERMEMORY_API_KEY}`, + }, + params: { q: query } + }) + + return response.json() +} +``` + ### Variants The `variant` prop controls the visual layout and initial viewport settings: @@ -214,6 +337,9 @@ The `variant` prop controls the visual layout and initial viewport settings: - **Select Node**: Click on any document or memory - **Drag Nodes**: Click and drag individual nodes - **Fit to View**: Auto-fit button to center all content +- **Space Filter**: Click the space selector to filter by space +- **Space Search**: Type in the search box and press Enter to find spaces +- **Memory Limit**: Select a limit (50-3K) when filtering by space ## Browser Support diff --git a/packages/memory-graph/package.json b/packages/memory-graph/package.json index 198810cd..ed3c2d4f 100644 --- a/packages/memory-graph/package.json +++ b/packages/memory-graph/package.json @@ -1,6 +1,6 @@ { "name": "@supermemory/memory-graph", - "version": "0.1.1", + "version": "0.1.2", "description": "Interactive graph visualization component for Supermemory - visualize and explore your memory connections", "type": "module", "main": "./dist/memory-graph.cjs", diff --git a/packages/memory-graph/src/api-types.ts b/packages/memory-graph/src/api-types.ts index 7742e39f..0ebc86ee 100644 --- a/packages/memory-graph/src/api-types.ts +++ b/packages/memory-graph/src/api-types.ts @@ -2,78 +2,78 @@ // These mirror the API response types from @repo/validation/api export interface MemoryEntry { - id: string; - customId?: string | null; - documentId: string; - content: string | null; - summary?: string | null; - title?: string | null; - url?: string | null; - type?: string | null; - metadata?: Record<string, string | number | boolean> | null; - embedding?: number[] | null; - embeddingModel?: string | null; - tokenCount?: number | null; - createdAt: string | Date; - updatedAt: string | Date; + id: string + customId?: string | null + documentId: string + content: string | null + summary?: string | null + title?: string | null + url?: string | null + type?: string | null + metadata?: Record<string, string | number | boolean> | null + embedding?: number[] | null + embeddingModel?: string | null + tokenCount?: number | null + createdAt: string | Date + updatedAt: string | Date // Fields from join relationship - sourceAddedAt?: Date | null; - sourceRelevanceScore?: number | null; - sourceMetadata?: Record<string, unknown> | null; - spaceContainerTag?: string | null; + sourceAddedAt?: Date | null + sourceRelevanceScore?: number | null + sourceMetadata?: Record<string, unknown> | null + spaceContainerTag?: string | null // Version chain fields - updatesMemoryId?: string | null; - nextVersionId?: string | null; - relation?: "updates" | "extends" | "derives" | null; + updatesMemoryId?: string | null + nextVersionId?: string | null + relation?: "updates" | "extends" | "derives" | null // Memory status fields - isForgotten?: boolean; - forgetAfter?: Date | string | null; - isLatest?: boolean; + isForgotten?: boolean + forgetAfter?: Date | string | null + isLatest?: boolean // Space/container fields - spaceId?: string | null; + spaceId?: string | null // Legacy fields - memory?: string | null; + memory?: string | null memoryRelations?: Array<{ - relationType: "updates" | "extends" | "derives"; - targetMemoryId: string; - }> | null; - parentMemoryId?: string | null; + relationType: "updates" | "extends" | "derives" + targetMemoryId: string + }> | null + parentMemoryId?: string | null } export interface DocumentWithMemories { - id: string; - customId?: string | null; - contentHash: string | null; - orgId: string; - userId: string; - connectionId?: string | null; - title?: string | null; - content?: string | null; - summary?: string | null; - url?: string | null; - source?: string | null; - type?: string | null; - status: "pending" | "processing" | "done" | "failed"; - metadata?: Record<string, string | number | boolean> | null; - processingMetadata?: Record<string, unknown> | null; - raw?: string | null; - tokenCount?: number | null; - wordCount?: number | null; - chunkCount?: number | null; - averageChunkSize?: number | null; - summaryEmbedding?: number[] | null; - summaryEmbeddingModel?: string | null; - createdAt: string | Date; - updatedAt: string | Date; - memoryEntries: MemoryEntry[]; + id: string + customId?: string | null + contentHash: string | null + orgId: string + userId: string + connectionId?: string | null + title?: string | null + content?: string | null + summary?: string | null + url?: string | null + source?: string | null + type?: string | null + status: "pending" | "processing" | "done" | "failed" + metadata?: Record<string, string | number | boolean> | null + processingMetadata?: Record<string, unknown> | null + raw?: string | null + tokenCount?: number | null + wordCount?: number | null + chunkCount?: number | null + averageChunkSize?: number | null + summaryEmbedding?: number[] | null + summaryEmbeddingModel?: string | null + createdAt: string | Date + updatedAt: string | Date + memoryEntries: MemoryEntry[] } export interface DocumentsResponse { - documents: DocumentWithMemories[]; + documents: DocumentWithMemories[] pagination: { - currentPage: number; - limit: number; - totalItems: number; - totalPages: number; - }; + currentPage: number + limit: number + totalItems: number + totalPages: number + } } diff --git a/packages/memory-graph/src/assets/icons.tsx b/packages/memory-graph/src/assets/icons.tsx index 5383f690..5eb38b42 100644 --- a/packages/memory-graph/src/assets/icons.tsx +++ b/packages/memory-graph/src/assets/icons.tsx @@ -22,7 +22,7 @@ export const OneDrive = ({ className }: { className?: string }) => ( fill="#28A8EA" /> </svg> -); +) export const GoogleDrive = ({ className }: { className?: string }) => ( <svg @@ -56,7 +56,7 @@ export const GoogleDrive = ({ className }: { className?: string }) => ( fill="#FFBA00" /> </svg> -); +) export const Notion = ({ className }: { className?: string }) => ( <svg @@ -71,7 +71,7 @@ export const Notion = ({ className }: { className?: string }) => ( /> <path d="M164.09.608L16.092 11.538C4.155 12.573 0 20.374 0 29.726v162.245c0 7.284 2.585 13.516 8.826 21.843l34.789 45.237c5.715 7.284 10.912 8.844 21.825 8.327l171.864-10.404c14.532-1.035 18.696-7.801 18.696-19.24V55.207c0-5.911-2.336-7.614-9.21-12.66l-1.185-.856L198.37 8.409C186.94.1 182.27-.952 164.09.608M69.327 52.22c-14.033.945-17.216 1.159-25.186-5.323L23.876 30.778c-2.06-2.086-1.026-4.69 4.163-5.207l142.274-10.395c11.947-1.043 18.17 3.12 22.842 6.758l24.401 17.68c1.043.525 3.638 3.637.517 3.637L71.146 52.095zm-16.36 183.954V81.222c0-6.767 2.077-9.887 8.3-10.413L230.02 60.93c5.724-.517 8.31 3.12 8.31 9.879v153.917c0 6.767-1.044 12.49-10.387 13.008l-161.487 9.361c-9.343.517-13.489-2.594-13.489-10.921M212.377 89.53c1.034 4.681 0 9.362-4.681 9.897l-7.783 1.542v114.404c-6.758 3.637-12.981 5.715-18.18 5.715c-8.308 0-10.386-2.604-16.609-10.396l-50.898-80.079v77.476l16.1 3.646s0 9.362-12.989 9.362l-35.814 2.077c-1.043-2.086 0-7.284 3.63-8.318l9.351-2.595V109.823l-12.98-1.052c-1.044-4.68 1.55-11.439 8.826-11.965l38.426-2.585l52.958 81.113v-71.76l-13.498-1.552c-1.043-5.733 3.111-9.896 8.3-10.404z" /> </svg> -); +) export const GoogleDocs = ({ className }: { className?: string }) => ( <svg @@ -85,7 +85,7 @@ export const GoogleDocs = ({ className }: { className?: string }) => ( fill="currentColor" /> </svg> -); +) export const GoogleSheets = ({ className }: { className?: string }) => ( <svg @@ -99,7 +99,7 @@ export const GoogleSheets = ({ className }: { className?: string }) => ( fill="currentColor" /> </svg> -); +) export const GoogleSlides = ({ className }: { className?: string }) => ( <svg @@ -113,7 +113,7 @@ export const GoogleSlides = ({ className }: { className?: string }) => ( fill="currentColor" /> </svg> -); +) export const NotionDoc = ({ className }: { className?: string }) => ( <svg @@ -127,7 +127,7 @@ export const NotionDoc = ({ className }: { className?: string }) => ( fill="currentColor" /> </svg> -); +) export const MicrosoftWord = ({ className }: { className?: string }) => ( <svg @@ -141,7 +141,7 @@ export const MicrosoftWord = ({ className }: { className?: string }) => ( fill="currentColor" /> </svg> -); +) export const MicrosoftExcel = ({ className }: { className?: string }) => ( <svg @@ -155,7 +155,7 @@ export const MicrosoftExcel = ({ className }: { className?: string }) => ( fill="currentColor" /> </svg> -); +) export const MicrosoftPowerpoint = ({ className }: { className?: string }) => ( <svg @@ -169,7 +169,7 @@ export const MicrosoftPowerpoint = ({ className }: { className?: string }) => ( fill="currentColor" /> </svg> -); +) export const MicrosoftOneNote = ({ className }: { className?: string }) => ( <svg @@ -183,7 +183,7 @@ export const MicrosoftOneNote = ({ className }: { className?: string }) => ( fill="currentColor" /> </svg> -); +) export const PDF = ({ className }: { className?: string }) => ( <svg @@ -205,4 +205,4 @@ export const PDF = ({ className }: { className?: string }) => ( fill="currentColor" /> </svg> -); +) diff --git a/packages/memory-graph/src/components/canvas-common.css.ts b/packages/memory-graph/src/components/canvas-common.css.ts index 91005488..4f4a3504 100644 --- a/packages/memory-graph/src/components/canvas-common.css.ts +++ b/packages/memory-graph/src/components/canvas-common.css.ts @@ -1,4 +1,4 @@ -import { style } from "@vanilla-extract/css"; +import { style } from "@vanilla-extract/css" /** * Canvas wrapper/container that fills its parent @@ -7,4 +7,4 @@ import { style } from "@vanilla-extract/css"; export const canvasWrapper = style({ position: "absolute", inset: 0, -}); +}) diff --git a/packages/memory-graph/src/components/graph-canvas.tsx b/packages/memory-graph/src/components/graph-canvas.tsx index 59efa74d..ee4f5885 100644 --- a/packages/memory-graph/src/components/graph-canvas.tsx +++ b/packages/memory-graph/src/components/graph-canvas.tsx @@ -1,4 +1,4 @@ -"use client"; +"use client" import { memo, @@ -7,15 +7,15 @@ import { useLayoutEffect, useMemo, useRef, -} from "react"; -import { colors } from "@/constants"; +} from "react" +import { colors } from "@/constants" import type { DocumentWithMemories, GraphCanvasProps, GraphNode, MemoryEntry, -} from "@/types"; -import { canvasWrapper } from "./canvas-common.css"; +} from "@/types" +import { canvasWrapper } from "./canvas-common.css" export const GraphCanvas = memo<GraphCanvasProps>( ({ @@ -42,160 +42,160 @@ export const GraphCanvas = memo<GraphCanvasProps>( draggingNodeId, highlightDocumentIds, }) => { - const canvasRef = useRef<HTMLCanvasElement>(null); - const animationRef = useRef<number>(0); - const startTimeRef = useRef<number>(Date.now()); - const mousePos = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); - const currentHoveredNode = useRef<string | null>(null); + const canvasRef = useRef<HTMLCanvasElement>(null) + const animationRef = useRef<number>(0) + const startTimeRef = useRef<number>(Date.now()) + const mousePos = useRef<{ x: number; y: number }>({ x: 0, y: 0 }) + const currentHoveredNode = useRef<string | null>(null) // Initialize start time once useEffect(() => { - startTimeRef.current = Date.now(); - }, []); + startTimeRef.current = Date.now() + }, []) // Efficient hit detection const getNodeAtPosition = useCallback( (x: number, y: number): string | null => { // Check from top-most to bottom-most: memory nodes are drawn after documents for (let i = nodes.length - 1; i >= 0; i--) { - const node = nodes[i]!; - const screenX = node.x * zoom + panX; - const screenY = node.y * zoom + panY; - const nodeSize = node.size * zoom; + const node = nodes[i]! + const screenX = node.x * zoom + panX + const screenY = node.y * zoom + panY + const nodeSize = node.size * zoom - const dx = x - screenX; - const dy = y - screenY; - const distance = Math.sqrt(dx * dx + dy * dy); + const dx = x - screenX + const dy = y - screenY + const distance = Math.sqrt(dx * dx + dy * dy) if (distance <= nodeSize / 2) { - return node.id; + return node.id } } - return null; + return null }, [nodes, panX, panY, zoom], - ); + ) // Handle mouse events const handleMouseMove = useCallback( (e: React.MouseEvent) => { - const canvas = canvasRef.current; - if (!canvas) return; + const canvas = canvasRef.current + if (!canvas) return - const rect = canvas.getBoundingClientRect(); - const x = e.clientX - rect.left; - const y = e.clientY - rect.top; + const rect = canvas.getBoundingClientRect() + const x = e.clientX - rect.left + const y = e.clientY - rect.top - mousePos.current = { x, y }; + mousePos.current = { x, y } - const nodeId = getNodeAtPosition(x, y); + const nodeId = getNodeAtPosition(x, y) if (nodeId !== currentHoveredNode.current) { - currentHoveredNode.current = nodeId; - onNodeHover(nodeId); + currentHoveredNode.current = nodeId + onNodeHover(nodeId) } // Handle node dragging if (draggingNodeId) { - onNodeDragMove(e); + onNodeDragMove(e) } }, [getNodeAtPosition, onNodeHover, draggingNodeId, onNodeDragMove], - ); + ) const handleMouseDown = useCallback( (e: React.MouseEvent) => { - const canvas = canvasRef.current; - if (!canvas) return; + const canvas = canvasRef.current + if (!canvas) return - const rect = canvas.getBoundingClientRect(); - const x = e.clientX - rect.left; - const y = e.clientY - rect.top; + const rect = canvas.getBoundingClientRect() + const x = e.clientX - rect.left + const y = e.clientY - rect.top - const nodeId = getNodeAtPosition(x, y); + const nodeId = getNodeAtPosition(x, y) if (nodeId) { // When starting a node drag, prevent initiating pan - e.stopPropagation(); - onNodeDragStart(nodeId, e); - return; + e.stopPropagation() + onNodeDragStart(nodeId, e) + return } - onPanStart(e); + onPanStart(e) }, [getNodeAtPosition, onNodeDragStart, onPanStart], - ); + ) const handleClick = useCallback( (e: React.MouseEvent) => { - const canvas = canvasRef.current; - if (!canvas) return; + const canvas = canvasRef.current + if (!canvas) return - const rect = canvas.getBoundingClientRect(); - const x = e.clientX - rect.left; - const y = e.clientY - rect.top; + const rect = canvas.getBoundingClientRect() + const x = e.clientX - rect.left + const y = e.clientY - rect.top - const nodeId = getNodeAtPosition(x, y); + const nodeId = getNodeAtPosition(x, y) if (nodeId) { - onNodeClick(nodeId); + onNodeClick(nodeId) } }, [getNodeAtPosition, onNodeClick], - ); + ) // Professional rendering function with LOD const render = useCallback(() => { - const canvas = canvasRef.current; - if (!canvas) return; + const canvas = canvasRef.current + if (!canvas) return - const ctx = canvas.getContext("2d"); - if (!ctx) return; + const ctx = canvas.getContext("2d") + if (!ctx) return - const currentTime = Date.now(); - const _elapsed = currentTime - startTimeRef.current; + const currentTime = Date.now() + const _elapsed = currentTime - startTimeRef.current // Level-of-detail optimization based on zoom - const useSimplifiedRendering = zoom < 0.3; + const useSimplifiedRendering = zoom < 0.3 // Clear canvas - ctx.clearRect(0, 0, width, height); + ctx.clearRect(0, 0, width, height) // Set high quality rendering - ctx.imageSmoothingEnabled = true; - ctx.imageSmoothingQuality = "high"; + ctx.imageSmoothingEnabled = true + ctx.imageSmoothingQuality = "high" // Draw minimal background grid - ctx.strokeStyle = "rgba(148, 163, 184, 0.03)"; // Very subtle grid - ctx.lineWidth = 1; - const gridSpacing = 100 * zoom; - const offsetX = panX % gridSpacing; - const offsetY = panY % gridSpacing; + ctx.strokeStyle = "rgba(148, 163, 184, 0.03)" // Very subtle grid + ctx.lineWidth = 1 + const gridSpacing = 100 * zoom + const offsetX = panX % gridSpacing + const offsetY = panY % gridSpacing // Simple, clean grid lines for (let x = offsetX; x < width; x += gridSpacing) { - ctx.beginPath(); - ctx.moveTo(x, 0); - ctx.lineTo(x, height); - ctx.stroke(); + ctx.beginPath() + ctx.moveTo(x, 0) + ctx.lineTo(x, height) + ctx.stroke() } for (let y = offsetY; y < height; y += gridSpacing) { - ctx.beginPath(); - ctx.moveTo(0, y); - ctx.lineTo(width, y); - ctx.stroke(); + ctx.beginPath() + ctx.moveTo(0, y) + ctx.lineTo(width, y) + ctx.stroke() } // Create node lookup map - const nodeMap = new Map(nodes.map((node) => [node.id, node])); + const nodeMap = new Map(nodes.map((node) => [node.id, node])) // Draw enhanced edges with sophisticated styling - ctx.lineCap = "round"; + ctx.lineCap = "round" edges.forEach((edge) => { - const sourceNode = nodeMap.get(edge.source); - const targetNode = nodeMap.get(edge.target); + const sourceNode = nodeMap.get(edge.source) + const targetNode = nodeMap.get(edge.target) if (sourceNode && targetNode) { - const sourceX = sourceNode.x * zoom + panX; - const sourceY = sourceNode.y * zoom + panY; - const targetX = targetNode.x * zoom + panX; - const targetY = targetNode.y * zoom + panY; + const sourceX = sourceNode.x * zoom + panX + const sourceY = sourceNode.y * zoom + panY + const targetX = targetNode.x * zoom + panX + const targetY = targetNode.y * zoom + panY // Enhanced viewport culling with edge type considerations if ( @@ -204,7 +204,7 @@ export const GraphCanvas = memo<GraphCanvasProps>( targetX < -100 || targetX > width + 100 ) { - return; + return } // Skip very weak connections when zoomed out for performance @@ -213,368 +213,365 @@ export const GraphCanvas = memo<GraphCanvasProps>( edge.edgeType === "doc-memory" && edge.visualProps.opacity < 0.3 ) { - return; // Skip very weak doc-memory edges when zoomed out + return // Skip very weak doc-memory edges when zoomed out } } // Enhanced connection styling based on edge type - let connectionColor = colors.connection.weak; - let dashPattern: number[] = []; - let opacity = edge.visualProps.opacity; - let lineWidth = Math.max(1, edge.visualProps.thickness * zoom); + let connectionColor = colors.connection.weak + let dashPattern: number[] = [] + let opacity = edge.visualProps.opacity + let lineWidth = Math.max(1, edge.visualProps.thickness * zoom) if (edge.edgeType === "doc-memory") { // Doc-memory: Solid thin lines, subtle - dashPattern = []; - connectionColor = colors.connection.memory; - opacity = 0.9; - lineWidth = 1; + dashPattern = [] + connectionColor = colors.connection.memory + opacity = 0.9 + lineWidth = 1 } else if (edge.edgeType === "doc-doc") { // Doc-doc: Thick dashed lines with strong similarity emphasis - dashPattern = useSimplifiedRendering ? [] : [10, 5]; // Solid lines when zoomed out - opacity = Math.max(0, edge.similarity * 0.5); - lineWidth = Math.max(1, edge.similarity * 2); // Thicker for stronger similarity + dashPattern = useSimplifiedRendering ? [] : [10, 5] // Solid lines when zoomed out + opacity = Math.max(0, edge.similarity * 0.5) + lineWidth = Math.max(1, edge.similarity * 2) // Thicker for stronger similarity if (edge.similarity > 0.85) - connectionColor = colors.connection.strong; + connectionColor = colors.connection.strong else if (edge.similarity > 0.725) - connectionColor = colors.connection.medium; + connectionColor = colors.connection.medium } else if (edge.edgeType === "version") { // Version chains: Double line effect with relation-specific colors - dashPattern = []; - connectionColor = edge.color || colors.relations.updates; - opacity = 0.8; - lineWidth = 2; + dashPattern = [] + connectionColor = edge.color || colors.relations.updates + opacity = 0.8 + lineWidth = 2 } - ctx.strokeStyle = connectionColor; - ctx.lineWidth = lineWidth; - ctx.globalAlpha = opacity; - ctx.setLineDash(dashPattern); + ctx.strokeStyle = connectionColor + ctx.lineWidth = lineWidth + ctx.globalAlpha = opacity + ctx.setLineDash(dashPattern) if (edge.edgeType === "version") { // Special double-line rendering for version chains // First line (outer) - ctx.lineWidth = 3; - ctx.globalAlpha = opacity * 0.3; - ctx.beginPath(); - ctx.moveTo(sourceX, sourceY); - ctx.lineTo(targetX, targetY); - ctx.stroke(); + ctx.lineWidth = 3 + ctx.globalAlpha = opacity * 0.3 + ctx.beginPath() + ctx.moveTo(sourceX, sourceY) + ctx.lineTo(targetX, targetY) + ctx.stroke() // Second line (inner) - ctx.lineWidth = 1; - ctx.globalAlpha = opacity; - ctx.beginPath(); - ctx.moveTo(sourceX, sourceY); - ctx.lineTo(targetX, targetY); - ctx.stroke(); + ctx.lineWidth = 1 + ctx.globalAlpha = opacity + ctx.beginPath() + ctx.moveTo(sourceX, sourceY) + ctx.lineTo(targetX, targetY) + ctx.stroke() } else { // Simplified lines when zoomed out, curved when zoomed in if (useSimplifiedRendering) { // Straight lines for performance - ctx.beginPath(); - ctx.moveTo(sourceX, sourceY); - ctx.lineTo(targetX, targetY); - ctx.stroke(); + ctx.beginPath() + ctx.moveTo(sourceX, sourceY) + ctx.lineTo(targetX, targetY) + ctx.stroke() } else { // Regular curved line for doc-memory and doc-doc - const midX = (sourceX + targetX) / 2; - const midY = (sourceY + targetY) / 2; - const dx = targetX - sourceX; - const dy = targetY - sourceY; - const distance = Math.sqrt(dx * dx + dy * dy); + const midX = (sourceX + targetX) / 2 + const midY = (sourceY + targetY) / 2 + const dx = targetX - sourceX + const dy = targetY - sourceY + const distance = Math.sqrt(dx * dx + dy * dy) const controlOffset = edge.edgeType === "doc-memory" ? 15 - : Math.min(30, distance * 0.2); + : Math.min(30, distance * 0.2) - ctx.beginPath(); - ctx.moveTo(sourceX, sourceY); + ctx.beginPath() + ctx.moveTo(sourceX, sourceY) ctx.quadraticCurveTo( midX + controlOffset * (dy / distance), midY - controlOffset * (dx / distance), targetX, targetY, - ); - ctx.stroke(); + ) + ctx.stroke() } } // Subtle arrow head for version edges if (edge.edgeType === "version") { - const angle = Math.atan2(targetY - sourceY, targetX - sourceX); - const arrowLength = Math.max(6, 8 * zoom); // Shorter, more subtle - const arrowWidth = Math.max(8, 12 * zoom); + const angle = Math.atan2(targetY - sourceY, targetX - sourceX) + const arrowLength = Math.max(6, 8 * zoom) // Shorter, more subtle + const arrowWidth = Math.max(8, 12 * zoom) // Calculate arrow position offset from node edge - const nodeRadius = (targetNode.size * zoom) / 2; - const offsetDistance = nodeRadius + 2; - const arrowX = targetX - Math.cos(angle) * offsetDistance; - const arrowY = targetY - Math.sin(angle) * offsetDistance; + const nodeRadius = (targetNode.size * zoom) / 2 + const offsetDistance = nodeRadius + 2 + const arrowX = targetX - Math.cos(angle) * offsetDistance + const arrowY = targetY - Math.sin(angle) * offsetDistance - ctx.save(); - ctx.translate(arrowX, arrowY); - ctx.rotate(angle); - ctx.setLineDash([]); + ctx.save() + ctx.translate(arrowX, arrowY) + ctx.rotate(angle) + ctx.setLineDash([]) // Simple outlined arrow (not filled) - ctx.strokeStyle = connectionColor; - ctx.lineWidth = Math.max(1, 1.5 * zoom); - ctx.globalAlpha = opacity; - - ctx.beginPath(); - ctx.moveTo(0, 0); - ctx.lineTo(-arrowLength, arrowWidth / 2); - ctx.moveTo(0, 0); - ctx.lineTo(-arrowLength, -arrowWidth / 2); - ctx.stroke(); - - ctx.restore(); + ctx.strokeStyle = connectionColor + ctx.lineWidth = Math.max(1, 1.5 * zoom) + ctx.globalAlpha = opacity + + ctx.beginPath() + ctx.moveTo(0, 0) + ctx.lineTo(-arrowLength, arrowWidth / 2) + ctx.moveTo(0, 0) + ctx.lineTo(-arrowLength, -arrowWidth / 2) + ctx.stroke() + + ctx.restore() } } - }); + }) - ctx.globalAlpha = 1; - ctx.setLineDash([]); + ctx.globalAlpha = 1 + ctx.setLineDash([]) // Prepare highlight set from provided document IDs (customId or internal) - const highlightSet = new Set<string>(highlightDocumentIds ?? []); + const highlightSet = new Set<string>(highlightDocumentIds ?? []) // Draw nodes with enhanced styling and LOD optimization nodes.forEach((node) => { - const screenX = node.x * zoom + panX; - const screenY = node.y * zoom + panY; - const nodeSize = node.size * zoom; + const screenX = node.x * zoom + panX + const screenY = node.y * zoom + panY + const nodeSize = node.size * zoom // Enhanced viewport culling - const margin = nodeSize + 50; + const margin = nodeSize + 50 if ( screenX < -margin || screenX > width + margin || screenY < -margin || screenY > height + margin ) { - return; + return } - const isHovered = currentHoveredNode.current === node.id; - const isDragging = node.isDragging; + const isHovered = currentHoveredNode.current === node.id + const isDragging = node.isDragging const isHighlightedDocument = (() => { - if (node.type !== "document" || highlightSet.size === 0) return false; - const doc = node.data as DocumentWithMemories; - if (doc.customId && highlightSet.has(doc.customId)) return true; - return highlightSet.has(doc.id); - })(); + if (node.type !== "document" || highlightSet.size === 0) return false + const doc = node.data as DocumentWithMemories + if (doc.customId && highlightSet.has(doc.customId)) return true + return highlightSet.has(doc.id) + })() if (node.type === "document") { // Enhanced glassmorphism document styling - const docWidth = nodeSize * 1.4; - const docHeight = nodeSize * 0.9; + const docWidth = nodeSize * 1.4 + const docHeight = nodeSize * 0.9 // Multi-layer glass effect ctx.fillStyle = isDragging ? colors.document.accent : isHovered ? colors.document.secondary - : colors.document.primary; - ctx.globalAlpha = 1; + : colors.document.primary + ctx.globalAlpha = 1 // Enhanced border with subtle glow ctx.strokeStyle = isDragging ? colors.document.glow : isHovered ? colors.document.accent - : colors.document.border; - ctx.lineWidth = isDragging ? 3 : isHovered ? 2 : 1; + : colors.document.border + ctx.lineWidth = isDragging ? 3 : isHovered ? 2 : 1 // Rounded rectangle with enhanced styling - const radius = useSimplifiedRendering ? 6 : 12; - ctx.beginPath(); + const radius = useSimplifiedRendering ? 6 : 12 + ctx.beginPath() ctx.roundRect( screenX - docWidth / 2, screenY - docHeight / 2, docWidth, docHeight, radius, - ); - ctx.fill(); - ctx.stroke(); + ) + ctx.fill() + ctx.stroke() // Subtle inner highlight for glass effect (skip when zoomed out) if (!useSimplifiedRendering && (isHovered || isDragging)) { - ctx.strokeStyle = "rgba(255, 255, 255, 0.1)"; - ctx.lineWidth = 1; - ctx.beginPath(); + ctx.strokeStyle = "rgba(255, 255, 255, 0.1)" + ctx.lineWidth = 1 + ctx.beginPath() ctx.roundRect( screenX - docWidth / 2 + 1, screenY - docHeight / 2 + 1, docWidth - 2, docHeight - 2, radius - 1, - ); - ctx.stroke(); + ) + ctx.stroke() } // Highlight ring for search hits if (isHighlightedDocument) { - ctx.save(); - ctx.globalAlpha = 0.9; - ctx.strokeStyle = colors.accent.primary; - ctx.lineWidth = 3; - ctx.setLineDash([6, 4]); - const ringPadding = 10; - ctx.beginPath(); + ctx.save() + ctx.globalAlpha = 0.9 + ctx.strokeStyle = colors.accent.primary + ctx.lineWidth = 3 + ctx.setLineDash([6, 4]) + const ringPadding = 10 + ctx.beginPath() ctx.roundRect( screenX - docWidth / 2 - ringPadding, screenY - docHeight / 2 - ringPadding, docWidth + ringPadding * 2, docHeight + ringPadding * 2, radius + 6, - ); - ctx.stroke(); - ctx.setLineDash([]); - ctx.restore(); + ) + ctx.stroke() + ctx.setLineDash([]) + ctx.restore() } } else { // Enhanced memory styling with status indicators - const mem = node.data as MemoryEntry; + const mem = node.data as MemoryEntry const isForgotten = mem.isForgotten || (mem.forgetAfter && - new Date(mem.forgetAfter).getTime() < Date.now()); - const isLatest = mem.isLatest; + new Date(mem.forgetAfter).getTime() < Date.now()) + const isLatest = mem.isLatest // Check if memory is expiring soon (within 7 days) const expiringSoon = mem.forgetAfter && !isForgotten && new Date(mem.forgetAfter).getTime() - Date.now() < - 1000 * 60 * 60 * 24 * 7; + 1000 * 60 * 60 * 24 * 7 // Check if memory is new (created within last 24 hours) const isNew = !isForgotten && - new Date(mem.createdAt).getTime() > - Date.now() - 1000 * 60 * 60 * 24; + new Date(mem.createdAt).getTime() > Date.now() - 1000 * 60 * 60 * 24 // Determine colors based on status - let fillColor = colors.memory.primary; - let borderColor = colors.memory.border; - let glowColor = colors.memory.glow; + let fillColor = colors.memory.primary + let borderColor = colors.memory.border + let glowColor = colors.memory.glow if (isForgotten) { - fillColor = colors.status.forgotten; - borderColor = "rgba(220,38,38,0.3)"; - glowColor = "rgba(220,38,38,0.2)"; + fillColor = colors.status.forgotten + borderColor = "rgba(220,38,38,0.3)" + glowColor = "rgba(220,38,38,0.2)" } else if (expiringSoon) { - borderColor = colors.status.expiring; - glowColor = colors.accent.amber; + borderColor = colors.status.expiring + glowColor = colors.accent.amber } else if (isNew) { - borderColor = colors.status.new; - glowColor = colors.accent.emerald; + borderColor = colors.status.new + glowColor = colors.accent.emerald } if (isDragging) { - fillColor = colors.memory.accent; - borderColor = glowColor; + fillColor = colors.memory.accent + borderColor = glowColor } else if (isHovered) { - fillColor = colors.memory.secondary; + fillColor = colors.memory.secondary } - const radius = nodeSize / 2; + const radius = nodeSize / 2 - ctx.fillStyle = fillColor; - ctx.globalAlpha = isLatest ? 1 : 0.4; - ctx.strokeStyle = borderColor; - ctx.lineWidth = isDragging ? 3 : isHovered ? 2 : 1.5; + ctx.fillStyle = fillColor + ctx.globalAlpha = isLatest ? 1 : 0.4 + ctx.strokeStyle = borderColor + ctx.lineWidth = isDragging ? 3 : isHovered ? 2 : 1.5 if (useSimplifiedRendering) { // Simple circles when zoomed out for performance - ctx.beginPath(); - ctx.arc(screenX, screenY, radius, 0, 2 * Math.PI); - ctx.fill(); - ctx.stroke(); + ctx.beginPath() + ctx.arc(screenX, screenY, radius, 0, 2 * Math.PI) + ctx.fill() + ctx.stroke() } else { // HEXAGONAL memory nodes when zoomed in - const sides = 6; - ctx.beginPath(); + const sides = 6 + ctx.beginPath() for (let i = 0; i < sides; i++) { - const angle = (i * 2 * Math.PI) / sides - Math.PI / 2; // Start from top - const x = screenX + radius * Math.cos(angle); - const y = screenY + radius * Math.sin(angle); + const angle = (i * 2 * Math.PI) / sides - Math.PI / 2 // Start from top + const x = screenX + radius * Math.cos(angle) + const y = screenY + radius * Math.sin(angle) if (i === 0) { - ctx.moveTo(x, y); + ctx.moveTo(x, y) } else { - ctx.lineTo(x, y); + ctx.lineTo(x, y) } } - ctx.closePath(); - ctx.fill(); - ctx.stroke(); + ctx.closePath() + ctx.fill() + ctx.stroke() // Inner highlight for glass effect if (isHovered || isDragging) { - ctx.strokeStyle = "rgba(147, 197, 253, 0.3)"; - ctx.lineWidth = 1; - const innerRadius = radius - 2; - ctx.beginPath(); + ctx.strokeStyle = "rgba(147, 197, 253, 0.3)" + ctx.lineWidth = 1 + const innerRadius = radius - 2 + ctx.beginPath() for (let i = 0; i < sides; i++) { - const angle = (i * 2 * Math.PI) / sides - Math.PI / 2; - const x = screenX + innerRadius * Math.cos(angle); - const y = screenY + innerRadius * Math.sin(angle); + const angle = (i * 2 * Math.PI) / sides - Math.PI / 2 + const x = screenX + innerRadius * Math.cos(angle) + const y = screenY + innerRadius * Math.sin(angle) if (i === 0) { - ctx.moveTo(x, y); + ctx.moveTo(x, y) } else { - ctx.lineTo(x, y); + ctx.lineTo(x, y) } } - ctx.closePath(); - ctx.stroke(); + ctx.closePath() + ctx.stroke() } } // Status indicators overlay (always preserve these as required) if (isForgotten) { // Cross for forgotten memories - ctx.strokeStyle = "rgba(220,38,38,0.4)"; - ctx.lineWidth = 2; - const r = nodeSize * 0.25; - ctx.beginPath(); - ctx.moveTo(screenX - r, screenY - r); - ctx.lineTo(screenX + r, screenY + r); - ctx.moveTo(screenX + r, screenY - r); - ctx.lineTo(screenX - r, screenY + r); - ctx.stroke(); + ctx.strokeStyle = "rgba(220,38,38,0.4)" + ctx.lineWidth = 2 + const r = nodeSize * 0.25 + ctx.beginPath() + ctx.moveTo(screenX - r, screenY - r) + ctx.lineTo(screenX + r, screenY + r) + ctx.moveTo(screenX + r, screenY - r) + ctx.lineTo(screenX - r, screenY + r) + ctx.stroke() } else if (isNew) { // Small dot for new memories - ctx.fillStyle = colors.status.new; - ctx.beginPath(); + ctx.fillStyle = colors.status.new + ctx.beginPath() ctx.arc( screenX + nodeSize * 0.25, screenY - nodeSize * 0.25, Math.max(2, nodeSize * 0.15), // Scale with node size, minimum 2px 0, 2 * Math.PI, - ); - ctx.fill(); + ) + ctx.fill() } } // Enhanced hover glow effect (skip when zoomed out for performance) if (!useSimplifiedRendering && (isHovered || isDragging)) { const glowColor = - node.type === "document" - ? colors.document.glow - : colors.memory.glow; + node.type === "document" ? colors.document.glow : colors.memory.glow - ctx.strokeStyle = glowColor; - ctx.lineWidth = 1; - ctx.setLineDash([3, 3]); - ctx.globalAlpha = 0.6; + ctx.strokeStyle = glowColor + ctx.lineWidth = 1 + ctx.setLineDash([3, 3]) + ctx.globalAlpha = 0.6 - ctx.beginPath(); - const glowSize = nodeSize * 0.7; + ctx.beginPath() + const glowSize = nodeSize * 0.7 if (node.type === "document") { ctx.roundRect( screenX - glowSize, @@ -582,33 +579,33 @@ export const GraphCanvas = memo<GraphCanvasProps>( glowSize * 2, glowSize * 1.4, 15, - ); + ) } else { // Hexagonal glow for memory nodes - const glowRadius = glowSize; - const sides = 6; + const glowRadius = glowSize + const sides = 6 for (let i = 0; i < sides; i++) { - const angle = (i * 2 * Math.PI) / sides - Math.PI / 2; - const x = screenX + glowRadius * Math.cos(angle); - const y = screenY + glowRadius * Math.sin(angle); + const angle = (i * 2 * Math.PI) / sides - Math.PI / 2 + const x = screenX + glowRadius * Math.cos(angle) + const y = screenY + glowRadius * Math.sin(angle) if (i === 0) { - ctx.moveTo(x, y); + ctx.moveTo(x, y) } else { - ctx.lineTo(x, y); + ctx.lineTo(x, y) } } - ctx.closePath(); + ctx.closePath() } - ctx.stroke(); - ctx.setLineDash([]); + ctx.stroke() + ctx.setLineDash([]) } - }); + }) - ctx.globalAlpha = 1; - }, [nodes, edges, panX, panY, zoom, width, height, highlightDocumentIds]); + ctx.globalAlpha = 1 + }, [nodes, edges, panX, panY, zoom, width, height, highlightDocumentIds]) // Change-based rendering instead of continuous animation - const lastRenderParams = useRef<string>(""); + const lastRenderParams = useRef<string>("") // Create a render key that changes when visual state changes const renderKey = useMemo(() => { @@ -617,9 +614,9 @@ export const GraphCanvas = memo<GraphCanvasProps>( (n) => `${n.id}:${n.x}:${n.y}:${n.isDragging ? "1" : "0"}:${currentHoveredNode.current === n.id ? "1" : "0"}`, ) - .join("|"); - const highlightKey = (highlightDocumentIds ?? []).join("|"); - return `${nodePositions}-${edges.length}-${panX}-${panY}-${zoom}-${width}-${height}-${highlightKey}`; + .join("|") + const highlightKey = (highlightDocumentIds ?? []).join("|") + return `${nodePositions}-${edges.length}-${panX}-${panY}-${zoom}-${width}-${height}-${highlightKey}` }, [ nodes, edges.length, @@ -629,33 +626,33 @@ export const GraphCanvas = memo<GraphCanvasProps>( width, height, highlightDocumentIds, - ]); + ]) // Only render when something actually changed useEffect(() => { if (renderKey !== lastRenderParams.current) { - lastRenderParams.current = renderKey; - render(); + lastRenderParams.current = renderKey + render() } - }, [renderKey, render]); + }, [renderKey, render]) // Cleanup any existing animation frames useEffect(() => { return () => { if (animationRef.current) { - cancelAnimationFrame(animationRef.current); + cancelAnimationFrame(animationRef.current) } - }; - }, []); + } + }, []) // Add native wheel event listener to prevent browser zoom useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; + const canvas = canvasRef.current + if (!canvas) return const handleNativeWheel = (e: WheelEvent) => { - e.preventDefault(); - e.stopPropagation(); + e.preventDefault() + e.stopPropagation() // Call the onWheel handler with a synthetic-like event // @ts-expect-error - partial WheelEvent object @@ -668,50 +665,49 @@ export const GraphCanvas = memo<GraphCanvasProps>( nativeEvent: e, preventDefault: () => {}, stopPropagation: () => {}, - } as React.WheelEvent); - }; + } as React.WheelEvent) + } // Add listener with passive: false to ensure preventDefault works - canvas.addEventListener("wheel", handleNativeWheel, { passive: false }); + canvas.addEventListener("wheel", handleNativeWheel, { passive: false }) // Also prevent gesture events for touch devices const handleGesture = (e: Event) => { - e.preventDefault(); - }; + e.preventDefault() + } canvas.addEventListener("gesturestart", handleGesture, { passive: false, - }); + }) canvas.addEventListener("gesturechange", handleGesture, { passive: false, - }); - canvas.addEventListener("gestureend", handleGesture, { passive: false }); + }) + canvas.addEventListener("gestureend", handleGesture, { passive: false }) return () => { - canvas.removeEventListener("wheel", handleNativeWheel); - canvas.removeEventListener("gesturestart", handleGesture); - canvas.removeEventListener("gesturechange", handleGesture); - canvas.removeEventListener("gestureend", handleGesture); - }; - }, [onWheel]); + canvas.removeEventListener("wheel", handleNativeWheel) + canvas.removeEventListener("gesturestart", handleGesture) + canvas.removeEventListener("gesturechange", handleGesture) + canvas.removeEventListener("gestureend", handleGesture) + } + }, [onWheel]) // High-DPI handling -------------------------------------------------- - const dpr = - typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1; + const dpr = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1 useLayoutEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; + const canvas = canvasRef.current + if (!canvas) return // upscale backing store - canvas.style.width = `${width}px`; - canvas.style.height = `${height}px`; - canvas.width = width * dpr; - canvas.height = height * dpr; - - const ctx = canvas.getContext("2d"); - ctx?.scale(dpr, dpr); - }, [width, height, dpr]); + canvas.style.width = `${width}px` + canvas.style.height = `${height}px` + canvas.width = width * dpr + canvas.height = height * dpr + + const ctx = canvas.getContext("2d") + ctx?.scale(dpr, dpr) + }, [width, height, dpr]) // ----------------------------------------------------------------------- return ( @@ -723,22 +719,22 @@ export const GraphCanvas = memo<GraphCanvasProps>( onMouseDown={handleMouseDown} onMouseLeave={() => { if (draggingNodeId) { - onNodeDragEnd(); + onNodeDragEnd() } else { - onPanEnd(); + onPanEnd() } }} onMouseMove={(e) => { - handleMouseMove(e); + handleMouseMove(e) if (!draggingNodeId) { - onPanMove(e); + onPanMove(e) } }} onMouseUp={() => { if (draggingNodeId) { - onNodeDragEnd(); + onNodeDragEnd() } else { - onPanEnd(); + onPanEnd() } }} onTouchStart={onTouchStart} @@ -757,8 +753,8 @@ export const GraphCanvas = memo<GraphCanvasProps>( }} width={width} /> - ); + ) }, -); +) -GraphCanvas.displayName = "GraphCanvas"; +GraphCanvas.displayName = "GraphCanvas" diff --git a/packages/memory-graph/src/components/legend.css.ts b/packages/memory-graph/src/components/legend.css.ts index b758cf9d..823edc75 100644 --- a/packages/memory-graph/src/components/legend.css.ts +++ b/packages/memory-graph/src/components/legend.css.ts @@ -1,5 +1,5 @@ -import { style, styleVariants, globalStyle } from "@vanilla-extract/css"; -import { themeContract } from "../styles/theme.css"; +import { style, styleVariants, globalStyle } from "@vanilla-extract/css" +import { themeContract } from "../styles/theme.css" /** * Legend container base @@ -12,7 +12,7 @@ const legendContainerBase = style({ width: "fit-content", height: "fit-content", maxHeight: "calc(100vh - 2rem)", // Prevent overflow -}); +}) /** * Legend container variants for positioning @@ -59,7 +59,7 @@ export const legendContainer = styleVariants({ }, }, ], -}); +}) /** * Mobile size variants @@ -72,7 +72,7 @@ export const mobileSize = styleVariants({ width: "4rem", // w-16 height: "3rem", // h-12 }, -}); +}) /** * Legend content wrapper @@ -80,7 +80,7 @@ export const mobileSize = styleVariants({ export const legendContent = style({ position: "relative", zIndex: 10, -}); +}) /** * Collapsed trigger button @@ -99,26 +99,26 @@ export const collapsedTrigger = style({ backgroundColor: "rgba(255, 255, 255, 0.05)", }, }, -}); +}) export const collapsedContent = style({ display: "flex", flexDirection: "column", alignItems: "center", gap: themeContract.space[1], -}); +}) export const collapsedText = style({ fontSize: themeContract.typography.fontSize.xs, color: themeContract.colors.text.secondary, fontWeight: themeContract.typography.fontWeight.medium, -}); +}) export const collapsedIcon = style({ width: "0.75rem", height: "0.75rem", color: themeContract.colors.text.muted, -}); +}) /** * Header @@ -132,13 +132,13 @@ export const legendHeader = style({ paddingTop: themeContract.space[3], paddingBottom: themeContract.space[3], borderBottom: "1px solid rgba(71, 85, 105, 0.5)", // slate-600/50 -}); +}) export const legendTitle = style({ fontSize: themeContract.typography.fontSize.sm, fontWeight: themeContract.typography.fontWeight.medium, color: themeContract.colors.text.primary, -}); +}) export const headerTrigger = style({ padding: themeContract.space[1], @@ -150,13 +150,13 @@ export const headerTrigger = style({ backgroundColor: "rgba(255, 255, 255, 0.1)", }, }, -}); +}) export const headerIcon = style({ width: "1rem", height: "1rem", color: themeContract.colors.text.muted, -}); +}) /** * Content sections @@ -168,7 +168,7 @@ export const sectionsContainer = style({ paddingRight: themeContract.space[4], paddingTop: themeContract.space[3], paddingBottom: themeContract.space[3], -}); +}) export const sectionWrapper = style({ marginTop: themeContract.space[3], @@ -177,7 +177,7 @@ export const sectionWrapper = style({ marginTop: 0, }, }, -}); +}) export const sectionTitle = style({ fontSize: themeContract.typography.fontSize.xs, @@ -186,36 +186,36 @@ export const sectionTitle = style({ textTransform: "uppercase", letterSpacing: "0.05em", marginBottom: themeContract.space[2], -}); +}) export const itemsList = style({ display: "flex", flexDirection: "column", gap: "0.375rem", // gap-1.5 -}); +}) export const legendItem = style({ display: "flex", alignItems: "center", gap: themeContract.space[2], -}); +}) export const legendIcon = style({ width: "0.75rem", height: "0.75rem", flexShrink: 0, -}); +}) export const legendText = style({ fontSize: themeContract.typography.fontSize.xs, -}); +}) /** * Shape styles */ export const hexagon = style({ clipPath: "polygon(50% 0%, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)", -}); +}) export const documentNode = style({ width: "1rem", @@ -224,7 +224,7 @@ export const documentNode = style({ border: "1px solid rgba(255, 255, 255, 0.25)", borderRadius: themeContract.radii.sm, flexShrink: 0, -}); +}) export const memoryNode = style([ hexagon, @@ -235,14 +235,14 @@ export const memoryNode = style([ border: "1px solid rgba(147, 197, 253, 0.35)", flexShrink: 0, }, -]); +]) export const memoryNodeOlder = style([ memoryNode, { opacity: 0.4, }, -]); +]) export const forgottenNode = style([ hexagon, @@ -254,7 +254,7 @@ export const forgottenNode = style([ position: "relative", flexShrink: 0, }, -]); +]) export const forgottenIcon = style({ position: "absolute", @@ -265,7 +265,7 @@ export const forgottenIcon = style({ color: "rgb(248, 113, 113)", fontSize: themeContract.typography.fontSize.xs, lineHeight: "1", -}); +}) export const expiringNode = style([ hexagon, @@ -276,7 +276,7 @@ export const expiringNode = style([ border: "2px solid rgb(245, 158, 11)", flexShrink: 0, }, -]); +]) export const newNode = style([ hexagon, @@ -288,7 +288,7 @@ export const newNode = style([ position: "relative", flexShrink: 0, }, -]); +]) export const newBadge = style({ position: "absolute", @@ -298,28 +298,28 @@ export const newBadge = style({ height: "0.5rem", backgroundColor: "rgb(16, 185, 129)", borderRadius: themeContract.radii.full, -}); +}) export const connectionLine = style({ width: "1rem", height: 0, borderTop: "1px solid rgb(148, 163, 184)", flexShrink: 0, -}); +}) export const similarityLine = style({ width: "1rem", height: 0, borderTop: "2px dashed rgb(148, 163, 184)", flexShrink: 0, -}); +}) export const relationLine = style({ width: "1rem", height: 0, borderTop: "2px solid", flexShrink: 0, -}); +}) export const weakSimilarity = style({ width: "0.75rem", @@ -327,7 +327,7 @@ export const weakSimilarity = style({ borderRadius: themeContract.radii.full, background: "rgba(148, 163, 184, 0.2)", flexShrink: 0, -}); +}) export const strongSimilarity = style({ width: "0.75rem", @@ -335,11 +335,12 @@ export const strongSimilarity = style({ borderRadius: themeContract.radii.full, background: "rgba(148, 163, 184, 0.6)", flexShrink: 0, -}); +}) export const gradientCircle = style({ width: "0.75rem", height: "0.75rem", - background: "linear-gradient(to right, rgb(148, 163, 184), rgb(96, 165, 250))", + background: + "linear-gradient(to right, rgb(148, 163, 184), rgb(96, 165, 250))", borderRadius: themeContract.radii.full, -}); +}) diff --git a/packages/memory-graph/src/components/legend.tsx b/packages/memory-graph/src/components/legend.tsx index 16f588a9..db06da10 100644 --- a/packages/memory-graph/src/components/legend.tsx +++ b/packages/memory-graph/src/components/legend.tsx @@ -1,44 +1,44 @@ -"use client"; +"use client" -import { useIsMobile } from "@/hooks/use-mobile"; +import { useIsMobile } from "@/hooks/use-mobile" import { Collapsible, CollapsibleContent, CollapsibleTrigger, -} from "@/ui/collapsible"; -import { GlassMenuEffect } from "@/ui/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"; -import * as styles from "./legend.css"; +} from "@/ui/collapsible" +import { GlassMenuEffect } from "@/ui/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" +import * as styles from "./legend.css" // 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=/`; -}; + 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(";"); + 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); + 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; -}; + return null +} interface ExtendedLegendProps extends LegendProps { - id?: string; - nodes?: GraphNode[]; - edges?: GraphEdge[]; - isLoading?: boolean; + id?: string + nodes?: GraphNode[] + edges?: GraphEdge[] + isLoading?: boolean } export const Legend = memo(function Legend({ @@ -48,55 +48,57 @@ export const Legend = memo(function Legend({ edges = [], isLoading = false, }: ExtendedLegendProps) { - const isMobile = useIsMobile(); - const [isExpanded, setIsExpanded] = useState(true); - const [isInitialized, setIsInitialized] = useState(false); + 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"); + const savedState = getCookie("legendCollapsed") if (savedState === "true") { - setIsExpanded(false); + setIsExpanded(false) } else if (savedState === "false") { - setIsExpanded(true); + setIsExpanded(true) } else { // Default: collapsed on mobile, expanded on desktop - setIsExpanded(!isMobile); + setIsExpanded(!isMobile) } - setIsInitialized(true); + setIsInitialized(true) } - }, [isInitialized, isMobile]); + }, [isInitialized, isMobile]) // Save to cookie when state changes const handleToggleExpanded = (expanded: boolean) => { - setIsExpanded(expanded); - setCookie("legendCollapsed", expanded ? "false" : "true"); - }; + setIsExpanded(expanded) + setCookie("legendCollapsed", expanded ? "false" : "true") + } // Get container class based on variant and mobile state const getContainerClass = () => { if (variant === "console") { - return isMobile ? styles.legendContainer.consoleMobile : styles.legendContainer.consoleDesktop; + return isMobile + ? styles.legendContainer.consoleMobile + : styles.legendContainer.consoleDesktop } - return isMobile ? styles.legendContainer.consumerMobile : styles.legendContainer.consumerDesktop; - }; + return isMobile + ? styles.legendContainer.consumerMobile + : styles.legendContainer.consumerDesktop + } // Calculate stats - const memoryCount = nodes.filter((n) => n.type === "memory").length; - const documentCount = nodes.filter((n) => n.type === "document").length; + const memoryCount = nodes.filter((n) => n.type === "memory").length + const documentCount = nodes.filter((n) => n.type === "document").length - const containerClass = isMobile && !isExpanded - ? `${getContainerClass()} ${styles.mobileSize.collapsed}` - : isMobile - ? `${getContainerClass()} ${styles.mobileSize.expanded}` - : getContainerClass(); + const containerClass = + isMobile && !isExpanded + ? `${getContainerClass()} ${styles.mobileSize.collapsed}` + : isMobile + ? `${getContainerClass()} ${styles.mobileSize.expanded}` + : getContainerClass() return ( - <div - className={containerClass} - id={id} - > + <div className={containerClass} id={id}> <Collapsible onOpenChange={handleToggleExpanded} open={isExpanded}> {/* Glass effect background */} <GlassMenuEffect rounded="xl" /> @@ -128,18 +130,22 @@ export const Legend = memo(function Legend({ {/* Stats Section */} {!isLoading && ( <div className={styles.sectionWrapper}> - <div className={styles.sectionTitle}> - Statistics - </div> + <div className={styles.sectionTitle}>Statistics</div> <div className={styles.itemsList}> <div className={styles.legendItem}> - <Brain className={styles.legendIcon} style={{ color: "rgb(96, 165, 250)" }} /> + <Brain + className={styles.legendIcon} + style={{ color: "rgb(96, 165, 250)" }} + /> <span className={styles.legendText}> {memoryCount} memories </span> </div> <div className={styles.legendItem}> - <FileText className={styles.legendIcon} style={{ color: "rgb(203, 213, 225)" }} /> + <FileText + className={styles.legendIcon} + style={{ color: "rgb(203, 213, 225)" }} + /> <span className={styles.legendText}> {documentCount} documents </span> @@ -156,9 +162,7 @@ export const Legend = memo(function Legend({ {/* Node Types */} <div className={styles.sectionWrapper}> - <div className={styles.sectionTitle}> - Nodes - </div> + <div className={styles.sectionTitle}>Nodes</div> <div className={styles.itemsList}> <div className={styles.legendItem}> <div className={styles.documentNode} /> @@ -166,26 +170,26 @@ export const Legend = memo(function Legend({ </div> <div className={styles.legendItem}> <div className={styles.memoryNode} /> - <span className={styles.legendText}>Memory (latest)</span> + <span className={styles.legendText}> + Memory (latest) + </span> </div> <div className={styles.legendItem}> <div className={styles.memoryNodeOlder} /> - <span className={styles.legendText}>Memory (older)</span> + <span className={styles.legendText}> + Memory (older) + </span> </div> </div> </div> {/* Status Indicators */} <div className={styles.sectionWrapper}> - <div className={styles.sectionTitle}> - Status - </div> + <div className={styles.sectionTitle}>Status</div> <div className={styles.itemsList}> <div className={styles.legendItem}> <div className={styles.forgottenNode}> - <div className={styles.forgottenIcon}> - ✕ - </div> + <div className={styles.forgottenIcon}>✕</div> </div> <span className={styles.legendText}>Forgotten</span> </div> @@ -204,9 +208,7 @@ export const Legend = memo(function Legend({ {/* Connection Types */} <div className={styles.sectionWrapper}> - <div className={styles.sectionTitle}> - Connections - </div> + <div className={styles.sectionTitle}>Connections</div> <div className={styles.itemsList}> <div className={styles.legendItem}> <div className={styles.connectionLine} /> @@ -214,16 +216,16 @@ export const Legend = memo(function Legend({ </div> <div className={styles.legendItem}> <div className={styles.similarityLine} /> - <span className={styles.legendText}>Doc similarity</span> + <span className={styles.legendText}> + Doc similarity + </span> </div> </div> </div> {/* Relation Types */} <div className={styles.sectionWrapper}> - <div className={styles.sectionTitle}> - Relations - </div> + <div className={styles.sectionTitle}>Relations</div> <div className={styles.itemsList}> {[ ["updates", colors.relations.updates], @@ -237,7 +239,10 @@ export const Legend = memo(function Legend({ /> <span className={styles.legendText} - style={{ color: color, textTransform: "capitalize" }} + style={{ + color: color, + textTransform: "capitalize", + }} > {label} </span> @@ -248,9 +253,7 @@ export const Legend = memo(function Legend({ {/* Similarity Strength */} <div className={styles.sectionWrapper}> - <div className={styles.sectionTitle}> - Similarity - </div> + <div className={styles.sectionTitle}>Similarity</div> <div className={styles.itemsList}> <div className={styles.legendItem}> <div className={styles.weakSimilarity} /> @@ -269,7 +272,7 @@ export const Legend = memo(function Legend({ </div> </Collapsible> </div> - ); -}); + ) +}) -Legend.displayName = "Legend"; +Legend.displayName = "Legend" diff --git a/packages/memory-graph/src/components/loading-indicator.css.ts b/packages/memory-graph/src/components/loading-indicator.css.ts index 09010f28..4aac7cfd 100644 --- a/packages/memory-graph/src/components/loading-indicator.css.ts +++ b/packages/memory-graph/src/components/loading-indicator.css.ts @@ -1,6 +1,6 @@ -import { style } from "@vanilla-extract/css"; -import { themeContract } from "../styles/theme.css"; -import { animations } from "../styles"; +import { style } from "@vanilla-extract/css" +import { themeContract } from "../styles/theme.css" +import { animations } from "../styles" /** * Loading indicator container @@ -13,7 +13,7 @@ export const loadingContainer = style({ overflow: "hidden", top: "5.5rem", // Below spaces dropdown (~88px) left: themeContract.space[4], -}); +}) /** * Content wrapper @@ -26,7 +26,7 @@ export const loadingContent = style({ paddingRight: themeContract.space[4], paddingTop: themeContract.space[3], paddingBottom: themeContract.space[3], -}); +}) /** * Flex container for icon and text @@ -35,7 +35,7 @@ export const loadingFlex = style({ display: "flex", alignItems: "center", gap: themeContract.space[2], -}); +}) /** * Spinning icon @@ -45,11 +45,11 @@ export const loadingIcon = style({ height: "1rem", animation: `${animations.spin} 1s linear infinite`, color: themeContract.colors.memory.border, -}); +}) /** * Loading text */ export const loadingText = style({ fontSize: themeContract.typography.fontSize.sm, -}); +}) diff --git a/packages/memory-graph/src/components/loading-indicator.tsx b/packages/memory-graph/src/components/loading-indicator.tsx index be31430b..bbb2312c 100644 --- a/packages/memory-graph/src/components/loading-indicator.tsx +++ b/packages/memory-graph/src/components/loading-indicator.tsx @@ -1,20 +1,20 @@ -"use client"; +"use client" -import { GlassMenuEffect } from "@/ui/glass-effect"; -import { Sparkles } from "lucide-react"; -import { memo } from "react"; -import type { LoadingIndicatorProps } from "@/types"; +import { GlassMenuEffect } from "@/ui/glass-effect" +import { Sparkles } from "lucide-react" +import { memo } from "react" +import type { LoadingIndicatorProps } from "@/types" import { loadingContainer, loadingContent, loadingFlex, loadingIcon, loadingText, -} from "./loading-indicator.css"; +} from "./loading-indicator.css" export const LoadingIndicator = memo<LoadingIndicatorProps>( ({ isLoading, isLoadingMore, totalLoaded, variant = "console" }) => { - if (!isLoading && !isLoadingMore) return null; + if (!isLoading && !isLoadingMore) return null return ( <div className={loadingContainer}> @@ -33,8 +33,8 @@ export const LoadingIndicator = memo<LoadingIndicatorProps>( </div> </div> </div> - ); + ) }, -); +) -LoadingIndicator.displayName = "LoadingIndicator"; +LoadingIndicator.displayName = "LoadingIndicator" diff --git a/packages/memory-graph/src/components/memory-graph.css.ts b/packages/memory-graph/src/components/memory-graph.css.ts index f5b38273..1726b741 100644 --- a/packages/memory-graph/src/components/memory-graph.css.ts +++ b/packages/memory-graph/src/components/memory-graph.css.ts @@ -1,5 +1,5 @@ -import { style } from "@vanilla-extract/css"; -import { themeContract } from "../styles/theme.css"; +import { style } from "@vanilla-extract/css" +import { themeContract } from "../styles/theme.css" /** * Error state container @@ -10,12 +10,12 @@ export const errorContainer = style({ alignItems: "center", justifyContent: "center", backgroundColor: themeContract.colors.background.primary, -}); +}) export const errorCard = style({ borderRadius: themeContract.radii.xl, overflow: "hidden", -}); +}) export const errorContent = style({ position: "relative", @@ -25,7 +25,7 @@ export const errorContent = style({ paddingRight: themeContract.space[6], paddingTop: themeContract.space[4], paddingBottom: themeContract.space[4], -}); +}) /** * Main graph container @@ -37,7 +37,7 @@ export const mainContainer = style({ borderRadius: themeContract.radii.xl, overflow: "hidden", backgroundColor: themeContract.colors.background.primary, -}); +}) /** * Spaces selector positioning @@ -48,7 +48,7 @@ export const spacesSelectorContainer = style({ top: themeContract.space[4], left: themeContract.space[4], zIndex: 15, // Above base elements, below loading/panels -}); +}) /** * Graph canvas container @@ -61,7 +61,7 @@ export const graphContainer = style({ touchAction: "none", userSelect: "none", WebkitUserSelect: "none", -}); +}) /** * Navigation controls positioning @@ -72,4 +72,4 @@ export const navControlsContainer = style({ bottom: themeContract.space[4], left: themeContract.space[4], zIndex: 15, // Same level as spaces dropdown -}); +}) diff --git a/packages/memory-graph/src/components/memory-graph.tsx b/packages/memory-graph/src/components/memory-graph.tsx index 21d4a08f..8f356d2f 100644 --- a/packages/memory-graph/src/components/memory-graph.tsx +++ b/packages/memory-graph/src/components/memory-graph.tsx @@ -1,21 +1,21 @@ -"use client"; - -import { GlassMenuEffect } from "@/ui/glass-effect"; -import { AnimatePresence } from "motion/react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { GraphCanvas } from "./graph-canvas"; -import { useGraphData } from "@/hooks/use-graph-data"; -import { useGraphInteractions } from "@/hooks/use-graph-interactions"; -import { injectStyles } from "@/lib/inject-styles"; -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 * as styles from "./memory-graph.css"; -import { defaultTheme } from "@/styles/theme.css"; - -import type { MemoryGraphProps } from "@/types"; +"use client" + +import { GlassMenuEffect } from "@/ui/glass-effect" +import { AnimatePresence } from "motion/react" +import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { GraphCanvas } from "./graph-canvas" +import { useGraphData } from "@/hooks/use-graph-data" +import { useGraphInteractions } from "@/hooks/use-graph-interactions" +import { injectStyles } from "@/lib/inject-styles" +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 * as styles from "./memory-graph.css" +import { defaultTheme } from "@/styles/theme.css" + +import type { MemoryGraphProps } from "@/types" export const MemoryGraph = ({ children, @@ -34,23 +34,45 @@ export const MemoryGraph = ({ occludedRightPx = 0, autoLoadOnViewport = true, themeClassName, + selectedSpace: externalSelectedSpace, + onSpaceChange: externalOnSpaceChange, + memoryLimit, + isExperimental, }: MemoryGraphProps) => { // Inject styles on first render (client-side only) useEffect(() => { - injectStyles(); - }, []); + injectStyles() + }, []) // Derive totalLoaded from documents if not provided - const effectiveTotalLoaded = totalLoaded ?? documents.length; + const effectiveTotalLoaded = totalLoaded ?? documents.length // No-op for loadMoreDocuments if not provided - const effectiveLoadMoreDocuments = loadMoreDocuments ?? (async () => {}); + const effectiveLoadMoreDocuments = loadMoreDocuments ?? (async () => {}) // Derive showSpacesSelector from variant if not explicitly provided // console variant shows spaces selector, consumer variant hides it - const finalShowSpacesSelector = showSpacesSelector ?? (variant === "console"); + const finalShowSpacesSelector = showSpacesSelector ?? variant === "console" - const [selectedSpace, setSelectedSpace] = useState<string>("all"); - const [containerSize, setContainerSize] = useState({ width: 0, height: 0 }); - const containerRef = useRef<HTMLDivElement>(null); + // Internal state for controlled/uncontrolled pattern + const [internalSelectedSpace, setInternalSelectedSpace] = + useState<string>("all") + + const [containerSize, setContainerSize] = useState({ width: 0, height: 0 }) + const containerRef = useRef<HTMLDivElement>(null) + + // Use external state if provided, otherwise use internal state + const selectedSpace = externalSelectedSpace ?? internalSelectedSpace + + // Handle space change + const handleSpaceChange = useCallback( + (spaceId: string) => { + if (externalOnSpaceChange) { + externalOnSpaceChange(spaceId) + } else { + setInternalSelectedSpace(spaceId) + } + }, + [externalOnSpaceChange], + ) // Create data object with pagination to satisfy type requirements const data = useMemo(() => { @@ -64,8 +86,8 @@ export const MemoryGraph = ({ totalPages: 1, }, } - : null; - }, [documents]); + : null + }, [documents]) // Graph interactions with variant-specific settings const { @@ -95,7 +117,7 @@ export const MemoryGraph = ({ centerViewportOn, zoomIn, zoomOut, - } = useGraphInteractions(variant); + } = useGraphInteractions(variant) // Graph data const { nodes, edges } = useGraphData( @@ -103,14 +125,13 @@ export const MemoryGraph = ({ selectedSpace, nodePositions, draggingNodeId, - ); + memoryLimit, + ) // Auto-fit once per unique highlight set to show the full graph for context - const lastFittedHighlightKeyRef = useRef<string>(""); + const lastFittedHighlightKeyRef = useRef<string>("") useEffect(() => { - const highlightKey = highlightsVisible - ? highlightDocumentIds.join("|") - : ""; + const highlightKey = highlightsVisible ? highlightDocumentIds.join("|") : "" if ( highlightKey && highlightKey !== lastFittedHighlightKeyRef.current && @@ -121,8 +142,8 @@ export const MemoryGraph = ({ autoFitToViewport(nodes, containerSize.width, containerSize.height, { occludedRightPx, animate: true, - }); - lastFittedHighlightKeyRef.current = highlightKey; + }) + lastFittedHighlightKeyRef.current = highlightKey } }, [ highlightsVisible, @@ -132,10 +153,10 @@ export const MemoryGraph = ({ nodes.length, occludedRightPx, autoFitToViewport, - ]); + ]) // Auto-fit graph when component mounts or nodes change significantly - const hasAutoFittedRef = useRef(false); + const hasAutoFittedRef = useRef(false) useEffect(() => { // Only auto-fit once when we have nodes and container size if ( @@ -147,90 +168,85 @@ export const MemoryGraph = ({ // 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); + autoFitToViewport(nodes, containerSize.width, containerSize.height) + hasAutoFittedRef.current = true + }, 100) + + return () => clearTimeout(timer) } - }, [ - nodes, - containerSize.width, - containerSize.height, - autoFitToViewport, - ]); + }, [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; + hasAutoFittedRef.current = false } - }, [nodes.length]); + }, [nodes.length]) // Extract unique spaces from memories and calculate counts const { availableSpaces, spaceMemoryCounts } = useMemo(() => { - if (!data?.documents) return { availableSpaces: [], spaceMemoryCounts: {} }; + if (!data?.documents) return { availableSpaces: [], spaceMemoryCounts: {} } - const spaceSet = new Set<string>(); - const counts: Record<string, number> = {}; + 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; - }); - }); + 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]); + } + }, [data]) // Handle container resize useEffect(() => { const updateSize = () => { if (containerRef.current) { - const newWidth = containerRef.current.clientWidth; - const newHeight = containerRef.current.clientHeight; - + 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 { width: newWidth, height: newHeight } } - return prev; - }); + 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); - + 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); + const resizeObserver = new ResizeObserver(updateSize) if (containerRef.current) { - resizeObserver.observe(containerRef.current); + resizeObserver.observe(containerRef.current) } - + return () => { - clearTimeout(timer); - window.removeEventListener("resize", updateSize); - resizeObserver.disconnect(); - }; - }, []); + 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(nodeId, e, nodes) }, [handleNodeDragStart, nodes], - ); + ) // Navigation callbacks const handleCenter = useCallback(() => { @@ -239,35 +255,50 @@ export const MemoryGraph = ({ 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) + 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) { + 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]) + }, [ + 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]); + 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(() => { @@ -277,7 +308,7 @@ export const MemoryGraph = ({ !data?.documents || data.documents.length === 0 ) - return; + return // Calculate viewport bounds const viewportBounds = { @@ -285,26 +316,26 @@ export const MemoryGraph = ({ 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; + const visibilityRatio = visibleDocuments.length / data.documents.length if (visibilityRatio >= 0.8) { - effectiveLoadMoreDocuments(); + effectiveLoadMoreDocuments() } }, [ isLoadingMore, @@ -317,35 +348,35 @@ export const MemoryGraph = ({ containerSize.height, nodes, effectiveLoadMoreDocuments, - ]); + ]) // Throttled version to avoid excessive checks - const lastLoadCheckRef = useRef(0); + const lastLoadCheckRef = useRef(0) const throttledCheckAndLoadMore = useCallback(() => { - const now = Date.now(); + const now = Date.now() if (now - lastLoadCheckRef.current > 1000) { // Check at most once per second - lastLoadCheckRef.current = now; - checkAndLoadMore(); + lastLoadCheckRef.current = now + checkAndLoadMore() } - }, [checkAndLoadMore]); + }, [checkAndLoadMore]) // Monitor viewport changes to trigger loading useEffect(() => { - if (!autoLoadOnViewport) return; - throttledCheckAndLoadMore(); - }, [throttledCheckAndLoadMore, autoLoadOnViewport]); + if (!autoLoadOnViewport) return + throttledCheckAndLoadMore() + }, [throttledCheckAndLoadMore, autoLoadOnViewport]) // Initial load trigger when graph is first rendered useEffect(() => { - if (!autoLoadOnViewport) return; + 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 + throttledCheckAndLoadMore() + }, 500) // Small delay to allow initial layout } - }, [data, hasMore, throttledCheckAndLoadMore, autoLoadOnViewport]); + }, [data, hasMore, throttledCheckAndLoadMore, autoLoadOnViewport]) if (error) { return ( @@ -359,17 +390,19 @@ export const MemoryGraph = ({ </div> </div> </div> - ); + ) } return ( - <div className={`${themeClassName ?? defaultTheme} ${styles.mainContainer}`}> - {/* Spaces selector - only shown for console */} - {finalShowSpacesSelector && availableSpaces.length > 0 && ( + <div + className={`${themeClassName ?? defaultTheme} ${styles.mainContainer}`} + > + {/* Spaces selector - only shown for console variant */} + {variant === "console" && availableSpaces.length > 0 && ( <div className={styles.spacesSelectorContainer}> <SpacesDropdown availableSpaces={availableSpaces} - onSpaceChange={setSelectedSpace} + onSpaceChange={handleSpaceChange} selectedSpace={selectedSpace} spaceMemoryCounts={spaceMemoryCounts} /> @@ -411,11 +444,8 @@ export const MemoryGraph = ({ )} {/* Graph container */} - <div - className={styles.graphContainer} - ref={containerRef} - > - {(containerSize.width > 0 && containerSize.height > 0) && ( + <div className={styles.graphContainer} ref={containerRef}> + {containerSize.width > 0 && containerSize.height > 0 && ( <GraphCanvas draggingNodeId={draggingNodeId} edges={edges} @@ -446,8 +476,12 @@ export const MemoryGraph = ({ {containerSize.width > 0 && ( <NavigationControls onCenter={handleCenter} - onZoomIn={() => zoomIn(containerSize.width / 2, containerSize.height / 2)} - onZoomOut={() => zoomOut(containerSize.width / 2, containerSize.height / 2)} + onZoomIn={() => + zoomIn(containerSize.width / 2, containerSize.height / 2) + } + onZoomOut={() => + zoomOut(containerSize.width / 2, containerSize.height / 2) + } onAutoFit={handleAutoFit} nodes={nodes} className={styles.navControlsContainer} @@ -455,5 +489,5 @@ export const MemoryGraph = ({ )} </div> </div> - ); -}; + ) +} diff --git a/packages/memory-graph/src/components/navigation-controls.css.ts b/packages/memory-graph/src/components/navigation-controls.css.ts index 3a4094bd..c17f09b4 100644 --- a/packages/memory-graph/src/components/navigation-controls.css.ts +++ b/packages/memory-graph/src/components/navigation-controls.css.ts @@ -1,5 +1,5 @@ -import { style } from "@vanilla-extract/css"; -import { themeContract } from "../styles/theme.css"; +import { style } from "@vanilla-extract/css" +import { themeContract } from "../styles/theme.css" /** * Navigation controls container @@ -8,7 +8,7 @@ export const navContainer = style({ display: "flex", flexDirection: "column", gap: themeContract.space[1], -}); +}) /** * Base button styles for navigation controls @@ -34,12 +34,12 @@ const navButtonBase = style({ color: "rgba(255, 255, 255, 1)", }, }, -}); +}) /** * Standard navigation button */ -export const navButton = navButtonBase; +export const navButton = navButtonBase /** * Zoom controls container @@ -47,7 +47,7 @@ export const navButton = navButtonBase; export const zoomContainer = style({ display: "flex", flexDirection: "column", -}); +}) /** * Zoom in button (top rounded) @@ -61,7 +61,7 @@ export const zoomInButton = style([ borderBottomRightRadius: 0, borderBottom: 0, }, -]); +]) /** * Zoom out button (bottom rounded) @@ -74,4 +74,4 @@ export const zoomOutButton = style([ borderBottomLeftRadius: themeContract.radii.lg, borderBottomRightRadius: themeContract.radii.lg, }, -]); +]) diff --git a/packages/memory-graph/src/components/navigation-controls.tsx b/packages/memory-graph/src/components/navigation-controls.tsx index 19caa888..ce25aa5b 100644 --- a/packages/memory-graph/src/components/navigation-controls.tsx +++ b/packages/memory-graph/src/components/navigation-controls.tsx @@ -1,33 +1,33 @@ -"use client"; +"use client" -import { memo } from "react"; -import type { GraphNode } from "@/types"; +import { memo } from "react" +import type { GraphNode } from "@/types" import { navContainer, navButton, zoomContainer, zoomInButton, zoomOutButton, -} from "./navigation-controls.css"; +} from "./navigation-controls.css" interface NavigationControlsProps { - onCenter: () => void; - onZoomIn: () => void; - onZoomOut: () => void; - onAutoFit: () => void; - nodes: GraphNode[]; - className?: string; + 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 null } const containerClassName = className ? `${navContainer} ${className}` - : navContainer; + : navContainer return ( <div className={containerClassName}> @@ -66,8 +66,8 @@ export const NavigationControls = memo<NavigationControlsProps>( </button> </div> </div> - ); + ) }, -); +) -NavigationControls.displayName = "NavigationControls"; +NavigationControls.displayName = "NavigationControls" diff --git a/packages/memory-graph/src/components/node-detail-panel.css.ts b/packages/memory-graph/src/components/node-detail-panel.css.ts index a3c30e06..5429e2bd 100644 --- a/packages/memory-graph/src/components/node-detail-panel.css.ts +++ b/packages/memory-graph/src/components/node-detail-panel.css.ts @@ -1,5 +1,5 @@ -import { style } from "@vanilla-extract/css"; -import { themeContract } from "../styles/theme.css"; +import { style } from "@vanilla-extract/css" +import { themeContract } from "../styles/theme.css" /** * Main container (positioned absolutely) @@ -16,8 +16,9 @@ export const container = style({ right: themeContract.space[4], // Add shadow for depth - boxShadow: "0 20px 25px -5px rgb(0 0 0 / 0.3), 0 8px 10px -6px rgb(0 0 0 / 0.3)", -}); + boxShadow: + "0 20px 25px -5px rgb(0 0 0 / 0.3), 0 8px 10px -6px rgb(0 0 0 / 0.3)", +}) /** * Content wrapper with scrolling @@ -28,7 +29,7 @@ export const content = style({ padding: themeContract.space[4], overflowY: "auto", maxHeight: "80vh", -}); +}) /** * Header section @@ -38,25 +39,25 @@ export const header = style({ alignItems: "center", justifyContent: "space-between", marginBottom: themeContract.space[3], -}); +}) export const headerLeft = style({ display: "flex", alignItems: "center", gap: themeContract.space[2], -}); +}) export const headerIcon = style({ width: "1.25rem", height: "1.25rem", color: themeContract.colors.text.secondary, -}); +}) export const headerIconMemory = style({ width: "1.25rem", height: "1.25rem", color: "rgb(96, 165, 250)", // blue-400 -}); +}) export const closeButton = style({ height: "32px", @@ -69,12 +70,12 @@ export const closeButton = style({ color: themeContract.colors.text.primary, }, }, -}); +}) export const closeIcon = style({ width: "1rem", height: "1rem", -}); +}) /** * Content sections @@ -83,22 +84,22 @@ export const sections = style({ display: "flex", flexDirection: "column", gap: themeContract.space[3], -}); +}) -export const section = style({}); +export const section = style({}) export const sectionLabel = style({ fontSize: themeContract.typography.fontSize.xs, color: themeContract.colors.text.muted, textTransform: "uppercase", letterSpacing: "0.05em", -}); +}) export const sectionValue = style({ fontSize: themeContract.typography.fontSize.sm, color: themeContract.colors.text.secondary, marginTop: themeContract.space[1], -}); +}) export const sectionValueTruncated = style({ fontSize: themeContract.typography.fontSize.sm, @@ -108,7 +109,7 @@ export const sectionValueTruncated = style({ display: "-webkit-box", WebkitLineClamp: 3, WebkitBoxOrient: "vertical", -}); +}) export const link = style({ fontSize: themeContract.typography.fontSize.sm, @@ -125,22 +126,22 @@ export const link = style({ color: "rgb(165, 180, 252)", // indigo-300 }, }, -}); +}) export const linkIcon = style({ width: "0.75rem", height: "0.75rem", -}); +}) export const badge = style({ marginTop: themeContract.space[2], -}); +}) export const expiryText = style({ fontSize: themeContract.typography.fontSize.xs, color: themeContract.colors.text.muted, marginTop: themeContract.space[1], -}); +}) /** * Footer section (metadata) @@ -148,7 +149,7 @@ export const expiryText = style({ export const footer = style({ paddingTop: themeContract.space[2], borderTop: "1px solid rgba(71, 85, 105, 0.5)", // slate-700/50 -}); +}) export const metadata = style({ display: "flex", @@ -156,15 +157,15 @@ export const metadata = style({ gap: themeContract.space[4], fontSize: themeContract.typography.fontSize.xs, color: themeContract.colors.text.muted, -}); +}) export const metadataItem = style({ display: "flex", alignItems: "center", gap: themeContract.space[1], -}); +}) export const metadataIcon = style({ width: "0.75rem", height: "0.75rem", -}); +}) diff --git a/packages/memory-graph/src/components/node-detail-panel.tsx b/packages/memory-graph/src/components/node-detail-panel.tsx index e2ae0133..b022364d 100644 --- a/packages/memory-graph/src/components/node-detail-panel.tsx +++ b/packages/memory-graph/src/components/node-detail-panel.tsx @@ -1,11 +1,11 @@ -"use client"; - -import { Badge } from "@/ui/badge"; -import { Button } from "@/ui/button"; -import { GlassMenuEffect } from "@/ui/glass-effect"; -import { Brain, Calendar, ExternalLink, FileText, Hash, X } from "lucide-react"; -import { motion } from "motion/react"; -import { memo } from "react"; +"use client" + +import { Badge } from "@/ui/badge" +import { Button } from "@/ui/button" +import { GlassMenuEffect } from "@/ui/glass-effect" +import { Brain, Calendar, ExternalLink, FileText, Hash, X } from "lucide-react" +import { motion } from "motion/react" +import { memo } from "react" import { GoogleDocs, GoogleDrive, @@ -18,249 +18,233 @@ import { NotionDoc, OneDrive, PDF, -} from "@/assets/icons"; -import { HeadingH3Bold } from "@/ui/heading"; -import type { - DocumentWithMemories, - MemoryEntry, -} from "@/types"; -import type { NodeDetailPanelProps } from "@/types"; -import * as styles from "./node-detail-panel.css"; +} from "@/assets/icons" +import { HeadingH3Bold } from "@/ui/heading" +import type { DocumentWithMemories, MemoryEntry } from "@/types" +import type { NodeDetailPanelProps } from "@/types" +import * as styles from "./node-detail-panel.css" const formatDocumentType = (type: string) => { // Special case for PDF - if (type.toLowerCase() === "pdf") return "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(" "); -}; + .join(" ") +} const getDocumentIcon = (type: string) => { - const iconProps = { className: "w-5 h-5 text-slate-300" }; + const iconProps = { className: "w-5 h-5 text-slate-300" } switch (type) { case "google_doc": - return <GoogleDocs {...iconProps} />; + return <GoogleDocs {...iconProps} /> case "google_sheet": - return <GoogleSheets {...iconProps} />; + return <GoogleSheets {...iconProps} /> case "google_slide": - return <GoogleSlides {...iconProps} />; + return <GoogleSlides {...iconProps} /> case "google_drive": - return <GoogleDrive {...iconProps} />; + return <GoogleDrive {...iconProps} /> case "notion": case "notion_doc": - return <NotionDoc {...iconProps} />; + return <NotionDoc {...iconProps} /> case "word": case "microsoft_word": - return <MicrosoftWord {...iconProps} />; + return <MicrosoftWord {...iconProps} /> case "excel": case "microsoft_excel": - return <MicrosoftExcel {...iconProps} />; + return <MicrosoftExcel {...iconProps} /> case "powerpoint": case "microsoft_powerpoint": - return <MicrosoftPowerpoint {...iconProps} />; + return <MicrosoftPowerpoint {...iconProps} /> case "onenote": case "microsoft_onenote": - return <MicrosoftOneNote {...iconProps} />; + return <MicrosoftOneNote {...iconProps} /> case "onedrive": - return <OneDrive {...iconProps} />; + return <OneDrive {...iconProps} /> case "pdf": - return <PDF {...iconProps} />; + return <PDF {...iconProps} /> default: - {/*@ts-ignore */} - return <FileText {...iconProps} />; + { + /*@ts-ignore */ + } + return <FileText {...iconProps} /> } -}; - -export const NodeDetailPanel = memo( - function NodeDetailPanel({ node, onClose, variant = "console" }: NodeDetailPanelProps) { - if (!node) return null; - - const isDocument = node.type === "document"; - const data = node.data; +} + +export const NodeDetailPanel = memo(function NodeDetailPanel({ + node, + onClose, + variant = "console", +}: NodeDetailPanelProps) { + if (!node) return null + + const isDocument = node.type === "document" + const data = node.data + + return ( + <motion.div + animate={{ opacity: 1 }} + className={styles.container} + exit={{ opacity: 0 }} + initial={{ opacity: 0 }} + transition={{ + duration: 0.2, + ease: "easeInOut", + }} + > + {/* Glass effect background */} + <GlassMenuEffect rounded="xl" /> - return ( <motion.div animate={{ opacity: 1 }} - className={styles.container} - exit={{ opacity: 0 }} + className={styles.content} initial={{ opacity: 0 }} - transition={{ - duration: 0.2, - ease: "easeInOut", - }} + transition={{ delay: 0.05, duration: 0.15 }} > - {/* Glass effect background */} - <GlassMenuEffect rounded="xl" /> - - <motion.div - animate={{ opacity: 1 }} - className={styles.content} - initial={{ opacity: 0 }} - transition={{ delay: 0.05, duration: 0.15 }} - > - <div className={styles.header}> - <div className={styles.headerLeft}> - {isDocument ? ( - getDocumentIcon((data as DocumentWithMemories).type ?? "") - ) : ( + <div className={styles.header}> + <div className={styles.headerLeft}> + {isDocument ? ( + getDocumentIcon((data as DocumentWithMemories).type ?? "") + ) : ( // @ts-ignore - <Brain className={styles.headerIconMemory} /> - )} - <HeadingH3Bold> - {isDocument ? "Document" : "Memory"} - </HeadingH3Bold> - </div> - <motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}> - <Button - className={styles.closeButton} - onClick={onClose} - size="sm" - variant="ghost" - > - {/* @ts-ignore */} - <X className={styles.closeIcon} /> - </Button> - </motion.div> + <Brain className={styles.headerIconMemory} /> + )} + <HeadingH3Bold>{isDocument ? "Document" : "Memory"}</HeadingH3Bold> </div> + <motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}> + <Button + className={styles.closeButton} + onClick={onClose} + size="sm" + variant="ghost" + > + {/* @ts-ignore */} + <X className={styles.closeIcon} /> + </Button> + </motion.div> + </div> + + <div className={styles.sections}> + {isDocument ? ( + <> + <div className={styles.section}> + <span className={styles.sectionLabel}>Title</span> + <p className={styles.sectionValue}> + {(data as DocumentWithMemories).title || "Untitled Document"} + </p> + </div> - <div className={styles.sections}> - {isDocument ? ( - <> + {(data as DocumentWithMemories).summary && ( <div className={styles.section}> - <span className={styles.sectionLabel}> - Title - </span> - <p className={styles.sectionValue}> - {(data as DocumentWithMemories).title || - "Untitled Document"} + <span className={styles.sectionLabel}>Summary</span> + <p className={styles.sectionValueTruncated}> + {(data as DocumentWithMemories).summary} </p> </div> + )} - {(data as DocumentWithMemories).summary && ( - <div className={styles.section}> - <span className={styles.sectionLabel}> - Summary - </span> - <p className={styles.sectionValueTruncated}> - {(data as DocumentWithMemories).summary} - </p> - </div> - )} + <div className={styles.section}> + <span className={styles.sectionLabel}>Type</span> + <p className={styles.sectionValue}> + {formatDocumentType( + (data as DocumentWithMemories).type ?? "", + )} + </p> + </div> - <div className={styles.section}> - <span className={styles.sectionLabel}> - Type - </span> - <p className={styles.sectionValue}> - {formatDocumentType((data as DocumentWithMemories).type ?? "")} - </p> - </div> + <div className={styles.section}> + <span className={styles.sectionLabel}>Memory Count</span> + <p className={styles.sectionValue}> + {(data as DocumentWithMemories).memoryEntries.length} memories + </p> + </div> + {((data as DocumentWithMemories).url || + (data as DocumentWithMemories).customId) && ( <div className={styles.section}> - <span className={styles.sectionLabel}> - Memory Count - </span> - <p className={styles.sectionValue}> - {(data as DocumentWithMemories).memoryEntries.length}{" "} - memories - </p> + <span className={styles.sectionLabel}>URL</span> + <a + className={styles.link} + 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" + > + {/* @ts-ignore */} + <ExternalLink className={styles.linkIcon} /> + View Document + </a> </div> - - {((data as DocumentWithMemories).url || - (data as DocumentWithMemories).customId) && ( - <div className={styles.section}> - <span className={styles.sectionLabel}> - URL - </span> - <a - className={styles.link} - 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" - > - {/* @ts-ignore */} - <ExternalLink className={styles.linkIcon} /> - View Document - </a> - </div> + )} + </> + ) : ( + <> + <div className={styles.section}> + <span className={styles.sectionLabel}>Memory</span> + <p className={styles.sectionValue}> + {(data as MemoryEntry).memory} + </p> + {(data as MemoryEntry).isForgotten && ( + <Badge className={styles.badge} variant="destructive"> + Forgotten + </Badge> )} - </> - ) : ( - <> - <div className={styles.section}> - <span className={styles.sectionLabel}> - Memory - </span> - <p className={styles.sectionValue}> - {(data as MemoryEntry).memory} + {(data as MemoryEntry).forgetAfter && ( + <p className={styles.expiryText}> + Expires:{" "} + {(data as MemoryEntry).forgetAfter + ? new Date( + (data as MemoryEntry).forgetAfter!, + ).toLocaleDateString() + : ""}{" "} + {"forgetReason" in data && (data as any).forgetReason + ? `- ${(data as any).forgetReason}` + : null} </p> - {(data as MemoryEntry).isForgotten && ( - <Badge className={styles.badge} variant="destructive"> - Forgotten - </Badge> - )} - {(data as MemoryEntry).forgetAfter && ( - <p className={styles.expiryText}> - Expires:{" "} - {(data as MemoryEntry).forgetAfter - ? new Date( - (data as MemoryEntry).forgetAfter!, - ).toLocaleDateString() - : ""}{" "} - {("forgetReason" in data && - (data as any).forgetReason - ? `- ${(data as any).forgetReason}` - : null)} - </p> - )} - </div> - - <div className={styles.section}> - <span className={styles.sectionLabel}> - Space - </span> - <p className={styles.sectionValue}> - {(data as MemoryEntry).spaceId || "Default"} - </p> - </div> - </> - )} + )} + </div> - <div className={styles.footer}> - <div className={styles.metadata}> - <span className={styles.metadataItem}> - {/* @ts-ignore */} - <Calendar className={styles.metadataIcon} /> - {new Date(data.createdAt).toLocaleDateString()} - </span> - <span className={styles.metadataItem}> - {/* @ts-ignore */} - <Hash className={styles.metadataIcon} /> - {node.id} - </span> + <div className={styles.section}> + <span className={styles.sectionLabel}>Space</span> + <p className={styles.sectionValue}> + {(data as MemoryEntry).spaceId || "Default"} + </p> </div> + </> + )} + + <div className={styles.footer}> + <div className={styles.metadata}> + <span className={styles.metadataItem}> + {/* @ts-ignore */} + <Calendar className={styles.metadataIcon} /> + {new Date(data.createdAt).toLocaleDateString()} + </span> + <span className={styles.metadataItem}> + {/* @ts-ignore */} + <Hash className={styles.metadataIcon} /> + {node.id} + </span> </div> </div> - </motion.div> + </div> </motion.div> - ); - }, -); + </motion.div> + ) +}) -NodeDetailPanel.displayName = "NodeDetailPanel"; +NodeDetailPanel.displayName = "NodeDetailPanel" diff --git a/packages/memory-graph/src/components/spaces-dropdown.css.ts b/packages/memory-graph/src/components/spaces-dropdown.css.ts index d7af2258..58fa73e4 100644 --- a/packages/memory-graph/src/components/spaces-dropdown.css.ts +++ b/packages/memory-graph/src/components/spaces-dropdown.css.ts @@ -1,12 +1,17 @@ -import { style } from "@vanilla-extract/css"; -import { themeContract } from "../styles/theme.css"; +import { style, keyframes } from "@vanilla-extract/css" +import { themeContract } from "../styles/theme.css" + +const spin = keyframes({ + "0%": { transform: "rotate(0deg)" }, + "100%": { transform: "rotate(360deg)" }, +}) /** * Dropdown container */ export const container = style({ position: "relative", -}); +}) /** * Main trigger button with gradient border effect @@ -37,40 +42,40 @@ export const trigger = style({ boxShadow: "inset 0px 2px 1px rgba(84, 84, 84, 0.25)", }, }, -}); +}) export const triggerIcon = style({ width: "1rem", height: "1rem", color: themeContract.colors.text.secondary, -}); +}) export const triggerContent = style({ flex: 1, textAlign: "left", -}); +}) export const triggerLabel = style({ fontSize: themeContract.typography.fontSize.sm, color: themeContract.colors.text.secondary, fontWeight: themeContract.typography.fontWeight.medium, -}); +}) export const triggerSubtext = style({ fontSize: themeContract.typography.fontSize.xs, color: themeContract.colors.text.muted, -}); +}) export const triggerChevron = style({ width: "1rem", height: "1rem", color: themeContract.colors.text.secondary, transition: "transform 200ms ease", -}); +}) export const triggerChevronOpen = style({ transform: "rotate(180deg)", -}); +}) /** * Dropdown menu @@ -90,11 +95,97 @@ export const dropdown = style({ "0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)", // shadow-xl zIndex: 20, overflow: "hidden", -}); +}) export const dropdownInner = style({ padding: themeContract.space[1], -}); +}) + +/** + * Search container and form + */ +export const searchContainer = style({ + display: "flex", + alignItems: "center", + gap: themeContract.space[2], + padding: themeContract.space[2], + borderBottom: "1px solid rgba(71, 85, 105, 0.4)", // slate-700/40 +}) + +export const searchForm = style({ + flex: 1, + display: "flex", + alignItems: "center", + gap: themeContract.space[2], +}) + +export const searchButton = style({ + color: themeContract.colors.text.muted, + padding: themeContract.space[1], + cursor: "pointer", + border: "none", + background: "transparent", + transition: themeContract.transitions.normal, + + selectors: { + "&:hover:not(:disabled)": { + color: themeContract.colors.text.secondary, + }, + "&:disabled": { + opacity: 0.5, + cursor: "not-allowed", + }, + }, +}) + +export const searchIcon = style({ + width: "1rem", + height: "1rem", +}) + +export const searchInput = style({ + flex: 1, + backgroundColor: "transparent", + fontSize: themeContract.typography.fontSize.sm, + color: themeContract.colors.text.secondary, + border: "none", + outline: "none", + + "::placeholder": { + color: themeContract.colors.text.muted, + }, +}) + +export const searchSpinner = style({ + width: "1rem", + height: "1rem", + borderRadius: "50%", + border: "2px solid rgba(148, 163, 184, 0.3)", // slate-400 with opacity + borderTopColor: "rgb(148, 163, 184)", // slate-400 + animation: `${spin} 1s linear infinite`, +}) + +export const searchClearButton = style({ + color: themeContract.colors.text.muted, + cursor: "pointer", + border: "none", + background: "transparent", + transition: themeContract.transitions.normal, + + selectors: { + "&:hover": { + color: themeContract.colors.text.secondary, + }, + }, +}) + +/** + * Dropdown list container + */ +export const dropdownList = style({ + maxHeight: "16rem", // max-h-64 + overflowY: "auto", +}) /** * Dropdown items @@ -114,7 +205,7 @@ const dropdownItemBase = style({ cursor: "pointer", border: "none", background: "transparent", -}); +}) export const dropdownItem = style([ dropdownItemBase, @@ -127,7 +218,7 @@ export const dropdownItem = style([ }, }, }, -]); +]) export const dropdownItemActive = style([ dropdownItemBase, @@ -135,12 +226,20 @@ export const dropdownItemActive = style([ backgroundColor: "rgba(59, 130, 246, 0.2)", // blue-500/20 color: "rgb(147, 197, 253)", // blue-300 }, -]); +]) + +export const dropdownItemHighlighted = style([ + dropdownItemBase, + { + backgroundColor: "rgba(51, 65, 85, 0.7)", // slate-700/70 + color: themeContract.colors.text.secondary, + }, +]) export const dropdownItemLabel = style({ fontSize: themeContract.typography.fontSize.sm, flex: 1, -}); +}) export const dropdownItemLabelTruncate = style({ fontSize: themeContract.typography.fontSize.sm, @@ -148,11 +247,24 @@ export const dropdownItemLabelTruncate = style({ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", -}); +}) export const dropdownItemBadge = style({ backgroundColor: "rgba(51, 65, 85, 0.5)", // slate-700/50 color: themeContract.colors.text.secondary, fontSize: themeContract.typography.fontSize.xs, marginLeft: themeContract.space[2], -}); +}) + +/** + * Empty state message + */ +export const emptyState = style({ + paddingLeft: themeContract.space[3], + paddingRight: themeContract.space[3], + paddingTop: themeContract.space[2], + paddingBottom: themeContract.space[2], + fontSize: themeContract.typography.fontSize.sm, + color: themeContract.colors.text.muted, + textAlign: "center", +}) diff --git a/packages/memory-graph/src/components/spaces-dropdown.tsx b/packages/memory-graph/src/components/spaces-dropdown.tsx index b70059f5..d8a56fe3 100644 --- a/packages/memory-graph/src/components/spaces-dropdown.tsx +++ b/packages/memory-graph/src/components/spaces-dropdown.tsx @@ -1,15 +1,19 @@ -"use client"; +"use client" -import { Badge } from "@/ui/badge"; -import { ChevronDown, Eye } from "lucide-react"; -import { memo, useEffect, useRef, useState } from "react"; -import type { SpacesDropdownProps } from "@/types"; -import * as styles from "./spaces-dropdown.css"; +import { Badge } from "@/ui/badge" +import { ChevronDown, Eye, Search, X } from "lucide-react" +import { memo, useEffect, useRef, useState } from "react" +import type { SpacesDropdownProps } from "@/types" +import * as styles from "./spaces-dropdown.css" export const SpacesDropdown = memo<SpacesDropdownProps>( ({ selectedSpace, availableSpaces, spaceMemoryCounts, onSpaceChange }) => { - const [isOpen, setIsOpen] = useState(false); - const dropdownRef = useRef<HTMLDivElement>(null); + const [isOpen, setIsOpen] = useState(false) + const [searchQuery, setSearchQuery] = useState("") + const [highlightedIndex, setHighlightedIndex] = useState(-1) + const dropdownRef = useRef<HTMLDivElement>(null) + const searchInputRef = useRef<HTMLInputElement>(null) + const itemRefs = useRef<Map<number, HTMLButtonElement>>(new Map()) // Close dropdown when clicking outside useEffect(() => { @@ -18,38 +22,115 @@ export const SpacesDropdown = memo<SpacesDropdownProps>( dropdownRef.current && !dropdownRef.current.contains(event.target as Node) ) { - setIsOpen(false); + setIsOpen(false) } - }; + } - document.addEventListener("mousedown", handleClickOutside); - return () => - document.removeEventListener("mousedown", handleClickOutside); - }, []); + document.addEventListener("mousedown", handleClickOutside) + return () => document.removeEventListener("mousedown", handleClickOutside) + }, []) + + // Focus search input when dropdown opens + useEffect(() => { + if (isOpen && searchInputRef.current) { + searchInputRef.current.focus() + } + }, [isOpen]) + + // Clear search query and reset highlighted index when dropdown closes + useEffect(() => { + if (!isOpen) { + setSearchQuery("") + setHighlightedIndex(-1) + } + }, [isOpen]) + + // Filter spaces based on search query (client-side) + const filteredSpaces = searchQuery + ? availableSpaces.filter((space) => + space.toLowerCase().includes(searchQuery.toLowerCase()), + ) + : availableSpaces const totalMemories = Object.values(spaceMemoryCounts).reduce( (sum, count) => sum + count, 0, - ); + ) + + // Total items including "Latest" option + const totalItems = filteredSpaces.length + 1 + + // Scroll highlighted item into view + useEffect(() => { + if (highlightedIndex >= 0 && highlightedIndex < totalItems) { + const element = itemRefs.current.get(highlightedIndex) + if (element) { + element.scrollIntoView({ + block: "nearest", + behavior: "smooth", + }) + } + } + }, [highlightedIndex, totalItems]) + + // Handle keyboard navigation + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!isOpen) return + + switch (e.key) { + case "ArrowDown": + e.preventDefault() + setHighlightedIndex((prev) => (prev < totalItems - 1 ? prev + 1 : 0)) + break + case "ArrowUp": + e.preventDefault() + setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : totalItems - 1)) + break + case "Enter": + e.preventDefault() + if (highlightedIndex === 0) { + onSpaceChange("all") + setIsOpen(false) + } else if ( + highlightedIndex > 0 && + highlightedIndex <= filteredSpaces.length + ) { + const selectedSpace = filteredSpaces[highlightedIndex - 1] + if (selectedSpace) { + onSpaceChange(selectedSpace) + setIsOpen(false) + } + } + break + case "Escape": + e.preventDefault() + setIsOpen(false) + break + } + } return ( - <div className={styles.container} ref={dropdownRef}> + <div + className={styles.container} + ref={dropdownRef} + onKeyDown={handleKeyDown} + > <button className={styles.trigger} onClick={() => setIsOpen(!isOpen)} type="button" > - {/*@ts-ignore */} - <Eye className={styles.triggerIcon} /> + {/*@ts-ignore */} + <Eye className={styles.triggerIcon} /> <div className={styles.triggerContent}> <span className={styles.triggerLabel}> {selectedSpace === "all" - ? "All Spaces" + ? "Latest" : selectedSpace || "Select space"} </span> <div className={styles.triggerSubtext}> {selectedSpace === "all" - ? `${totalMemories} total memories` + ? "" : `${spaceMemoryCounts[selectedSpace] || 0} memories`} </div> </div> @@ -62,49 +143,105 @@ export const SpacesDropdown = memo<SpacesDropdownProps>( {isOpen && ( <div className={styles.dropdown}> <div className={styles.dropdownInner}> - <button - className={ - selectedSpace === "all" - ? styles.dropdownItemActive - : styles.dropdownItem - } - onClick={() => { - onSpaceChange("all"); - setIsOpen(false); - }} - type="button" - > - <span className={styles.dropdownItemLabel}>All Spaces</span> - <Badge className={styles.dropdownItemBadge}> - {totalMemories} - </Badge> - </button> - {availableSpaces.map((space) => ( + {/* Search Input - Always show for filtering */} + <div className={styles.searchContainer}> + <div className={styles.searchForm}> + {/*@ts-ignore */} + <Search className={styles.searchIcon} /> + <input + className={styles.searchInput} + onChange={(e) => setSearchQuery(e.target.value)} + placeholder="Search spaces..." + ref={searchInputRef} + type="text" + value={searchQuery} + /> + {searchQuery && ( + <button + className={styles.searchClearButton} + onClick={() => setSearchQuery("")} + type="button" + aria-label="Clear search" + > + {/*@ts-ignore */} + <X className={styles.searchIcon} /> + </button> + )} + </div> + </div> + + {/* Spaces List */} + <div className={styles.dropdownList}> + {/* Always show "Latest" option */} <button + ref={(el) => { + if (el) itemRefs.current.set(0, el) + }} className={ - selectedSpace === space + selectedSpace === "all" ? styles.dropdownItemActive - : styles.dropdownItem + : highlightedIndex === 0 + ? styles.dropdownItemHighlighted + : styles.dropdownItem } - key={space} onClick={() => { - onSpaceChange(space); - setIsOpen(false); + onSpaceChange("all") + setIsOpen(false) }} + onMouseEnter={() => setHighlightedIndex(0)} type="button" > - <span className={styles.dropdownItemLabelTruncate}>{space}</span> + <span className={styles.dropdownItemLabel}>Latest</span> <Badge className={styles.dropdownItemBadge}> - {spaceMemoryCounts[space] || 0} + {totalMemories} </Badge> </button> - ))} + + {/* Show all spaces, filtered by search query */} + {filteredSpaces.length > 0 + ? filteredSpaces.map((space, index) => { + const itemIndex = index + 1 + return ( + <button + ref={(el) => { + if (el) itemRefs.current.set(itemIndex, el) + }} + className={ + selectedSpace === space + ? styles.dropdownItemActive + : highlightedIndex === itemIndex + ? styles.dropdownItemHighlighted + : styles.dropdownItem + } + key={space} + onClick={() => { + onSpaceChange(space) + setIsOpen(false) + }} + onMouseEnter={() => setHighlightedIndex(itemIndex)} + type="button" + > + <span className={styles.dropdownItemLabelTruncate}> + {space} + </span> + <Badge className={styles.dropdownItemBadge}> + {spaceMemoryCounts[space] || 0} + </Badge> + </button> + ) + }) + : searchQuery && ( + <div className={styles.emptyState}> + No spaces found matching "{searchQuery}" + </div> + )} + </div> </div> </div> )} </div> - ); + ) }, -); +) -SpacesDropdown.displayName = "SpacesDropdown"; +SpacesDropdown.displayName = "SpacesDropdown" diff --git a/packages/memory-graph/src/constants.ts b/packages/memory-graph/src/constants.ts index 23193601..fddfdee5 100644 --- a/packages/memory-graph/src/constants.ts +++ b/packages/memory-graph/src/constants.ts @@ -47,7 +47,7 @@ export const colors = { extends: "rgba(16, 185, 129, 0.5)", // green derives: "rgba(147, 197, 253, 0.5)", // blue }, -}; +} export const LAYOUT_CONSTANTS = { centerX: 400, @@ -57,7 +57,7 @@ export const LAYOUT_CONSTANTS = { 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 = { @@ -71,7 +71,7 @@ export const GRAPH_SETTINGS = { 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 = { @@ -97,4 +97,4 @@ export const POSITIONING = { viewToggle: "top-4 right-4", // Consumer has view toggle nodeDetail: "top-4 right-4", }, -}; +} diff --git a/packages/memory-graph/src/hooks/use-graph-data.ts b/packages/memory-graph/src/hooks/use-graph-data.ts index 030eea61..526277bb 100644 --- a/packages/memory-graph/src/hooks/use-graph-data.ts +++ b/packages/memory-graph/src/hooks/use-graph-data.ts @@ -1,12 +1,12 @@ -"use client"; +"use client" import { calculateSemanticSimilarity, getConnectionVisualProps, getMagicalConnectionColor, -} from "@/lib/similarity"; -import { useMemo } from "react"; -import { colors, LAYOUT_CONSTANTS } from "@/constants"; +} from "@/lib/similarity" +import { useMemo } from "react" +import { colors, LAYOUT_CONSTANTS } from "@/constants" import type { DocumentsResponse, DocumentWithMemories, @@ -14,95 +14,106 @@ import type { GraphNode, MemoryEntry, MemoryRelation, -} from "@/types"; +} from "@/types" export function useGraphData( data: DocumentsResponse | null, selectedSpace: string, nodePositions: Map<string, { x: number; y: number }>, draggingNodeId: string | null, + memoryLimit?: number, ) { return useMemo(() => { - if (!data?.documents) return { nodes: [], edges: [] }; + if (!data?.documents) return { nodes: [], edges: [] } - const allNodes: GraphNode[] = []; - const allEdges: GraphEdge[] = []; + const allNodes: GraphNode[] = [] + const allEdges: GraphEdge[] = [] // Filter documents that have memories in selected space + // AND limit memories per document when memoryLimit is provided const filteredDocuments = data.documents - .map((doc) => ({ - ...doc, - memoryEntries: + .map((doc) => { + let memories = selectedSpace === "all" ? doc.memoryEntries : doc.memoryEntries.filter( (memory) => (memory.spaceContainerTag ?? memory.spaceId ?? "default") === selectedSpace, - ), - })) - .filter((doc) => doc.memoryEntries.length > 0); + ) + + // Apply memory limit if provided and a specific space is selected + if (selectedSpace !== "all" && memoryLimit && memoryLimit > 0) { + memories = memories.slice(0, memoryLimit) + } + + return { + ...doc, + memoryEntries: memories, + } + }) + .filter((doc) => doc.memoryEntries.length > 0) // Group documents by space for better clustering - const documentsBySpace = new Map<string, typeof filteredDocuments>(); + const documentsBySpace = new Map<string, typeof filteredDocuments>() filteredDocuments.forEach((doc) => { const docSpace = doc.memoryEntries[0]?.spaceContainerTag ?? doc.memoryEntries[0]?.spaceId ?? - "default"; + "default" if (!documentsBySpace.has(docSpace)) { - documentsBySpace.set(docSpace, []); + documentsBySpace.set(docSpace, []) } - const spaceDocsArr = documentsBySpace.get(docSpace); + const spaceDocsArr = documentsBySpace.get(docSpace) if (spaceDocsArr) { - spaceDocsArr.push(doc); + spaceDocsArr.push(doc) } - }); + }) // Enhanced Layout with Space Separation const { centerX, centerY, clusterRadius, spaceSpacing, documentSpacing } = - LAYOUT_CONSTANTS; + LAYOUT_CONSTANTS /* 1. Build DOCUMENT nodes with space-aware clustering */ - const documentNodes: GraphNode[] = []; - let spaceIndex = 0; + 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; + 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; + 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 + 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; + const positionInRing = docIndex - totalDocsInPreviousRings + const angleInRing = (positionInRing / docsInCurrentRing) * Math.PI * 2 // Radius increases significantly with each ring - const baseRadius = documentSpacing * 0.8; + const baseRadius = documentSpacing * 0.8 const radius = currentRing === 0 ? baseRadius - : baseRadius + currentRing * documentSpacing * 1.2; + : baseRadius + currentRing * documentSpacing * 1.2 - const defaultX = spaceCenterX + Math.cos(angleInRing) * radius; - const defaultY = spaceCenterY + Math.sin(angleInRing) * radius; + const defaultX = spaceCenterX + Math.cos(angleInRing) * radius + const defaultY = spaceCenterY + Math.sin(angleInRing) * radius - const customPos = nodePositions.get(doc.id); + const customPos = nodePositions.get(doc.id) documentNodes.push({ id: doc.id, @@ -114,81 +125,80 @@ export function useGraphData( color: colors.document.primary, isHovered: false, isDragging: draggingNodeId === doc.id, - } satisfies GraphNode); - }); + } satisfies GraphNode) + }) - spaceIndex++; - }); + spaceIndex++ + }) /* 2. Gentle document collision avoidance with dampening */ - const minDocDist = LAYOUT_CONSTANTS.minDocDist; + 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; + 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"; + "default" const spaceB = (nodeB.data as DocumentWithMemories).memoryEntries[0] ?.spaceContainerTag ?? (nodeB.data as DocumentWithMemories).memoryEntries[0]?.spaceId ?? - "default"; + "default" - if (spaceA !== spaceB) return; + 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; + 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; + 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); + 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; + 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 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 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; + 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; + docNode.x + Math.cos(clusterAngle) * distance + offsetX const defaultMemY = - docNode.y + Math.sin(clusterAngle) * distance + offsetY; + docNode.y + Math.sin(clusterAngle) * distance + offsetY if (!memoryNodeMap.has(memoryId)) { const memoryNode: GraphNode = { @@ -204,9 +214,9 @@ export function useGraphData( color: colors.memory.primary, isHovered: false, isDragging: draggingNodeId === memoryId, - }; - memoryNodeMap.set(memoryId, memoryNode); - allNodes.push(memoryNode); + } + memoryNodeMap.set(memoryId, memoryNode) + allNodes.push(memoryNode) } // Create doc-memory edge with similarity @@ -218,23 +228,23 @@ export function useGraphData( visualProps: getConnectionVisualProps(1), color: colors.connection.memory, edgeType: "doc-memory", - }); - }); - }); + }) + }) + }) // Build mapping of memoryId -> nodeId for version chains - const memNodeIdMap = new Map<string, string>(); + const memNodeIdMap = new Map<string, string>() allNodes.forEach((n) => { if (n.type === "memory") { - memNodeIdMap.set((n.data as MemoryEntry).id, n.id); + 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> = {}; + let parentRelations: Record<string, MemoryRelation> = {} if ( mem.memoryRelations && @@ -242,18 +252,21 @@ export function useGraphData( mem.memoryRelations.length > 0 ) { // Convert array to Record - parentRelations = mem.memoryRelations.reduce((acc, rel) => { - acc[rel.targetMemoryId] = rel.relationType; - return acc; - }, {} as Record<string, MemoryRelation>); + parentRelations = mem.memoryRelations.reduce( + (acc, rel) => { + acc[rel.targetMemoryId] = rel.relationType + return acc + }, + {} as Record<string, MemoryRelation>, + ) } 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); + const fromId = memNodeIdMap.get(pid) + const toId = memNodeIdMap.get(mem.id) if (fromId && toId) { allEdges.push({ id: `version-${fromId}-${toId}`, @@ -270,25 +283,25 @@ export function useGraphData( 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; + const docI = filteredDocuments[i] + if (!docI) continue for (let j = i + 1; j < filteredDocuments.length; j++) { - const docJ = filteredDocuments[j]; - if (!docJ) continue; + 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}`, @@ -298,11 +311,11 @@ export function useGraphData( visualProps: getConnectionVisualProps(sim), color: getMagicalConnectionColor(sim, 200), edgeType: "doc-doc", - }); + }) } } } - return { nodes: allNodes, edges: allEdges }; - }, [data, selectedSpace, nodePositions, draggingNodeId]); + return { nodes: allNodes, edges: allEdges } + }, [data, selectedSpace, nodePositions, draggingNodeId, memoryLimit]) } diff --git a/packages/memory-graph/src/hooks/use-graph-interactions.ts b/packages/memory-graph/src/hooks/use-graph-interactions.ts index fa794397..94fc88ee 100644 --- a/packages/memory-graph/src/hooks/use-graph-interactions.ts +++ b/packages/memory-graph/src/hooks/use-graph-interactions.ts @@ -1,48 +1,48 @@ -"use client"; +"use client" -import { useCallback, useRef, useState } from "react"; -import { GRAPH_SETTINGS } from "@/constants"; -import type { GraphNode } from "@/types"; +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 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()); + >(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: { 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); + const animationRef = useRef<number | null>(null) + const [isAnimating, setIsAnimating] = useState(false) // Smooth animation helper const animateToViewState = useCallback( @@ -53,219 +53,219 @@ export function useGraphInteractions( duration = 300, ) => { if (animationRef.current) { - cancelAnimationFrame(animationRef.current); + cancelAnimationFrame(animationRef.current) } - const startPanX = panX; - const startPanY = panY; - const startZoom = zoom; - const startTime = Date.now(); + const startPanX = panX + const startPanY = panY + const startZoom = zoom + const startTime = Date.now() - setIsAnimating(true); + setIsAnimating(true) const animate = () => { - const elapsed = Date.now() - startTime; - const progress = Math.min(elapsed / duration, 1); + 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 easeOut = 1 - (1 - progress) ** 3 - const currentPanX = startPanX + (targetPanX - startPanX) * easeOut; - const currentPanY = startPanY + (targetPanY - startPanY) * easeOut; - const currentZoom = startZoom + (targetZoom - startZoom) * easeOut; + const currentPanX = startPanX + (targetPanX - startPanX) * easeOut + const currentPanY = startPanY + (targetPanY - startPanY) * easeOut + const currentZoom = startZoom + (targetZoom - startZoom) * easeOut - setPanX(currentPanX); - setPanY(currentPanY); - setZoom(currentZoom); + setPanX(currentPanX) + setPanY(currentPanY) + setZoom(currentZoom) if (progress < 1) { - animationRef.current = requestAnimationFrame(animate); + animationRef.current = requestAnimationFrame(animate) } else { - setIsAnimating(false); - animationRef.current = null; + setIsAnimating(false) + animationRef.current = null } - }; + } - animate(); + 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; + const node = nodes?.find((n) => n.id === nodeId) + if (!node) return - setDraggingNodeId(nodeId); + setDraggingNodeId(nodeId) setDragStart({ x: e.clientX, y: e.clientY, nodeX: node.x, nodeY: node.y, - }); + }) }, [], - ); + ) const handleNodeDragMove = useCallback( (e: React.MouseEvent) => { - if (!draggingNodeId) return; + if (!draggingNodeId) return - const deltaX = (e.clientX - dragStart.x) / zoom; - const deltaY = (e.clientY - dragStart.y) / zoom; + const deltaX = (e.clientX - dragStart.x) / zoom + const deltaY = (e.clientY - dragStart.y) / zoom - const newX = dragStart.nodeX + deltaX; - const newY = dragStart.nodeY + deltaY; + 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); - }, []); + setDraggingNodeId(null) + }, []) // Pan handlers const handlePanStart = useCallback( (e: React.MouseEvent) => { - setIsPanning(true); - setPanStart({ x: e.clientX - panX, y: e.clientY - panY }); + setIsPanning(true) + setPanStart({ x: e.clientX - panX, y: e.clientY - panY }) }, [panX, panY], - ); + ) const handlePanMove = useCallback( (e: React.MouseEvent) => { - if (!isPanning || draggingNodeId) return; + if (!isPanning || draggingNodeId) return - const newPanX = e.clientX - panStart.x; - const newPanY = e.clientY - panStart.y; - setPanX(newPanX); - setPanY(newPanY); + const newPanX = e.clientX - panStart.x + const newPanY = e.clientY - panStart.y + setPanX(newPanX) + setPanY(newPanY) }, [isPanning, panStart, draggingNodeId], - ); + ) const handlePanEnd = useCallback(() => { - setIsPanning(false); - }, []); + setIsPanning(false) + }, []) // Zoom handlers const handleWheel = useCallback( (e: React.WheelEvent) => { // Always prevent default to stop browser navigation - e.preventDefault(); - e.stopPropagation(); + 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; + 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)); + 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; + 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; + const target = e.currentTarget if (target && "getBoundingClientRect" in target) { - const rect = target.getBoundingClientRect(); - mouseX = e.clientX - rect.left; - mouseY = e.clientY - rect.top; + 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; + 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; + const newPanX = mouseX - worldX * newZoom + const newPanY = mouseY - worldY * newZoom - setZoom(newZoom); - setPanX(newPanX); - setPanY(newPanY); + 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 + 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; + 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); + animateToViewState(newPanX, newPanY, newZoom, 200) } else { - setZoom(newZoom); - setPanX(newPanX); - setPanY(newPanY); + setZoom(newZoom) + setPanX(newPanX) + setPanY(newPanY) } } else { if (animate && !isAnimating) { - animateToViewState(panX, panY, newZoom, 200); + animateToViewState(panX, panY, newZoom, 200) } else { - setZoom(newZoom); + 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 + 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; + 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); + animateToViewState(newPanX, newPanY, newZoom, 200) } else { - setZoom(newZoom); - setPanX(newPanX); - setPanY(newPanY); + setZoom(newZoom) + setPanX(newPanX) + setPanY(newPanY) } } else { if (animate && !isAnimating) { - animateToViewState(panX, panY, newZoom, 200); + animateToViewState(panX, panY, newZoom, 200) } else { - setZoom(newZoom); + setZoom(newZoom) } } }, [zoom, panX, panY, isAnimating, animateToViewState], - ); + ) const resetView = useCallback(() => { - setPanX(settings.initialPanX); - setPanY(settings.initialPanY); - setZoom(settings.initialZoom); - setNodePositions(new Map()); - }, [settings]); + setPanX(settings.initialPanX) + setPanY(settings.initialPanY) + setZoom(settings.initialZoom) + setNodePositions(new Map()) + }, [settings]) // Auto-fit graph to viewport const autoFitToViewport = useCallback( @@ -275,74 +275,74 @@ export function useGraphInteractions( viewportHeight: number, options?: { occludedRightPx?: number; animate?: boolean }, ) => { - if (nodes.length === 0) return; + 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; + 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); - }); + 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; + const contentCenterX = (minX + maxX) / 2 + const contentCenterY = (minY + maxY) / 2 // Calculate the size of the content - const contentWidth = maxX - minX; - const contentHeight = maxY - minY; + 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; + 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); + 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); + 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; + 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 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); + 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); + setZoom(newZoom) + setPanX(newPanX) + setPanY(newPanY) } }, [zoom, panX, panY], - ); + ) // Touch gesture handlers for mobile pinch-to-zoom const handleTouchStart = useCallback((e: React.TouchEvent) => { @@ -350,117 +350,117 @@ export function useGraphInteractions( 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 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 })); + setTouchState((prev) => ({ ...prev, touches, isGesturing: false })) } - }, []); + }, []) const handleTouchMove = useCallback( (e: React.TouchEvent) => { - e.preventDefault(); + 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 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)); + 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; + 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; + 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; + 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; + const centerDx = center.x - touchState.lastCenter.x + const centerDy = center.y - touchState.lastCenter.y - setZoom(newZoom); - setPanX(newPanX + centerDx); - setPanY(newPanY + centerDy); + 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); + 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 })); + setTouchState((prev) => ({ ...prev, touches, isGesturing: false })) } else { - setTouchState((prev) => ({ ...prev, touches })); + setTouchState((prev) => ({ ...prev, touches })) } if (touches.length === 0) { - setIsPanning(false); + setIsPanning(false) } - }, []); + }, []) // Center viewport on a specific world position (with animation) const centerViewportOn = useCallback( @@ -471,63 +471,63 @@ export function useGraphInteractions( viewportHeight: number, animate = true, ) => { - const newPanX = viewportWidth / 2 - worldX * zoom; - const newPanY = viewportHeight / 2 - worldY * zoom; + const newPanX = viewportWidth / 2 - worldX * zoom + const newPanY = viewportHeight / 2 - worldY * zoom if (animate && !isAnimating) { - animateToViewState(newPanX, newPanY, zoom, 400); + animateToViewState(newPanX, newPanY, zoom, 400) } else { - setPanX(newPanX); - setPanY(newPanY); + setPanX(newPanX) + setPanY(newPanY) } }, [zoom, isAnimating, animateToViewState], - ); + ) // Node interaction handlers const handleNodeHover = useCallback((nodeId: string | null) => { - setHoveredNode(nodeId); - }, []); + setHoveredNode(nodeId) + }, []) const handleNodeClick = useCallback( (nodeId: string) => { - setSelectedNode(selectedNode === nodeId ? null : nodeId); + 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); + 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; + 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; + const target = e.currentTarget if (target && "getBoundingClientRect" in target) { - const rect = target.getBoundingClientRect(); - mouseX = e.clientX - rect.left; - mouseY = e.clientY - rect.top; + 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; + 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; + const newPanX = mouseX - worldX * newZoom + const newPanY = mouseY - worldY * newZoom - setZoom(newZoom); - setPanX(newPanX); - setPanY(newPanY); + setZoom(newZoom) + setPanX(newPanX) + setPanY(newPanY) }, [zoom, panX, panY], - ); + ) return { // State @@ -560,5 +560,5 @@ export function useGraphInteractions( autoFitToViewport, centerViewportOn, setSelectedNode, - }; + } } diff --git a/packages/memory-graph/src/index.tsx b/packages/memory-graph/src/index.tsx index 6e5c882f..f32f29bf 100644 --- a/packages/memory-graph/src/index.tsx +++ b/packages/memory-graph/src/index.tsx @@ -1,25 +1,25 @@ // Export the main component -export { MemoryGraph } from "./components/memory-graph"; +export { MemoryGraph } from "./components/memory-graph" // Export style injector for manual use if needed -export { injectStyles } from "./lib/inject-styles"; +export { injectStyles } from "./lib/inject-styles" // Export types for consumers -export type { MemoryGraphProps } from "./types"; +export type { MemoryGraphProps } from "./types" export type { DocumentWithMemories, MemoryEntry, DocumentsResponse, -} from "./api-types"; +} from "./api-types" export type { GraphNode, GraphEdge, MemoryRelation, -} from "./types"; +} from "./types" // Export theme system for custom theming -export { themeContract, defaultTheme } from "./styles/theme.css"; -export { sprinkles } from "./styles/sprinkles.css"; -export type { Sprinkles } from "./styles/sprinkles.css"; +export { themeContract, defaultTheme } from "./styles/theme.css" +export { sprinkles } from "./styles/sprinkles.css" +export type { Sprinkles } from "./styles/sprinkles.css" diff --git a/packages/memory-graph/src/lib/inject-styles.ts b/packages/memory-graph/src/lib/inject-styles.ts index 1a6bf4eb..e50b4f5e 100644 --- a/packages/memory-graph/src/lib/inject-styles.ts +++ b/packages/memory-graph/src/lib/inject-styles.ts @@ -4,33 +4,33 @@ */ // This will be replaced by the build plugin with the actual CSS content -declare const __MEMORY_GRAPH_CSS__: string; +declare const __MEMORY_GRAPH_CSS__: string // Track injection state -let injected = false; +let injected = false /** * Inject memory-graph styles into the document head. * Safe to call multiple times - will only inject once. */ export function injectStyles(): void { - // Only run in browser - if (typeof document === "undefined") return; + // Only run in browser + if (typeof document === "undefined") return - // Only inject once - if (injected) return; + // Only inject once + if (injected) return - // Check if already injected (e.g., by another instance) - if (document.querySelector('style[data-memory-graph]')) { - injected = true; - return; - } + // Check if already injected (e.g., by another instance) + if (document.querySelector("style[data-memory-graph]")) { + injected = true + return + } - injected = true; + injected = true - // Create and inject style element - const style = document.createElement("style"); - style.setAttribute("data-memory-graph", ""); - style.textContent = __MEMORY_GRAPH_CSS__; - document.head.appendChild(style); + // Create and inject style element + const style = document.createElement("style") + style.setAttribute("data-memory-graph", "") + style.textContent = __MEMORY_GRAPH_CSS__ + document.head.appendChild(style) } diff --git a/packages/memory-graph/src/styles/animations.css.ts b/packages/memory-graph/src/styles/animations.css.ts index d9430ec4..53444052 100644 --- a/packages/memory-graph/src/styles/animations.css.ts +++ b/packages/memory-graph/src/styles/animations.css.ts @@ -1,4 +1,4 @@ -import { keyframes } from "@vanilla-extract/css"; +import { keyframes } from "@vanilla-extract/css" /** * Animation keyframes @@ -8,12 +8,12 @@ import { keyframes } from "@vanilla-extract/css"; export const fadeIn = keyframes({ from: { opacity: 0 }, to: { opacity: 1 }, -}); +}) export const fadeOut = keyframes({ from: { opacity: 1 }, to: { opacity: 0 }, -}); +}) export const slideInFromRight = keyframes({ from: { @@ -24,7 +24,7 @@ export const slideInFromRight = keyframes({ transform: "translateX(0)", opacity: 1, }, -}); +}) export const slideInFromLeft = keyframes({ from: { @@ -35,7 +35,7 @@ export const slideInFromLeft = keyframes({ transform: "translateX(0)", opacity: 1, }, -}); +}) export const slideInFromTop = keyframes({ from: { @@ -46,7 +46,7 @@ export const slideInFromTop = keyframes({ transform: "translateY(0)", opacity: 1, }, -}); +}) export const slideInFromBottom = keyframes({ from: { @@ -57,12 +57,12 @@ export const slideInFromBottom = keyframes({ transform: "translateY(0)", opacity: 1, }, -}); +}) export const spin = keyframes({ from: { transform: "rotate(0deg)" }, to: { transform: "rotate(360deg)" }, -}); +}) export const pulse = keyframes({ "0%, 100%": { @@ -71,7 +71,7 @@ export const pulse = keyframes({ "50%": { opacity: 0.5, }, -}); +}) export const bounce = keyframes({ "0%, 100%": { @@ -82,7 +82,7 @@ export const bounce = keyframes({ transform: "translateY(0)", animationTimingFunction: "cubic-bezier(0, 0, 0.2, 1)", }, -}); +}) export const scaleIn = keyframes({ from: { @@ -93,7 +93,7 @@ export const scaleIn = keyframes({ transform: "scale(1)", opacity: 1, }, -}); +}) export const scaleOut = keyframes({ from: { @@ -104,7 +104,7 @@ export const scaleOut = keyframes({ transform: "scale(0.95)", opacity: 0, }, -}); +}) export const shimmer = keyframes({ "0%": { @@ -113,4 +113,4 @@ export const shimmer = keyframes({ "100%": { backgroundPosition: "1000px 0", }, -}); +}) diff --git a/packages/memory-graph/src/styles/effects.css.ts b/packages/memory-graph/src/styles/effects.css.ts index 2a290d32..306f912c 100644 --- a/packages/memory-graph/src/styles/effects.css.ts +++ b/packages/memory-graph/src/styles/effects.css.ts @@ -1,5 +1,5 @@ -import { style, styleVariants } from "@vanilla-extract/css"; -import { themeContract } from "./theme.css"; +import { style, styleVariants } from "@vanilla-extract/css" +import { themeContract } from "./theme.css" /** * Base glass-morphism effect @@ -10,7 +10,7 @@ const glassBase = style({ WebkitBackdropFilter: "blur(12px)", border: `1px solid ${themeContract.colors.document.border}`, borderRadius: themeContract.radii.lg, -}); +}) /** * Glass effect variants @@ -47,7 +47,7 @@ export const glass = styleVariants({ WebkitBackdropFilter: "blur(20px)", }, ], -}); +}) /** * Glass panel styles for larger containers @@ -67,7 +67,7 @@ export const glassPanel = styleVariants({ border: `2px solid ${themeContract.colors.document.border}`, borderRadius: themeContract.radii.xl, }, -}); +}) /** * Focus ring styles for accessibility @@ -80,7 +80,7 @@ export const focusRing = style({ outlineOffset: "2px", }, }, -}); +}) /** * Transition presets @@ -104,7 +104,7 @@ export const transition = styleVariants({ transform: { transition: `transform ${themeContract.transitions.normal}`, }, -}); +}) /** * Hover glow effect @@ -117,4 +117,4 @@ export const hoverGlow = style({ boxShadow: `0 0 20px ${themeContract.colors.document.glow}`, }, }, -}); +}) diff --git a/packages/memory-graph/src/styles/global.css.ts b/packages/memory-graph/src/styles/global.css.ts index cbe37913..ec08d959 100644 --- a/packages/memory-graph/src/styles/global.css.ts +++ b/packages/memory-graph/src/styles/global.css.ts @@ -1,4 +1,4 @@ -import { globalStyle } from "@vanilla-extract/css"; +import { globalStyle } from "@vanilla-extract/css" /** * Global CSS reset and base styles @@ -7,39 +7,39 @@ import { globalStyle } from "@vanilla-extract/css"; // Box sizing reset globalStyle("*, *::before, *::after", { boxSizing: "border-box", -}); +}) // Remove default margins globalStyle("body, h1, h2, h3, h4, h5, h6, p, figure, blockquote, dl, dd", { margin: 0, -}); +}) // Remove list styles globalStyle("ul[role='list'], ol[role='list']", { listStyle: "none", -}); +}) // Core body defaults globalStyle("html, body", { height: "100%", -}); +}) globalStyle("body", { lineHeight: 1.5, WebkitFontSmoothing: "antialiased", MozOsxFontSmoothing: "grayscale", -}); +}) // Typography defaults globalStyle("h1, h2, h3, h4, h5, h6", { fontWeight: 500, lineHeight: 1.25, -}); +}) // Inherit fonts for inputs and buttons globalStyle("input, button, textarea, select", { font: "inherit", -}); +}) // Remove default button styles globalStyle("button", { @@ -47,25 +47,25 @@ globalStyle("button", { border: "none", padding: 0, cursor: "pointer", -}); +}) // Improve media defaults globalStyle("img, picture, video, canvas, svg", { display: "block", maxWidth: "100%", -}); +}) // Remove built-in form typography styles globalStyle("input, button, textarea, select", { font: "inherit", -}); +}) // Avoid text overflows globalStyle("p, h1, h2, h3, h4, h5, h6", { overflowWrap: "break-word", -}); +}) // Improve text rendering globalStyle("#root, #__next", { isolation: "isolate", -}); +}) diff --git a/packages/memory-graph/src/styles/index.ts b/packages/memory-graph/src/styles/index.ts index f619c689..15dbd1e9 100644 --- a/packages/memory-graph/src/styles/index.ts +++ b/packages/memory-graph/src/styles/index.ts @@ -4,17 +4,23 @@ */ // Import global styles (side effect) -import "./global.css"; +import "./global.css" // Theme -export { themeContract, defaultTheme } from "./theme.css"; +export { themeContract, defaultTheme } from "./theme.css" // Sprinkles utilities -export { sprinkles } from "./sprinkles.css"; -export type { Sprinkles } from "./sprinkles.css"; +export { sprinkles } from "./sprinkles.css" +export type { Sprinkles } from "./sprinkles.css" // Animations -export * as animations from "./animations.css"; +export * as animations from "./animations.css" // Glass-morphism effects -export { glass, glassPanel, focusRing, transition, hoverGlow } from "./effects.css"; +export { + glass, + glassPanel, + focusRing, + transition, + hoverGlow, +} from "./effects.css" diff --git a/packages/memory-graph/src/styles/sprinkles.css.ts b/packages/memory-graph/src/styles/sprinkles.css.ts index ecd7a024..6cb06261 100644 --- a/packages/memory-graph/src/styles/sprinkles.css.ts +++ b/packages/memory-graph/src/styles/sprinkles.css.ts @@ -1,5 +1,5 @@ -import { defineProperties, createSprinkles } from "@vanilla-extract/sprinkles"; -import { themeContract } from "./theme.css"; +import { defineProperties, createSprinkles } from "@vanilla-extract/sprinkles" +import { themeContract } from "./theme.css" /** * Responsive conditions for mobile-first design @@ -122,7 +122,7 @@ const responsiveProperties = defineProperties({ // User select userSelect: ["auto", "none", "text", "all"], }, -}); +}) /** * Color properties (non-responsive) @@ -152,7 +152,7 @@ const colorProperties = defineProperties({ memoryBorder: themeContract.colors.memory.border, }, }, -}); +}) /** * Border properties @@ -167,7 +167,7 @@ const borderProperties = defineProperties({ }, borderStyle: ["none", "solid", "dashed", "dotted"], }, -}); +}) /** * Opacity properties @@ -188,7 +188,7 @@ const opacityProperties = defineProperties({ 100: "1", }, }, -}); +}) /** * Combined sprinkles system @@ -199,6 +199,6 @@ export const sprinkles = createSprinkles( colorProperties, borderProperties, opacityProperties, -); +) -export type Sprinkles = Parameters<typeof sprinkles>[0]; +export type Sprinkles = Parameters<typeof sprinkles>[0] diff --git a/packages/memory-graph/src/styles/theme.css.ts b/packages/memory-graph/src/styles/theme.css.ts index bf08e3eb..0f3f5fea 100644 --- a/packages/memory-graph/src/styles/theme.css.ts +++ b/packages/memory-graph/src/styles/theme.css.ts @@ -1,4 +1,4 @@ -import { createTheme, createThemeContract } from "@vanilla-extract/css"; +import { createTheme, createThemeContract } from "@vanilla-extract/css" /** * Theme contract defines the structure of the design system. @@ -124,7 +124,7 @@ export const themeContract = createThemeContract({ modal: null, tooltip: null, }, -}); +}) /** * Default theme implementation based on the original constants.ts colors @@ -242,4 +242,4 @@ export const defaultTheme = createTheme(themeContract, { modal: "30", tooltip: "40", }, -}); +}) diff --git a/packages/memory-graph/src/types.ts b/packages/memory-graph/src/types.ts index 0a80df22..73d0602a 100644 --- a/packages/memory-graph/src/types.ts +++ b/packages/memory-graph/src/types.ts @@ -1,130 +1,148 @@ -import type { DocumentsResponse, DocumentWithMemories, MemoryEntry } from "./api-types"; +import type { + DocumentsResponse, + DocumentWithMemories, + MemoryEntry, +} from "./api-types" // Re-export for convenience -export type { DocumentsResponse, DocumentWithMemories, MemoryEntry }; +export type { DocumentsResponse, DocumentWithMemories, MemoryEntry } export interface GraphNode { - id: string; - type: "document" | "memory"; - x: number; - y: number; - data: DocumentWithMemories | MemoryEntry; - size: number; - color: string; - isHovered: boolean; - isDragging: boolean; + 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 type MemoryRelation = "updates" | "extends" | "derives" export interface GraphEdge { - id: string; - source: string; - target: string; - similarity: number; + 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; + 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; + selectedSpace: string + availableSpaces: string[] + spaceMemoryCounts: Record<string, number> + onSpaceChange: (space: string) => void } export interface NodeDetailPanelProps { - node: GraphNode | null; - onClose: () => void; - variant?: "console" | "consumer"; + 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; + 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[]; + highlightDocumentIds?: string[] } export interface MemoryGraphProps { /** The documents to display in the graph */ - documents: DocumentWithMemories[]; + documents: DocumentWithMemories[] /** Whether the initial data is loading */ - isLoading?: boolean; + isLoading?: boolean /** Error that occurred during data fetching */ - error?: Error | null; + error?: Error | null /** Optional children to render when no documents exist */ - children?: React.ReactNode; + children?: React.ReactNode /** Whether more data is being loaded (for pagination) */ - isLoadingMore?: boolean; + isLoadingMore?: boolean /** Total number of documents loaded */ - totalLoaded?: number; + totalLoaded?: number /** Whether there are more documents to load */ - hasMore?: boolean; + hasMore?: boolean /** Callback to load more documents (for pagination) */ - loadMoreDocuments?: () => Promise<void>; + loadMoreDocuments?: () => Promise<void> /** Show/hide the spaces filter dropdown */ - showSpacesSelector?: boolean; + showSpacesSelector?: boolean /** Visual variant - "console" for full view, "consumer" for embedded */ - variant?: "console" | "consumer"; + variant?: "console" | "consumer" /** Optional ID for the legend component */ - legendId?: string; + legendId?: string /** Document IDs to highlight in the graph */ - highlightDocumentIds?: string[]; + highlightDocumentIds?: string[] /** Whether highlights are currently visible */ - highlightsVisible?: boolean; + highlightsVisible?: boolean /** Pixels occluded on the right side of the viewport */ - occludedRightPx?: number; + occludedRightPx?: number /** Whether to auto-load more documents based on viewport visibility */ - autoLoadOnViewport?: boolean; + autoLoadOnViewport?: boolean /** Theme class name to apply */ - themeClassName?: string; + themeClassName?: string + + // External space control + /** Currently selected space (for controlled component) */ + selectedSpace?: string + /** Callback when space selection changes (for controlled component) */ + onSpaceChange?: (spaceId: string) => void + + // Memory limit control + /** Maximum number of memories to display per document when a space is selected */ + memoryLimit?: number + + // Feature flags + /** Enable experimental features */ + isExperimental?: boolean } export interface LegendProps { - variant?: "console" | "consumer"; - nodes?: GraphNode[]; - edges?: GraphEdge[]; - isLoading?: boolean; - hoveredNode?: string | null; + variant?: "console" | "consumer" + nodes?: GraphNode[] + edges?: GraphEdge[] + isLoading?: boolean + hoveredNode?: string | null } export interface LoadingIndicatorProps { - isLoading: boolean; - isLoadingMore: boolean; - totalLoaded: number; - variant?: "console" | "consumer"; + isLoading: boolean + isLoadingMore: boolean + totalLoaded: number + variant?: "console" | "consumer" } export interface ControlsProps { - onZoomIn: () => void; - onZoomOut: () => void; - onResetView: () => void; - variant?: "console" | "consumer"; + onZoomIn: () => void + onZoomOut: () => void + onResetView: () => void + variant?: "console" | "consumer" } diff --git a/packages/memory-graph/src/ui/badge.css.ts b/packages/memory-graph/src/ui/badge.css.ts index 1af96c1d..0f8cbc92 100644 --- a/packages/memory-graph/src/ui/badge.css.ts +++ b/packages/memory-graph/src/ui/badge.css.ts @@ -1,6 +1,6 @@ -import { recipe, type RecipeVariants } from "@vanilla-extract/recipes"; -import { style, globalStyle } from "@vanilla-extract/css"; -import { themeContract } from "../styles/theme.css"; +import { recipe, type RecipeVariants } from "@vanilla-extract/recipes" +import { style, globalStyle } from "@vanilla-extract/css" +import { themeContract } from "../styles/theme.css" /** * Base styles for SVG icons inside badges @@ -9,7 +9,7 @@ export const badgeIcon = style({ width: "0.75rem", height: "0.75rem", pointerEvents: "none", -}); +}) /** * Badge recipe with variants @@ -44,14 +44,14 @@ const badgeBase = style({ borderColor: themeContract.colors.status.forgotten, }, }, -}); +}) // Global style for SVG children globalStyle(`${badgeBase} > svg`, { width: "0.75rem", height: "0.75rem", pointerEvents: "none", -}); +}) export const badge = recipe({ base: badgeBase, @@ -114,6 +114,6 @@ export const badge = recipe({ defaultVariants: { variant: "default", }, -}); +}) -export type BadgeVariants = RecipeVariants<typeof badge>; +export type BadgeVariants = RecipeVariants<typeof badge> diff --git a/packages/memory-graph/src/ui/badge.tsx b/packages/memory-graph/src/ui/badge.tsx index 0708888f..a08578c3 100644 --- a/packages/memory-graph/src/ui/badge.tsx +++ b/packages/memory-graph/src/ui/badge.tsx @@ -1,27 +1,20 @@ -import { Slot } from "@radix-ui/react-slot"; -import type * as React from "react"; -import { badge, type BadgeVariants } from "./badge.css"; +import { Slot } from "@radix-ui/react-slot" +import type * as React from "react" +import { badge, type BadgeVariants } from "./badge.css" function Badge({ className, variant, asChild = false, ...props -}: React.ComponentProps<"span"> & - BadgeVariants & { asChild?: boolean }) { - const Comp = asChild ? Slot : "span"; +}: React.ComponentProps<"span"> & BadgeVariants & { asChild?: boolean }) { + const Comp = asChild ? Slot : "span" const combinedClassName = className ? `${badge({ variant })} ${className}` - : badge({ variant }); + : badge({ variant }) - return ( - <Comp - className={combinedClassName} - data-slot="badge" - {...props} - /> - ); + return <Comp className={combinedClassName} data-slot="badge" {...props} /> } -export { Badge, badge as badgeVariants }; +export { Badge, badge as badgeVariants } diff --git a/packages/memory-graph/src/ui/button.css.ts b/packages/memory-graph/src/ui/button.css.ts index ad9cce8c..375d567b 100644 --- a/packages/memory-graph/src/ui/button.css.ts +++ b/packages/memory-graph/src/ui/button.css.ts @@ -1,6 +1,6 @@ -import { recipe, type RecipeVariants } from "@vanilla-extract/recipes"; -import { style } from "@vanilla-extract/css"; -import { themeContract } from "../styles/theme.css"; +import { recipe, type RecipeVariants } from "@vanilla-extract/recipes" +import { style } from "@vanilla-extract/css" +import { themeContract } from "../styles/theme.css" /** * Base styles for SVG icons inside buttons @@ -14,7 +14,7 @@ export const buttonIcon = style({ height: "1rem", }, }, -}); +}) /** * Button recipe with variants @@ -205,6 +205,6 @@ export const button = recipe({ variant: "default", size: "default", }, -}); +}) -export type ButtonVariants = RecipeVariants<typeof button>; +export type ButtonVariants = RecipeVariants<typeof button> diff --git a/packages/memory-graph/src/ui/button.tsx b/packages/memory-graph/src/ui/button.tsx index 031f2cc8..89015299 100644 --- a/packages/memory-graph/src/ui/button.tsx +++ b/packages/memory-graph/src/ui/button.tsx @@ -1,6 +1,6 @@ -import { Slot } from "@radix-ui/react-slot"; -import type * as React from "react"; -import { button, type ButtonVariants } from "./button.css"; +import { Slot } from "@radix-ui/react-slot" +import type * as React from "react" +import { button, type ButtonVariants } from "./button.css" function Button({ className, @@ -10,21 +10,15 @@ function Button({ ...props }: React.ComponentProps<"button"> & ButtonVariants & { - asChild?: boolean; + asChild?: boolean }) { - const Comp = asChild ? Slot : "button"; + const Comp = asChild ? Slot : "button" const combinedClassName = className ? `${button({ variant, size })} ${className}` - : button({ variant, size }); + : button({ variant, size }) - return ( - <Comp - className={combinedClassName} - data-slot="button" - {...props} - /> - ); + return <Comp className={combinedClassName} data-slot="button" {...props} /> } -export { Button, button as buttonVariants }; +export { Button, button as buttonVariants } diff --git a/packages/memory-graph/src/ui/collapsible.tsx b/packages/memory-graph/src/ui/collapsible.tsx index 0551ffdd..f8de4e4c 100644 --- a/packages/memory-graph/src/ui/collapsible.tsx +++ b/packages/memory-graph/src/ui/collapsible.tsx @@ -1,11 +1,11 @@ -"use client"; +"use client" -import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"; +import * as CollapsiblePrimitive from "@radix-ui/react-collapsible" function Collapsible({ ...props }: React.ComponentProps<typeof CollapsiblePrimitive.Root>) { - return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />; + return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} /> } function CollapsibleTrigger({ @@ -16,7 +16,7 @@ function CollapsibleTrigger({ data-slot="collapsible-trigger" {...props} /> - ); + ) } function CollapsibleContent({ @@ -27,7 +27,7 @@ function CollapsibleContent({ data-slot="collapsible-content" {...props} /> - ); + ) } -export { Collapsible, CollapsibleTrigger, CollapsibleContent }; +export { Collapsible, CollapsibleTrigger, CollapsibleContent } diff --git a/packages/memory-graph/src/ui/glass-effect.css.ts b/packages/memory-graph/src/ui/glass-effect.css.ts index 16e0fcdc..c7160d98 100644 --- a/packages/memory-graph/src/ui/glass-effect.css.ts +++ b/packages/memory-graph/src/ui/glass-effect.css.ts @@ -1,6 +1,6 @@ -import { style } from "@vanilla-extract/css"; -import { recipe } from "@vanilla-extract/recipes"; -import { themeContract } from "../styles/theme.css"; +import { style } from "@vanilla-extract/css" +import { recipe } from "@vanilla-extract/recipes" +import { themeContract } from "../styles/theme.css" /** * Glass menu effect container @@ -8,7 +8,7 @@ import { themeContract } from "../styles/theme.css"; export const glassMenuContainer = style({ position: "absolute", inset: 0, -}); +}) /** * Glass menu effect with customizable border radius @@ -55,4 +55,4 @@ export const glassMenuEffect = recipe({ defaultVariants: { rounded: "3xl", }, -}); +}) diff --git a/packages/memory-graph/src/ui/glass-effect.tsx b/packages/memory-graph/src/ui/glass-effect.tsx index e1908f52..5c072edf 100644 --- a/packages/memory-graph/src/ui/glass-effect.tsx +++ b/packages/memory-graph/src/ui/glass-effect.tsx @@ -1,11 +1,8 @@ -import { - glassMenuContainer, - glassMenuEffect, -} from "./glass-effect.css"; +import { glassMenuContainer, glassMenuEffect } from "./glass-effect.css" interface GlassMenuEffectProps { - rounded?: "none" | "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "full"; - className?: string; + rounded?: "none" | "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "full" + className?: string } export function GlassMenuEffect({ @@ -17,5 +14,5 @@ export function GlassMenuEffect({ {/* Frosted glass effect with translucent border */} <div className={glassMenuEffect({ rounded })} /> </div> - ); + ) } diff --git a/packages/memory-graph/src/ui/heading.css.ts b/packages/memory-graph/src/ui/heading.css.ts index 128d97a6..384e4fdb 100644 --- a/packages/memory-graph/src/ui/heading.css.ts +++ b/packages/memory-graph/src/ui/heading.css.ts @@ -1,5 +1,5 @@ -import { style } from "@vanilla-extract/css"; -import { themeContract } from "../styles/theme.css"; +import { style } from "@vanilla-extract/css" +import { themeContract } from "../styles/theme.css" /** * Responsive heading style with bold weight @@ -21,4 +21,4 @@ export const headingH3Bold = style({ fontSize: themeContract.typography.fontSize.base, // 16px }, }, -}); +}) diff --git a/packages/memory-graph/src/ui/heading.tsx b/packages/memory-graph/src/ui/heading.tsx index 65e8abc8..ed9b80ef 100644 --- a/packages/memory-graph/src/ui/heading.tsx +++ b/packages/memory-graph/src/ui/heading.tsx @@ -1,18 +1,16 @@ -import { Root } from "@radix-ui/react-slot"; -import { headingH3Bold } from "./heading.css"; +import { Root } from "@radix-ui/react-slot" +import { headingH3Bold } from "./heading.css" export function HeadingH3Bold({ className, asChild, ...props }: React.ComponentProps<"h3"> & { asChild?: boolean }) { - const Comp = asChild ? Root : "h3"; + const Comp = asChild ? Root : "h3" const combinedClassName = className ? `${headingH3Bold} ${className}` - : headingH3Bold; + : headingH3Bold - return ( - <Comp className={combinedClassName} {...props} /> - ); + return <Comp className={combinedClassName} {...props} /> } diff --git a/packages/memory-graph/tsconfig.json b/packages/memory-graph/tsconfig.json index c8878527..4e7fc4ba 100644 --- a/packages/memory-graph/tsconfig.json +++ b/packages/memory-graph/tsconfig.json @@ -1,26 +1,26 @@ { - "extends": "@total-typescript/tsconfig/bundler/dom/library", - "compilerOptions": { - "jsx": "react-jsx", - "baseUrl": ".", - "paths": { - "@/*": ["./src/*"] - }, - "outDir": "./dist", - "declaration": true, - "declarationMap": true, - "emitDeclarationOnly": false, - "skipLibCheck": true, - "strict": true, - "esModuleInterop": true, - "moduleResolution": "bundler", - "resolveJsonModule": true, - "allowSyntheticDefaultImports": true, - "forceConsistentCasingInFileNames": true, - "noUnusedLocals": false, - "noUnusedParameters": false, - "noEmit": false - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "**/*.test.*", "**/*.spec.*"] + "extends": "@total-typescript/tsconfig/bundler/dom/library", + "compilerOptions": { + "jsx": "react-jsx", + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, + "outDir": "./dist", + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": false, + "skipLibCheck": true, + "strict": true, + "esModuleInterop": true, + "moduleResolution": "bundler", + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noEmit": false + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.*", "**/*.spec.*"] } diff --git a/packages/memory-graph/vite.config.ts b/packages/memory-graph/vite.config.ts index f6055602..923952d4 100644 --- a/packages/memory-graph/vite.config.ts +++ b/packages/memory-graph/vite.config.ts @@ -1,90 +1,93 @@ -import { defineConfig, type Plugin } from 'vite' -import react from '@vitejs/plugin-react' -import { resolve } from 'path' -import { readFileSync } from 'fs' -import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin'; +import { defineConfig, type Plugin } from "vite" +import react from "@vitejs/plugin-react" +import { resolve } from "path" +import { readFileSync } from "fs" +import { vanillaExtractPlugin } from "@vanilla-extract/vite-plugin" /** * Custom plugin to embed CSS content into the JS bundle for runtime injection. * This allows the package to work with any bundler (Vite, webpack, Next.js, etc.) */ function injectCssPlugin(): Plugin { - let cssContent = ''; + let cssContent = "" - return { - name: 'inject-css-content', - enforce: 'post', - generateBundle(_, bundle) { - // Find the generated CSS file - for (const [fileName, chunk] of Object.entries(bundle)) { - if (fileName.endsWith('.css') && chunk.type === 'asset') { - cssContent = chunk.source as string; - break; - } - } + return { + name: "inject-css-content", + enforce: "post", + generateBundle(_, bundle) { + // Find the generated CSS file + for (const [fileName, chunk] of Object.entries(bundle)) { + if (fileName.endsWith(".css") && chunk.type === "asset") { + cssContent = chunk.source as string + break + } + } - // Replace placeholder in JS files with actual CSS content - for (const [fileName, chunk] of Object.entries(bundle)) { - if ((fileName.endsWith('.js') || fileName.endsWith('.cjs')) && chunk.type === 'chunk') { - // Escape the CSS for embedding in JS string - const escapedCss = JSON.stringify(cssContent); - chunk.code = chunk.code.replace( - /__MEMORY_GRAPH_CSS__/g, - escapedCss - ); - } - } - } - }; + // Replace placeholder in JS files with actual CSS content + for (const [fileName, chunk] of Object.entries(bundle)) { + if ( + (fileName.endsWith(".js") || fileName.endsWith(".cjs")) && + chunk.type === "chunk" + ) { + // Escape the CSS for embedding in JS string + const escapedCss = JSON.stringify(cssContent) + chunk.code = chunk.code.replace(/__MEMORY_GRAPH_CSS__/g, escapedCss) + } + } + }, + } } // https://vitejs.dev/config/ export default defineConfig({ - plugins: [react(), vanillaExtractPlugin(), injectCssPlugin()], - build: { - lib: { - entry: resolve(__dirname, 'src/index.tsx'), - name: 'MemoryGraph', - formats: ['es', 'cjs'], - fileName: (format) => { - if (format === 'es') return 'memory-graph.js' - if (format === 'cjs') return 'memory-graph.cjs' - return 'memory-graph.js' - } - }, - rollupOptions: { - // Externalize only peer dependencies (React) - external: ['react', 'react-dom', 'react/jsx-runtime'], - output: { - // Provide global variables for UMD build (if needed later) - globals: { - react: 'React', - 'react-dom': 'ReactDOM', - 'react/jsx-runtime': 'react/jsx-runtime' - }, - // Preserve CSS as separate file (for manual import fallback) - assetFileNames: (assetInfo) => { - // Vanilla-extract generates index.css, rename to memory-graph.css - if (assetInfo.name === 'index.css' || assetInfo.name === 'style.css') { - return 'memory-graph.css' - } - return assetInfo.name || 'asset' - }, - // Don't preserve modules - bundle everything except externals - preserveModules: false, - } - }, - // Ensure CSS is extracted - cssCodeSplit: false, - // Generate sourcemaps for debugging - sourcemap: true, - // Optimize deps - minify: 'esbuild', - target: 'esnext' - }, - resolve: { - alias: { - '@': resolve(__dirname, './src') - } - } + plugins: [react(), vanillaExtractPlugin(), injectCssPlugin()], + build: { + lib: { + entry: resolve(__dirname, "src/index.tsx"), + name: "MemoryGraph", + formats: ["es", "cjs"], + fileName: (format) => { + if (format === "es") return "memory-graph.js" + if (format === "cjs") return "memory-graph.cjs" + return "memory-graph.js" + }, + }, + rollupOptions: { + // Externalize only peer dependencies (React) + external: ["react", "react-dom", "react/jsx-runtime"], + output: { + // Provide global variables for UMD build (if needed later) + globals: { + react: "React", + "react-dom": "ReactDOM", + "react/jsx-runtime": "react/jsx-runtime", + }, + // Preserve CSS as separate file (for manual import fallback) + assetFileNames: (assetInfo) => { + // Vanilla-extract generates index.css, rename to memory-graph.css + if ( + assetInfo.name === "index.css" || + assetInfo.name === "style.css" + ) { + return "memory-graph.css" + } + return assetInfo.name || "asset" + }, + // Don't preserve modules - bundle everything except externals + preserveModules: false, + }, + }, + // Ensure CSS is extracted + cssCodeSplit: false, + // Generate sourcemaps for debugging + sourcemap: true, + // Optimize deps + minify: "esbuild", + target: "esnext", + }, + resolve: { + alias: { + "@": resolve(__dirname, "./src"), + }, + }, }) |