diff --git a/apps/mcp/src/client.ts b/apps/mcp/src/client.ts index 2924f6fb4..ace65912f 100644 --- a/apps/mcp/src/client.ts +++ b/apps/mcp/src/client.ts @@ -45,52 +45,41 @@ export interface Project { documentCount?: number } -// Graph API types -export interface GraphApiMemory { +// Documents API types +export interface DocumentMemoryEntry { id: string memory: string - isStatic: boolean - isLatest: boolean - isForgotten: boolean - forgetAfter: string | null - version: number - parentMemoryId: string | null + spaceId: string + isStatic?: boolean + isLatest?: boolean + isForgotten?: boolean + forgetAfter?: string | null + forgetReason?: string | null + version?: number + parentMemoryId?: string | null + rootMemoryId?: string | null createdAt: string updatedAt: string } -export interface GraphApiDocument { +export interface DocumentWithMemories { id: string title: string | null - summary: string | null - documentType: string + summary?: string | null + type: string createdAt: string updatedAt: string - x: number - y: number - memories: GraphApiMemory[] + memoryEntries: DocumentMemoryEntry[] } -export interface GraphApiEdge { - source: string - target: string - similarity: number -} - -export interface GraphViewportResponse { - documents: GraphApiDocument[] - edges: GraphApiEdge[] - viewport: { minX: number; maxX: number; minY: number; maxY: number } - totalCount: number -} - -export interface GraphBoundsResponse { - bounds: { - minX: number - maxX: number - minY: number - maxY: number - } | null +export interface DocumentsApiResponse { + documents: DocumentWithMemories[] + pagination: { + currentPage: number + limit: number + totalItems: number + totalPages: number + } } export function getMemoryText(m: Memory): string { @@ -171,9 +160,13 @@ export class SupermemoryClient { message: `Successfully forgot memory (exact match) with ID: ${result.id}`, containerTag: this.containerTag, } - } catch (error: any) { + } catch (error: unknown) { // If not 404, it's a real error - re-throw it - if (error?.status !== 404) { + const status = + error && typeof error === "object" && "status" in error + ? (error as Record).status + : undefined + if (status !== 404) { throw error } // Otherwise continue to semantic search fallback @@ -332,53 +325,33 @@ export class SupermemoryClient { } } - // Fetch graph bounds for coordinate range - async getGraphBounds(containerTags?: string[]): Promise { - try { - const params = new URLSearchParams() - if (containerTags?.length) { - params.set("containerTags", JSON.stringify(containerTags)) - } - const url = `${this.apiUrl}/v3/graph/bounds${params.toString() ? `?${params}` : ""}` - const response = await fetch(url, { - method: "GET", - headers: { - Authorization: `Bearer ${this.bearerToken}`, - "Content-Type": "application/json", - }, - }) - if (!response.ok) { - throw Object.assign(new Error("Failed to fetch graph bounds"), { - status: response.status, - }) - } - return (await response.json()) as GraphBoundsResponse - } catch (error) { - this.handleError(error) - } - } - - // Fetch graph data for a viewport region - async getGraphViewport( - viewport: { minX: number; maxX: number; minY: number; maxY: number }, + // Fetch documents with their memory entries + async getDocuments( containerTags?: string[], + page = 1, limit = 200, - ): Promise { + ): Promise { try { - const response = await fetch(`${this.apiUrl}/v3/graph/viewport`, { + const response = await fetch(`${this.apiUrl}/v3/documents/documents`, { method: "POST", headers: { Authorization: `Bearer ${this.bearerToken}`, "Content-Type": "application/json", }, - body: JSON.stringify({ viewport, containerTags, limit }), + body: JSON.stringify({ + page, + limit, + sort: "createdAt", + order: "desc", + containerTags, + }), }) if (!response.ok) { - throw Object.assign(new Error("Failed to fetch graph viewport"), { + throw Object.assign(new Error("Failed to fetch documents"), { status: response.status, }) } - return (await response.json()) as GraphViewportResponse + return (await response.json()) as DocumentsApiResponse } catch (error) { this.handleError(error) } diff --git a/apps/mcp/src/server.ts b/apps/mcp/src/server.ts index 17f8ecd4f..d387fde94 100644 --- a/apps/mcp/src/server.ts +++ b/apps/mcp/src/server.ts @@ -311,21 +311,14 @@ export class SupermemoryMCP extends McpAgent { ? [effectiveContainerTag] : undefined - const [bounds, viewport] = await Promise.all([ - client.getGraphBounds(containerTags), - client.getGraphViewport( - { minX: 0, maxX: 1000, minY: 0, maxY: 1000 }, - containerTags, - 200, - ), - ]) - - const memoryCount = viewport.documents.reduce( - (sum, d) => sum + d.memories.length, + const result = await client.getDocuments(containerTags, 1, 200) + + const memoryCount = result.documents.reduce( + (sum, d) => sum + d.memoryEntries.length, 0, ) const textParts = [ - `Memory Graph: ${viewport.documents.length} documents, ${memoryCount} memories, ${viewport.edges.length} connections`, + `Memory Graph: ${result.documents.length} documents, ${memoryCount} memories`, ] if (effectiveContainerTag) { textParts.push(`Project: ${effectiveContainerTag}`) @@ -335,10 +328,8 @@ export class SupermemoryMCP extends McpAgent { content: [{ type: "text" as const, text: textParts.join(". ") }], structuredContent: { containerTag: effectiveContainerTag, - bounds: bounds.bounds, - documents: viewport.documents, - edges: viewport.edges, - totalCount: viewport.totalCount, + documents: result.documents, + totalCount: result.pagination.totalItems, }, } } catch (error) { @@ -359,20 +350,15 @@ export class SupermemoryMCP extends McpAgent { }, ) - // App-only tool for the UI to fetch additional graph data + // App-only tool for the UI to fetch additional documents (pagination) registerAppTool( this.server, "fetch-graph-data", { - description: "Fetch graph data for a viewport region", + description: "Fetch documents with memories for graph display", inputSchema: z.object({ containerTag: z.string().optional(), - viewport: z.object({ - minX: z.number(), - maxX: z.number(), - minY: z.number(), - maxY: z.number(), - }), + page: z.number().optional().default(1), limit: z.number().optional().default(200), }), _meta: { @@ -385,12 +371,7 @@ export class SupermemoryMCP extends McpAgent { // @ts-expect-error - zod type inference issue with MCP SDK async (args: { containerTag?: string - viewport: { - minX: number - maxX: number - minY: number - maxY: number - } + page?: number limit?: number }) => { try { @@ -400,9 +381,9 @@ export class SupermemoryMCP extends McpAgent { const containerTags = effectiveContainerTag ? [effectiveContainerTag] : undefined - const data = await client.getGraphViewport( - args.viewport, + const data = await client.getDocuments( containerTags, + args.page, args.limit, ) diff --git a/apps/mcp/src/ui/mcp-app.ts b/apps/mcp/src/ui/mcp-app.ts index 1802cf82c..6939edcc8 100644 --- a/apps/mcp/src/ui/mcp-app.ts +++ b/apps/mcp/src/ui/mcp-app.ts @@ -27,13 +27,18 @@ interface GraphApiMemory { id: string memory: string isStatic: boolean + spaceId: string isLatest: boolean isForgotten: boolean forgetAfter: string | null + forgetReason: string | null version: number parentMemoryId: string | null + rootMemoryId: string | null createdAt: string updatedAt: string + relation?: "updates" | "extends" | "derives" | null + memoryRelations?: Record | null } interface GraphApiDocument { @@ -43,22 +48,12 @@ interface GraphApiDocument { documentType: string createdAt: string updatedAt: string - x: number - y: number memories: GraphApiMemory[] } -interface GraphApiEdge { - source: string - target: string - similarity: number -} - interface ToolResultData { containerTag?: string - bounds: { minX: number; maxX: number; minY: number; maxY: number } | null documents: GraphApiDocument[] - edges: GraphApiEdge[] totalCount: number } @@ -91,8 +86,7 @@ type GraphNode = MemoryNode | DocumentNode interface GraphLink extends LinkObject { source: string | GraphNode target: string | GraphNode - edgeType: "doc-memory" | "version" | "similarity" - similarity?: number + edgeType: "derives" | "updates" | "extends" } // ============================================================================= @@ -107,14 +101,14 @@ const MEMORY_BORDER = { const EDGE_COLORS = { dark: { - "doc-memory": "#4A5568", - version: "#8B5CF6", - similarity: "#00D4B8", + derives: "#38BDF8", + updates: "#A78BFA", + extends: "#2DD4BF", }, light: { - "doc-memory": "#A0AEC0", - version: "#8B5CF6", - similarity: "#0D9488", + derives: "#7DD3FC", + updates: "#A78BFA", + extends: "#5EEAD4", }, } @@ -157,33 +151,20 @@ function getMemoryBorderColor(mem: GraphApiMemory): string { return MEMORY_BORDER.default } -function normalizeDocCoordinates( - documents: GraphApiDocument[], -): GraphApiDocument[] { - if (documents.length <= 1) return documents - - let minX = Number.POSITIVE_INFINITY - let maxX = Number.NEGATIVE_INFINITY - let minY = Number.POSITIVE_INFINITY - let maxY = Number.NEGATIVE_INFINITY - for (const doc of documents) { - minX = Math.min(minX, doc.x) - maxX = Math.max(maxX, doc.x) - minY = Math.min(minY, doc.y) - maxY = Math.max(maxY, doc.y) +/** Simple hash to get deterministic initial positions from doc ID */ +function hashCode(s: string): number { + let h = 0 + for (let i = 0; i < s.length; i++) { + h = (Math.imul(31, h) + s.charCodeAt(i)) | 0 } + return h +} - const rangeX = maxX - minX || 1 - const rangeY = maxY - minY || 1 - // Small spread so documents start near each other. - // The force simulation will naturally separate them. - const SPREAD = 50 - - return documents.map((doc) => ({ - ...doc, - x: ((doc.x - minX) / rangeX - 0.5) * SPREAD, - y: ((doc.y - minY) / rangeY - 0.5) * SPREAD, - })) +function initialPosition(id: string, spread: number): { x: number; y: number } { + const h = hashCode(id) + const angle = ((h & 0xffff) / 0xffff) * Math.PI * 2 + const radius = (((h >>> 16) & 0xffff) / 0xffff) * spread + return { x: Math.cos(angle) * radius, y: Math.sin(angle) * radius } } function transformData(data: ToolResultData): { @@ -192,11 +173,18 @@ function transformData(data: ToolResultData): { } { const nodes: GraphNode[] = [] const links: GraphLink[] = [] - const nodeIds = new Set() + const SPREAD = 50 - const normalizedDocs = normalizeDocCoordinates(data.documents) + // Pre-populate all node IDs so edge targets are always resolvable + // regardless of iteration order. + const nodeIds = new Set() + for (const doc of data.documents) { + nodeIds.add(doc.id) + for (const mem of doc.memories) nodeIds.add(mem.id) + } - for (const doc of normalizedDocs) { + for (const doc of data.documents) { + const pos = initialPosition(doc.id, SPREAD) nodes.push({ id: doc.id, nodeType: "document", @@ -205,10 +193,9 @@ function transformData(data: ToolResultData): { docType: doc.documentType, createdAt: doc.createdAt, memoryCount: doc.memories.length, - x: doc.x, - y: doc.y, + x: pos.x, + y: pos.y, } as DocumentNode) - nodeIds.add(doc.id) const memCount = doc.memories.length for (let i = 0; i < memCount; i++) { @@ -227,34 +214,38 @@ function transformData(data: ToolResultData): { parentMemoryId: mem.parentMemoryId, createdAt: mem.createdAt, borderColor: getMemoryBorderColor(mem), - x: doc.x + Math.cos(angle) * CLUSTER_SPREAD, - y: doc.y + Math.sin(angle) * CLUSTER_SPREAD, + x: pos.x + Math.cos(angle) * CLUSTER_SPREAD, + y: pos.y + Math.sin(angle) * CLUSTER_SPREAD, } as MemoryNode) - nodeIds.add(mem.id) - - // Doc-memory link - links.push({ source: doc.id, target: mem.id, edgeType: "doc-memory" }) - - // Version chain link - if (mem.parentMemoryId && nodeIds.has(mem.parentMemoryId)) { - links.push({ - source: mem.parentMemoryId, - target: mem.id, - edgeType: "version", - }) + + // Derives link (doc -> memory) + links.push({ source: doc.id, target: mem.id, edgeType: "derives" }) + + // Memory-to-memory relation edges from backend data. + // Uses memoryRelations as primary source, falls back to parentMemoryId. + // Keep in sync with packages/memory-graph/src/hooks/use-graph-data.ts + let relations: Record = {} + if ( + // Defensive: data comes from structuredContent cast, may be unexpected type + mem.memoryRelations && + typeof mem.memoryRelations === "object" && + Object.keys(mem.memoryRelations).length > 0 + ) { + relations = mem.memoryRelations + } else if (mem.parentMemoryId) { + relations = { [mem.parentMemoryId]: "updates" } } - } - } - // Similarity edges from API - for (const edge of data.edges) { - if (nodeIds.has(edge.source) && nodeIds.has(edge.target)) { - links.push({ - source: edge.source, - target: edge.target, - edgeType: "similarity", - similarity: edge.similarity, - }) + for (const [targetId, relationType] of Object.entries(relations)) { + if (!nodeIds.has(targetId)) continue + const edgeType = + relationType === "updates" || + relationType === "extends" || + relationType === "derives" + ? relationType + : "updates" + links.push({ source: targetId, target: mem.id, edgeType }) + } } } @@ -317,7 +308,7 @@ function drawDocumentNode( // ============================================================================= function getLinkColor(link: GraphLink): string { const palette = isDark ? EDGE_COLORS.dark : EDGE_COLORS.light - return palette[link.edgeType] || palette["doc-memory"] + return palette[link.edgeType] || palette["derives"] } const graph = new ForceGraph(container) @@ -370,18 +361,17 @@ const graph = new ForceGraph(container) }, ) .linkWidth((link: GraphLink) => { - if (link.edgeType === "version") return 2 - if (link.edgeType === "similarity") - return 0.5 + (link.similarity || 0) * 1.5 + if (link.edgeType === "updates") return 2 + if (link.edgeType === "extends") return 0.5 return 1 }) .linkColor(getLinkColor) .linkLineDash((link: GraphLink) => { - if (link.edgeType === "similarity") return [4, 2] + if (link.edgeType === "extends") return [4, 2] return null as unknown as number[] }) .linkDirectionalArrowLength((link: GraphLink) => - link.edgeType === "version" ? 4 : 0, + link.edgeType === "updates" ? 4 : 0, ) .linkDirectionalArrowRelPos(1) .onNodeClick(handleNodeClick) @@ -395,11 +385,11 @@ const graph = new ForceGraph(container) .d3Force( "link", forceLink() - .distance((l: GraphLink) => (l.edgeType === "doc-memory" ? 40 : 80)) + .distance((l: GraphLink) => (l.edgeType === "derives" ? 40 : 80)) .strength((l: GraphLink) => { - if (l.edgeType === "doc-memory") return 0.8 - if (l.edgeType === "version") return 1.0 - return (l.similarity || 0.3) * 0.3 + if (l.edgeType === "derives") return 0.8 + if (l.edgeType === "updates") return 1.0 + return 0.15 // extends }), ) .d3Force("collide", forceCollide(18)) diff --git a/apps/memory-graph-playground/next-env.d.ts b/apps/memory-graph-playground/next-env.d.ts index 9edff1c7c..c4b7818fb 100644 --- a/apps/memory-graph-playground/next-env.d.ts +++ b/apps/memory-graph-playground/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/memory-graph-playground/src/app/page.tsx b/apps/memory-graph-playground/src/app/page.tsx index 0682905a1..68a6f9450 100644 --- a/apps/memory-graph-playground/src/app/page.tsx +++ b/apps/memory-graph-playground/src/app/page.tsx @@ -1,10 +1,13 @@ "use client" -import { useState, useCallback } from "react" +import { useState, useCallback, useMemo } from "react" import { MemoryGraph, type DocumentWithMemories, + type GraphApiDocument, + type GraphApiMemory, } from "@supermemory/memory-graph" +import { generateMockGraphData } from "@supermemory/memory-graph/mock-data" interface DocumentsResponse { documents: DocumentWithMemories[] @@ -16,24 +19,50 @@ interface DocumentsResponse { } } +/** Convert the external API format to the internal graph format */ +function toGraphDocuments(docs: DocumentWithMemories[]): GraphApiDocument[] { + return docs.map((doc) => ({ + id: doc.id, + title: doc.title, + summary: doc.summary ?? null, + documentType: doc.documentType, + createdAt: doc.createdAt, + updatedAt: doc.updatedAt, + memories: doc.memories.map( + (mem): GraphApiMemory => ({ + id: mem.id, + memory: mem.content, + isStatic: mem.isStatic ?? false, + spaceId: mem.spaceId ?? "", + isLatest: mem.isLatest ?? true, + isForgotten: mem.isForgotten ?? false, + forgetAfter: mem.forgetAfter ?? null, + forgetReason: mem.forgetReason ?? null, + version: mem.version ?? 1, + parentMemoryId: mem.parentMemoryId ?? null, + rootMemoryId: mem.rootMemoryId ?? null, + createdAt: mem.createdAt, + updatedAt: mem.updatedAt, + }), + ), + })) +} + export default function Home() { const [apiKey, setApiKey] = useState("") const [documents, setDocuments] = useState([]) const [isLoading, setIsLoading] = useState(false) - const [isLoadingMore, setIsLoadingMore] = useState(false) const [error, setError] = useState(null) - const [hasMore, setHasMore] = useState(false) - const [currentPage, setCurrentPage] = useState(0) const [showGraph, setShowGraph] = useState(false) - - // State for controlled space selection - const [selectedSpace, setSelectedSpace] = useState("all") + const [stressTestCount, setStressTestCount] = useState(0) // State for slideshow const [isSlideshowActive, setIsSlideshowActive] = useState(false) - const [currentSlideshowNode, setCurrentSlideshowNode] = useState< - string | null - >(null) + + // Mock data for stress testing + const [mockData, setMockData] = useState<{ + documents: GraphApiDocument[] + } | null>(null) const PAGE_SIZE = 500 @@ -43,8 +72,6 @@ export default function Home() { if (page === 1) { setIsLoading(true) - } else { - setIsLoadingMore(true) } setError(null) @@ -76,43 +103,37 @@ export default function Home() { setDocuments(data.documents) } - setCurrentPage(data.pagination.currentPage) - setHasMore(data.pagination.currentPage < data.pagination.totalPages) setShowGraph(true) + setMockData(null) + setStressTestCount(0) } catch (err) { setError(err instanceof Error ? err : new Error("Unknown error")) } finally { setIsLoading(false) - setIsLoadingMore(false) } }, [apiKey], ) - const loadMoreDocuments = useCallback(async () => { - if (hasMore && !isLoadingMore) { - await fetchDocuments(currentPage + 1, true) - } - }, [hasMore, isLoadingMore, currentPage, fetchDocuments]) - const handleSubmit = (e: React.FormEvent) => { e.preventDefault() if (apiKey) { setDocuments([]) - setCurrentPage(0) - setSelectedSpace("all") fetchDocuments(1) } } - // Handle space change - const handleSpaceChange = useCallback((spaceId: string) => { - setSelectedSpace(spaceId) - }, []) - - // Reset to defaults - const handleReset = () => { - setSelectedSpace("all") + const handleStressTest = (count: number) => { + const data = generateMockGraphData({ + documentCount: count, + memoriesPerDoc: [2, 5], + seed: 12345, + }) + setMockData({ documents: data.documents }) + setDocuments([]) + setStressTestCount(count) + setShowGraph(true) + setError(null) } // Toggle slideshow @@ -122,16 +143,22 @@ export default function Home() { // Handle slideshow node change const handleSlideshowNodeChange = useCallback((nodeId: string | null) => { - // Track which node is being shown in slideshow - setCurrentSlideshowNode(nodeId) console.log("Slideshow showing node:", nodeId) }, []) - // Handle slideshow stop (when user clicks outside) + // Handle slideshow stop const handleSlideshowStop = useCallback(() => { setIsSlideshowActive(false) }, []) + // Convert real documents to graph format + const graphDocuments = useMemo(() => { + if (mockData) return mockData.documents + return toGraphDocuments(documents) + }, [documents, mockData]) + + const displayCount = mockData ? stressTestCount : documents.length + return (
{/* Header */} @@ -165,68 +192,65 @@ export default function Home() {
- {/* State Display Panel - For Testing */} - {showGraph && ( -
-
-
-
- Selected Space: - {selectedSpace} -
-
- Documents: - - {documents.length} - -
+ {/* Controls Panel */} +
+
+
+
+ Documents: + {displayCount}
-
+ {stressTestCount > 0 && ( + + Stress Test Mode + + )} +
+
+ {/* Stress test buttons */} + Stress Test: + {[50, 100, 200, 500].map((count) => ( + ))} +
+ -
- -
+ + Slideshow +
- )} +
{/* Main content */}
@@ -239,6 +263,7 @@ export default function Home() { fill="none" viewBox="0 0 24 24" stroke="currentColor" + aria-hidden="true" >

- Enter your Supermemory API key above to visualize your memory - graph. + Enter your API key above, or click a stress test button to + generate mock data.

Features to test:

    -
  • ✨ Search and filter by spaces
  • -
  • ✨ Arrow key navigation in spaces dropdown
  • Pan and zoom the graph
  • Click on nodes to see details
  • Drag nodes around
  • -
  • Filter by space
  • -
  • Pagination loads more documents
  • +
  • Arrow key navigation
  • +
  • Stress test with 50-500 documents
  • +
  • FPS counter (shown during stress tests)
@@ -274,20 +298,12 @@ export default function Home() { ) : (
0} isSlideshowActive={isSlideshowActive} onSlideshowNodeChange={handleSlideshowNodeChange} onSlideshowStop={handleSlideshowStop} diff --git a/apps/web/components/graph-layout-view.tsx b/apps/web/components/graph-layout-view.tsx index 180ce6424..449617171 100644 --- a/apps/web/components/graph-layout-view.tsx +++ b/apps/web/components/graph-layout-view.tsx @@ -3,7 +3,7 @@ import { memo, useCallback, useRef } from "react" import { useQueryState } from "nuqs" import Image from "next/image" -import { MemoryGraph } from "./memory-graph/memory-graph" +import { MemoryGraph } from "./memory-graph" import { useProject } from "@/stores" import { useGraphHighlights } from "@/stores/highlights" import { Button } from "@ui/components/button" diff --git a/apps/web/components/memory-graph/api-types.ts b/apps/web/components/memory-graph/api-types.ts deleted file mode 100644 index 0ebc86ee9..000000000 --- a/apps/web/components/memory-graph/api-types.ts +++ /dev/null @@ -1,79 +0,0 @@ -// Standalone TypeScript types for Memory Graph -// 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 | 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 | null - spaceContainerTag?: string | null - // Version chain fields - updatesMemoryId?: string | null - nextVersionId?: string | null - relation?: "updates" | "extends" | "derives" | null - // Memory status fields - isForgotten?: boolean - forgetAfter?: Date | string | null - isLatest?: boolean - // Space/container fields - spaceId?: string | null - // Legacy fields - memory?: string | null - memoryRelations?: Array<{ - 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 | null - processingMetadata?: Record | 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[] - pagination: { - currentPage: number - limit: number - totalItems: number - totalPages: number - } -} diff --git a/apps/web/components/memory-graph/canvas/renderer.ts b/apps/web/components/memory-graph/canvas/renderer.ts deleted file mode 100644 index 7d9cc3c31..000000000 --- a/apps/web/components/memory-graph/canvas/renderer.ts +++ /dev/null @@ -1,681 +0,0 @@ -import type { ViewportState } from "./viewport" -import type { GraphNode, GraphEdge, DocumentNodeData } from "../types" - -export interface RenderState { - selectedNodeId: string | null - hoveredNodeId: string | null - highlightIds: Set - dimProgress: number -} - -export function renderFrame( - ctx: CanvasRenderingContext2D, - nodes: GraphNode[], - edges: GraphEdge[], - viewport: ViewportState, - width: number, - height: number, - state: RenderState, - nodeMap: Map, -): void { - ctx.clearRect(0, 0, width, height) - drawDocDocLines(ctx, nodes, viewport, width, height) - drawEdges(ctx, edges, viewport, width, height, state, nodeMap) - drawNodes(ctx, nodes, viewport, width, height, state) -} - -// Connect each visible doc to its 2 nearest neighbors -function drawDocDocLines( - ctx: CanvasRenderingContext2D, - nodes: GraphNode[], - viewport: ViewportState, - width: number, - height: number, -): void { - const docs: { x: number; y: number }[] = [] - for (const n of nodes) { - if (n.type !== "document") continue - const s = viewport.worldToScreen(n.x, n.y) - if (s.x > -100 && s.x < width + 100 && s.y > -100 && s.y < height + 100) { - docs.push(s) - } - } - if (docs.length < 2) return - - ctx.strokeStyle = "#8DA3F4" - ctx.lineWidth = 1 - ctx.globalAlpha = 0.3 - ctx.setLineDash([4, 6]) - ctx.beginPath() - - // Deduplicate: only draw line when i < neighbor index - for (let i = 0; i < docs.length; i++) { - const d = docs[i]! - let best1 = -1 - let best2 = -1 - let dist1 = Number.POSITIVE_INFINITY - let dist2 = Number.POSITIVE_INFINITY - - for (let j = 0; j < docs.length; j++) { - if (j === i) continue - const dx = docs[j]!.x - d.x - const dy = docs[j]!.y - d.y - const dist = dx * dx + dy * dy - if (dist < dist1) { - best2 = best1 - dist2 = dist1 - best1 = j - dist1 = dist - } else if (dist < dist2) { - best2 = j - dist2 = dist - } - } - - if (best1 >= 0 && i < best1) { - ctx.moveTo(d.x, d.y) - ctx.lineTo(docs[best1]!.x, docs[best1]!.y) - } - if (best2 >= 0 && i < best2) { - ctx.moveTo(d.x, d.y) - ctx.lineTo(docs[best2]!.x, docs[best2]!.y) - } - } - - ctx.stroke() - ctx.setLineDash([]) - ctx.globalAlpha = 1 -} - -// --- Edges --- - -const EDGE_STYLE: Record = { - "doc-memory": { color: "#4A5568", width: 1.5 }, - version: { color: "#8B5CF6", width: 2 }, -} - -const SIM_STRONG = { color: "#00D4B8", width: 2 } as const -const SIM_MEDIUM = { color: "#6B8FBF", width: 1.5 } as const -const SIM_WEAK = { color: "#4A6A8A", width: 1 } as const - -function edgeStyle(edge: GraphEdge): { color: string; width: number } { - const preset = EDGE_STYLE[edge.edgeType] - if (preset) return preset - if (edge.similarity >= 0.9) return SIM_STRONG - if (edge.similarity >= 0.8) return SIM_MEDIUM - return SIM_WEAK -} - -// Unique key for batching: "color|width" -function batchKey(style: { color: string; width: number }): string { - return `${style.color}|${style.width}` -} - -interface PreparedEdge { - startX: number - startY: number - endX: number - endY: number - connected: boolean - style: { color: string; width: number } - isVersion: boolean - arrowSize: number -} - -function drawEdges( - ctx: CanvasRenderingContext2D, - edges: GraphEdge[], - viewport: ViewportState, - width: number, - height: number, - state: RenderState, - nodeMap: Map, -): void { - const margin = 100 - const hasDim = state.selectedNodeId !== null && state.dimProgress > 0 - - // Prepare all visible edges - const prepared: PreparedEdge[] = [] - - for (const edge of edges) { - const src = - typeof edge.source === "string" ? nodeMap.get(edge.source) : edge.source - const tgt = - typeof edge.target === "string" ? nodeMap.get(edge.target) : edge.target - if (!src || !tgt) continue - - // Skip doc-memory edges when memory dots are too small to see connections - if (edge.edgeType === "doc-memory") { - const mem = src.type === "memory" ? src : tgt - if (mem.size * viewport.zoom < 3) continue - } - - const s = viewport.worldToScreen(src.x, src.y) - const t = viewport.worldToScreen(tgt.x, tgt.y) - - if ( - (s.x < -margin && t.x < -margin) || - (s.x > width + margin && t.x > width + margin) || - (s.y < -margin && t.y < -margin) || - (s.y > height + margin && t.y > height + margin) - ) - continue - - const dx = t.x - s.x - const dy = t.y - s.y - const dist = Math.sqrt(dx * dx + dy * dy) - if (dist < 1) continue - - const ux = dx / dist - const uy = dy / dist - const sr = src.size * viewport.zoom * 0.5 - const tr = tgt.size * viewport.zoom * 0.5 - - let connected = true - if (hasDim) { - const srcId = - typeof edge.source === "string" ? edge.source : edge.source.id - const tgtId = - typeof edge.target === "string" ? edge.target : edge.target.id - connected = - srcId === state.selectedNodeId || tgtId === state.selectedNodeId - } - - prepared.push({ - startX: s.x + ux * sr, - startY: s.y + uy * sr, - endX: t.x - ux * tr, - endY: t.y - uy * tr, - connected, - style: edgeStyle(edge), - isVersion: edge.edgeType === "version", - arrowSize: - edge.edgeType === "version" ? Math.max(6, 8 * viewport.zoom) : 0, - }) - } - - // Batch by style + dim state: group into "key|connected" and "key|dimmed" - const batches = new Map() - for (const e of prepared) { - const dimKey = hasDim ? (e.connected ? "|c" : "|d") : "" - const key = batchKey(e.style) + dimKey - let batch = batches.get(key) - if (!batch) { - batch = [] - batches.set(key, batch) - } - batch.push(e) - } - - // Draw each batch in a single beginPath/stroke - ctx.setLineDash([]) - for (const [key, batch] of batches) { - const first = batch[0]! - const isDimmed = key.endsWith("|d") - - ctx.globalAlpha = isDimmed ? 1 - state.dimProgress * 0.8 : 1 - ctx.strokeStyle = first.style.color - ctx.lineWidth = first.style.width - - ctx.beginPath() - for (const e of batch) { - ctx.moveTo(e.startX, e.startY) - ctx.lineTo(e.endX, e.endY) - } - ctx.stroke() - - // Arrow heads for version edges (fill calls — unavoidable per-arrow) - const versionEdges = batch.filter((e) => e.isVersion) - if (versionEdges.length > 0) { - ctx.fillStyle = first.style.color - for (const e of versionEdges) { - drawArrowHead(ctx, e.startX, e.startY, e.endX, e.endY, e.arrowSize) - } - } - } - - ctx.globalAlpha = 1 -} - -function drawArrowHead( - ctx: CanvasRenderingContext2D, - fromX: number, - fromY: number, - toX: number, - toY: number, - size: number, -): void { - const angle = Math.atan2(toY - fromY, toX - fromX) - ctx.beginPath() - ctx.moveTo(toX, toY) - ctx.lineTo( - toX - size * Math.cos(angle - Math.PI / 6), - toY - size * Math.sin(angle - Math.PI / 6), - ) - ctx.lineTo( - toX - size * Math.cos(angle + Math.PI / 6), - toY - size * Math.sin(angle + Math.PI / 6), - ) - ctx.closePath() - ctx.fill() -} - -// --- Nodes --- - -function drawNodes( - ctx: CanvasRenderingContext2D, - nodes: GraphNode[], - viewport: ViewportState, - width: number, - height: number, - state: RenderState, -): void { - const margin = 60 - const memDots: { x: number; y: number; r: number; color: string }[] = [] - const docDots: { x: number; y: number; s: number }[] = [] - - for (const node of nodes) { - const screen = viewport.worldToScreen(node.x, node.y) - const screenSize = node.size * viewport.zoom - - // Frustum cull (use at least 2px so tiny nodes aren't culled) - const cullSize = Math.max(screenSize, 2) - if ( - screen.x + cullSize < -margin || - screen.x - cullSize > width + margin || - screen.y + cullSize < -margin || - screen.y - cullSize > height + margin - ) - continue - - const isSelected = node.id === state.selectedNodeId - const isHovered = node.id === state.hoveredNodeId - const isHighlighted = state.highlightIds.has(node.id) - - // LOD: tiny nodes → batched dots (but selected/highlighted always get full detail) - if (screenSize < 8 && !isSelected && !isHovered && !isHighlighted) { - if (node.type === "document") { - docDots.push({ x: screen.x, y: screen.y, s: Math.max(3, screenSize) }) - } else { - memDots.push({ - x: screen.x, - y: screen.y, - r: Math.max(2, screenSize * 0.45), - color: node.borderColor || "#3B73B8", - }) - } - continue - } - - let alpha = 1 - if (state.selectedNodeId && state.dimProgress > 0 && !isSelected) { - alpha = 1 - state.dimProgress * 0.7 - } - ctx.globalAlpha = alpha - - if (node.type === "document") { - drawDocumentNode( - ctx, - screen.x, - screen.y, - screenSize, - node, - isSelected, - isHovered, - isHighlighted, - ) - } else { - drawMemoryNode( - ctx, - screen.x, - screen.y, - screenSize, - node, - isSelected, - isHovered, - isHighlighted, - ) - } - - if (isSelected || isHighlighted) { - drawGlow(ctx, screen.x, screen.y, screenSize, node.type) - } - } - - const dimAlpha = - state.selectedNodeId && state.dimProgress > 0 - ? 1 - state.dimProgress * 0.7 - : 1 - - // Batch: document dots as filled squares - if (docDots.length > 0) { - ctx.fillStyle = "#1B1F24" - ctx.strokeStyle = "#2A2F36" - ctx.lineWidth = 1 - ctx.globalAlpha = dimAlpha - for (const d of docDots) { - const h = d.s * 0.5 - ctx.fillRect(d.x - h, d.y - h, d.s, d.s) - ctx.strokeRect(d.x - h, d.y - h, d.s, d.s) - } - } - - // Batch: memory dots — dark fill, then colored border strokes - if (memDots.length > 0) { - ctx.globalAlpha = dimAlpha - - // Pass 1: all dark fills in one batch - ctx.fillStyle = "#0D2034" - ctx.beginPath() - for (const d of memDots) { - ctx.moveTo(d.x + d.r, d.y) - ctx.arc(d.x, d.y, d.r, 0, Math.PI * 2) - } - ctx.fill() - - // Pass 2: colored strokes grouped by border color - ctx.lineWidth = 1.5 - const byColor = new Map() - for (const d of memDots) { - let batch = byColor.get(d.color) - if (!batch) { - batch = [] - byColor.set(d.color, batch) - } - batch.push(d) - } - for (const [color, batch] of byColor) { - ctx.strokeStyle = color - ctx.beginPath() - for (const d of batch) { - ctx.moveTo(d.x + d.r, d.y) - ctx.arc(d.x, d.y, d.r, 0, Math.PI * 2) - } - ctx.stroke() - } - } - - ctx.globalAlpha = 1 -} - -function drawDocumentNode( - ctx: CanvasRenderingContext2D, - sx: number, - sy: number, - size: number, - node: GraphNode, - isSelected: boolean, - isHovered: boolean, - isHighlighted: boolean, -): void { - const half = size * 0.5 - const cornerR = 8 * (size / 50) - - // Outer rect - ctx.fillStyle = "#1B1F24" - ctx.strokeStyle = - isSelected || isHighlighted ? "#3B73B8" : isHovered ? "#3B73B8" : "#2A2F36" - ctx.lineWidth = isSelected || isHighlighted ? 2 : 1 - roundRect(ctx, sx - half, sy - half, size, size, cornerR) - ctx.fill() - ctx.stroke() - - // Inner rect - const innerSize = size * 0.72 - const innerHalf = innerSize * 0.5 - const innerR = 6 * (size / 50) - ctx.fillStyle = "#13161A" - roundRect(ctx, sx - innerHalf, sy - innerHalf, innerSize, innerSize, innerR) - ctx.fill() - - // Icon - const iconSize = size * 0.35 - const docType = - node.type === "document" ? (node.data as DocumentNodeData).type : "text" - drawDocIcon(ctx, sx, sy, iconSize, docType || "text") -} - -function drawMemoryNode( - ctx: CanvasRenderingContext2D, - sx: number, - sy: number, - size: number, - node: GraphNode, - isSelected: boolean, - isHovered: boolean, - _isHighlighted: boolean, -): void { - const radius = size * 0.5 - - // Fill - ctx.fillStyle = isHovered ? "#112840" : "#0D2034" - drawHexagon(ctx, sx, sy, radius) - ctx.fill() - - // Stroke with time-based border color - const borderColor = node.borderColor || "#3B73B8" - ctx.strokeStyle = isSelected ? "#3B73B8" : borderColor - ctx.lineWidth = isHovered ? 2 : 1.5 - ctx.stroke() -} - -function drawGlow( - ctx: CanvasRenderingContext2D, - sx: number, - sy: number, - size: number, - nodeType: "document" | "memory", -): void { - ctx.strokeStyle = "#3B73B8" - ctx.lineWidth = 2 - ctx.setLineDash([3, 3]) - ctx.globalAlpha = 0.8 - - if (nodeType === "document") { - const glowSize = size * 1.15 - const half = glowSize * 0.5 - const r = 8 * (glowSize / 50) - roundRect(ctx, sx - half, sy - half, glowSize, glowSize, r) - } else { - drawHexagon(ctx, sx, sy, size * 0.5 * 1.15) - } - - ctx.stroke() - ctx.setLineDash([]) - ctx.globalAlpha = 1 -} - -// --- Shapes --- - -function drawHexagon( - ctx: CanvasRenderingContext2D, - cx: number, - cy: number, - radius: number, -): void { - ctx.beginPath() - for (let i = 0; i < 6; i++) { - const angle = (Math.PI / 3) * i - Math.PI / 6 - const x = cx + radius * Math.cos(angle) - const y = cy + radius * Math.sin(angle) - i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y) - } - ctx.closePath() -} - -function roundRect( - ctx: CanvasRenderingContext2D, - x: number, - y: number, - w: number, - h: number, - r: number, -): void { - ctx.beginPath() - ctx.moveTo(x + r, y) - ctx.lineTo(x + w - r, y) - ctx.arcTo(x + w, y, x + w, y + r, r) - ctx.lineTo(x + w, y + h - r) - ctx.arcTo(x + w, y + h, x + w - r, y + h, r) - ctx.lineTo(x + r, y + h) - ctx.arcTo(x, y + h, x, y + h - r, r) - ctx.lineTo(x, y + r) - ctx.arcTo(x, y, x + r, y, r) - ctx.closePath() -} - -// --- Document icons --- - -const ICON_COLOR = "#3B73B8" - -function drawDocIcon( - ctx: CanvasRenderingContext2D, - x: number, - y: number, - size: number, - type: string, -): void { - ctx.save() - ctx.fillStyle = ICON_COLOR - ctx.strokeStyle = ICON_COLOR - ctx.lineWidth = Math.max(1, size / 12) - ctx.lineCap = "round" - ctx.lineJoin = "round" - - switch (type) { - case "webpage": - case "url": - drawGlobeIcon(ctx, x, y, size) - break - case "pdf": - drawTextLabel(ctx, x, y, size, "PDF", 0.35) - break - case "md": - case "markdown": - drawTextLabel(ctx, x, y, size, "MD", 0.3) - break - case "doc": - case "docx": - drawTextLabel(ctx, x, y, size, "DOC", 0.28) - break - case "csv": - drawGridIcon(ctx, x, y, size) - break - case "json": - drawBracesIcon(ctx, x, y, size) - break - case "notion": - case "notion_doc": - drawTextLabel(ctx, x, y, size, "N", 0.4) - break - case "google_doc": - case "google_sheet": - case "google_slide": - drawTextLabel(ctx, x, y, size, "G", 0.4) - break - default: - drawDocOutline(ctx, x, y, size) - break - } - - ctx.restore() -} - -function drawTextLabel( - ctx: CanvasRenderingContext2D, - x: number, - y: number, - size: number, - text: string, - fontRatio: number, -): void { - ctx.font = `bold ${size * fontRatio}px sans-serif` - ctx.textAlign = "center" - ctx.textBaseline = "middle" - ctx.fillText(text, x, y) -} - -function drawGlobeIcon( - ctx: CanvasRenderingContext2D, - x: number, - y: number, - size: number, -): void { - const r = size * 0.4 - ctx.beginPath() - ctx.arc(x, y, r, 0, Math.PI * 2) - ctx.stroke() - ctx.beginPath() - ctx.ellipse(x, y, r * 0.4, r, 0, 0, Math.PI * 2) - ctx.stroke() - ctx.beginPath() - ctx.moveTo(x - r, y) - ctx.lineTo(x + r, y) - ctx.stroke() -} - -function drawGridIcon( - ctx: CanvasRenderingContext2D, - x: number, - y: number, - size: number, -): void { - const w = size * 0.7 - const h = size * 0.7 - ctx.strokeRect(x - w / 2, y - h / 2, w, h) - ctx.beginPath() - ctx.moveTo(x, y - h / 2) - ctx.lineTo(x, y + h / 2) - ctx.moveTo(x - w / 2, y) - ctx.lineTo(x + w / 2, y) - ctx.stroke() -} - -function drawBracesIcon( - ctx: CanvasRenderingContext2D, - x: number, - y: number, - size: number, -): void { - const w = size * 0.6 - const h = size * 0.8 - ctx.beginPath() - ctx.moveTo(x - w / 4, y - h / 2) - ctx.quadraticCurveTo(x - w / 2, y - h / 3, x - w / 2, y) - ctx.quadraticCurveTo(x - w / 2, y + h / 3, x - w / 4, y + h / 2) - ctx.stroke() - ctx.beginPath() - ctx.moveTo(x + w / 4, y - h / 2) - ctx.quadraticCurveTo(x + w / 2, y - h / 3, x + w / 2, y) - ctx.quadraticCurveTo(x + w / 2, y + h / 3, x + w / 4, y + h / 2) - ctx.stroke() -} - -function drawDocOutline( - ctx: CanvasRenderingContext2D, - x: number, - y: number, - size: number, -): void { - const w = size * 0.7 - const h = size * 0.85 - const fold = size * 0.2 - ctx.beginPath() - ctx.moveTo(x - w / 2, y - h / 2) - ctx.lineTo(x + w / 2 - fold, y - h / 2) - ctx.lineTo(x + w / 2, y - h / 2 + fold) - ctx.lineTo(x + w / 2, y + h / 2) - ctx.lineTo(x - w / 2, y + h / 2) - ctx.closePath() - ctx.stroke() - const sp = size * 0.15 - const lw = size * 0.4 - ctx.beginPath() - ctx.moveTo(x - lw / 2, y - sp) - ctx.lineTo(x + lw / 2, y - sp) - ctx.moveTo(x - lw / 2, y) - ctx.lineTo(x + lw / 2, y) - ctx.moveTo(x - lw / 2, y + sp) - ctx.lineTo(x + lw / 2, y + sp) - ctx.stroke() -} diff --git a/apps/web/components/memory-graph/canvas/simulation.ts b/apps/web/components/memory-graph/canvas/simulation.ts deleted file mode 100644 index f0a4088bd..000000000 --- a/apps/web/components/memory-graph/canvas/simulation.ts +++ /dev/null @@ -1,79 +0,0 @@ -import * as d3 from "d3-force" -import type { GraphNode, GraphEdge } from "../types" - -export class ForceSimulation { - private sim: d3.Simulation | null = null - - init(nodes: GraphNode[], edges: GraphEdge[]): void { - this.destroy() - - try { - this.sim = d3 - .forceSimulation(nodes) - .alphaDecay(0.03) - .alphaMin(0.001) - .velocityDecay(0.6) - - this.sim.force( - "link", - d3 - .forceLink(edges) - .id((d) => d.id) - .distance((link) => (link.edgeType === "doc-memory" ? 150 : 300)) - .strength((link) => { - if (link.edgeType === "doc-memory") return 0.8 - if (link.edgeType === "version") return 1.0 - return link.similarity * 0.3 - }), - ) - - this.sim.force("charge", d3.forceManyBody().strength(-1000)) - - this.sim.force( - "collide", - d3 - .forceCollide() - .radius((d) => (d.type === "document" ? 80 : 40)) - .strength(0.7), - ) - - this.sim.force("x", d3.forceX().strength(0.05)) - this.sim.force("y", d3.forceY().strength(0.05)) - - // Pre-settle synchronously, then start the live simulation - this.sim.stop() - this.sim.alpha(1) - for (let i = 0; i < 50; i++) this.sim.tick() - this.sim.alphaTarget(0).restart() - } catch (e) { - console.error("ForceSimulation.init failed:", e) - this.destroy() - } - } - - update(nodes: GraphNode[], edges: GraphEdge[]): void { - if (!this.sim) return - this.sim.nodes(nodes) - const linkForce = this.sim.force>("link") - if (linkForce) linkForce.links(edges) - } - - reheat(): void { - this.sim?.alphaTarget(0.3).restart() - } - - coolDown(): void { - this.sim?.alphaTarget(0) - } - - isActive(): boolean { - return (this.sim?.alpha() ?? 0) > 0.001 - } - - destroy(): void { - if (this.sim) { - this.sim.stop() - this.sim = null - } - } -} diff --git a/apps/web/components/memory-graph/canvas/version-chain.ts b/apps/web/components/memory-graph/canvas/version-chain.ts deleted file mode 100644 index ec151701b..000000000 --- a/apps/web/components/memory-graph/canvas/version-chain.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { GraphApiDocument, GraphApiMemory } from "../types" - -export interface ChainEntry { - id: string - version: number - memory: string - isForgotten: boolean - isLatest: boolean -} - -export class VersionChainIndex { - private memoryMap = new Map() - private cache = new Map() - private lastDocs: GraphApiDocument[] | null = null - - rebuild(documents: GraphApiDocument[]): void { - if (documents === this.lastDocs) return - this.lastDocs = documents - this.memoryMap.clear() - this.cache.clear() - - for (const doc of documents) { - for (const m of doc.memories) { - this.memoryMap.set(m.id, m) - } - } - } - - getChain(memoryId: string): ChainEntry[] | null { - const cached = this.cache.get(memoryId) - if (cached) return cached - - const mem = this.memoryMap.get(memoryId) - if (!mem || mem.version <= 1) return null - - // Walk parentMemoryId up to the root - const chain: ChainEntry[] = [] - const visited = new Set() - let current: GraphApiMemory | undefined = mem - while (current && !visited.has(current.id)) { - visited.add(current.id) - chain.push({ - id: current.id, - version: current.version, - memory: current.memory, - isForgotten: current.isForgotten, - isLatest: current.isLatest, - }) - current = current.parentMemoryId - ? this.memoryMap.get(current.parentMemoryId) - : undefined - } - - chain.reverse() - - // Cache for every member in the chain - for (const entry of chain) { - this.cache.set(entry.id, chain) - } - - return chain - } -} diff --git a/apps/web/components/memory-graph/constants.ts b/apps/web/components/memory-graph/constants.ts deleted file mode 100644 index b879bf1d6..000000000 --- a/apps/web/components/memory-graph/constants.ts +++ /dev/null @@ -1,62 +0,0 @@ -export const colors = { - background: { - primary: "#0f1419", - secondary: "#1a1f29", - accent: "#252a35", - }, - hexagon: { - active: { fill: "#0D2034", stroke: "#3B73B8", strokeWidth: 1.68 }, - inactive: { fill: "#0B1826", stroke: "#3D4857", strokeWidth: 1.4 }, - hovered: { fill: "#112840", stroke: "#4A8AD0", strokeWidth: 2 }, - }, - document: { - outer: { fill: "#1B1F24", stroke: "#2A2F36", radius: 8 }, - inner: { fill: "#13161A", radius: 6 }, - iconColor: "#3B73B8", - }, - text: { - primary: "#ffffff", - secondary: "#e2e8f0", - muted: "#94a3b8", - }, -} - -export const MEMORY_BORDER = { - forgotten: "#EF4444", - expiring: "#F59E0B", - recent: "#10B981", - default: "#3B73B8", -} as const - -export const EDGE_COLORS = { - docMemory: "#4A5568", - similarityStrong: "#00D4B8", - similarityMedium: "#6B8FBF", - similarityWeak: "#4A6A8A", - version: "#8B5CF6", -} as const - -export const FORCE_CONFIG = { - linkStrength: { - docMemory: 0.8, - version: 1.0, - docDocBase: 0.3, - }, - linkDistance: 300, - docMemoryDistance: 150, - chargeStrength: -1000, - collisionRadius: { document: 80, memory: 40 }, - alphaDecay: 0.03, - alphaMin: 0.001, - velocityDecay: 0.6, - alphaTarget: 0.3, -} - -export const GRAPH_SETTINGS = { - console: { initialZoom: 0.8, initialPanX: 0, initialPanY: 0 }, - consumer: { initialZoom: 0.5, initialPanX: 400, initialPanY: 300 }, -} - -export const ANIMATION = { - dimDuration: 1500, -} diff --git a/apps/web/components/memory-graph/graph-canvas.tsx b/apps/web/components/memory-graph/graph-canvas.tsx deleted file mode 100644 index 06b7bc4c3..000000000 --- a/apps/web/components/memory-graph/graph-canvas.tsx +++ /dev/null @@ -1,248 +0,0 @@ -"use client" - -import { memo, useEffect, useLayoutEffect, useRef } from "react" -import type { GraphCanvasProps, GraphNode } from "./types" -import { ViewportState } from "./canvas/viewport" -import { SpatialIndex } from "./canvas/hit-test" -import { InputHandler } from "./canvas/input-handler" -import { renderFrame } from "./canvas/renderer" -import { GRAPH_SETTINGS } from "./constants" - -export const GraphCanvas = memo(function GraphCanvas({ - nodes, - edges, - width, - height, - highlightDocumentIds, - selectedNodeId = null, - onNodeHover, - onNodeClick, - onNodeDragStart, - onNodeDragEnd, - onViewportChange, - canvasRef: externalCanvasRef, - variant = "console", - simulation, - viewportRef: externalViewportRef, -}) { - const internalCanvasRef = useRef(null) - const canvasRef = externalCanvasRef || internalCanvasRef - - // Engine instances — mutable, never trigger re-renders - const viewportRef = useRef(null) - const spatialRef = useRef(new SpatialIndex()) - const inputRef = useRef(null) - const rafRef = useRef(0) - const renderNeeded = useRef(true) - const nodeMapRef = useRef(new Map()) - - // All mutable render state in a single ref — the rAF loop reads from here - const s = useRef({ - nodes, - edges, - width, - height, - selectedNodeId, - hoveredNodeId: null as string | null, - highlightIds: new Set(highlightDocumentIds ?? []), - dimProgress: 0, - dimTarget: selectedNodeId ? 1 : 0, - }) - - // Sync incoming props to mutable state (no re-renders) - s.current.nodes = nodes - s.current.edges = edges - s.current.width = width - s.current.height = height - - // Stable callback refs so InputHandler never needs recreation - const cb = useRef({ - onNodeHover, - onNodeClick, - onNodeDragStart, - onNodeDragEnd, - onViewportChange, - simulation, - }) - cb.current = { - onNodeHover, - onNodeClick, - onNodeDragStart, - onNodeDragEnd, - onViewportChange, - simulation, - } - - // Rebuild nodeMap + spatial index when nodes change - useEffect(() => { - const map = nodeMapRef.current - map.clear() - for (const n of nodes) map.set(n.id, n) - spatialRef.current.rebuild(nodes) - renderNeeded.current = true - }, [nodes]) - - useEffect(() => { - s.current.highlightIds = new Set(highlightDocumentIds ?? []) - renderNeeded.current = true - }, [highlightDocumentIds]) - - useEffect(() => { - s.current.selectedNodeId = selectedNodeId - s.current.dimTarget = selectedNodeId ? 1 : 0 - renderNeeded.current = true - }, [selectedNodeId]) - - // Create viewport + input handler (once per variant) - useLayoutEffect(() => { - const canvas = canvasRef.current - if (!canvas) return - - const cfg = GRAPH_SETTINGS[variant] - const vp = new ViewportState( - cfg.initialPanX, - cfg.initialPanY, - cfg.initialZoom, - ) - viewportRef.current = vp - if (externalViewportRef) { - ;( - externalViewportRef as React.MutableRefObject - ).current = vp - } - - const handler = new InputHandler(canvas, vp, spatialRef.current, { - onNodeHover: (id) => { - s.current.hoveredNodeId = id - cb.current.onNodeHover(id) - renderNeeded.current = true - }, - onNodeClick: (id) => cb.current.onNodeClick(id), - onNodeDragStart: (id, node) => { - cb.current.onNodeDragStart(id) - cb.current.simulation?.reheat() - }, - onNodeDragEnd: () => { - cb.current.onNodeDragEnd() - cb.current.simulation?.coolDown() - }, - onRequestRender: () => { - renderNeeded.current = true - }, - }) - inputRef.current = handler - - return () => handler.destroy() - }, [variant]) - - // High-DPI canvas sizing - const dpr = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1 - - useLayoutEffect(() => { - const canvas = canvasRef.current - if (!canvas || width === 0 || height === 0) return - - const MAX = 16384 - const d = Math.min(MAX / width, MAX / height, dpr) - canvas.style.width = `${width}px` - canvas.style.height = `${height}px` - canvas.width = Math.min(width * d, MAX) - canvas.height = Math.min(height * d, MAX) - - const ctx = canvas.getContext("2d") - if (ctx) { - ctx.scale(d, d) - ctx.imageSmoothingEnabled = true - ctx.imageSmoothingQuality = "high" - } - renderNeeded.current = true - }, [width, height, dpr]) - - // Single render loop — runs for component lifetime, reads everything from refs - useEffect(() => { - let lastReportedZoom = 0 - - const tick = () => { - rafRef.current = requestAnimationFrame(tick) - - const vp = viewportRef.current - const canvas = canvasRef.current - if (!vp || !canvas) return - - const ctx = canvas.getContext("2d") - if (!ctx) return - - const cur = s.current - - // 1. Viewport momentum / spring zoom / lerp pan - const vpMoving = vp.tick() - - // 2. Dim animation (ease toward target) - const dd = cur.dimTarget - cur.dimProgress - let dimming = false - if (Math.abs(dd) > 0.01) { - cur.dimProgress += dd * 0.1 - dimming = true - } else { - cur.dimProgress = cur.dimTarget - } - - // 3. Simulation physics - const simActive = cb.current.simulation?.isActive() ?? false - - // 4. Spatial index rebuild (only when positions actually move) - const spatialChanged = - simActive || inputRef.current?.getDraggingNode() - ? spatialRef.current.rebuild(cur.nodes) - : false - - // Skip frame if nothing changed - if ( - !vpMoving && - !simActive && - !dimming && - !spatialChanged && - !renderNeeded.current - ) - return - renderNeeded.current = false - - // Throttled zoom reporting for NavigationControls - if ( - vpMoving && - cb.current.onViewportChange && - Math.abs(vp.zoom - lastReportedZoom) > 0.005 - ) { - lastReportedZoom = vp.zoom - cb.current.onViewportChange(vp.zoom) - } - - renderFrame( - ctx, - cur.nodes, - cur.edges, - vp, - cur.width, - cur.height, - { - selectedNodeId: cur.selectedNodeId, - hoveredNodeId: cur.hoveredNodeId, - highlightIds: cur.highlightIds, - dimProgress: cur.dimProgress, - }, - nodeMapRef.current, - ) - } - - rafRef.current = requestAnimationFrame(tick) - return () => cancelAnimationFrame(rafRef.current) - }, []) - - return ( - - ) -}) diff --git a/apps/web/components/memory-graph/graph-card.tsx b/apps/web/components/memory-graph/graph-card.tsx index 841039ef4..d6837521b 100644 --- a/apps/web/components/memory-graph/graph-card.tsx +++ b/apps/web/components/memory-graph/graph-card.tsx @@ -85,6 +85,8 @@ function StaticGraphPreview({ height={height} className="absolute inset-0" viewBox={`0 0 ${width} ${height}`} + role="img" + aria-label="Memory graph preview" > {edges.map((e, i) => ( ( ({ containerTags, width = 216, height = 220, className }) => { const { setViewMode } = useViewMode() - const { data, isLoading, error } = useGraphApi({ + const { documents, isLoading, error } = useGraphApi({ containerTags, - limit: 20, enabled: true, }) @@ -139,11 +140,8 @@ export const GraphCard = memo( ) } - const documentCount = data.stats?.documentsWithSpatial ?? 0 - const memoryCount = data.documents.reduce( - (sum, d) => sum + d.memories.length, - 0, - ) + const documentCount = documents.length + const memoryCount = documents.reduce((sum, d) => sum + d.memories.length, 0) return ( - ) -}) - -// Hexagon SVG for memory nodes -const HexagonIcon = memo(function HexagonIcon({ - fill = "#0D2034", - stroke = "#3B73B8", - opacity = 1, - size = 12, -}: { - fill?: string - stroke?: string - opacity?: number - size?: number -}) { - return ( - - ) -}) - -// Document icon (rounded square) -const DocumentIcon = memo(function DocumentIcon() { - return ( -
-
-
- ) -}) - -// Connection icon (graph) -const ConnectionIcon = memo(function ConnectionIcon() { - return ( - - ) -}) - -// Line icon for connections -const LineIcon = memo(function LineIcon({ - color, - dashed = false, -}: { - color: string - dashed?: boolean -}) { - return ( -
-
-
- ) -}) - -// Similarity circle icon -const SimilarityCircle = memo(function SimilarityCircle({ - variant, -}: { - variant: "strong" | "weak" -}) { - return ( -
- ) -}) - -// Accordion row with count -const StatRow = memo(function StatRow({ - icon, - label, - count, - expandable = false, - expanded = false, - onToggle, - children, -}: { - icon: React.ReactNode - label: string - count: number - expandable?: boolean - expanded?: boolean - onToggle?: () => void - children?: React.ReactNode -}) { - return ( -
- - {expandable && expanded && children && ( -
{children}
- )} -
- ) -}) - -// Toggle row for relations/similarity -const ToggleRow = memo(function ToggleRow({ - icon, - label, - checked, - onChange, -}: { - icon: React.ReactNode - label: string - checked: boolean - onChange: (checked: boolean) => void -}) { - return ( -
-
- {icon} - {label} -
- -
- ) -}) - -export const Legend = memo(function Legend({ - variant: _variant = "console", - id, - nodes = [], - edges = [], - isLoading: _isLoading = false, -}: ExtendedLegendProps) { - const isMobile = useIsMobile() - const [isExpanded, setIsExpanded] = useState(false) - const [isInitialized, setIsInitialized] = useState(false) - - // Toggle states for relations - const [showUpdates, setShowUpdates] = useState(true) - const [showExtends, setShowExtends] = useState(true) - const [showInferences, setShowInferences] = useState(false) - - // Toggle states for similarity - const [showStrong, setShowStrong] = useState(true) - const [showWeak, setShowWeak] = useState(true) - - // Expanded accordion states - const [memoriesExpanded, setMemoriesExpanded] = useState(false) - const [documentsExpanded, setDocumentsExpanded] = useState(false) - const [connectionsExpanded, setConnectionsExpanded] = useState(true) - - // Load saved preference on client side - useEffect(() => { - if (!isInitialized) { - const savedState = getCookie("legendCollapsed") - if (savedState === "true") { - setIsExpanded(false) - } else if (savedState === "false") { - setIsExpanded(true) - } else { - // Default: collapsed on mobile, collapsed on desktop too (per Figma) - setIsExpanded(false) - } - setIsInitialized(true) - } - }, [isInitialized]) - - // Save to cookie when state changes - const handleToggleExpanded = (expanded: boolean) => { - setIsExpanded(expanded) - setCookie("legendCollapsed", expanded ? "false" : "true") - } - - // Calculate stats - const memoryCount = nodes.filter((n) => n.type === "memory").length - const documentCount = nodes.filter((n) => n.type === "document").length - const connectionCount = edges.length - - // Hide on mobile - if (isMobile) return null - - return ( -
- - {/* Glass background */} -
- -
- {/* Header - always visible */} - - {isExpanded ? ( - - ) : ( - - )} - - Legend - - - - -
- {/* Main content column */} -
- {/* STATISTICS Section */} -
- - STATISTICS - -
- {/* Memories */} - - } - label="Memories" - count={memoryCount} - expandable - expanded={memoriesExpanded} - onToggle={() => setMemoriesExpanded(!memoriesExpanded)} - > -
-
-
- - - Memory (latest) - -
- 76 -
-
-
- - - Memory (oldest) - -
- 182 -
-
-
-
- -
- - Score - -
- 23 -
-
-
- - - New memory - -
- 17 -
-
-
- - - Expiring soon - -
- 11 -
-
-
-
- -
- - Forgotten - -
- 6 -
-
-
- - {/* Documents */} - } - label="Documents" - count={documentCount} - expandable - expanded={documentsExpanded} - onToggle={() => setDocumentsExpanded(!documentsExpanded)} - /> - - {/* Connections */} - } - label="Connections" - count={connectionCount} - expandable - expanded={connectionsExpanded} - onToggle={() => - setConnectionsExpanded(!connectionsExpanded) - } - > -
-
-
- - - Doc > Memory - -
-
- } - label="Doc similarity" - checked={showStrong} - onChange={setShowStrong} - /> -
-
-
-
- - {/* RELATIONS Section */} -
- - RELATIONS - -
- } - label="Updates" - checked={showUpdates} - onChange={setShowUpdates} - /> - } - label="Extends" - checked={showExtends} - onChange={setShowExtends} - /> - } - label="Inferences" - checked={showInferences} - onChange={setShowInferences} - /> -
-
- - {/* SIMILARITY Section */} -
- - SIMILARITY - -
- } - label="Strong" - checked={showStrong} - onChange={setShowStrong} - /> - } - label="Weak" - checked={showWeak} - onChange={setShowWeak} - /> -
-
-
- - {/* Scrollbar indicator */} -
-
- -
- -
- ) -}) - -Legend.displayName = "Legend" diff --git a/apps/web/components/memory-graph/loading-indicator.tsx b/apps/web/components/memory-graph/loading-indicator.tsx deleted file mode 100644 index 410263043..000000000 --- a/apps/web/components/memory-graph/loading-indicator.tsx +++ /dev/null @@ -1,32 +0,0 @@ -"use client" - -import { GlassMenuEffect } from "@repo/ui/other/glass-effect" -import { Sparkles } from "lucide-react" -import { memo } from "react" -import type { LoadingIndicatorProps } from "./types" - -export const LoadingIndicator = memo( - ({ isLoading, isLoadingMore, totalLoaded, variant = "console" }) => { - if (!isLoading && !isLoadingMore) return null - - return ( -
- {/* Glass effect background */} - - -
-
- - - {isLoading - ? "Loading memory graph..." - : `Loading more documents... (${totalLoaded})`} - -
-
-
- ) - }, -) - -LoadingIndicator.displayName = "LoadingIndicator" diff --git a/apps/web/components/memory-graph/memory-graph-wrapper.tsx b/apps/web/components/memory-graph/memory-graph-wrapper.tsx new file mode 100644 index 000000000..a387f58e0 --- /dev/null +++ b/apps/web/components/memory-graph/memory-graph-wrapper.tsx @@ -0,0 +1,80 @@ +"use client" + +import { useEffect, useRef, useState } from "react" +import { MemoryGraph as MemoryGraphBase } from "@supermemory/memory-graph" +import { useGraphApi } from "./hooks/use-graph-api" + +export interface MemoryGraphWrapperProps { + children?: React.ReactNode + isLoading?: boolean + error?: Error | null + variant?: "console" | "consumer" + legendId?: string + highlightDocumentIds?: string[] + highlightsVisible?: boolean + containerTags?: string[] + documentIds?: string[] + maxNodes?: number + isSlideshowActive?: boolean + onSlideshowNodeChange?: (nodeId: string | null) => void + onSlideshowStop?: () => void + canvasRef?: React.RefObject +} + +export function MemoryGraph({ + children, + isLoading: externalIsLoading = false, + error: externalError = null, + variant = "console", + containerTags, + maxNodes = 200, + canvasRef, + ...rest +}: MemoryGraphWrapperProps) { + const [containerSize, setContainerSize] = useState({ width: 0, height: 0 }) + const containerRef = useRef(null) + + useEffect(() => { + const el = containerRef.current + if (!el) return + const ro = new ResizeObserver(() => { + setContainerSize({ width: el.clientWidth, height: el.clientHeight }) + }) + ro.observe(el) + setContainerSize({ width: el.clientWidth, height: el.clientHeight }) + return () => ro.disconnect() + }, []) + + const { + documents, + isLoading: apiIsLoading, + isLoadingMore, + error: apiError, + hasMore, + loadMore, + totalCount, + } = useGraphApi({ + containerTags, + enabled: containerSize.width > 0 && containerSize.height > 0, + }) + + return ( +
+ loadMore() : undefined} + hasMore={hasMore} + error={externalError || apiError} + variant={variant} + maxNodes={maxNodes} + canvasRef={canvasRef} + totalCount={totalCount} + {...rest} + > + {children} + +
+ ) +} diff --git a/apps/web/components/memory-graph/memory-graph.tsx b/apps/web/components/memory-graph/memory-graph.tsx deleted file mode 100644 index 278257873..000000000 --- a/apps/web/components/memory-graph/memory-graph.tsx +++ /dev/null @@ -1,513 +0,0 @@ -"use client" - -import { GlassMenuEffect } from "@repo/ui/other/glass-effect" -import { useCallback, useEffect, useMemo, useRef, useState } from "react" -import { useHotkeys } from "react-hotkeys-hook" -import { GraphCanvas } from "./graph-canvas" -import { useGraphApi } from "./hooks/use-graph-api" -import { useGraphData } from "./hooks/use-graph-data" -import { ForceSimulation } from "./canvas/simulation" -import { VersionChainIndex } from "./canvas/version-chain" -import type { ViewportState } from "./canvas/viewport" -import { Legend } from "./legend" -import { LoadingIndicator } from "./loading-indicator" -import { NavigationControls } from "./navigation-controls" -import { NodeHoverPopover } from "./node-hover-popover" -import { colors } from "./constants" -import type { GraphNode } from "./types" - -export interface MemoryGraphProps { - children?: React.ReactNode - isLoading?: boolean - error?: Error | null - variant?: "console" | "consumer" - legendId?: string - highlightDocumentIds?: string[] - highlightsVisible?: boolean - containerTags?: string[] - documentIds?: string[] - maxNodes?: number - isSlideshowActive?: boolean - onSlideshowNodeChange?: (nodeId: string | null) => void - onSlideshowStop?: () => void - canvasRef?: React.RefObject -} - -export const MemoryGraph = ({ - children, - isLoading: externalIsLoading = false, - error: externalError = null, - variant = "console", - legendId, - highlightDocumentIds = [], - highlightsVisible = true, - containerTags, - documentIds, - maxNodes = 200, - isSlideshowActive = false, - onSlideshowNodeChange, - onSlideshowStop, - canvasRef, -}: MemoryGraphProps) => { - const [containerSize, setContainerSize] = useState({ width: 0, height: 0 }) - const [containerBounds, setContainerBounds] = useState(null) - const containerRef = useRef(null) - const viewportRef = useRef(null) - const simulationRef = useRef(null) - const chainIndex = useRef(new VersionChainIndex()) - - // React state only for things that affect DOM - const [hoveredNode, setHoveredNode] = useState(null) - const [selectedNode, setSelectedNode] = useState(null) - const [zoomDisplay, setZoomDisplay] = useState(50) - - const { - data: apiData, - isLoading: apiIsLoading, - error: apiError, - } = useGraphApi({ - containerTags, - documentIds, - limit: maxNodes, - enabled: containerSize.width > 0 && containerSize.height > 0, - }) - - const { nodes, edges } = useGraphData( - apiData.documents, - apiData.edges, - null, - containerSize.width, - containerSize.height, - ) - - // Rebuild version chain index when documents change - useEffect(() => { - chainIndex.current.rebuild(apiData.documents) - }, [apiData.documents]) - - // Force simulation (created once, updated when data changes) - useEffect(() => { - if (nodes.length === 0) return - - if (!simulationRef.current) { - simulationRef.current = new ForceSimulation() - } - simulationRef.current.init(nodes, edges) - - return () => { - simulationRef.current?.destroy() - simulationRef.current = null - } - }, [nodes, edges]) - - // Auto-fit when data first loads - const hasAutoFittedRef = useRef(false) - useEffect(() => { - if ( - !hasAutoFittedRef.current && - nodes.length > 0 && - viewportRef.current && - containerSize.width > 0 - ) { - const timer = setTimeout(() => { - viewportRef.current?.fitToNodes( - nodes, - containerSize.width, - containerSize.height, - ) - hasAutoFittedRef.current = true - }, 100) - return () => clearTimeout(timer) - } - }, [nodes, containerSize.width, containerSize.height]) - - useEffect(() => { - if (nodes.length === 0) hasAutoFittedRef.current = false - }, [nodes.length]) - - // Container resize observer - useEffect(() => { - const el = containerRef.current - if (!el) return - - const ro = new ResizeObserver(() => { - setContainerSize({ width: el.clientWidth, height: el.clientHeight }) - setContainerBounds(el.getBoundingClientRect()) - }) - ro.observe(el) - setContainerSize({ width: el.clientWidth, height: el.clientHeight }) - setContainerBounds(el.getBoundingClientRect()) - - return () => ro.disconnect() - }, []) - - // Callbacks for GraphCanvas - const handleNodeHover = useCallback( - (id: string | null) => setHoveredNode(id), - [], - ) - - const handleNodeClick = useCallback((id: string | null) => { - setSelectedNode((prev) => (id === null ? null : prev === id ? null : id)) - }, []) - - const handleNodeDragStart = useCallback((_id: string) => { - // Drag is handled imperatively by InputHandler - }, []) - - const handleNodeDragEnd = useCallback(() => { - // Drag end handled by InputHandler - }, []) - - const handleViewportChange = useCallback((zoom: number) => { - setZoomDisplay(Math.round(zoom * 100)) - }, []) - - // Navigation - const handleAutoFit = useCallback(() => { - if (nodes.length === 0 || !viewportRef.current) return - viewportRef.current.fitToNodes( - nodes, - containerSize.width, - containerSize.height, - ) - }, [nodes, containerSize.width, containerSize.height]) - - const handleCenter = useCallback(() => { - if (nodes.length === 0 || !viewportRef.current) return - let sx = 0 - let sy = 0 - for (const n of nodes) { - sx += n.x - sy += n.y - } - viewportRef.current.centerOn( - sx / nodes.length, - sy / nodes.length, - containerSize.width, - containerSize.height, - ) - }, [nodes, containerSize.width, containerSize.height]) - - const handleZoomIn = useCallback(() => { - const vp = viewportRef.current - if (!vp) return - vp.zoomTo(vp.zoom * 1.3, containerSize.width / 2, containerSize.height / 2) - }, [containerSize.width, containerSize.height]) - - const handleZoomOut = useCallback(() => { - const vp = viewportRef.current - if (!vp) return - vp.zoomTo(vp.zoom / 1.3, containerSize.width / 2, containerSize.height / 2) - }, [containerSize.width, containerSize.height]) - - // Keyboard shortcuts - useHotkeys("z", handleAutoFit, [handleAutoFit]) - useHotkeys("c", handleCenter, [handleCenter]) - useHotkeys("equal", handleZoomIn, [handleZoomIn]) - useHotkeys("minus", handleZoomOut, [handleZoomOut]) - useHotkeys("escape", () => setSelectedNode(null), []) - - // Arrow key navigation through nodes - const selectAndCenter = useCallback( - (nodeId: string) => { - setSelectedNode(nodeId) - const n = nodes.find((nd) => nd.id === nodeId) - if (n && viewportRef.current) - viewportRef.current.centerOn( - n.x, - n.y, - containerSize.width, - containerSize.height, - ) - }, - [nodes, containerSize.width, containerSize.height], - ) - - const navigateUp = useCallback(() => { - if (!selectedNode) return - const chain = chainIndex.current.getChain(selectedNode) - if (chain && chain.length > 1) { - const idx = chain.findIndex((e) => e.id === selectedNode) - if (idx > 0) { - selectAndCenter(chain[idx - 1]!.id) - return - } - } - // At top of chain or no chain — go to parent document - const node = nodes.find((n) => n.id === selectedNode) - if (node?.type === "memory" && "documentId" in node.data) { - selectAndCenter(node.data.documentId) - } - }, [selectedNode, nodes, selectAndCenter]) - - const navigateDown = useCallback(() => { - if (!selectedNode) return - // Version chain navigation - const chain = chainIndex.current.getChain(selectedNode) - if (chain && chain.length > 1) { - const idx = chain.findIndex((e) => e.id === selectedNode) - if (idx >= 0 && idx < chain.length - 1) { - selectAndCenter(chain[idx + 1]!.id) - return - } - } - // On a document — go to its first memory - const node = nodes.find((n) => n.id === selectedNode) - if (node?.type === "document") { - const child = nodes.find( - (n) => - n.type === "memory" && - "documentId" in n.data && - n.data.documentId === selectedNode, - ) - if (child) selectAndCenter(child.id) - } - }, [selectedNode, nodes, selectAndCenter]) - - const navigateNext = useCallback(() => { - if (!selectedNode) return - const node = nodes.find((n) => n.id === selectedNode) - if (!node) return - - if (node.type === "document") { - const docs = nodes.filter((n) => n.type === "document") - const idx = docs.findIndex((n) => n.id === selectedNode) - const next = docs[(idx + 1) % docs.length]! - setSelectedNode(next.id) - if (viewportRef.current) - viewportRef.current.centerOn( - next.x, - next.y, - containerSize.width, - containerSize.height, - ) - } else { - const docId = "documentId" in node.data ? node.data.documentId : null - const siblings = nodes.filter( - (n) => - n.type === "memory" && - "documentId" in n.data && - n.data.documentId === docId, - ) - if (siblings.length === 0) return - const idx = siblings.findIndex((n) => n.id === selectedNode) - const next = siblings[(idx + 1) % siblings.length]! - setSelectedNode(next.id) - if (viewportRef.current) - viewportRef.current.centerOn( - next.x, - next.y, - containerSize.width, - containerSize.height, - ) - } - }, [selectedNode, nodes, containerSize.width, containerSize.height]) - - const navigatePrev = useCallback(() => { - if (!selectedNode) return - const node = nodes.find((n) => n.id === selectedNode) - if (!node) return - - if (node.type === "document") { - const docs = nodes.filter((n) => n.type === "document") - const idx = docs.findIndex((n) => n.id === selectedNode) - const prev = docs[(idx - 1 + docs.length) % docs.length]! - setSelectedNode(prev.id) - if (viewportRef.current) - viewportRef.current.centerOn( - prev.x, - prev.y, - containerSize.width, - containerSize.height, - ) - } else { - const docId = "documentId" in node.data ? node.data.documentId : null - const siblings = nodes.filter( - (n) => - n.type === "memory" && - "documentId" in n.data && - n.data.documentId === docId, - ) - if (siblings.length === 0) return - const idx = siblings.findIndex((n) => n.id === selectedNode) - const prev = siblings[(idx - 1 + siblings.length) % siblings.length]! - setSelectedNode(prev.id) - if (viewportRef.current) - viewportRef.current.centerOn( - prev.x, - prev.y, - containerSize.width, - containerSize.height, - ) - } - }, [selectedNode, nodes, containerSize.width, containerSize.height]) - - useHotkeys("up", navigateUp, [navigateUp]) - useHotkeys("down", navigateDown, [navigateDown]) - useHotkeys("right", navigateNext, [navigateNext]) - useHotkeys("left", navigatePrev, [navigatePrev]) - - // Slideshow - useEffect(() => { - if (!isSlideshowActive || nodes.length === 0) { - if (!isSlideshowActive) { - setSelectedNode(null) - simulationRef.current?.coolDown() - } - return - } - - let lastIdx = -1 - const pick = () => { - if (nodes.length === 0) return - let idx: number - if (nodes.length > 1) { - do { - idx = Math.floor(Math.random() * nodes.length) - } while (idx === lastIdx) - } else { - idx = 0 - } - lastIdx = idx - const n = nodes[idx]! - setSelectedNode(n.id) - viewportRef.current?.centerOn( - n.x, - n.y, - containerSize.width, - containerSize.height, - ) - simulationRef.current?.reheat() - onSlideshowNodeChange?.(n.id) - setTimeout(() => simulationRef.current?.coolDown(), 1000) - } - - pick() - const interval = setInterval(pick, 3500) - return () => clearInterval(interval) - }, [ - isSlideshowActive, - nodes, - containerSize.width, - containerSize.height, - onSlideshowNodeChange, - ]) - - // Active node: selected takes priority, then hovered - const activeNodeId = selectedNode ?? hoveredNode - const activeNodeData = useMemo(() => { - if (!activeNodeId) return null - return nodes.find((n) => n.id === activeNodeId) ?? null - }, [activeNodeId, nodes]) - - const activePopoverPosition = useMemo(() => { - if (!activeNodeData || !viewportRef.current) return null - const vp = viewportRef.current - const screen = vp.worldToScreen(activeNodeData.x, activeNodeData.y) - return { - screenX: screen.x, - screenY: screen.y, - nodeRadius: (activeNodeData.size * vp.zoom) / 2, - } - }, [activeNodeData]) - - const activeVersionChain = useMemo(() => { - if (!activeNodeData || activeNodeData.type !== "memory") return null - return chainIndex.current.getChain(activeNodeData.id) - }, [activeNodeData]) - - const isLoading = externalIsLoading || apiIsLoading - const error = externalError || apiError - - if (error) { - return ( -
-
- -
- Error loading graph: {error.message} -
-
-
- ) - } - - return ( -
- - - {!isLoading && !nodes.some((n) => n.type === "document") && children} - -
- {containerSize.width > 0 && containerSize.height > 0 && ( - - )} - - {activeNodeData && activePopoverPosition && ( - - )} - -
- {containerSize.width > 0 && ( - - )} - -
-
-
- ) -} diff --git a/apps/web/components/memory-graph/navigation-controls.tsx b/apps/web/components/memory-graph/navigation-controls.tsx deleted file mode 100644 index 27d2a7e0b..000000000 --- a/apps/web/components/memory-graph/navigation-controls.tsx +++ /dev/null @@ -1,164 +0,0 @@ -"use client" - -import { memo } from "react" -import type { GraphNode } from "./types" -import { cn } from "@lib/utils" -import { Settings } from "lucide-react" - -interface NavigationControlsProps { - onCenter: () => void - onZoomIn: () => void - onZoomOut: () => void - onAutoFit: () => void - nodes: GraphNode[] - className?: string - zoomLevel: number -} - -// Keyboard shortcut badge component -const KeyboardShortcut = memo(function KeyboardShortcut({ - keys, -}: { - keys: string -}) { - return ( -
- - {keys} - -
- ) -}) - -// Navigation buttons component -const NavigationButtons = memo(function NavigationButtons({ - onAutoFit, - onCenter, - onZoomIn, - onZoomOut, - zoomLevel, -}: { - onAutoFit: () => void - onCenter: () => void - onZoomIn: () => void - onZoomOut: () => void - zoomLevel: number -}) { - return ( -
- {/* Fit button */} - - - {/* Center button */} - - - {/* Zoom controls */} -
- {zoomLevel}% -
- - -
-
-
- ) -}) - -const SettingsButton = memo(function SettingsButton() { - return ( - - ) -}) - -export const NavigationControls = memo( - ({ - onCenter, - onZoomIn, - onZoomOut, - onAutoFit, - nodes, - className = "", - zoomLevel, - }) => { - if (nodes.length === 0) { - return null - } - - return ( -
-
- - {/* Commented out for now as we are not using this */} - {/**/} -
-
- ) - }, -) - -NavigationControls.displayName = "NavigationControls" diff --git a/apps/web/components/memory-graph/node-hover-popover.tsx b/apps/web/components/memory-graph/node-hover-popover.tsx deleted file mode 100644 index a1e1deeba..000000000 --- a/apps/web/components/memory-graph/node-hover-popover.tsx +++ /dev/null @@ -1,467 +0,0 @@ -"use client" - -import { memo, useMemo, useCallback, useState } from "react" -import type { GraphNode, DocumentNodeData, MemoryNodeData } from "./types" -import type { ChainEntry } from "./canvas/version-chain" - -export interface NodeHoverPopoverProps { - node: GraphNode - screenX: number - screenY: number - nodeRadius: number - containerBounds?: DOMRect - versionChain?: ChainEntry[] | null - onNavigateNext?: () => void - onNavigatePrev?: () => void - onNavigateUp?: () => void - onNavigateDown?: () => void - onSelectNode?: (nodeId: string) => void -} - -function KeyBadge({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ) -} - -function NavButton({ - icon, - label, - onClick, -}: { - icon: string - label: string - onClick?: () => void -}) { - return ( - - ) -} - -function CopyableId({ label, value }: { label: string; value: string }) { - const [copied, setCopied] = useState(false) - const copy = useCallback(() => { - navigator.clipboard.writeText(value) - setCopied(true) - setTimeout(() => setCopied(false), 1500) - }, [value]) - - const short = - value.length > 12 ? `${value.slice(0, 6)}...${value.slice(-4)}` : value - - return ( - - ) -} - -type Quadrant = "right" | "left" | "above" | "below" - -function pickBestQuadrant( - screenX: number, - screenY: number, - nodeRadius: number, - containerWidth: number, - containerHeight: number, - popoverWidth: number, - popoverHeight: number, -): Quadrant { - const gap = 24 - const spaceRight = containerWidth - (screenX + nodeRadius + gap) - const spaceLeft = screenX - nodeRadius - gap - const spaceAbove = screenY - nodeRadius - gap - const spaceBelow = containerHeight - (screenY + nodeRadius + gap) - - const fits: [Quadrant, number][] = [ - ["right", spaceRight >= popoverWidth ? spaceRight : -1], - ["left", spaceLeft >= popoverWidth ? spaceLeft : -1], - ["above", spaceAbove >= popoverHeight ? spaceAbove : -1], - ["below", spaceBelow >= popoverHeight ? spaceBelow : -1], - ] - - const preferred: Quadrant[] = ["right", "left", "below", "above"] - for (const q of preferred) { - const entry = fits.find(([dir]) => dir === q) - if (entry && entry[1] > 0) return q - } - - return fits.sort((a, b) => b[1] - a[1])[0]![0] -} - -function truncate(s: string, max: number) { - return s.length > max ? `${s.substring(0, max)}...` : s -} - -function VersionTimeline({ - chain, - currentId, - onSelect, -}: { - chain: ChainEntry[] - currentId: string - onSelect?: (id: string) => void -}) { - return ( -
- {chain.map((entry) => { - const isCurrent = entry.id === currentId - return ( - - ) - })} -
- ) -} - -export const NodeHoverPopover = memo( - function NodeHoverPopover({ - node, - screenX, - screenY, - nodeRadius, - containerBounds, - versionChain, - onNavigateNext, - onNavigatePrev, - onNavigateUp, - onNavigateDown, - onSelectNode, - }) { - const CARD_W = 280 - const SHORTCUTS_W = 100 - const GAP = 24 - const TOTAL_W = CARD_W + 12 + SHORTCUTS_W - - const isMemory = node.type === "memory" - const data = node.data - - const memoryMeta = useMemo(() => { - if (!isMemory) return null - const md = data as MemoryNodeData - return { - version: md.version ?? 1, - isLatest: md.isLatest ?? false, - isForgotten: md.isForgotten ?? false, - forgetReason: md.forgetReason ?? null, - forgetAfter: md.forgetAfter ?? null, - } - }, [isMemory, data]) - - const hasChain = versionChain && versionChain.length > 1 - const hasForgetInfo = - memoryMeta && (memoryMeta.isForgotten || memoryMeta.forgetAfter) - - const CARD_H = hasChain ? 200 : hasForgetInfo ? 165 : 135 - const TOTAL_H = CARD_H - - const { popoverX, popoverY, connectorPath } = useMemo(() => { - const cw = containerBounds?.width ?? 800 - const ch = containerBounds?.height ?? 600 - - const quadrant = pickBestQuadrant( - screenX, - screenY, - nodeRadius, - cw, - ch, - TOTAL_W + GAP, - TOTAL_H, - ) - - let px: number - let py: number - let connStart: { x: number; y: number } - - switch (quadrant) { - case "right": - px = screenX + nodeRadius + GAP - py = screenY - TOTAL_H / 2 - connStart = { x: screenX + nodeRadius, y: screenY } - break - case "left": - px = screenX - nodeRadius - GAP - TOTAL_W - py = screenY - TOTAL_H / 2 - connStart = { x: screenX - nodeRadius, y: screenY } - break - case "below": - px = screenX - TOTAL_W / 2 - py = screenY + nodeRadius + GAP - connStart = { x: screenX, y: screenY + nodeRadius } - break - case "above": - px = screenX - TOTAL_W / 2 - py = screenY - nodeRadius - GAP - TOTAL_H - connStart = { x: screenX, y: screenY - nodeRadius } - break - } - - px = Math.max(8, Math.min(cw - TOTAL_W - 8, px)) - py = Math.max(8, Math.min(ch - TOTAL_H - 8, py)) - - const cardCenterX = px + CARD_W / 2 - const cardCenterY = py + TOTAL_H / 2 - const path = `M ${connStart.x} ${connStart.y} L ${cardCenterX} ${cardCenterY}` - - return { popoverX: px, popoverY: py, connectorPath: path } - }, [screenX, screenY, nodeRadius, containerBounds, TOTAL_W, TOTAL_H]) - - const content = useMemo(() => { - if (isMemory) { - const md = data as MemoryNodeData - return md.memory || md.content || "" - } - const dd = data as DocumentNodeData - return dd.summary || dd.title || "" - }, [isMemory, data]) - - const docData = !isMemory ? (data as DocumentNodeData) : null - - return ( -
- - - - -
- {/* Card */} -
- {/* Content — show timeline if chain exists, otherwise plain text */} - {hasChain ? ( - - ) : ( -
-

- {truncate(content, 100) || "No content"} -

-
- )} - - {/* Forget info (memory-only) */} - {memoryMeta && hasForgetInfo && ( -
- {memoryMeta.forgetAfter && ( - - Expires:{" "} - {new Date(memoryMeta.forgetAfter).toLocaleDateString()} - - )} - {memoryMeta.forgetReason && ( - - Reason: {memoryMeta.forgetReason} - - )} - {memoryMeta.isForgotten && !memoryMeta.forgetReason && ( - - Forgotten - - )} -
- )} - - {/* Bottom bar */} -
- {memoryMeta ? ( - <> - - v{memoryMeta.version}{" "} - {memoryMeta.isForgotten - ? "Forgotten" - : memoryMeta.isLatest - ? "Latest" - : "Superseded"} - - - ) : ( - <> - - {docData?.type || "document"} - - - {docData?.memories?.length ?? 0} memories - - - )} -
- - {/* ID row */} -
- {isMemory ? ( - - ) : ( - - )} -
-
- - {/* Navigation */} -
- {isMemory && ( - - )} - {(isMemory ? hasChain : true) && ( - - )} - - -
-
-
- ) - }, -) diff --git a/apps/web/components/memory-graph/node-popover.tsx b/apps/web/components/memory-graph/node-popover.tsx deleted file mode 100644 index 7a2a9c704..000000000 --- a/apps/web/components/memory-graph/node-popover.tsx +++ /dev/null @@ -1,362 +0,0 @@ -"use client" - -import { memo, useEffect } from "react" -import type { GraphNode } from "./types" -import { cn } from "@lib/utils" - -export interface NodePopoverProps { - node: GraphNode - x: number // Screen X position - y: number // Screen Y position - onClose: () => void - containerBounds?: DOMRect // Optional container bounds to limit backdrop - onBackdropClick?: () => void // Optional callback when backdrop is clicked -} - -export const NodePopover = memo(function NodePopover({ - node, - x, - y, - onClose, - containerBounds, - onBackdropClick, -}) { - // Handle Escape key to close popover - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") { - onClose() - } - } - - window.addEventListener("keydown", handleKeyDown) - return () => window.removeEventListener("keydown", handleKeyDown) - }, [onClose]) - - // Calculate backdrop bounds - use container bounds if provided, otherwise full viewport - const backdropStyle = containerBounds - ? { - left: `${containerBounds.left}px`, - top: `${containerBounds.top}px`, - width: `${containerBounds.width}px`, - height: `${containerBounds.height}px`, - } - : undefined - - const handleBackdropClick = () => { - onBackdropClick?.() - onClose() - } - - return ( - <> - {/* Invisible backdrop to catch clicks outside */} -
- - {/* Popover content */} -
e.stopPropagation()} // Prevent closing when clicking inside - className="fixed backdrop-blur-[12px] bg-white/5 border border-white/25 rounded-xl p-4 w-80 z-[1000] pointer-events-auto shadow-[0_20px_25px_-5px_rgb(0_0_0/0.3),0_8px_10px_-6px_rgb(0_0_0/0.3)]" - style={{ - left: `${x}px`, - top: `${y}px`, - }} - > - {node.type === "document" ? ( - // Document popover -
- {/* Header */} -
-
- - - - - - - -

Document

-
- -
- - {/* Sections */} -
- {/* Title */} -
-
- Title -
-

- {(node.data as any).title || "Untitled Document"} -

-
- - {/* Summary - truncated to 2 lines */} - {(node.data as any).summary && ( -
-
- Summary -
-

- {(node.data as any).summary} -

-
- )} - - {/* Type */} -
-
- Type -
-

- {(node.data as any).type || "Document"} -

-
- - {/* Memory Count */} -
-
- Memory Count -
-

- {(node.data as any).memoryEntries?.length || 0} memories -

-
- - {/* URL */} - {((node.data as any).url || (node.data as any).customId) && ( - - )} - - {/* Footer with metadata */} -
-
- - - - - - - - {new Date( - (node.data as any).createdAt, - ).toLocaleDateString()} - -
-
- - - - - - - - {node.id} - -
-
-
-
- ) : ( - // Memory popover -
- {/* Header */} -
-
- - - - -

Memory

-
- -
- - {/* Sections */} -
- {/* Memory content */} -
-
- Memory -
-

- {(node.data as any).memory || - (node.data as any).content || - "No content"} -

- {(node.data as any).isForgotten && ( -
- Forgotten -
- )} - {/* Expires (inline with memory if exists) */} - {(node.data as any).forgetAfter && ( -

- Expires:{" "} - {new Date( - (node.data as any).forgetAfter, - ).toLocaleDateString()} - {(node.data as any).forgetReason && - ` - ${(node.data as any).forgetReason}`} -

- )} -
- - {/* Space */} -
-
- Space -
-

- {(node.data as any).spaceId || "Default"} -

-
- - {/* Footer with metadata */} -
-
- - - - - - - - {new Date( - (node.data as any).createdAt, - ).toLocaleDateString()} - -
-
- - - - - - - - {node.id} - -
-
-
-
- )} -
- - ) -}) diff --git a/apps/web/components/memory-graph/types.ts b/apps/web/components/memory-graph/types.ts deleted file mode 100644 index f20243f61..000000000 --- a/apps/web/components/memory-graph/types.ts +++ /dev/null @@ -1,163 +0,0 @@ -import type { - DocumentsResponse, - DocumentWithMemories, - MemoryEntry, -} from "./api-types" - -export type { DocumentsResponse, DocumentWithMemories, MemoryEntry } - -// Graph API types matching backend response - -export interface GraphApiMemory { - id: string - memory: string - isStatic: boolean - spaceId: string - isLatest: boolean - isForgotten: boolean - forgetAfter: string | null - forgetReason: string | null - version: number - parentMemoryId: string | null - rootMemoryId: string | null - createdAt: string - updatedAt: string -} - -export interface GraphApiDocument { - id: string - title: string | null - summary: string | null - documentType: string - createdAt: string - updatedAt: string - x: number // backend coordinates (dynamic range) - y: number // backend coordinates (dynamic range) - memories: GraphApiMemory[] -} - -export interface GraphApiEdge { - source: string - target: string - similarity: number // 0-1 -} - -export interface GraphViewportResponse { - documents: GraphApiDocument[] - edges: GraphApiEdge[] - viewport: { - minX: number - maxX: number - minY: number - maxY: number - } - totalCount: number -} - -export interface GraphBoundsResponse { - bounds: { - minX: number - maxX: number - minY: number - maxY: number - } | null -} - -export interface GraphStatsResponse { - totalDocuments: number - documentsWithSpatial: number - totalDocumentEdges: number -} - -// Typed node data - -export interface DocumentNodeData { - id: string - title: string | null - summary: string | null - type: string - createdAt: string - updatedAt: string - memories: GraphApiMemory[] -} - -export interface MemoryNodeData { - id: string - memory: string - content: string - documentId: string - isStatic: boolean - isLatest: boolean - isForgotten: boolean - forgetAfter: string | null - forgetReason: string | null - version: number - parentMemoryId: string | null - spaceId: string - createdAt: string - updatedAt: string -} - -export interface GraphNode { - id: string - type: "document" | "memory" - x: number - y: number - data: DocumentNodeData | MemoryNodeData - size: number - borderColor: string - isHovered: boolean - isDragging: boolean - // D3-force simulation properties - vx?: number - vy?: number - fx?: number | null - fy?: number | null -} - -export interface GraphEdge { - id: string - source: string | GraphNode - target: string | GraphNode - similarity: number - visualProps: { - opacity: number - thickness: number - } - edgeType: "doc-memory" | "similarity" | "version" -} - -export interface GraphCanvasProps { - nodes: GraphNode[] - edges: GraphEdge[] - width: number - height: number - highlightDocumentIds?: string[] - selectedNodeId?: string | null - onNodeHover: (nodeId: string | null) => void - onNodeClick: (nodeId: string | null) => void - onNodeDragStart: (nodeId: string) => void - onNodeDragEnd: () => void - onViewportChange?: (zoom: number) => void - canvasRef?: React.RefObject - variant?: "console" | "consumer" - simulation?: import("./canvas/simulation").ForceSimulation - viewportRef?: React.RefObject< - import("./canvas/viewport").ViewportState | null - > -} - -export interface LegendProps { - variant?: "console" | "consumer" - nodes?: GraphNode[] - edges?: GraphEdge[] - isLoading?: boolean - hoveredNode?: string | null -} - -export interface LoadingIndicatorProps { - isLoading: boolean - isLoadingMore: boolean - totalLoaded: number - variant?: "console" | "consumer" -} diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index e53a5c2fc..c4992e71b 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -6,6 +6,7 @@ const nextConfig: NextConfig = { ignoreBuildErrors: true, }, transpilePackages: [ + "@supermemory/memory-graph", "@tiptap/core", "@tiptap/react", "@tiptap/pm", diff --git a/apps/web/package.json b/apps/web/package.json index db7632c71..5551c607b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -106,7 +106,8 @@ "vaul": "^1.1.2", "zustand": "^5.0.7", "@repo/lib": "workspace:*", - "@repo/validation": "workspace:*" + "@repo/validation": "workspace:*", + "@supermemory/memory-graph": "workspace:*" }, "devDependencies": { "@biomejs/biome": "^2.2.2", diff --git a/bun.lock b/bun.lock index b98246e1a..945692e60 100644 --- a/bun.lock +++ b/bun.lock @@ -161,6 +161,7 @@ "@repo/lib": "workspace:*", "@repo/validation": "workspace:*", "@sentry/nextjs": "^10.33.0", + "@supermemory/memory-graph": "workspace:*", "@tailwindcss/typography": "^0.5.16", "@tanstack/react-form": "^1.12.4", "@tanstack/react-query": "^5.90.14", @@ -294,26 +295,18 @@ }, "packages/memory-graph": { "name": "@supermemory/memory-graph", - "version": "0.1.8", + "version": "0.2.0", "dependencies": { - "@emotion/is-prop-valid": "^1.4.0", - "@radix-ui/react-collapsible": "^1.1.12", - "@radix-ui/react-slot": "^1.2.4", - "@vanilla-extract/css": "^1.17.4", - "@vanilla-extract/recipes": "^0.5.7", - "@vanilla-extract/sprinkles": "^1.6.5", "d3-force": "^3.0.0", - "lucide-react": "^0.552.0", - "motion": "^12.23.24", }, "devDependencies": { "@types/d3-force": "^3.0.10", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", - "@vanilla-extract/vite-plugin": "^5.1.1", "@vitejs/plugin-react": "^5.1.0", "typescript": "^5.9.3", "vite": "^7.2.1", + "vitest": "^3.2.4", }, "peerDependencies": { "react": ">=18.0.0", @@ -2059,22 +2052,12 @@ "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], - "@vanilla-extract/babel-plugin-debug-ids": ["@vanilla-extract/babel-plugin-debug-ids@1.2.2", "", { "dependencies": { "@babel/core": "^7.23.9" } }, "sha512-MeDWGICAF9zA/OZLOKwhoRlsUW+fiMwnfuOAqFVohL31Agj7Q/RBWAYweqjHLgFBCsdnr6XIfwjJnmb2znEWxw=="], - - "@vanilla-extract/compiler": ["@vanilla-extract/compiler@0.3.4", "", { "dependencies": { "@vanilla-extract/css": "^1.18.0", "@vanilla-extract/integration": "^8.0.7", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0", "vite-node": "^3.2.2" } }, "sha512-W9HXf9EAccpE1vEIATvSoBVj/bQnmHfYHfDJjUN8dcOHW6oMcnoGTqweDM9I66BHqlNH4d0IsaeZKSViOv7K4w=="], - "@vanilla-extract/css": ["@vanilla-extract/css@1.18.0", "", { "dependencies": { "@emotion/hash": "^0.9.0", "@vanilla-extract/private": "^1.0.9", "css-what": "^6.1.0", "cssesc": "^3.0.0", "csstype": "^3.2.3", "dedent": "^1.5.3", "deep-object-diff": "^1.1.9", "deepmerge": "^4.2.2", "lru-cache": "^10.4.3", "media-query-parser": "^2.0.2", "modern-ahocorasick": "^1.0.0", "picocolors": "^1.0.0" } }, "sha512-/p0dwOjr0o8gE5BRQ5O9P0u/2DjUd6Zfga2JGmE4KaY7ZITWMszTzk4x4CPlM5cKkRr2ZGzbE6XkuPNfp9shSQ=="], - "@vanilla-extract/integration": ["@vanilla-extract/integration@8.0.7", "", { "dependencies": { "@babel/core": "^7.23.9", "@babel/plugin-syntax-typescript": "^7.23.3", "@vanilla-extract/babel-plugin-debug-ids": "^1.2.2", "@vanilla-extract/css": "^1.18.0", "dedent": "^1.5.3", "esbuild": "npm:esbuild@>=0.17.6 <0.28.0", "eval": "0.1.8", "find-up": "^5.0.0", "javascript-stringify": "^2.0.1", "mlly": "^1.4.2" } }, "sha512-ILob4F9cEHXpbWAVt3Y2iaQJpqYq/c/5TJC8Fz58C2XmX3QW2Y589krvViiyJhQfydCGK3EbwPQhVFjQaBeKfg=="], - "@vanilla-extract/private": ["@vanilla-extract/private@1.0.9", "", {}, "sha512-gT2jbfZuaaCLrAxwXbRgIhGhcXbRZCG3v4TTUnjw0EJ7ArdBRxkq4msNJkbuRkCgfIK5ATmprB5t9ljvLeFDEA=="], "@vanilla-extract/recipes": ["@vanilla-extract/recipes@0.5.7", "", { "peerDependencies": { "@vanilla-extract/css": "^1.0.0" } }, "sha512-Fvr+htdyb6LVUu+PhH61UFPhwkjgDEk8L4Zq9oIdte42sntpKrgFy90MyTRtGwjVALmrJ0pwRUVr8UoByYeW8A=="], - "@vanilla-extract/sprinkles": ["@vanilla-extract/sprinkles@1.6.5", "", { "peerDependencies": { "@vanilla-extract/css": "^1.0.0" } }, "sha512-HOYidLONR/SeGk8NBAeI64I4gYdsMX9vJmniL13ZcLVwawyK0s2GUENEAcGA+GYLIoeyQB61UqmhqPodJry7zA=="], - - "@vanilla-extract/vite-plugin": ["@vanilla-extract/vite-plugin@5.1.4", "", { "dependencies": { "@vanilla-extract/compiler": "^0.3.4", "@vanilla-extract/integration": "^8.0.7" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-fTYNKUK3n4ApkUf2FEcO7mpqNKEHf9kDGg8DXlkqHtPxgwPhjuaajmDfQCSBsNgnA2SLI+CB5EO6kLQuKsw2Rw=="], - "@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="], "@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.4", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA=="], @@ -2829,8 +2812,6 @@ "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], - "eval": ["eval@0.1.8", "", { "dependencies": { "@types/node": "*", "require-like": ">= 0.1.1" } }, "sha512-EzV94NYKoO09GLXGjXj9JIlXijVck4ONSr5wiCWDvhsvj5jxSrzTmRU/9C1DyB6uToszLs8aifA6NQ7lEQdvFw=="], - "event-target-polyfill": ["event-target-polyfill@0.0.4", "", {}, "sha512-Gs6RLjzlLRdT8X9ZipJdIZI/Y6/HhRLyq9RdDlCsnpxr/+Nn6bU2EFGuC94GjxqhM+Nmij2Vcq98yoHrU8uNFQ=="], "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], @@ -3299,8 +3280,6 @@ "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], - "javascript-stringify": ["javascript-stringify@2.1.0", "", {}, "sha512-JVAfqNPTvNq3sB/VHQJAFxN/sPgKnsKrCwyRt15zwNCdrMMJDdcEOdubuy+DuJYYdm0ox1J4uzEuYKkN+9yhVg=="], - "jest-worker": ["jest-worker@27.5.1", "", { "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", "supports-color": "^8.0.0" } }, "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg=="], "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], @@ -4177,8 +4156,6 @@ "require-in-the-middle": ["require-in-the-middle@8.0.1", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3" } }, "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ=="], - "require-like": ["require-like@0.1.2", "", {}, "sha512-oyrU88skkMtDdauHDuKVrgR+zuItqr6/c//FXzvmxRGMexSDc6hNvJInGW3LL46n+8b50RykrvwSUIIQH2LQ5A=="], - "resend": ["resend@4.8.0", "", { "dependencies": { "@react-email/render": "1.1.2" } }, "sha512-R8eBOFQDO6dzRTDmaMEdpqrkmgSjPpVXt4nGfWsZdYOet0kqra0xgbvTES6HmCriZEXbmGk3e0DiGIaLFTFSHA=="], "resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="], @@ -5169,8 +5146,6 @@ "@supermemory/ai-sdk/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], - "@supermemory/memory-graph/lucide-react": ["lucide-react@0.552.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-g9WCjmfwqbexSnZE+2cl21PCfXOcqnGeWeMTNAOGEfpPbm/ZF4YIq77Z8qWrxbu660EKuLB4nSLggoKnCb+isw=="], - "@supermemory/memory-graph/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "@supermemory/tools/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.70", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.22" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W3WjQlb0Ho+CVAQUvb8Rtk3hGS3Jlgy79ihY2H0yj2k4yU8XuxpQw0Oz+7JQsB47j+jlHhk7nUXtxhAeRg3S3Q=="], @@ -5199,8 +5174,6 @@ "@vanilla-extract/css/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], - "@vanilla-extract/integration/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], - "@vitest/mocker/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], "accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], @@ -6043,58 +6016,6 @@ "@supermemory/tools/@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.22", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-fFT1KfUUKktfAFm5mClJhS1oux9tP2qgzmEZVl5UdwltQ1LO/s8hd7znVrgKzivwv1s1FIPza0s9OpJaNB/vHw=="], - "@vanilla-extract/integration/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], - - "@vanilla-extract/integration/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], - - "@vanilla-extract/integration/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="], - - "@vanilla-extract/integration/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="], - - "@vanilla-extract/integration/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="], - - "@vanilla-extract/integration/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="], - - "@vanilla-extract/integration/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="], - - "@vanilla-extract/integration/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="], - - "@vanilla-extract/integration/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="], - - "@vanilla-extract/integration/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="], - - "@vanilla-extract/integration/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="], - - "@vanilla-extract/integration/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="], - - "@vanilla-extract/integration/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="], - - "@vanilla-extract/integration/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="], - - "@vanilla-extract/integration/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="], - - "@vanilla-extract/integration/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="], - - "@vanilla-extract/integration/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="], - - "@vanilla-extract/integration/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="], - - "@vanilla-extract/integration/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="], - - "@vanilla-extract/integration/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="], - - "@vanilla-extract/integration/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="], - - "@vanilla-extract/integration/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="], - - "@vanilla-extract/integration/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="], - - "@vanilla-extract/integration/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="], - - "@vanilla-extract/integration/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="], - - "@vanilla-extract/integration/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], - "agents/@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], "agents/@modelcontextprotocol/sdk/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], diff --git a/packages/memory-graph/package.json b/packages/memory-graph/package.json index a0cb5bb48..403375bc3 100644 --- a/packages/memory-graph/package.json +++ b/packages/memory-graph/package.json @@ -1,37 +1,27 @@ { "name": "@supermemory/memory-graph", - "version": "0.1.8", + "version": "0.2.0", "description": "Interactive graph visualization component for Supermemory - visualize and explore your memory connections", "type": "module", - "main": "./dist/memory-graph.cjs", - "module": "./dist/memory-graph.js", - "types": "./dist/index.d.ts", + "main": "./src/index.tsx", + "module": "./src/index.tsx", + "types": "./src/index.tsx", "exports": { - ".": { - "import": { - "types": "./dist/index.d.ts", - "default": "./dist/memory-graph.js" - }, - "require": { - "types": "./dist/index.d.ts", - "default": "./dist/memory-graph.cjs" - } - }, - "./styles.css": "./dist/memory-graph.css", + ".": "./src/index.tsx", + "./mock-data": "./src/mock-data.ts", "./package.json": "./package.json" }, "files": [ "dist", "README.md" ], - "sideEffects": [ - "**/*.css" - ], "scripts": { "dev": "vite build --watch", "build": "vite build && tsc --emitDeclarationOnly", "check-types": "tsc --noEmit", - "prepublishOnly": "bun run build" + "prepack": "bun run build && bun run scripts/swap-exports.ts pack", + "postpack": "bun run scripts/swap-exports.ts unpack", + "test": "vitest run" }, "keywords": [ "supermemory", @@ -59,23 +49,15 @@ "react-dom": ">=18.0.0" }, "dependencies": { - "@emotion/is-prop-valid": "^1.4.0", - "@radix-ui/react-collapsible": "^1.1.12", - "@radix-ui/react-slot": "^1.2.4", - "@vanilla-extract/css": "^1.17.4", - "@vanilla-extract/recipes": "^0.5.7", - "@vanilla-extract/sprinkles": "^1.6.5", - "d3-force": "^3.0.0", - "lucide-react": "^0.552.0", - "motion": "^12.23.24" + "d3-force": "^3.0.0" }, "devDependencies": { "@types/d3-force": "^3.0.10", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", - "@vanilla-extract/vite-plugin": "^5.1.1", "@vitejs/plugin-react": "^5.1.0", "typescript": "^5.9.3", - "vite": "^7.2.1" + "vite": "^7.2.1", + "vitest": "^3.2.4" } } diff --git a/packages/memory-graph/scripts/swap-exports.ts b/packages/memory-graph/scripts/swap-exports.ts new file mode 100644 index 000000000..1049ca439 --- /dev/null +++ b/packages/memory-graph/scripts/swap-exports.ts @@ -0,0 +1,50 @@ +/** + * Swaps package.json exports between source (for workspace/monorepo use) + * and dist (for npm publishing). + * + * Usage: + * bun run scripts/swap-exports.ts pack # switch to dist exports + * bun run scripts/swap-exports.ts unpack # switch back to source exports + */ +import { readFileSync, writeFileSync } from "node:fs" +import { join } from "node:path" + +const pkgPath = join(import.meta.dirname, "..", "package.json") +const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) + +const mode = process.argv[2] + +if (mode === "pack") { + // Switch to dist exports for npm publishing + pkg.main = "./dist/memory-graph.cjs" + pkg.module = "./dist/memory-graph.js" + pkg.types = "./dist/index.d.ts" + pkg.exports = { + ".": { + types: "./dist/index.d.ts", + import: "./dist/memory-graph.js", + require: "./dist/memory-graph.cjs", + }, + "./mock-data": { + types: "./dist/mock-data.d.ts", + import: "./dist/mock-data.js", + }, + "./package.json": "./package.json", + } +} else if (mode === "unpack") { + // Switch back to source exports for workspace use + pkg.main = "./src/index.tsx" + pkg.module = "./src/index.tsx" + pkg.types = "./src/index.tsx" + pkg.exports = { + ".": "./src/index.tsx", + "./mock-data": "./src/mock-data.ts", + "./package.json": "./package.json", + } +} else { + console.error("Usage: bun run scripts/swap-exports.ts [pack|unpack]") + process.exit(1) +} + +writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`) +console.log(`Exports swapped to ${mode === "pack" ? "dist" : "source"} mode`) diff --git a/packages/memory-graph/src/__tests__/edge-logic.test.ts b/packages/memory-graph/src/__tests__/edge-logic.test.ts new file mode 100644 index 000000000..ff9003f4a --- /dev/null +++ b/packages/memory-graph/src/__tests__/edge-logic.test.ts @@ -0,0 +1,655 @@ +/** + * Adversarial tests for the three core changes in graph-perf-consolidation. + * + * Focuses on the exact logic that was changed, NOT on surrounding scaffolding: + * + * (1) use-graph-data.ts — edges useMemo now uses memoryRelations Record as + * primary source, falls back to parentMemoryId for legacy data. + * The old code expected an ARRAY [{relationType, targetMemoryId}]. + * The new code expects a Record. + * + * (2) MCP mcp-app.ts — transformData now pre-populates nodeIds before edge + * computation, fixing a forward-reference bug where edges to memories + * that appeared later in the iteration order were silently dropped. + * + * (3) Invalid relationType values now default to "updates" instead of blowing up. + * + * The edges useMemo is a pure function of `documents` — no React hook machinery + * is needed. We extract the identical logic here and test it directly. + */ + +import { describe, it, expect } from "vitest" +import type { GraphApiDocument, GraphApiMemory } from "../types" +import { getEdgeVisualProps } from "../hooks/use-graph-data" + +// --------------------------------------------------------------------------- +// Pure extraction of edges useMemo from use-graph-data.ts +// This is a verbatim copy of the logic so a regression in the source will +// cause this test to diverge — but more importantly, we can verify the exact +// semantics match the spec described in the commit messages. +// --------------------------------------------------------------------------- + +interface ComputedEdge { + id: string + source: string + target: string + edgeType: string +} + +function computeEdges(documents: GraphApiDocument[]): ComputedEdge[] { + if (!documents || documents.length === 0) return [] + + const result: ComputedEdge[] = [] + + // Pre-populate all node IDs (the key step from the forward-reference fix) + const allNodeIds = new Set() + for (const doc of documents) { + allNodeIds.add(doc.id) + for (const mem of doc.memories) allNodeIds.add(mem.id) + } + + // 1. Derives edges: document -> memory (structural) + for (const doc of documents) { + for (const mem of doc.memories) { + result.push({ + id: `dm-${doc.id}-${mem.id}`, + source: doc.id, + target: mem.id, + edgeType: "derives", + }) + } + } + + // 2. Memory-to-memory relation edges from backend data. + // Uses memoryRelations Record as primary source, + // falls back to parentMemoryId for legacy data. + for (const doc of documents) { + for (const mem of doc.memories) { + let relations: Record = {} + + if ( + mem.memoryRelations && + typeof mem.memoryRelations === "object" && + Object.keys(mem.memoryRelations).length > 0 + ) { + relations = mem.memoryRelations + } else if (mem.parentMemoryId) { + // Legacy fallback: parentMemoryId implies "updates" + relations = { [mem.parentMemoryId]: "updates" } + } + + for (const [targetId, relationType] of Object.entries(relations)) { + if (!allNodeIds.has(targetId)) continue + const edgeType = + relationType === "updates" || + relationType === "extends" || + relationType === "derives" + ? relationType + : "updates" + result.push({ + id: `rel-${targetId}-${mem.id}`, + source: targetId, + target: mem.id, + edgeType, + }) + } + } + } + + return result +} + +// --------------------------------------------------------------------------- +// MCP transformData logic — pure extraction from mcp-app.ts transformData(). +// Includes the forward-reference fix (nodeIds pre-populated before edges). +// --------------------------------------------------------------------------- + +interface McpLink { + source: string + target: string + edgeType: "derives" | "updates" | "extends" +} + +function mcpComputeLinks( + documents: Array<{ + id: string + memories: Array<{ + id: string + parentMemoryId: string | null + memoryRelations?: Record | null + }> + }>, +): McpLink[] { + const links: McpLink[] = [] + + // Pre-populate all node IDs (the forward-reference fix) + const nodeIds = new Set() + for (const doc of documents) { + nodeIds.add(doc.id) + for (const mem of doc.memories) nodeIds.add(mem.id) + } + + for (const doc of documents) { + for (const mem of doc.memories) { + // Derives link (doc -> memory) + links.push({ source: doc.id, target: mem.id, edgeType: "derives" }) + + let relations: Record = {} + if ( + mem.memoryRelations && + typeof mem.memoryRelations === "object" && + Object.keys(mem.memoryRelations).length > 0 + ) { + relations = mem.memoryRelations + } else if (mem.parentMemoryId) { + relations = { [mem.parentMemoryId]: "updates" } + } + + for (const [targetId, relationType] of Object.entries(relations)) { + if (!nodeIds.has(targetId)) continue + const edgeType = + relationType === "updates" || + relationType === "extends" || + relationType === "derives" + ? relationType + : "updates" + links.push({ source: targetId, target: mem.id, edgeType }) + } + } + } + + return links +} + +/** The BUGGY version of mcpComputeLinks: nodeIds populated lazily (per-memory) + * rather than upfront. Used to prove the regression test would catch the bug. */ +function mcpComputeLinks_BUGGY( + documents: Array<{ + id: string + memories: Array<{ + id: string + parentMemoryId: string | null + memoryRelations?: Record | null + }> + }>, +): McpLink[] { + const links: McpLink[] = [] + const nodeIds = new Set() // NOT pre-populated — reproduces the old bug + + for (const doc of documents) { + nodeIds.add(doc.id) + for (const mem of doc.memories) { + nodeIds.add(mem.id) // only added as we reach this memory + + links.push({ source: doc.id, target: mem.id, edgeType: "derives" }) + + let relations: Record = {} + if ( + mem.memoryRelations && + typeof mem.memoryRelations === "object" && + Object.keys(mem.memoryRelations).length > 0 + ) { + relations = mem.memoryRelations + } else if (mem.parentMemoryId) { + relations = { [mem.parentMemoryId]: "updates" } + } + + for (const [targetId, relationType] of Object.entries(relations)) { + if (!nodeIds.has(targetId)) continue // forward refs silently dropped here! + const edgeType = + relationType === "updates" || + relationType === "extends" || + relationType === "derives" + ? relationType + : "updates" + links.push({ source: targetId, target: mem.id, edgeType }) + } + } + } + + return links +} + +// --------------------------------------------------------------------------- +// Helper factories +// --------------------------------------------------------------------------- + +function makeMem( + overrides: Partial & { id: string }, +): GraphApiMemory { + return { + memory: `Memory ${overrides.id}`, + isStatic: false, + spaceId: "default", + isLatest: true, + isForgotten: false, + forgetAfter: null, + forgetReason: null, + version: 1, + parentMemoryId: null, + rootMemoryId: null, + createdAt: "2024-01-01", + updatedAt: "2024-01-01", + ...overrides, + } +} + +function makeDoc(id: string, memories: GraphApiMemory[]): GraphApiDocument { + return { + id, + title: `Doc ${id}`, + summary: null, + documentType: "text", + createdAt: "2024-01-01", + updatedAt: "2024-01-01", + memories, + } +} + +// =========================================================================== +// (1) memoryRelations Record as PRIMARY source +// =========================================================================== + +describe("use-graph-data edges: memoryRelations Record as primary source", () => { + it("creates an extends edge from memoryRelations { targetId: 'extends' }", () => { + const docs = [ + makeDoc("d1", [ + makeMem({ id: "m1" }), + makeMem({ id: "m2", memoryRelations: { m1: "extends" } }), + ]), + ] + const edges = computeEdges(docs) + const relEdge = edges.find((e) => e.source === "m1" && e.target === "m2") + expect(relEdge).toBeDefined() + expect(relEdge?.edgeType).toBe("extends") + }) + + it("creates an updates edge from memoryRelations { targetId: 'updates' }", () => { + const docs = [ + makeDoc("d1", [ + makeMem({ id: "m1" }), + makeMem({ id: "m2", memoryRelations: { m1: "updates" } }), + ]), + ] + const edges = computeEdges(docs) + const relEdge = edges.find((e) => e.source === "m1" && e.target === "m2") + expect(relEdge).toBeDefined() + expect(relEdge?.edgeType).toBe("updates") + }) + + it("creates a derives edge from memoryRelations { targetId: 'derives' }", () => { + const docs = [ + makeDoc("d1", [ + makeMem({ id: "m1" }), + makeMem({ id: "m2", memoryRelations: { m1: "derives" } }), + ]), + ] + const edges = computeEdges(docs) + const relEdge = edges.find( + (e) => e.source === "m1" && e.target === "m2" && e.id.startsWith("rel-"), + ) + expect(relEdge).toBeDefined() + expect(relEdge?.edgeType).toBe("derives") + }) + + it("creates multiple edges when memoryRelations has multiple targets", () => { + const docs = [ + makeDoc("d1", [ + makeMem({ id: "m1" }), + makeMem({ id: "m2" }), + makeMem({ + id: "m3", + memoryRelations: { m1: "updates", m2: "extends" }, + }), + ]), + ] + const edges = computeEdges(docs) + const relEdgesToM3 = edges.filter( + (e) => e.target === "m3" && e.id.startsWith("rel-"), + ) + expect(relEdgesToM3.length).toBe(2) + const types = new Set(relEdgesToM3.map((e) => e.edgeType)) + expect(types.has("updates")).toBe(true) + expect(types.has("extends")).toBe(true) + }) +}) + +// =========================================================================== +// (2) memoryRelations TAKES PRECEDENCE over parentMemoryId +// =========================================================================== + +describe("use-graph-data edges: memoryRelations wins over parentMemoryId", () => { + /** + * Critical regression test: if a memory has BOTH memoryRelations and + * parentMemoryId, only memoryRelations should be used. The fallback + * parentMemoryId path must NOT fire when memoryRelations is present. + */ + it("uses extends from memoryRelations, ignores parentMemoryId updates fallback", () => { + const docs = [ + makeDoc("d1", [ + makeMem({ id: "mParent" }), + makeMem({ id: "mOther" }), + makeMem({ + id: "m3", + // parentMemoryId would imply an "updates" edge to mParent + parentMemoryId: "mParent", + // memoryRelations says extends to mOther — this should win + memoryRelations: { mOther: "extends" }, + }), + ]), + ] + const edges = computeEdges(docs) + const relEdges = edges.filter( + (e) => e.id.startsWith("rel-") && e.target === "m3", + ) + + // Should be exactly ONE rel edge (extends to mOther), NOT two + expect(relEdges.length).toBe(1) + expect(relEdges[0]?.edgeType).toBe("extends") + expect(relEdges[0]?.source).toBe("mOther") + + // The parentMemoryId-implied updates edge to mParent must NOT exist + const badEdge = relEdges.find((e) => e.source === "mParent") + expect(badEdge).toBeUndefined() + }) +}) + +// =========================================================================== +// (3) parentMemoryId FALLBACK when memoryRelations absent/null/empty +// =========================================================================== + +describe("use-graph-data edges: parentMemoryId fallback", () => { + it("creates updates edge from parentMemoryId when memoryRelations is absent", () => { + const docs = [ + makeDoc("d1", [ + makeMem({ id: "m1" }), + makeMem({ id: "m2", parentMemoryId: "m1" }), + ]), + ] + const edges = computeEdges(docs) + const relEdge = edges.find( + (e) => e.source === "m1" && e.target === "m2" && e.id.startsWith("rel-"), + ) + expect(relEdge).toBeDefined() + expect(relEdge?.edgeType).toBe("updates") + }) + + it("creates updates edge from parentMemoryId when memoryRelations is null", () => { + const docs = [ + makeDoc("d1", [ + makeMem({ id: "m1" }), + makeMem({ id: "m2", parentMemoryId: "m1", memoryRelations: null }), + ]), + ] + const edges = computeEdges(docs) + const relEdge = edges.find( + (e) => e.source === "m1" && e.target === "m2" && e.id.startsWith("rel-"), + ) + expect(relEdge).toBeDefined() + expect(relEdge?.edgeType).toBe("updates") + }) + + it("creates updates edge from parentMemoryId when memoryRelations is empty {}", () => { + const docs = [ + makeDoc("d1", [ + makeMem({ id: "m1" }), + makeMem({ id: "m2", parentMemoryId: "m1", memoryRelations: {} }), + ]), + ] + const edges = computeEdges(docs) + const relEdge = edges.find( + (e) => e.source === "m1" && e.target === "m2" && e.id.startsWith("rel-"), + ) + expect(relEdge).toBeDefined() + expect(relEdge?.edgeType).toBe("updates") + }) + + it("drops the parentMemoryId edge when the parent is not in the dataset", () => { + const docs = [ + makeDoc("d1", [ + makeMem({ id: "m1", parentMemoryId: "ghost_id_not_in_docs" }), + ]), + ] + const edges = computeEdges(docs) + const ghostEdges = edges.filter((e) => e.source === "ghost_id_not_in_docs") + expect(ghostEdges.length).toBe(0) + }) +}) + +// =========================================================================== +// (4) Invalid relationType defaults to "updates" +// =========================================================================== + +describe("use-graph-data edges: invalid relationType defaults to updates", () => { + it("maps an unknown relationType string to 'updates' edge type", () => { + const docs = [ + makeDoc("d1", [ + makeMem({ id: "m1" }), + makeMem({ + id: "m2", + // biome-ignore lint/suspicious/noExplicitAny: intentional bad-data test + memoryRelations: { m1: "totally-bogus-type" as any }, + }), + ]), + ] + const edges = computeEdges(docs) + const relEdge = edges.find( + (e) => e.source === "m1" && e.target === "m2" && e.id.startsWith("rel-"), + ) + expect(relEdge).toBeDefined() + expect(relEdge?.edgeType).toBe("updates") + }) +}) + +// =========================================================================== +// (5) MCP forward-reference fix — THE CRITICAL BUG +// =========================================================================== + +describe("MCP transformData: forward-reference fix (pre-populated nodeIds)", () => { + /** + * SETUP: Two memories in the same document. m1 is processed first. + * m1 has memoryRelations pointing at m2, which appears AFTER m1. + * + * OLD BUG: nodeIds was built lazily — when m1 was processed, m2 had not + * yet been added to nodeIds, so `!nodeIds.has("m2")` was true and the + * edge was silently skipped. + * + * FIX: All node IDs are pre-populated before any edge is evaluated, so + * m2 is always in nodeIds when m1's relations are checked. + */ + it("creates edge for forward-referenced memory within same document", () => { + const docs = [ + { + id: "d1", + memories: [ + // m1 comes FIRST and references m2 which comes SECOND + { + id: "m1", + parentMemoryId: null, + memoryRelations: { m2: "extends" }, + }, + { id: "m2", parentMemoryId: null, memoryRelations: null }, + ], + }, + ] + + // Fixed version creates the edge + const fixedLinks = mcpComputeLinks(docs) + const edge = fixedLinks.find((l) => l.source === "m2" && l.target === "m1") + expect(edge).toBeDefined() + expect(edge?.edgeType).toBe("extends") + }) + + it("creates edge for forward-referenced memory in a LATER document", () => { + // m1 in doc1 references m2 in doc2. m2 is only encountered during + // doc2's iteration, AFTER m1's relations are evaluated. + const docs = [ + { + id: "d1", + memories: [ + { + id: "m1", + parentMemoryId: null, + memoryRelations: { m2: "updates" }, + }, + ], + }, + { + id: "d2", + memories: [{ id: "m2", parentMemoryId: null, memoryRelations: null }], + }, + ] + + const fixedLinks = mcpComputeLinks(docs) + const edge = fixedLinks.find((l) => l.source === "m2" && l.target === "m1") + expect(edge).toBeDefined() + expect(edge?.edgeType).toBe("updates") + }) + + /** + * REGRESSION PROOF: Run the same scenario through the BUGGY implementation. + * The buggy version MUST drop the edge. If this test fails (i.e. buggy code + * creates the edge too), the forward-reference scenario doesn't actually + * demonstrate the bug, and our fix tests prove nothing. + */ + it("proves the bug: lazy nodeIds population drops forward-referenced edges", () => { + const docs = [ + { + id: "d1", + memories: [ + { + id: "m1", + parentMemoryId: null, + memoryRelations: { m2: "extends" }, + }, + { id: "m2", parentMemoryId: null, memoryRelations: null }, + ], + }, + ] + + // The buggy version MUST drop the m1->m2 edge (forward reference) + const buggyLinks = mcpComputeLinks_BUGGY(docs) + const buggyEdge = buggyLinks.find( + (l) => l.source === "m2" && l.target === "m1", + ) + expect(buggyEdge).toBeUndefined() // confirms the bug was real + + // The fixed version MUST create it + const fixedLinks = mcpComputeLinks(docs) + const fixedEdge = fixedLinks.find( + (l) => l.source === "m2" && l.target === "m1", + ) + expect(fixedEdge).toBeDefined() // confirms the fix works + }) + + it("proves the bug: cross-document forward reference also dropped by lazy code", () => { + const docs = [ + { + id: "d1", + memories: [ + { + id: "m1", + parentMemoryId: null, + memoryRelations: { m2: "updates" }, + }, + ], + }, + { + id: "d2", + memories: [{ id: "m2", parentMemoryId: null, memoryRelations: null }], + }, + ] + + // Buggy: m2 is not in nodeIds when m1 is processed — edge dropped + const buggyLinks = mcpComputeLinks_BUGGY(docs) + expect( + buggyLinks.find((l) => l.source === "m2" && l.target === "m1"), + ).toBeUndefined() + + // Fixed: m2 is pre-populated — edge created + const fixedLinks = mcpComputeLinks(docs) + expect( + fixedLinks.find((l) => l.source === "m2" && l.target === "m1"), + ).toBeDefined() + }) +}) + +// =========================================================================== +// (6) Structural correctness: derives edges always created, doc->mem +// =========================================================================== + +describe("use-graph-data edges: derives edges always present for all doc->mem pairs", () => { + it("creates exactly one derives edge per memory across multiple documents", () => { + const docs = [ + makeDoc("d1", [makeMem({ id: "m1" }), makeMem({ id: "m2" })]), + makeDoc("d2", [ + makeMem({ id: "m3" }), + makeMem({ id: "m4" }), + makeMem({ id: "m5" }), + ]), + ] + const edges = computeEdges(docs) + const derivesEdges = edges.filter((e) => e.edgeType === "derives") + // 2 memories in d1 + 3 in d2 = 5 derives edges + expect(derivesEdges.length).toBe(5) + expect(derivesEdges.map((e) => e.target).sort()).toEqual([ + "m1", + "m2", + "m3", + "m4", + "m5", + ]) + }) + + it("returns empty array for empty documents input", () => { + expect(computeEdges([])).toEqual([]) + }) + + it("creates no relation edges when all memories are standalone", () => { + const docs = [ + makeDoc("d1", [ + makeMem({ id: "m1" }), // no parentMemoryId, no memoryRelations + makeMem({ id: "m2" }), + ]), + ] + const edges = computeEdges(docs) + const relEdges = edges.filter((e) => e.id.startsWith("rel-")) + expect(relEdges.length).toBe(0) + }) +}) + +// =========================================================================== +// (7) getEdgeVisualProps: the MemoryRelation type is the canonical source +// =========================================================================== + +describe("getEdgeVisualProps: all MemoryRelation values return valid visual props", () => { + const relations = ["updates", "extends", "derives"] as const + + for (const rel of relations) { + it(`returns positive opacity and thickness for '${rel}'`, () => { + const props = getEdgeVisualProps(rel) + expect(props.opacity).toBeGreaterThan(0) + expect(props.thickness).toBeGreaterThan(0) + }) + } + + it("extends edges have higher opacity than derives edges (rare but meaningful)", () => { + const ext = getEdgeVisualProps("extends") + const der = getEdgeVisualProps("derives") + expect(ext.opacity).toBeGreaterThan(der.opacity) + }) + + it("updates edges have higher opacity than derives edges (version chains are prominent)", () => { + const upd = getEdgeVisualProps("updates") + const der = getEdgeVisualProps("derives") + expect(upd.opacity).toBeGreaterThan(der.opacity) + }) + + it("unknown edge type returns default props (opacity 0.4, thickness 1.2)", () => { + // The default case returns { opacity: 0.4, thickness: 1.2 }. + // A safe conservative fallback matching derives (the most common edge type). + const unknown = getEdgeVisualProps("nonexistent") + expect(unknown.opacity).toBeCloseTo(0.4) + expect(unknown.thickness).toBeCloseTo(1.2) + }) +}) diff --git a/packages/memory-graph/src/__tests__/graph-data-utils.test.ts b/packages/memory-graph/src/__tests__/graph-data-utils.test.ts new file mode 100644 index 000000000..9c643925a --- /dev/null +++ b/packages/memory-graph/src/__tests__/graph-data-utils.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect } from "vitest" +import { + getMemoryBorderColor, + getEdgeVisualProps, +} from "../hooks/use-graph-data" +import { DEFAULT_COLORS } from "../constants" +import type { GraphApiMemory } from "../types" + +function makeMemory(overrides: Partial = {}): GraphApiMemory { + return { + id: "m1", + memory: "test", + isStatic: false, + spaceId: "default", + isLatest: true, + isForgotten: false, + forgetAfter: null, + forgetReason: null, + version: 1, + parentMemoryId: null, + rootMemoryId: null, + createdAt: "2024-01-01", + updatedAt: "2024-01-01", + ...overrides, + } +} + +describe("getMemoryBorderColor", () => { + const colors = DEFAULT_COLORS + + it("returns forgotten color for forgotten memories", () => { + const mem = makeMemory({ isForgotten: true }) + expect(getMemoryBorderColor(mem, colors)).toBe(colors.memBorderForgotten) + }) + + it("returns expiring color for memories expiring within 7 days", () => { + const soon = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString() + const mem = makeMemory({ forgetAfter: soon }) + expect(getMemoryBorderColor(mem, colors)).toBe(colors.memBorderExpiring) + }) + + it("returns recent color for memories created within 24 hours", () => { + const recent = new Date(Date.now() - 1000).toISOString() + const mem = makeMemory({ createdAt: recent }) + expect(getMemoryBorderColor(mem, colors)).toBe(colors.memBorderRecent) + }) + + it("returns default color for normal memories", () => { + const old = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString() + const mem = makeMemory({ createdAt: old }) + expect(getMemoryBorderColor(mem, colors)).toBe(colors.memStrokeDefault) + }) + + it("forgotten takes priority over expiring", () => { + const soon = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString() + const mem = makeMemory({ isForgotten: true, forgetAfter: soon }) + expect(getMemoryBorderColor(mem, colors)).toBe(colors.memBorderForgotten) + }) +}) + +describe("getEdgeVisualProps", () => { + it("returns correct props for derives edges", () => { + const props = getEdgeVisualProps("derives") + expect(props.opacity).toBeCloseTo(0.4) + expect(props.thickness).toBeCloseTo(1.2) + }) + + it("returns correct props for updates edges", () => { + const props = getEdgeVisualProps("updates") + expect(props.opacity).toBeCloseTo(0.7) + expect(props.thickness).toBeCloseTo(2) + }) + + it("returns correct props for extends edges", () => { + const props = getEdgeVisualProps("extends") + expect(props.opacity).toBeCloseTo(0.55) + expect(props.thickness).toBeCloseTo(1.5) + }) + + it("returns default props for unknown edge types", () => { + const props = getEdgeVisualProps("unknown") + expect(props.opacity).toBeCloseTo(0.4) + expect(props.thickness).toBeCloseTo(1.2) + }) +}) diff --git a/packages/memory-graph/src/__tests__/mock-data.test.ts b/packages/memory-graph/src/__tests__/mock-data.test.ts new file mode 100644 index 000000000..4774497d6 --- /dev/null +++ b/packages/memory-graph/src/__tests__/mock-data.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect } from "vitest" +import { generateMockGraphData } from "../mock-data" + +describe("generateMockGraphData", () => { + it("produces deterministic output with same seed", () => { + const data1 = generateMockGraphData({ documentCount: 10, seed: 42 }) + const data2 = generateMockGraphData({ documentCount: 10, seed: 42 }) + + expect(data1.documents.length).toBe(data2.documents.length) + expect(data1.documents[0]!.id).toBe(data2.documents[0]!.id) + expect(data1.documents[0]!.title).toBe(data2.documents[0]!.title) + }) + + it("produces different output with different seeds", () => { + const data1 = generateMockGraphData({ documentCount: 10, seed: 42 }) + const data2 = generateMockGraphData({ documentCount: 10, seed: 99 }) + + // At least some documents should differ + const titles1 = data1.documents.map((d) => d.title).join(",") + const titles2 = data2.documents.map((d) => d.title).join(",") + expect(titles1).not.toBe(titles2) + }) + + it("generates correct number of documents", () => { + const data = generateMockGraphData({ documentCount: 25, seed: 1 }) + expect(data.documents.length).toBe(25) + }) + + it("documents have required fields", () => { + const data = generateMockGraphData({ documentCount: 5, seed: 1 }) + for (const doc of data.documents) { + expect(doc.id).toBeDefined() + expect(doc.title).toBeDefined() + expect(doc.summary).toBeDefined() + expect(doc.documentType).toBeDefined() + expect(doc.createdAt).toBeDefined() + expect(doc.updatedAt).toBeDefined() + expect(Array.isArray(doc.memories)).toBe(true) + } + }) + + it("memories have required fields", () => { + const data = generateMockGraphData({ documentCount: 5, seed: 1 }) + const doc = data.documents.find((d) => d.memories.length > 0) + expect(doc).toBeDefined() + + for (const mem of doc!.memories) { + expect(mem.id).toBeDefined() + expect(mem.memory).toBeDefined() + expect(typeof mem.isStatic).toBe("boolean") + expect(typeof mem.isForgotten).toBe("boolean") + expect(typeof mem.isLatest).toBe("boolean") + expect(typeof mem.version).toBe("number") + expect(mem.createdAt).toBeDefined() + expect(mem.updatedAt).toBeDefined() + } + }) + + it("handles zero documents", () => { + const data = generateMockGraphData({ documentCount: 0, seed: 1 }) + expect(data.documents.length).toBe(0) + }) + + it("respects memoriesPerDoc range", () => { + const data = generateMockGraphData({ + documentCount: 50, + memoriesPerDoc: [3, 3], + seed: 1, + }) + for (const doc of data.documents) { + expect(doc.memories.length).toBe(3) + } + }) + + it("generates version chains for some documents", () => { + const data = generateMockGraphData({ + documentCount: 50, + memoriesPerDoc: [3, 6], + seed: 42, + }) + // With 50 docs and 30% chain probability, we should have some chains + const hasChain = data.documents.some((doc) => + doc.memories.some((mem) => mem.parentMemoryId !== null), + ) + expect(hasChain).toBe(true) + }) +}) diff --git a/packages/memory-graph/src/__tests__/renderer-utils.test.ts b/packages/memory-graph/src/__tests__/renderer-utils.test.ts new file mode 100644 index 000000000..481256a2b --- /dev/null +++ b/packages/memory-graph/src/__tests__/renderer-utils.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "vitest" +import { lightenColor } from "../canvas/renderer" + +describe("lightenColor", () => { + test("lightens a dark hex color", () => { + // #1B1F24 lightened by 0.08 → each channel +20 (0.08*255≈20) + const result = lightenColor("#1B1F24", 0.08) + // R: 0x1B(27)+20=47=0x2f, G: 0x1F(31)+20=51=0x33, B: 0x24(36)+20=56=0x38 + expect(result).toBe("#2f3338") + }) + + test("clamps channels at 255", () => { + // #FFFFFF lightened by 0.1 → all channels clamped at 255 + const result = lightenColor("#ffffff", 0.1) + expect(result).toBe("#ffffff") + }) + + test("handles zero amount (no change)", () => { + const result = lightenColor("#1B1F24", 0) + expect(result).toBe("#1b1f24") + }) + + test("returns input unchanged for 3-digit hex", () => { + expect(lightenColor("#abc", 0.1)).toBe("#abc") + }) + + test("returns input unchanged for rgb() format", () => { + expect(lightenColor("rgb(27, 31, 36)", 0.1)).toBe("rgb(27, 31, 36)") + }) + + test("returns input unchanged for 8-digit hex with alpha", () => { + expect(lightenColor("#1B1F24FF", 0.1)).toBe("#1B1F24FF") + }) + + test("caches result for repeated calls", () => { + const first = lightenColor("#0D2034", 0.08) + const second = lightenColor("#0D2034", 0.08) + expect(first).toBe(second) + }) + + test("cache invalidates on different input", () => { + const a = lightenColor("#0D2034", 0.08) + const b = lightenColor("#1B1F24", 0.08) + expect(a).not.toBe(b) + }) + + test("cache invalidates on different amount", () => { + const a = lightenColor("#1B1F24", 0.05) + const b = lightenColor("#1B1F24", 0.1) + expect(a).not.toBe(b) + }) + + test("handles hex without # prefix", () => { + const result = lightenColor("1B1F24", 0.08) + expect(result).toBe("#2f3338") + }) +}) diff --git a/packages/memory-graph/src/__tests__/simulation.test.ts b/packages/memory-graph/src/__tests__/simulation.test.ts new file mode 100644 index 000000000..61d46752e --- /dev/null +++ b/packages/memory-graph/src/__tests__/simulation.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect } from "vitest" +import { ForceSimulation } from "../canvas/simulation" +import type { GraphNode, GraphEdge } from "../types" + +function makeNode(id: string, x: number, y: number): GraphNode { + return { + id, + type: "document", + x, + y, + size: 50, + borderColor: "#fff", + isHovered: false, + isDragging: false, + data: { + id, + title: id, + summary: null, + type: "text", + createdAt: "2024-01-01", + updatedAt: "2024-01-01", + memories: [], + }, + } +} + +function makeEdge(source: string, target: string): GraphEdge { + return { + id: `e-${source}-${target}`, + source, + target, + visualProps: { opacity: 0.5, thickness: 1.5 }, + edgeType: "derives", + } +} + +describe("ForceSimulation", () => { + it("init creates simulation and isActive returns true", () => { + const sim = new ForceSimulation() + const nodes = [makeNode("a", 0, 0), makeNode("b", 100, 100)] + const edges = [makeEdge("a", "b")] + sim.init(nodes, edges) + expect(sim.isActive()).toBe(true) + sim.destroy() + }) + + it("destroy stops simulation", () => { + const sim = new ForceSimulation() + const nodes = [makeNode("a", 0, 0)] + sim.init(nodes, []) + sim.destroy() + expect(sim.isActive()).toBe(false) + }) + + it("init moves nodes from initial positions (pre-tick)", () => { + const sim = new ForceSimulation() + const nodes = [ + makeNode("a", 0, 0), + makeNode("b", 0, 0), // Same position - repulsion should move them + ] + sim.init(nodes, []) + + // After init with pre-ticks, nodes at same position should have moved apart + const dx = nodes[0]!.x - nodes[1]!.x + const dy = nodes[0]!.y - nodes[1]!.y + const dist = Math.sqrt(dx * dx + dy * dy) + expect(dist).toBeGreaterThan(0) + sim.destroy() + }) + + it("update hot-swaps nodes without full re-init", () => { + const sim = new ForceSimulation() + const nodes = [makeNode("a", 0, 0), makeNode("b", 100, 100)] + sim.init(nodes, []) + + // Update with same nodes but different positions + nodes[0]!.x = 50 + expect(() => sim.update(nodes, [])).not.toThrow() + expect(sim.isActive()).toBe(true) + sim.destroy() + }) + + it("reheat increases simulation energy", () => { + const sim = new ForceSimulation() + const nodes = [makeNode("a", 0, 0)] + sim.init(nodes, []) + // Should not throw + expect(() => sim.reheat()).not.toThrow() + sim.destroy() + }) + + it("coolDown reduces simulation energy", () => { + const sim = new ForceSimulation() + const nodes = [makeNode("a", 0, 0)] + sim.init(nodes, []) + expect(() => sim.coolDown()).not.toThrow() + sim.destroy() + }) + + it("handles empty nodes array", () => { + const sim = new ForceSimulation() + expect(() => sim.init([], [])).not.toThrow() + sim.destroy() + }) + + it("handles edges with missing nodes gracefully", () => { + const sim = new ForceSimulation() + const nodes = [makeNode("a", 0, 0)] + const edges = [makeEdge("a", "nonexistent")] + // Should not throw even with dangling edge + expect(() => sim.init(nodes, edges)).not.toThrow() + sim.destroy() + }) +}) diff --git a/packages/memory-graph/src/__tests__/spatial-index.test.ts b/packages/memory-graph/src/__tests__/spatial-index.test.ts new file mode 100644 index 000000000..46d7beabe --- /dev/null +++ b/packages/memory-graph/src/__tests__/spatial-index.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect } from "vitest" +import { SpatialIndex } from "../canvas/hit-test" +import type { GraphNode } from "../types" + +function makeNode( + id: string, + x: number, + y: number, + type: "document" | "memory" = "document", + size = 50, +): GraphNode { + return { + id, + type, + x, + y, + size, + borderColor: "#fff", + isHovered: false, + isDragging: false, + data: { + id, + title: id, + summary: null, + type: "text", + createdAt: "2024-01-01", + updatedAt: "2024-01-01", + memories: [], + }, + } +} + +describe("SpatialIndex", () => { + it("rebuild returns true on first build", () => { + const idx = new SpatialIndex() + const result = idx.rebuild([makeNode("a", 100, 100)]) + expect(result).toBe(true) + }) + + it("rebuild returns false when hash unchanged", () => { + const idx = new SpatialIndex() + const nodes = [makeNode("a", 100, 100)] + idx.rebuild(nodes) + const result = idx.rebuild(nodes) + expect(result).toBe(false) + }) + + it("rebuild returns true when positions change", () => { + const idx = new SpatialIndex() + const nodes = [makeNode("a", 100, 100)] + idx.rebuild(nodes) + nodes[0]!.x = 500 + const result = idx.rebuild(nodes) + expect(result).toBe(true) + }) + + it("rebuild detects sub-pixel movements (10x granularity)", () => { + const idx = new SpatialIndex() + const nodes = [makeNode("a", 100.0, 100.0)] + idx.rebuild(nodes) + // Move by 0.2 pixels — should be detected with 10x rounding + nodes[0]!.x = 100.2 + const result = idx.rebuild(nodes) + expect(result).toBe(true) + }) + + it("queryPoint finds correct node (document - square hit test)", () => { + const idx = new SpatialIndex() + const node = makeNode("a", 100, 100, "document", 50) + idx.rebuild([node]) + const found = idx.queryPoint(105, 105) + expect(found).not.toBeNull() + expect(found!.id).toBe("a") + }) + + it("queryPoint finds correct node (memory - circle hit test)", () => { + const idx = new SpatialIndex() + const node = makeNode("m1", 200, 200, "memory", 36) + idx.rebuild([node]) + // Inside the circle (radius = 18) + const found = idx.queryPoint(210, 210) + expect(found).not.toBeNull() + expect(found!.id).toBe("m1") + }) + + it("queryPoint returns null for empty grid", () => { + const idx = new SpatialIndex() + idx.rebuild([]) + expect(idx.queryPoint(100, 100)).toBeNull() + }) + + it("queryPoint returns null for distant coordinates", () => { + const idx = new SpatialIndex() + idx.rebuild([makeNode("a", 100, 100)]) + expect(idx.queryPoint(5000, 5000)).toBeNull() + }) + + it("queryPoint handles overlapping nodes (returns last in render order)", () => { + const idx = new SpatialIndex() + const nodes = [ + makeNode("a", 100, 100, "document", 50), + makeNode("b", 110, 110, "document", 50), + ] + idx.rebuild(nodes) + // Both nodes overlap at (105, 105), should return the last one (higher z) + const found = idx.queryPoint(105, 105) + expect(found).not.toBeNull() + expect(found!.id).toBe("b") + }) + + it("queryPoint works across cell boundaries", () => { + const idx = new SpatialIndex() + // Node at cell boundary (cellSize = 200) + const node = makeNode("edge", 199, 199, "document", 50) + idx.rebuild([node]) + // Query from adjacent cell + const found = idx.queryPoint(201, 201) + expect(found).not.toBeNull() + expect(found!.id).toBe("edge") + }) + + it("handles many nodes without errors", () => { + const idx = new SpatialIndex() + const nodes = Array.from({ length: 1000 }, (_, i) => + makeNode(`n${i}`, Math.random() * 2000, Math.random() * 2000), + ) + expect(() => idx.rebuild(nodes)).not.toThrow() + // Should find at least some nodes + const found = idx.queryPoint(nodes[0]!.x, nodes[0]!.y) + expect(found).not.toBeNull() + }) +}) diff --git a/packages/memory-graph/src/__tests__/version-chain.test.ts b/packages/memory-graph/src/__tests__/version-chain.test.ts new file mode 100644 index 000000000..eb1bf0298 --- /dev/null +++ b/packages/memory-graph/src/__tests__/version-chain.test.ts @@ -0,0 +1,406 @@ +import { describe, it, expect } from "vitest" +import { VersionChainIndex } from "../canvas/version-chain" +import type { GraphApiDocument, GraphApiMemory } from "../types" + +function makeMem( + overrides: Partial & { id: string }, +): GraphApiMemory { + return { + memory: `Memory ${overrides.id}`, + isStatic: false, + spaceId: "default", + isLatest: true, + isForgotten: false, + forgetAfter: null, + forgetReason: null, + version: 1, + parentMemoryId: null, + rootMemoryId: null, + createdAt: "2024-01-01", + updatedAt: "2024-01-01", + ...overrides, + } +} + +function makeDoc(id: string, memories: GraphApiMemory[]): GraphApiDocument { + return { + id, + title: `Doc ${id}`, + summary: null, + documentType: "text", + createdAt: "2024-01-01", + updatedAt: "2024-01-01", + memories, + } +} + +describe("VersionChainIndex", () => { + it("getChain returns null for standalone memory (no parent, no children)", () => { + const idx = new VersionChainIndex() + const doc = makeDoc("d1", [makeMem({ id: "m1", version: 1 })]) + idx.rebuild([doc]) + // Single memory with no parent and no children — not a chain + expect(idx.getChain("m1")).toBeNull() + }) + + it("getChain from latest node returns full chain in version order", () => { + const idx = new VersionChainIndex() + const doc = makeDoc("d1", [ + makeMem({ id: "m1", version: 1 }), + makeMem({ + id: "m2", + parentMemoryId: "m1", + rootMemoryId: "m1", + version: 2, + }), + makeMem({ + id: "m3", + parentMemoryId: "m2", + rootMemoryId: "m1", + version: 3, + }), + ]) + idx.rebuild([doc]) + + // Query from the latest (version 3) — walks back m3->m2->m1, reverses to [m1,m2,m3] + const chain = idx.getChain("m3") + expect(chain).not.toBeNull() + expect(chain!.length).toBe(3) + expect(chain!.map((e) => e.id)).toEqual(["m1", "m2", "m3"]) + }) + + it("getChain from middle element returns full chain (backward + forward)", () => { + const idx = new VersionChainIndex() + const doc = makeDoc("d1", [ + makeMem({ id: "m1", version: 1 }), + makeMem({ + id: "m2", + parentMemoryId: "m1", + rootMemoryId: "m1", + version: 2, + }), + makeMem({ + id: "m3", + parentMemoryId: "m2", + rootMemoryId: "m1", + version: 3, + }), + ]) + idx.rebuild([doc]) + + // Query from m2 (version 2) — walks back to m1, forward to m3 + const chain = idx.getChain("m2") + expect(chain).not.toBeNull() + expect(chain!.length).toBe(3) + expect(chain!.map((e) => e.id)).toEqual(["m1", "m2", "m3"]) + }) + + it("caches chain results for all entries in the chain", () => { + const idx = new VersionChainIndex() + const doc = makeDoc("d1", [ + makeMem({ id: "m1", version: 1 }), + makeMem({ + id: "m2", + parentMemoryId: "m1", + rootMemoryId: "m1", + version: 2, + }), + ]) + idx.rebuild([doc]) + + const chain1 = idx.getChain("m2") + // After querying m2, m1 should also be cached (same chain object) + const chain2 = idx.getChain("m1") + // m1 is version 1, but it was cached as part of m2's chain + expect(chain2).toBe(chain1) // same reference + }) + + it("getChain from v1 root with children returns full chain", () => { + const idx = new VersionChainIndex() + const doc = makeDoc("d1", [ + makeMem({ id: "m1", version: 1 }), + makeMem({ + id: "m2", + parentMemoryId: "m1", + rootMemoryId: "m1", + version: 2, + }), + makeMem({ + id: "m3", + parentMemoryId: "m2", + rootMemoryId: "m1", + version: 3, + }), + ]) + idx.rebuild([doc]) + + // Query from v1 root — walks forward to m2, m3 + const chain = idx.getChain("m1") + expect(chain).not.toBeNull() + expect(chain!.length).toBe(3) + expect(chain!.map((e) => e.id)).toEqual(["m1", "m2", "m3"]) + }) + + it("getChain returns null for unknown ID", () => { + const idx = new VersionChainIndex() + idx.rebuild([makeDoc("d1", [makeMem({ id: "m1", version: 1 })])]) + expect(idx.getChain("nonexistent")).toBeNull() + }) + + it("handles empty documents array", () => { + const idx = new VersionChainIndex() + expect(() => idx.rebuild([])).not.toThrow() + expect(idx.getChain("anything")).toBeNull() + }) + + it("rebuild clears previous chains (new array reference)", () => { + const idx = new VersionChainIndex() + const doc1 = makeDoc("d1", [ + makeMem({ id: "m1", version: 1 }), + makeMem({ + id: "m2", + parentMemoryId: "m1", + rootMemoryId: "m1", + version: 2, + }), + ]) + idx.rebuild([doc1]) + expect(idx.getChain("m2")).not.toBeNull() + + // Rebuild with different data (new array reference) + const doc2 = makeDoc("d2", [makeMem({ id: "m3", version: 1 })]) + idx.rebuild([doc2]) + expect(idx.getChain("m2")).toBeNull() + expect(idx.getChain("m1")).toBeNull() + }) + + it("rebuild skips if same array reference", () => { + const idx = new VersionChainIndex() + const docs = [ + makeDoc("d1", [ + makeMem({ id: "m1", version: 1 }), + makeMem({ + id: "m2", + parentMemoryId: "m1", + rootMemoryId: "m1", + version: 2, + }), + ]), + ] + idx.rebuild(docs) + const chain1 = idx.getChain("m2") + + // Same reference — rebuild is a no-op + idx.rebuild(docs) + const chain2 = idx.getChain("m2") + expect(chain2).toBe(chain1) + }) + + it("handles multiple independent chains across documents", () => { + const idx = new VersionChainIndex() + const docs = [ + makeDoc("d1", [ + makeMem({ id: "m1", version: 1 }), + makeMem({ + id: "m2", + parentMemoryId: "m1", + rootMemoryId: "m1", + version: 2, + }), + ]), + makeDoc("d2", [ + makeMem({ id: "m3", version: 1 }), + makeMem({ + id: "m4", + parentMemoryId: "m3", + rootMemoryId: "m3", + version: 2, + }), + ]), + ] + idx.rebuild(docs) + + const chain1 = idx.getChain("m2") + const chain2 = idx.getChain("m4") + expect(chain1).not.toBeNull() + expect(chain2).not.toBeNull() + expect(chain1!.map((e) => e.id)).toEqual(["m1", "m2"]) + expect(chain2!.map((e) => e.id)).toEqual(["m3", "m4"]) + }) + + it("handles circular parent references without infinite loop", () => { + const idx = new VersionChainIndex() + const doc = makeDoc("d1", [ + makeMem({ id: "m1", version: 1, parentMemoryId: "m2" }), + makeMem({ id: "m2", version: 2, parentMemoryId: "m1" }), + ]) + idx.rebuild([doc]) + + // Cycle: m1->m2->m1. The visited set prevents infinite loops. + const chain = idx.getChain("m1") + expect(chain).not.toBeNull() + expect(chain!.length).toBe(2) + }) + + it("branching children: follows first child by document order", () => { + const idx = new VersionChainIndex() + const doc = makeDoc("d1", [ + makeMem({ id: "m1", version: 1 }), + makeMem({ + id: "m2a", + parentMemoryId: "m1", + rootMemoryId: "m1", + version: 2, + }), + makeMem({ + id: "m2b", + parentMemoryId: "m1", + rootMemoryId: "m1", + version: 2, + }), + ]) + idx.rebuild([doc]) + + // m1 has two children; forward walk picks the first (m2a) + const chain = idx.getChain("m1") + expect(chain).not.toBeNull() + expect(chain!.length).toBe(2) + expect(chain!.map((e) => e.id)).toEqual(["m1", "m2a"]) + }) + + it("chain entries have correct fields", () => { + const idx = new VersionChainIndex() + const doc = makeDoc("d1", [ + makeMem({ id: "m1", version: 1, isForgotten: true, isLatest: false }), + makeMem({ + id: "m2", + parentMemoryId: "m1", + rootMemoryId: "m1", + version: 2, + isLatest: true, + }), + ]) + idx.rebuild([doc]) + + const chain = idx.getChain("m2") + expect(chain).not.toBeNull() + expect(chain![0]).toEqual({ + id: "m1", + version: 1, + memory: "Memory m1", + isForgotten: true, + isLatest: false, + }) + expect(chain![1]).toEqual({ + id: "m2", + version: 2, + memory: "Memory m2", + isForgotten: false, + isLatest: true, + }) + }) + + // --- Additional edge cases --- + + it("getChain returns null for orphaned non-root memory (v2+, no parent in index, no children)", () => { + const idx = new VersionChainIndex() + // m2 claims version 2 and has a parentMemoryId, but that parent is not in any document. + // The backward walk reaches a dead end after m2 itself (parent not in memoryMap). + // all.length === 1 → returns null, same as a standalone v1. + const doc = makeDoc("d1", [ + makeMem({ + id: "m2", + version: 2, + parentMemoryId: "m_ghost", + rootMemoryId: "m_ghost", + }), + ]) + idx.rebuild([doc]) + expect(idx.getChain("m2")).toBeNull() + }) + + it("cross-document chain: parent in doc1, child in doc2 resolves correctly", () => { + // rebuild() walks all documents in one pass, so parentMemoryId references + // are resolved across document boundaries. This mirrors production usage where + // chainIndex.current.rebuild(limitedDocuments) receives all documents at once. + const idx = new VersionChainIndex() + const docs = [ + makeDoc("d1", [makeMem({ id: "m1", version: 1 })]), + makeDoc("d2", [ + makeMem({ + id: "m2", + parentMemoryId: "m1", + rootMemoryId: "m1", + version: 2, + }), + ]), + ] + idx.rebuild(docs) + + // Querying from child (in d2) should walk back to parent (in d1) + const chainFromChild = idx.getChain("m2") + expect(chainFromChild).not.toBeNull() + expect(chainFromChild!.map((e) => e.id)).toEqual(["m1", "m2"]) + + // Querying from parent (in d1) should walk forward to child (in d2) + const chainFromParent = idx.getChain("m1") + expect(chainFromParent).not.toBeNull() + expect(chainFromParent!.map((e) => e.id)).toEqual(["m1", "m2"]) + }) + + it("circular reference: both IDs present in result (order is undefined for malformed cycles)", () => { + // The existing circular test asserts length=2 but not membership. + // Circular data (m1.parent=m2, m2.parent=m1) is malformed and will never + // appear in production — the visited set merely guarantees termination. + // Order is intentionally unspecified because the backward-walk start node + // determines which ID appears first, which has no semantic meaning for corrupt data. + const idx = new VersionChainIndex() + const doc = makeDoc("d1", [ + makeMem({ id: "m1", version: 1, parentMemoryId: "m2" }), + makeMem({ id: "m2", version: 2, parentMemoryId: "m1" }), + ]) + idx.rebuild([doc]) + + const chain = idx.getChain("m1") + expect(chain).not.toBeNull() + expect(chain!.length).toBe(2) + // Both nodes must appear — order is implementation-defined for cycles + const ids = chain!.map((e) => e.id) + expect(ids).toContain("m1") + expect(ids).toContain("m2") + }) + + it("getChain from middle node with cold cache exercises real backward+forward traversal", () => { + // This test deliberately queries the MIDDLE node first (cache is empty at that point) + // to confirm the combined backward+forward traversal is the live code path being + // exercised — not merely the cache fast-path from a prior call to another node. + const idx = new VersionChainIndex() + const doc = makeDoc("d1", [ + makeMem({ id: "m1", version: 1 }), + makeMem({ + id: "m2", + parentMemoryId: "m1", + rootMemoryId: "m1", + version: 2, + }), + makeMem({ + id: "m3", + parentMemoryId: "m2", + rootMemoryId: "m1", + version: 3, + }), + ]) + idx.rebuild([doc]) + + // Cold cache: getChain("m2") must walk backward to m1 AND forward to m3. + const chain = idx.getChain("m2") + expect(chain).not.toBeNull() + expect(chain!.length).toBe(3) + expect(chain!.map((e) => e.id)).toEqual(["m1", "m2", "m3"]) + + // After the traversal, neighboring nodes must return the same cached reference — + // confirming that the cache-population loop ran for all three entries. + expect(idx.getChain("m1")).toBe(chain) + expect(idx.getChain("m3")).toBe(chain) + }) +}) diff --git a/packages/memory-graph/src/__tests__/viewport.test.ts b/packages/memory-graph/src/__tests__/viewport.test.ts new file mode 100644 index 000000000..aee0b14b0 --- /dev/null +++ b/packages/memory-graph/src/__tests__/viewport.test.ts @@ -0,0 +1,219 @@ +import { describe, it, expect } from "vitest" +import { ViewportState } from "../canvas/viewport" +import type { GraphNode } from "../types" + +function makeNode(id: string, x: number, y: number): GraphNode { + return { + id, + type: "document", + x, + y, + size: 50, + borderColor: "#fff", + isHovered: false, + isDragging: false, + data: { + id, + title: id, + summary: null, + type: "text", + createdAt: "2024-01-01", + updatedAt: "2024-01-01", + memories: [], + }, + } +} + +/** Run tick() until animation converges (or max iterations) */ +function tickUntilSettled(vp: ViewportState, maxIter = 500): void { + for (let i = 0; i < maxIter; i++) { + if (!vp.tick()) break + } +} + +describe("ViewportState", () => { + it("constructor sets initial values", () => { + const vp = new ViewportState() + expect(vp.panX).toBe(0) + expect(vp.panY).toBe(0) + expect(vp.zoom).toBe(0.5) // default initial zoom + }) + + it("constructor accepts custom initial values", () => { + const vp = new ViewportState(10, 20, 1.5) + expect(vp.panX).toBe(10) + expect(vp.panY).toBe(20) + expect(vp.zoom).toBe(1.5) + }) + + it("worldToScreen and screenToWorld are inverse operations", () => { + const vp = new ViewportState(100, 50, 1.5) + + const worldX = 300 + const worldY = 400 + const screen = vp.worldToScreen(worldX, worldY) + const world = vp.screenToWorld(screen.x, screen.y) + + expect(world.x).toBeCloseTo(worldX, 5) + expect(world.y).toBeCloseTo(worldY, 5) + }) + + it("worldToScreen applies zoom and pan: screen = world * zoom + pan", () => { + const vp = new ViewportState(10, 20, 2) + + const screen = vp.worldToScreen(100, 200) + expect(screen.x).toBe(100 * 2 + 10) // 210 + expect(screen.y).toBe(200 * 2 + 20) // 420 + }) + + it("screenToWorld reverses: world = (screen - pan) / zoom", () => { + const vp = new ViewportState(10, 20, 2) + + const world = vp.screenToWorld(210, 420) + expect(world.x).toBeCloseTo(100, 5) + expect(world.y).toBeCloseTo(200, 5) + }) + + it("pan offsets correctly and accumulates", () => { + const vp = new ViewportState(0, 0, 1) + vp.pan(50, 30) + expect(vp.panX).toBe(50) + expect(vp.panY).toBe(30) + + vp.pan(10, 20) + expect(vp.panX).toBe(60) + expect(vp.panY).toBe(50) + }) + + it("pan cancels any animated pan target", () => { + const vp = new ViewportState(0, 0, 1) + vp.centerOn(500, 500, 800, 600) // sets targetPanX/Y + vp.pan(10, 10) // should cancel the target + // After pan, tick should return false (no animation) + expect(vp.tick()).toBe(false) + }) + + it("zoomImmediate multiplies current zoom by delta", () => { + const vp = new ViewportState(0, 0, 1) + const initialZoom = vp.zoom + vp.zoomImmediate(2, 0, 0) + expect(vp.zoom).toBeCloseTo(initialZoom * 2) + }) + + it("zoomImmediate preserves world point under anchor", () => { + const vp = new ViewportState(100, 50, 1) + const anchorX = 400 + const anchorY = 300 + + // Get world point under anchor before zoom + const worldBefore = vp.screenToWorld(anchorX, anchorY) + vp.zoomImmediate(2, anchorX, anchorY) + // After zoom, same screen point should map to same world point + const worldAfter = vp.screenToWorld(anchorX, anchorY) + + expect(worldAfter.x).toBeCloseTo(worldBefore.x, 3) + expect(worldAfter.y).toBeCloseTo(worldBefore.y, 3) + }) + + it("zoomImmediate clamps to MIN_ZOOM (0.1)", () => { + const vp = new ViewportState(0, 0, 0.5) + // Try to zoom way down: 0.5 * 0.01 = 0.005, should clamp to 0.1 + vp.zoomImmediate(0.01, 0, 0) + expect(vp.zoom).toBeCloseTo(0.1) + }) + + it("zoomImmediate clamps to MAX_ZOOM (5.0)", () => { + const vp = new ViewportState(0, 0, 2) + // Try to zoom way up: 2 * 100 = 200, should clamp to 5 + vp.zoomImmediate(100, 0, 0) + expect(vp.zoom).toBeCloseTo(5.0) + }) + + it("zoomTo sets target zoom (animated via tick)", () => { + const vp = new ViewportState(0, 0, 0.5) + vp.zoomTo(2, 400, 300) + // Zoom hasn't changed yet — it's animated + expect(vp.zoom).toBe(0.5) + // After ticking, zoom should approach target + tickUntilSettled(vp) + expect(vp.zoom).toBeCloseTo(2, 1) + }) + + it("tick returns false when no animation is active", () => { + const vp = new ViewportState() + expect(vp.tick()).toBe(false) + }) + + it("tick returns true during inertia", () => { + const vp = new ViewportState() + vp.releaseWithVelocity(10, 10) + expect(vp.tick()).toBe(true) + }) + + it("tick returns true during zoom animation", () => { + const vp = new ViewportState(0, 0, 0.5) + vp.zoomTo(2, 0, 0) + expect(vp.tick()).toBe(true) + }) + + it("tick returns true during pan animation", () => { + const vp = new ViewportState(0, 0, 1) + vp.centerOn(500, 500, 800, 600) + expect(vp.tick()).toBe(true) + }) + + it("fitToNodes centers and scales to fit all nodes", () => { + const vp = new ViewportState(0, 0, 0.5) + const nodes = [ + makeNode("a", 0, 0), + makeNode("b", 1000, 0), + makeNode("c", 0, 1000), + makeNode("d", 1000, 1000), + ] + vp.fitToNodes(nodes, 800, 600) + tickUntilSettled(vp) + + // After fitting, all nodes should be visible within the viewport + for (const node of nodes) { + const screen = vp.worldToScreen(node.x, node.y) + expect(screen.x).toBeGreaterThan(-100) + expect(screen.x).toBeLessThan(900) + expect(screen.y).toBeGreaterThan(-100) + expect(screen.y).toBeLessThan(700) + } + }) + + it("fitToNodes handles single node without throwing", () => { + const vp = new ViewportState() + expect(() => + vp.fitToNodes([makeNode("a", 500, 500)], 800, 600), + ).not.toThrow() + }) + + it("fitToNodes handles empty nodes array without throwing", () => { + const vp = new ViewportState() + const zoomBefore = vp.zoom + vp.fitToNodes([], 800, 600) + // Should be a no-op + expect(vp.zoom).toBe(zoomBefore) + }) + + it("centerOn animates pan to center a world point on screen", () => { + const vp = new ViewportState(0, 0, 1) + vp.centerOn(500, 300, 800, 600) + tickUntilSettled(vp) + + // After settling, world point (500, 300) should map to screen center (400, 300) + const screen = vp.worldToScreen(500, 300) + expect(screen.x).toBeCloseTo(400, 0) + expect(screen.y).toBeCloseTo(300, 0) + }) + + it("inertia decays to zero", () => { + const vp = new ViewportState() + vp.releaseWithVelocity(100, 100) + tickUntilSettled(vp) + // After settling, tick should return false + expect(vp.tick()).toBe(false) + }) +}) diff --git a/packages/memory-graph/src/api-types.ts b/packages/memory-graph/src/api-types.ts index 0ebc86ee9..20f000591 100644 --- a/packages/memory-graph/src/api-types.ts +++ b/packages/memory-graph/src/api-types.ts @@ -1,71 +1,42 @@ -// Standalone TypeScript types for Memory Graph -// These mirror the API response types from @repo/validation/api +export type MemoryRelation = "updates" | "extends" | "derives" 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 | null - embedding?: number[] | null - embeddingModel?: string | null - tokenCount?: number | null - createdAt: string | Date - updatedAt: string | Date - // Fields from join relationship - sourceAddedAt?: Date | null + memory: string + content?: string | null + createdAt: string + updatedAt: string + spaceId?: string | null + embedding?: number[] + isStatic?: boolean + isForgotten?: boolean + forgetAfter?: string | null + forgetReason?: string | null + version?: number + parentMemoryId?: string | null + rootMemoryId?: string | null + isLatest?: boolean + // Relation fields from backend + relation?: MemoryRelation | null + updatesMemoryId?: string | null + nextVersionId?: string | null + memoryRelations?: Record | null + // Source/join fields + sourceAddedAt?: string | null sourceRelevanceScore?: number | null sourceMetadata?: Record | null spaceContainerTag?: string | null - // Version chain fields - updatesMemoryId?: string | null - nextVersionId?: string | null - relation?: "updates" | "extends" | "derives" | null - // Memory status fields - isForgotten?: boolean - forgetAfter?: Date | string | null - isLatest?: boolean - // Space/container fields - spaceId?: string | null - // Legacy fields - memory?: string | null - memoryRelations?: Array<{ - 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 + title: string | null + url: string | null + documentType: string + createdAt: string + updatedAt: string summary?: string | null - url?: string | null - source?: string | null - type?: string | null - status: "pending" | "processing" | "done" | "failed" - metadata?: Record | null - processingMetadata?: Record | 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[] + memories: MemoryEntry[] } export interface DocumentsResponse { diff --git a/packages/memory-graph/src/assets/icons.tsx b/packages/memory-graph/src/assets/icons.tsx deleted file mode 100644 index 5eb38b429..000000000 --- a/packages/memory-graph/src/assets/icons.tsx +++ /dev/null @@ -1,208 +0,0 @@ -export const OneDrive = ({ className }: { className?: string }) => ( - - OneDrive - - - - - -) - -export const GoogleDrive = ({ className }: { className?: string }) => ( - - Google Drive - - - - - - - -) - -export const Notion = ({ className }: { className?: string }) => ( - - Notion - - - -) - -export const GoogleDocs = ({ className }: { className?: string }) => ( - - Google Docs - - -) - -export const GoogleSheets = ({ className }: { className?: string }) => ( - - Google Sheets - - -) - -export const GoogleSlides = ({ className }: { className?: string }) => ( - - Google Slides - - -) - -export const NotionDoc = ({ className }: { className?: string }) => ( - - Notion Doc - - -) - -export const MicrosoftWord = ({ className }: { className?: string }) => ( - - Microsoft Word - - -) - -export const MicrosoftExcel = ({ className }: { className?: string }) => ( - - Microsoft Excel - - -) - -export const MicrosoftPowerpoint = ({ className }: { className?: string }) => ( - - Microsoft PowerPoint - - -) - -export const MicrosoftOneNote = ({ className }: { className?: string }) => ( - - Microsoft OneNote - - -) - -export const PDF = ({ className }: { className?: string }) => ( - - PDF - - - - -) diff --git a/packages/memory-graph/src/canvas/document-icons.ts b/packages/memory-graph/src/canvas/document-icons.ts new file mode 100644 index 000000000..03df4003a --- /dev/null +++ b/packages/memory-graph/src/canvas/document-icons.ts @@ -0,0 +1,402 @@ +/** + * Canvas-native document type icon drawing functions. + * + * Each function draws a small vector icon at the given (x, y) center + * within a bounding box of `size` pixels. All drawing uses the current + * canvas fill/stroke styles set by the caller. + */ + +/** Shared rounded-rectangle path helper (also used by the renderer). */ +export function roundRect( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + w: number, + h: number, + r: number, +): void { + ctx.beginPath() + ctx.moveTo(x + r, y) + ctx.lineTo(x + w - r, y) + ctx.arcTo(x + w, y, x + w, y + r, r) + ctx.lineTo(x + w, y + h - r) + ctx.arcTo(x + w, y + h, x + w - r, y + h, r) + ctx.lineTo(x + r, y + h) + ctx.arcTo(x, y + h, x, y + h - r, r) + ctx.lineTo(x, y + r) + ctx.arcTo(x, y, x + r, y, r) + ctx.closePath() +} + +/** + * Draw the appropriate document-type icon on the canvas. + * + * Wraps the drawing in save/restore so callers don't need to worry about + * state leaking. Only `iconColor` is needed from the theme. + */ +export function drawDocIcon( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + size: number, + type: string, + iconColor: string, +): void { + ctx.save() + ctx.fillStyle = iconColor + ctx.strokeStyle = iconColor + ctx.lineWidth = Math.max(1, size / 12) + ctx.lineCap = "round" + ctx.lineJoin = "round" + + switch (type) { + case "webpage": + case "url": + drawGlobeIcon(ctx, x, y, size) + break + case "pdf": + drawTextLabel(ctx, x, y, size, "PDF", 0.35) + break + case "md": + case "markdown": + drawTextLabel(ctx, x, y, size, "MD", 0.3) + break + case "doc": + case "docx": + case "word": + case "microsoft_word": + drawTextLabel(ctx, x, y, size, "W", 0.4) + break + case "csv": + case "excel": + case "microsoft_excel": + case "google_sheet": + drawGridIcon(ctx, x, y, size) + break + case "json": + drawBracesIcon(ctx, x, y, size) + break + case "notion": + case "notion_doc": + drawNotionIcon(ctx, x, y, size) + break + case "google_doc": + drawGoogleDocIcon(ctx, x, y, size) + break + case "google_slide": + case "powerpoint": + case "microsoft_powerpoint": + drawSlidesIcon(ctx, x, y, size) + break + case "google_drive": + case "onedrive": + drawCloudIcon(ctx, x, y, size) + break + case "tweet": + drawXIcon(ctx, x, y, size) + break + case "youtube": + case "video": + drawPlayIcon(ctx, x, y, size) + break + case "image": + drawImageIcon(ctx, x, y, size) + break + case "text": + case "note": + drawTextNoteIcon(ctx, x, y, size) + break + case "onenote": + case "microsoft_onenote": + drawTextLabel(ctx, x, y, size, "N", 0.4) + break + case "mcp": + drawTextLabel(ctx, x, y, size, "MCP", 0.25) + break + default: + drawDocOutline(ctx, x, y, size) + break + } + + ctx.restore() +} + +// --------------------------------------------------------------------------- +// Individual icon drawing helpers +// --------------------------------------------------------------------------- + +function drawTextLabel( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + size: number, + text: string, + fontRatio: number, +): void { + ctx.font = `bold ${size * fontRatio}px sans-serif` + ctx.textAlign = "center" + ctx.textBaseline = "middle" + ctx.fillText(text, x, y) +} + +function drawGlobeIcon( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + size: number, +): void { + const r = size * 0.4 + ctx.beginPath() + ctx.arc(x, y, r, 0, Math.PI * 2) + ctx.stroke() + ctx.beginPath() + ctx.ellipse(x, y, r * 0.4, r, 0, 0, Math.PI * 2) + ctx.stroke() + ctx.beginPath() + ctx.moveTo(x - r, y) + ctx.lineTo(x + r, y) + ctx.stroke() +} + +function drawGridIcon( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + size: number, +): void { + const w = size * 0.7 + const h = size * 0.7 + ctx.strokeRect(x - w / 2, y - h / 2, w, h) + ctx.beginPath() + ctx.moveTo(x, y - h / 2) + ctx.lineTo(x, y + h / 2) + ctx.moveTo(x - w / 2, y) + ctx.lineTo(x + w / 2, y) + ctx.stroke() +} + +function drawBracesIcon( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + size: number, +): void { + const w = size * 0.6 + const h = size * 0.8 + ctx.beginPath() + ctx.moveTo(x - w / 4, y - h / 2) + ctx.quadraticCurveTo(x - w / 2, y - h / 3, x - w / 2, y) + ctx.quadraticCurveTo(x - w / 2, y + h / 3, x - w / 4, y + h / 2) + ctx.stroke() + ctx.beginPath() + ctx.moveTo(x + w / 4, y - h / 2) + ctx.quadraticCurveTo(x + w / 2, y - h / 3, x + w / 2, y) + ctx.quadraticCurveTo(x + w / 2, y + h / 3, x + w / 4, y + h / 2) + ctx.stroke() +} + +function drawDocOutline( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + size: number, +): void { + const w = size * 0.7 + const h = size * 0.85 + const fold = size * 0.2 + ctx.beginPath() + ctx.moveTo(x - w / 2, y - h / 2) + ctx.lineTo(x + w / 2 - fold, y - h / 2) + ctx.lineTo(x + w / 2, y - h / 2 + fold) + ctx.lineTo(x + w / 2, y + h / 2) + ctx.lineTo(x - w / 2, y + h / 2) + ctx.closePath() + ctx.stroke() + const sp = size * 0.15 + const lw = size * 0.4 + ctx.beginPath() + ctx.moveTo(x - lw / 2, y - sp) + ctx.lineTo(x + lw / 2, y - sp) + ctx.moveTo(x - lw / 2, y) + ctx.lineTo(x + lw / 2, y) + ctx.moveTo(x - lw / 2, y + sp) + ctx.lineTo(x + lw / 2, y + sp) + ctx.stroke() +} + +/** Draw a simplified Notion "N" logo mark */ +function drawNotionIcon( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + size: number, +): void { + const w = size * 0.5 + const h = size * 0.6 + const r = size * 0.08 + ctx.lineWidth = Math.max(1, size / 14) + roundRect(ctx, x - w / 2, y - h / 2, w, h, r) + ctx.stroke() + // Inner "N" shape + const inset = size * 0.12 + const left = x - w / 2 + inset + const right = x + w / 2 - inset + const top = y - h / 2 + inset + const bottom = y + h / 2 - inset + ctx.beginPath() + ctx.moveTo(left, top) + ctx.lineTo(left, bottom) + ctx.moveTo(left, top) + ctx.lineTo(right, bottom) + ctx.moveTo(right, top) + ctx.lineTo(right, bottom) + ctx.stroke() +} + +/** Draw a Google Docs icon (document with lines) */ +function drawGoogleDocIcon( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + size: number, +): void { + const w = size * 0.55 + const h = size * 0.7 + const fold = size * 0.15 + ctx.beginPath() + ctx.moveTo(x - w / 2, y - h / 2) + ctx.lineTo(x + w / 2 - fold, y - h / 2) + ctx.lineTo(x + w / 2, y - h / 2 + fold) + ctx.lineTo(x + w / 2, y + h / 2) + ctx.lineTo(x - w / 2, y + h / 2) + ctx.closePath() + ctx.stroke() + const lineW = w * 0.6 + const sp = size * 0.1 + ctx.beginPath() + ctx.moveTo(x - lineW / 2, y - sp) + ctx.lineTo(x + lineW / 2, y - sp) + ctx.moveTo(x - lineW / 2, y + sp * 0.3) + ctx.lineTo(x + lineW / 2, y + sp * 0.3) + ctx.moveTo(x - lineW / 2, y + sp * 1.6) + ctx.lineTo(x + lineW / 3, y + sp * 1.6) + ctx.stroke() +} + +/** Draw a slides/presentation icon (rectangle with play triangle) */ +function drawSlidesIcon( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + size: number, +): void { + const w = size * 0.7 + const h = size * 0.5 + ctx.strokeRect(x - w / 2, y - h / 2, w, h) + const triSize = size * 0.15 + ctx.beginPath() + ctx.moveTo(x - triSize * 0.5, y - triSize * 0.7) + ctx.lineTo(x - triSize * 0.5, y + triSize * 0.7) + ctx.lineTo(x + triSize * 0.7, y) + ctx.closePath() + ctx.fill() +} + +/** Draw a cloud icon for drive/storage types */ +function drawCloudIcon( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + size: number, +): void { + const s = size * 0.35 + ctx.beginPath() + ctx.arc(x - s * 0.3, y + s * 0.1, s * 0.5, Math.PI * 0.7, Math.PI * 1.9) + ctx.arc(x + s * 0.1, y - s * 0.3, s * 0.55, Math.PI * 1.1, Math.PI * 0.3) + ctx.arc(x + s * 0.5, y + s * 0.1, s * 0.4, Math.PI * 1.4, Math.PI * 0.6) + ctx.lineTo(x - s * 0.7, y + s * 0.45) + ctx.closePath() + ctx.stroke() +} + +/** Draw an X (formerly Twitter) icon */ +function drawXIcon( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + size: number, +): void { + const s = size * 0.3 + ctx.lineWidth = Math.max(1.5, size / 10) + ctx.beginPath() + ctx.moveTo(x - s, y - s) + ctx.lineTo(x + s, y + s) + ctx.moveTo(x + s, y - s) + ctx.lineTo(x - s, y + s) + ctx.stroke() +} + +/** Draw a play button icon for video/youtube */ +function drawPlayIcon( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + size: number, +): void { + const r = size * 0.38 + const w = r * 2.2 + const h = r * 1.5 + const cr = size * 0.08 + roundRect(ctx, x - w / 2, y - h / 2, w, h, cr) + ctx.stroke() + const triH = size * 0.22 + ctx.beginPath() + ctx.moveTo(x - triH * 0.45, y - triH) + ctx.lineTo(x - triH * 0.45, y + triH) + ctx.lineTo(x + triH * 0.7, y) + ctx.closePath() + ctx.fill() +} + +/** Draw an image/photo icon (landscape with mountain) */ +function drawImageIcon( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + size: number, +): void { + const w = size * 0.7 + const h = size * 0.55 + ctx.strokeRect(x - w / 2, y - h / 2, w, h) + ctx.beginPath() + ctx.moveTo(x - w / 2 + w * 0.1, y + h / 2 - h * 0.1) + ctx.lineTo(x - w * 0.05, y - h * 0.05) + ctx.lineTo(x + w * 0.15, y + h * 0.15) + ctx.lineTo(x + w * 0.25, y - h * 0.02) + ctx.lineTo(x + w / 2 - w * 0.1, y + h / 2 - h * 0.1) + ctx.stroke() + const sunR = size * 0.06 + ctx.beginPath() + ctx.arc(x - w * 0.15, y - h * 0.15, sunR, 0, Math.PI * 2) + ctx.fill() +} + +/** Draw a text/note icon (lines of text) */ +function drawTextNoteIcon( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + size: number, +): void { + const w = size * 0.55 + const h = size * 0.6 + const sp = h / 5 + ctx.beginPath() + for (let i = 0; i < 4; i++) { + const lineY = y - h / 2 + sp * (i + 0.5) + const lineW = i === 3 ? w * 0.6 : w + ctx.moveTo(x - w / 2, lineY) + ctx.lineTo(x - w / 2 + lineW, lineY) + } + ctx.stroke() +} diff --git a/apps/web/components/memory-graph/canvas/hit-test.ts b/packages/memory-graph/src/canvas/hit-test.ts similarity index 73% rename from apps/web/components/memory-graph/canvas/hit-test.ts rename to packages/memory-graph/src/canvas/hit-test.ts index 88a40678e..ac20bca72 100644 --- a/apps/web/components/memory-graph/canvas/hit-test.ts +++ b/packages/memory-graph/src/canvas/hit-test.ts @@ -27,15 +27,14 @@ export class SpatialIndex { const cx = Math.floor(worldX / this.cellSize) const cy = Math.floor(worldY / this.cellSize) - // Check current cell + 8 neighbors for (let dx = -1; dx <= 1; dx++) { for (let dy = -1; dy <= 1; dy++) { const cell = this.grid.get(`${cx + dx},${cy + dy}`) if (!cell) continue for (let i = cell.length - 1; i >= 0; i--) { - const node = cell[i]! - if (this.hitTest(node, worldX, worldY)) return node + const node = cell[i] + if (node && this.hitTest(node, worldX, worldY)) return node } } } @@ -46,13 +45,11 @@ export class SpatialIndex { const halfSize = node.size * 0.5 if (node.type === "document") { - // AABB rectangle hit test (50x50 node) return ( Math.abs(wx - node.x) <= halfSize && Math.abs(wy - node.y) <= halfSize ) } - // Circular hit test for hexagon memory nodes const dx = wx - node.x const dy = wy - node.y return dx * dx + dy * dy <= halfSize * halfSize @@ -61,9 +58,16 @@ export class SpatialIndex { private computeHash(nodes: GraphNode[]): number { let hash = nodes.length for (const n of nodes) { - // Round to nearest integer to avoid false rebuilds from tiny physics jitter - hash = (hash * 31 + (Math.round(n.x) | 0)) | 0 - hash = (hash * 31 + (Math.round(n.y) | 0)) | 0 + // Use finer granularity (10x) to detect sub-pixel movements + // and incorporate a simple string hash of the ID to avoid + // false matches when nodes swap positions + let idHash = 0 + for (let i = 0; i < n.id.length; i++) { + idHash = ((idHash << 5) - idHash + n.id.charCodeAt(i)) | 0 + } + hash = (hash * 31 + idHash) | 0 + hash = (hash * 31 + (Math.round(n.x * 10) | 0)) | 0 + hash = (hash * 31 + (Math.round(n.y * 10) | 0)) | 0 } return hash } diff --git a/apps/web/components/memory-graph/canvas/input-handler.ts b/packages/memory-graph/src/canvas/input-handler.ts similarity index 90% rename from apps/web/components/memory-graph/canvas/input-handler.ts rename to packages/memory-graph/src/canvas/input-handler.ts index a1a6fbf71..9f0afc195 100644 --- a/apps/web/components/memory-graph/canvas/input-handler.ts +++ b/packages/memory-graph/src/canvas/input-handler.ts @@ -1,6 +1,6 @@ -import type { ViewportState } from "./viewport" -import type { SpatialIndex } from "./hit-test" import type { GraphNode } from "../types" +import type { SpatialIndex } from "./hit-test" +import type { ViewportState } from "./viewport" interface InputCallbacks { onNodeHover: (id: string | null) => void @@ -20,22 +20,17 @@ export class InputHandler { private lastMouseX = 0 private lastMouseY = 0 - // Ring buffer for velocity tracking private posHistory: Array<{ x: number; y: number; t: number }> = [] private draggingNode: GraphNode | null = null - private dragStartX = 0 - private dragStartY = 0 private didDrag = false private currentHoveredId: string | null = null - // Touch state private lastTouchDistance = 0 private lastTouchCenter = { x: 0, y: 0 } private isTouchGesture = false - // Bound handlers for cleanup private boundMouseDown: (e: MouseEvent) => void private boundMouseMove: (e: MouseEvent) => void private boundMouseUp: (e: MouseEvent) => void @@ -128,8 +123,6 @@ export class InputHandler { if (node) { this.draggingNode = node - this.dragStartX = x - this.dragStartY = y node.fx = node.x node.fy = node.y this.callbacks.onNodeDragStart(node.id, node) @@ -162,7 +155,6 @@ export class InputHandler { this.lastMouseY = y this.didDrag = true - // Track positions for velocity (keep last 4) const now = performance.now() this.posHistory.push({ x, y, t: now }) if (this.posHistory.length > 4) this.posHistory.shift() @@ -171,7 +163,6 @@ export class InputHandler { return } - // Hover detection const world = this.viewport.screenToWorld(x, y) const node = this.spatialIndex.queryPoint(world.x, world.y) const id = node?.id ?? null @@ -196,13 +187,13 @@ export class InputHandler { if (this.isPanning) { this.isPanning = false - // Calculate release velocity from position history if (this.posHistory.length >= 2) { - const newest = this.posHistory[this.posHistory.length - 1]! - const oldest = this.posHistory[0]! + const newest = this.posHistory[this.posHistory.length - 1] + const oldest = this.posHistory[0] + if (!newest || !oldest) return const dt = newest.t - oldest.t if (dt > 0 && dt < 200) { - const vx = ((newest.x - oldest.x) / dt) * 16 // scale to ~60fps frame + const vx = ((newest.x - oldest.x) / dt) * 16 const vy = ((newest.y - oldest.y) / dt) * 16 this.viewport.releaseWithVelocity(vx, vy) } @@ -233,28 +224,26 @@ export class InputHandler { const { x, y } = this.canvasXY(e) - // Horizontal scroll -> pan if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) { this.viewport.pan(-e.deltaX, 0) this.callbacks.onRequestRender() return } - // Vertical scroll -> zoom const factor = e.deltaY > 0 ? 0.97 : 1.03 this.viewport.zoomImmediate(factor, x, y) this.callbacks.onRequestRender() } - // Touch handling private onTouchStart(e: TouchEvent): void { e.preventDefault() const touches = e.touches if (touches.length >= 2) { this.isTouchGesture = true - const t0 = touches[0]! - const t1 = touches[1]! + const t0 = touches[0] + const t1 = touches[1] + if (!t0 || !t1) return this.lastTouchDistance = Math.hypot( t1.clientX - t0.clientX, t1.clientY - t0.clientY, @@ -263,9 +252,9 @@ export class InputHandler { x: (t0.clientX + t1.clientX) / 2, y: (t0.clientY + t1.clientY) / 2, } - } else if (touches.length === 1) { + } else if (touches.length === 1 && touches[0]) { this.isTouchGesture = false - const t = touches[0]! + const t = touches[0] const rect = this.canvas.getBoundingClientRect() this.lastMouseX = t.clientX - rect.left this.lastMouseY = t.clientY - rect.top @@ -278,8 +267,9 @@ export class InputHandler { const touches = e.touches if (touches.length >= 2 && this.isTouchGesture) { - const t0 = touches[0]! - const t1 = touches[1]! + const t0 = touches[0] + const t1 = touches[1] + if (!t0 || !t1) return const dist = Math.hypot(t1.clientX - t0.clientX, t1.clientY - t0.clientY) const center = { x: (t0.clientX + t1.clientX) / 2, @@ -289,11 +279,9 @@ export class InputHandler { const cx = center.x - rect.left const cy = center.y - rect.top - // Pinch zoom const scale = dist / this.lastTouchDistance this.viewport.zoomImmediate(scale, cx, cy) - // Pan from center movement const dx = center.x - this.lastTouchCenter.x const dy = center.y - this.lastTouchCenter.y this.viewport.pan(dx, dy) @@ -301,8 +289,13 @@ export class InputHandler { this.lastTouchDistance = dist this.lastTouchCenter = center this.callbacks.onRequestRender() - } else if (touches.length === 1 && this.isPanning && !this.isTouchGesture) { - const t = touches[0]! + } else if ( + touches.length === 1 && + this.isPanning && + !this.isTouchGesture && + touches[0] + ) { + const t = touches[0] const rect = this.canvas.getBoundingClientRect() const x = t.clientX - rect.left const y = t.clientY - rect.top diff --git a/packages/memory-graph/src/canvas/renderer.ts b/packages/memory-graph/src/canvas/renderer.ts new file mode 100644 index 000000000..3c0d66b1f --- /dev/null +++ b/packages/memory-graph/src/canvas/renderer.ts @@ -0,0 +1,661 @@ +import type { + DocumentNodeData, + GraphEdge, + GraphNode, + GraphThemeColors, + MemoryNodeData, +} from "../types" +import type { ViewportState } from "./viewport" +import { drawDocIcon, roundRect } from "./document-icons" + +export interface RenderState { + selectedNodeId: string | null + hoveredNodeId: string | null + highlightIds: Set + dimProgress: number +} + +// Module-level reusable batch map – cleared each frame instead of reallocating +const edgeBatches = new Map() + +/** Group items by their `color` property into batches for efficient canvas drawing */ +function groupByColor( + items: T[], +): Map { + const map = new Map() + for (const item of items) { + let batch = map.get(item.color) + if (!batch) { + batch = [] + map.set(item.color, batch) + } + batch.push(item) + } + return map +} + +// Cache for lightenColor results to avoid per-frame hex parsing +let _lightenCache: { input: string; amount: number; result: string } | null = + null + +export function renderFrame( + ctx: CanvasRenderingContext2D, + nodes: GraphNode[], + edges: GraphEdge[], + viewport: ViewportState, + width: number, + height: number, + state: RenderState, + nodeMap: Map, + colors: GraphThemeColors, +): void { + ctx.clearRect(0, 0, width, height) + drawEdges(ctx, edges, viewport, width, height, state, nodeMap, colors) + drawNodes(ctx, nodes, viewport, width, height, state, colors) +} + +function edgeStyle( + edge: GraphEdge, + colors: GraphThemeColors, +): { color: string; width: number; opacity: number } { + if (edge.edgeType === "derives") + return { color: colors.edgeDerives, width: 1.2, opacity: 0.4 } + if (edge.edgeType === "updates") + return { color: colors.edgeUpdates, width: 2, opacity: 0.7 } + // "extends" and any unknown edge types + return { color: colors.edgeExtends, width: 1.5, opacity: 0.55 } +} + +function batchKey(style: { + color: string + width: number + opacity: number +}): string { + return `${style.color}|${style.width}|${style.opacity}` +} + +interface PreparedEdge { + startX: number + startY: number + endX: number + endY: number + connected: boolean + style: { color: string; width: number; opacity: number } + edgeType: string + arrowSize: number +} + +function drawEdges( + ctx: CanvasRenderingContext2D, + edges: GraphEdge[], + viewport: ViewportState, + width: number, + height: number, + state: RenderState, + nodeMap: Map, + colors: GraphThemeColors, +): void { + const margin = 100 + const hasDim = state.selectedNodeId !== null && state.dimProgress > 0 + + const prepared: PreparedEdge[] = [] + + for (const edge of edges) { + // Zoom-based edge culling for extends edges at very low zoom + if (edge.edgeType === "extends") { + if (viewport.zoom < 0.08) continue + } + + const src = + typeof edge.source === "string" ? nodeMap.get(edge.source) : edge.source + const tgt = + typeof edge.target === "string" ? nodeMap.get(edge.target) : edge.target + if (!src || !tgt) continue + + if (edge.edgeType === "derives") { + const mem = src.type === "memory" ? src : tgt + if (mem.size * viewport.zoom < 3) continue + } + + const s = viewport.worldToScreen(src.x, src.y) + const t = viewport.worldToScreen(tgt.x, tgt.y) + + if ( + (s.x < -margin && t.x < -margin) || + (s.x > width + margin && t.x > width + margin) || + (s.y < -margin && t.y < -margin) || + (s.y > height + margin && t.y > height + margin) + ) + continue + + const dx = t.x - s.x + const dy = t.y - s.y + const dist = Math.sqrt(dx * dx + dy * dy) + if (dist < 1) continue + + const ux = dx / dist + const uy = dy / dist + const sr = src.size * viewport.zoom * 0.5 + const tr = tgt.size * viewport.zoom * 0.5 + + let connected = true + if (hasDim) { + const srcId = + typeof edge.source === "string" ? edge.source : edge.source.id + const tgtId = + typeof edge.target === "string" ? edge.target : edge.target.id + connected = + srcId === state.selectedNodeId || tgtId === state.selectedNodeId + } + + prepared.push({ + startX: s.x + ux * sr, + startY: s.y + uy * sr, + endX: t.x - ux * tr, + endY: t.y - uy * tr, + connected, + style: edgeStyle(edge, colors), + edgeType: edge.edgeType ?? "derives", + arrowSize: + edge.edgeType === "updates" ? Math.max(6, 8 * viewport.zoom) : 0, + }) + } + + // Reuse module-level batch map + edgeBatches.clear() + for (const e of prepared) { + const dimKey = hasDim ? (e.connected ? "|c" : "|d") : "" + const key = `${e.edgeType}|${batchKey(e.style)}${dimKey}` + let batch = edgeBatches.get(key) + if (!batch) { + batch = [] + edgeBatches.set(key, batch) + } + batch.push(e) + } + + ctx.setLineDash([]) + for (const [key, batch] of edgeBatches) { + const first = batch[0] + if (!first) continue + const isDimmed = key.endsWith("|d") + const batchEdgeType = first.edgeType + + // Draw glow pass behind all edge types for luminous aesthetic + if (!isDimmed) { + const glowAlpha = + batchEdgeType === "updates" + ? first.style.opacity * 0.4 + : first.style.opacity * 0.3 + const glowWidth = + batchEdgeType === "updates" + ? first.style.width + 2 + : first.style.width + 1.5 + ctx.save() + ctx.globalAlpha = glowAlpha + ctx.strokeStyle = first.style.color + ctx.lineWidth = glowWidth + if (batchEdgeType === "extends") ctx.setLineDash([6, 4]) + ctx.beginPath() + for (const e of batch) { + ctx.moveTo(e.startX, e.startY) + ctx.lineTo(e.endX, e.endY) + } + ctx.stroke() + ctx.restore() + } + + const baseAlpha = first.style.opacity + ctx.globalAlpha = isDimmed + ? baseAlpha * (1 - state.dimProgress * 0.8) + : baseAlpha + ctx.strokeStyle = first.style.color + ctx.lineWidth = first.style.width + + // Extends edges use dashed lines + if (batchEdgeType === "extends") ctx.setLineDash([6, 4]) + + ctx.beginPath() + for (const e of batch) { + ctx.moveTo(e.startX, e.startY) + ctx.lineTo(e.endX, e.endY) + } + ctx.stroke() + + if (batchEdgeType === "extends") ctx.setLineDash([]) + + // Arrowheads for updates edges + if (batchEdgeType === "updates") { + ctx.globalAlpha = isDimmed + ? first.style.opacity * 0.6 * (1 - state.dimProgress * 0.8) + : first.style.opacity * 0.6 + ctx.fillStyle = first.style.color + for (const e of batch) { + drawArrowHead(ctx, e.startX, e.startY, e.endX, e.endY, e.arrowSize) + } + } + } + + ctx.globalAlpha = 1 +} + +function drawArrowHead( + ctx: CanvasRenderingContext2D, + fromX: number, + fromY: number, + toX: number, + toY: number, + size: number, +): void { + const angle = Math.atan2(toY - fromY, toX - fromX) + ctx.beginPath() + ctx.moveTo(toX, toY) + ctx.lineTo( + toX - size * Math.cos(angle - Math.PI / 6), + toY - size * Math.sin(angle - Math.PI / 6), + ) + ctx.lineTo( + toX - size * Math.cos(angle + Math.PI / 6), + toY - size * Math.sin(angle + Math.PI / 6), + ) + ctx.closePath() + ctx.fill() +} + +function drawNodes( + ctx: CanvasRenderingContext2D, + nodes: GraphNode[], + viewport: ViewportState, + width: number, + height: number, + state: RenderState, + colors: GraphThemeColors, +): void { + const margin = 60 + const memDots: { + x: number + y: number + r: number + color: string + dimmed: boolean + }[] = [] + const docDots: { x: number; y: number; s: number }[] = [] + + for (const node of nodes) { + const screen = viewport.worldToScreen(node.x, node.y) + const screenSize = node.size * viewport.zoom + + const cullSize = Math.max(screenSize, 2) + if ( + screen.x + cullSize < -margin || + screen.x - cullSize > width + margin || + screen.y + cullSize < -margin || + screen.y - cullSize > height + margin + ) + continue + + const isSelected = node.id === state.selectedNodeId + const isHovered = node.id === state.hoveredNodeId + const isHighlighted = state.highlightIds.has(node.id) + + if (screenSize < 8 && !isSelected && !isHovered && !isHighlighted) { + if (node.type === "document") { + docDots.push({ x: screen.x, y: screen.y, s: Math.max(3, screenSize) }) + } else { + const md = node.data as MemoryNodeData + memDots.push({ + x: screen.x, + y: screen.y, + r: Math.max(2, screenSize * 0.45), + color: node.borderColor || colors.memStrokeDefault, + dimmed: md.isLatest === false, + }) + } + continue + } + + let alpha = 1 + if (state.selectedNodeId && state.dimProgress > 0 && !isSelected) { + alpha = 1 - state.dimProgress * 0.7 + } + ctx.globalAlpha = alpha + + if (node.type === "document") { + drawDocumentNode( + ctx, + screen.x, + screen.y, + screenSize, + node, + isSelected, + isHovered, + isHighlighted, + colors, + ) + } else { + drawMemoryNode( + ctx, + screen.x, + screen.y, + screenSize, + node, + isSelected, + isHovered, + isHighlighted, + colors, + ) + } + + if (isSelected || isHighlighted || isHovered) { + drawGlow( + ctx, + screen.x, + screen.y, + screenSize, + node.type, + colors, + isHovered && !isSelected, + ) + } + } + + const dimAlpha = + state.selectedNodeId && state.dimProgress > 0 + ? 1 - state.dimProgress * 0.7 + : 1 + + if (docDots.length > 0) { + ctx.fillStyle = colors.docFill + ctx.strokeStyle = colors.docStroke + ctx.lineWidth = 1 + ctx.globalAlpha = dimAlpha + for (const d of docDots) { + const h = d.s * 0.5 + ctx.fillRect(d.x - h, d.y - h, d.s, d.s) + ctx.strokeRect(d.x - h, d.y - h, d.s, d.s) + } + } + + if (memDots.length > 0) { + // Draw normal (latest) memory dots + const normalDots = memDots.filter((d) => !d.dimmed) + const dimmedDots = memDots.filter((d) => d.dimmed) + + if (normalDots.length > 0) { + // Subtle glow behind memory dots for luminous effect + ctx.globalAlpha = dimAlpha * 0.25 + for (const [color, batch] of groupByColor(normalDots)) { + ctx.fillStyle = color + ctx.beginPath() + for (const d of batch) { + ctx.moveTo(d.x + d.r * 2.5, d.y) + ctx.arc(d.x, d.y, d.r * 2.5, 0, Math.PI * 2) + } + ctx.fill() + } + + // Filled dot + ctx.globalAlpha = dimAlpha + ctx.fillStyle = colors.memFill + ctx.beginPath() + for (const d of normalDots) { + ctx.moveTo(d.x + d.r, d.y) + ctx.arc(d.x, d.y, d.r, 0, Math.PI * 2) + } + ctx.fill() + + // Colored border + ctx.lineWidth = 1.5 + for (const [color, batch] of groupByColor(normalDots)) { + ctx.strokeStyle = color + ctx.beginPath() + for (const d of batch) { + ctx.moveTo(d.x + d.r, d.y) + ctx.arc(d.x, d.y, d.r, 0, Math.PI * 2) + } + ctx.stroke() + } + } + + // Draw dimmed (superseded) memory dots at reduced opacity + if (dimmedDots.length > 0) { + ctx.globalAlpha = dimAlpha * 0.5 + ctx.fillStyle = colors.memFill + ctx.beginPath() + for (const d of dimmedDots) { + ctx.moveTo(d.x + d.r, d.y) + ctx.arc(d.x, d.y, d.r, 0, Math.PI * 2) + } + ctx.fill() + + ctx.lineWidth = 1 + for (const [color, batch] of groupByColor(dimmedDots)) { + ctx.strokeStyle = color + ctx.beginPath() + for (const d of batch) { + ctx.moveTo(d.x + d.r, d.y) + ctx.arc(d.x, d.y, d.r, 0, Math.PI * 2) + } + ctx.stroke() + } + } + } + + ctx.globalAlpha = 1 +} + +function drawDocumentNode( + ctx: CanvasRenderingContext2D, + sx: number, + sy: number, + size: number, + node: GraphNode, + isSelected: boolean, + isHovered: boolean, + isHighlighted: boolean, + colors: GraphThemeColors, +): void { + const half = size * 0.5 + const cornerR = 8 * (size / 50) + + // Drop shadow for selected/hovered nodes + if (isSelected || isHovered) { + ctx.save() + ctx.shadowColor = colors.accent + ctx.shadowBlur = isSelected ? 16 : 10 + ctx.shadowOffsetX = 0 + ctx.shadowOffsetY = 0 + } + + // Subtle gradient fill for document nodes + const grad = ctx.createLinearGradient( + sx - half, + sy - half, + sx + half, + sy + half, + ) + grad.addColorStop(0, colors.docFill) + grad.addColorStop(1, lightenColor(colors.docFill, 0.08)) + ctx.fillStyle = grad + + ctx.strokeStyle = + isSelected || isHighlighted || isHovered ? colors.accent : colors.docStroke + ctx.lineWidth = isSelected || isHighlighted ? 2.5 : isHovered ? 1.5 : 1 + roundRect(ctx, sx - half, sy - half, size, size, cornerR) + ctx.fill() + ctx.stroke() + + if (isSelected || isHovered) { + ctx.restore() + } + + const innerSize = size * 0.72 + const innerHalf = innerSize * 0.5 + const innerR = 6 * (size / 50) + ctx.fillStyle = colors.docInnerFill + roundRect(ctx, sx - innerHalf, sy - innerHalf, innerSize, innerSize, innerR) + ctx.fill() + + const iconSize = size * 0.35 + const docType = + node.type === "document" ? (node.data as DocumentNodeData).type : "text" + drawDocIcon(ctx, sx, sy, iconSize, docType || "text", colors.iconColor) +} + +function drawMemoryNode( + ctx: CanvasRenderingContext2D, + sx: number, + sy: number, + size: number, + node: GraphNode, + isSelected: boolean, + isHovered: boolean, + _isHighlighted: boolean, + colors: GraphThemeColors, +): void { + const memData = node.data as MemoryNodeData + const isSuperseded = memData.isLatest === false + const isForgotten = memData.isForgotten + const radius = size * 0.5 + + // Dim superseded (non-latest) memory nodes with strikethrough effect + if (isSuperseded && !isSelected && !isHovered) { + const prevAlpha = ctx.globalAlpha + ctx.globalAlpha = prevAlpha * 0.5 + ctx.fillStyle = colors.memFill + drawHexagon(ctx, sx, sy, radius) + ctx.fill() + ctx.strokeStyle = node.borderColor || colors.memStrokeDefault + ctx.lineWidth = 1 + ctx.setLineDash([3, 3]) + ctx.stroke() + ctx.setLineDash([]) + + // Draw diagonal strikethrough for superseded nodes (visual clarity) + const strikeR = radius * 0.55 + ctx.beginPath() + ctx.moveTo(sx - strikeR, sy - strikeR) + ctx.lineTo(sx + strikeR, sy + strikeR) + ctx.strokeStyle = colors.textMuted + ctx.lineWidth = 1.5 + ctx.stroke() + + ctx.globalAlpha = prevAlpha + return + } + + // Drop shadow for selected/hovered memory nodes + if (isSelected || isHovered) { + ctx.save() + const shadowColor = isSelected ? colors.accent : colors.glowColor + ctx.shadowColor = shadowColor + ctx.shadowBlur = isSelected ? 18 : 12 + ctx.shadowOffsetX = 0 + ctx.shadowOffsetY = 0 + } + + ctx.fillStyle = isHovered ? colors.memFillHover : colors.memFill + drawHexagon(ctx, sx, sy, radius) + ctx.fill() + + const borderColor = node.borderColor || colors.memStrokeDefault + ctx.strokeStyle = isSelected ? colors.accent : borderColor + ctx.lineWidth = isSelected ? 2.5 : isHovered ? 2 : 1.5 + ctx.stroke() + + if (isSelected || isHovered) { + ctx.restore() + } + + // Draw X icon for forgotten nodes + if (isForgotten && size > 14) { + const iconR = radius * 0.3 + ctx.save() + ctx.strokeStyle = colors.memBorderForgotten + ctx.lineWidth = Math.max(1.5, size / 20) + ctx.lineCap = "round" + ctx.globalAlpha = 0.9 + ctx.beginPath() + ctx.moveTo(sx - iconR, sy - iconR) + ctx.lineTo(sx + iconR, sy + iconR) + ctx.moveTo(sx + iconR, sy - iconR) + ctx.lineTo(sx - iconR, sy + iconR) + ctx.stroke() + ctx.restore() + } +} + +function drawGlow( + ctx: CanvasRenderingContext2D, + sx: number, + sy: number, + size: number, + nodeType: "document" | "memory", + colors: GraphThemeColors, + isHoverOnly = false, +): void { + ctx.strokeStyle = colors.glowColor + ctx.lineWidth = isHoverOnly ? 1.5 : 2 + ctx.setLineDash(isHoverOnly ? [4, 4] : [3, 3]) + ctx.globalAlpha = isHoverOnly ? 0.5 : 0.8 + + const scale = isHoverOnly ? 1.1 : 1.15 + + if (nodeType === "document") { + const glowSize = size * scale + const half = glowSize * 0.5 + const r = 8 * (glowSize / 50) + roundRect(ctx, sx - half, sy - half, glowSize, glowSize, r) + } else { + drawHexagon(ctx, sx, sy, size * 0.5 * scale) + } + + ctx.stroke() + ctx.setLineDash([]) + ctx.globalAlpha = 1 +} + +function drawHexagon( + ctx: CanvasRenderingContext2D, + cx: number, + cy: number, + radius: number, +): void { + ctx.beginPath() + for (let i = 0; i < 6; i++) { + const angle = (Math.PI / 3) * i - Math.PI / 6 + const x = cx + radius * Math.cos(angle) + const y = cy + radius * Math.sin(angle) + i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y) + } + ctx.closePath() +} + +/** Lighten a 6-digit hex color by a fraction (0-1). Cached to avoid per-frame parsing. */ +export function lightenColor(hex: string, amount: number): string { + if ( + _lightenCache && + _lightenCache.input === hex && + _lightenCache.amount === amount + ) { + return _lightenCache.result + } + const h = hex.replace("#", "") + // Only handle standard 6-digit hex; return input unchanged for other formats + if (h.length !== 6) return hex + const r = Math.min( + 255, + Number.parseInt(h.substring(0, 2), 16) + Math.round(255 * amount), + ) + const g = Math.min( + 255, + Number.parseInt(h.substring(2, 4), 16) + Math.round(255 * amount), + ) + const b = Math.min( + 255, + Number.parseInt(h.substring(4, 6), 16) + Math.round(255 * amount), + ) + const result = `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}` + _lightenCache = { input: hex, amount, result } + return result +} diff --git a/packages/memory-graph/src/canvas/simulation.ts b/packages/memory-graph/src/canvas/simulation.ts new file mode 100644 index 000000000..442717933 --- /dev/null +++ b/packages/memory-graph/src/canvas/simulation.ts @@ -0,0 +1,98 @@ +import * as d3 from "d3-force" +import type { GraphEdge, GraphNode } from "../types" +import { FORCE_CONFIG } from "../constants" + +export class ForceSimulation { + private sim: d3.Simulation | null = null + + init(nodes: GraphNode[], edges: GraphEdge[]): void { + this.destroy() + + try { + // Only use structural edges (derives, updates) for the force layout. + // "extends" edges are visual-only -- they connect documents sharing a + // spaceId but should not pull documents together into a single mass. + const structuralEdges = edges.filter((e) => e.edgeType !== "extends") + + this.sim = d3 + .forceSimulation(nodes) + .alphaDecay(FORCE_CONFIG.alphaDecay) + .alphaMin(FORCE_CONFIG.alphaMin) + .velocityDecay(FORCE_CONFIG.velocityDecay) + + this.sim.force( + "link", + d3 + .forceLink(structuralEdges) + .id((d) => d.id) + .distance((link) => + link.edgeType === "derives" + ? FORCE_CONFIG.docMemoryDistance + : FORCE_CONFIG.linkDistance, + ) + .strength((link) => { + if (link.edgeType === "derives") + return FORCE_CONFIG.linkStrength.docMemory + if (link.edgeType === "updates") + return FORCE_CONFIG.linkStrength.version + return FORCE_CONFIG.linkStrength.fallback + }), + ) + + this.sim.force( + "charge", + d3.forceManyBody().strength(FORCE_CONFIG.chargeStrength), + ) + + this.sim.force( + "collide", + d3 + .forceCollide() + .radius((d) => + d.type === "document" + ? FORCE_CONFIG.collisionRadius.document + : FORCE_CONFIG.collisionRadius.memory, + ) + .strength(FORCE_CONFIG.collisionStrength), + ) + + this.sim.force("x", d3.forceX().strength(FORCE_CONFIG.centeringStrength)) + this.sim.force("y", d3.forceY().strength(FORCE_CONFIG.centeringStrength)) + + this.sim.stop() + this.sim.alpha(1) + for (let i = 0; i < FORCE_CONFIG.preSettleTicks; i++) this.sim.tick() + this.sim.alphaTarget(0).restart() + } catch (e) { + console.error("ForceSimulation.init failed:", e) + this.destroy() + } + } + + update(nodes: GraphNode[], edges: GraphEdge[]): void { + if (!this.sim) return + this.sim.nodes(nodes) + const linkForce = this.sim.force>("link") + if (linkForce) + linkForce.links(edges.filter((e) => e.edgeType !== "extends")) + } + + reheat(): void { + this.sim?.alphaTarget(FORCE_CONFIG.alphaTarget).restart() + } + + coolDown(): void { + this.sim?.alphaTarget(0) + } + + isActive(): boolean { + return (this.sim?.alpha() ?? 0) > FORCE_CONFIG.alphaMin + } + + destroy(): void { + if (this.sim) { + this.sim.stop() + this.sim = null + } + } +} diff --git a/packages/memory-graph/src/canvas/version-chain.ts b/packages/memory-graph/src/canvas/version-chain.ts new file mode 100644 index 000000000..6c692fa7c --- /dev/null +++ b/packages/memory-graph/src/canvas/version-chain.ts @@ -0,0 +1,97 @@ +import type { GraphApiDocument, GraphApiMemory } from "../types" + +export interface ChainEntry { + id: string + version: number + memory: string + isForgotten: boolean + isLatest: boolean +} + +export class VersionChainIndex { + private memoryMap = new Map() + private childrenMap = new Map() + private cache = new Map() + private lastDocs: GraphApiDocument[] | null = null + + rebuild(documents: GraphApiDocument[]): void { + if (documents === this.lastDocs) return + this.lastDocs = documents + this.memoryMap.clear() + this.childrenMap.clear() + this.cache.clear() + + for (const doc of documents) { + for (const m of doc.memories) { + this.memoryMap.set(m.id, m) + if (m.parentMemoryId) { + let children = this.childrenMap.get(m.parentMemoryId) + if (!children) { + children = [] + this.childrenMap.set(m.parentMemoryId, children) + } + children.push(m.id) + } + } + } + } + + getChain(memoryId: string): ChainEntry[] | null { + const cached = this.cache.get(memoryId) + if (cached) return cached + + const mem = this.memoryMap.get(memoryId) + if (!mem) return null + + // Walk backward to root + const backward: GraphApiMemory[] = [] + const visited = new Set() + let current: GraphApiMemory | undefined = mem + while (current && !visited.has(current.id)) { + visited.add(current.id) + backward.push(current) + current = current.parentMemoryId + ? this.memoryMap.get(current.parentMemoryId) + : undefined + } + backward.reverse() + + // Walk forward from the selected node to find descendants. + // Version chains are linear (each memory has one parent), so we + // follow the first child at each step. If branching occurs, only + // the first branch (by document order) is included. + const forward: GraphApiMemory[] = [] + let cursor: GraphApiMemory | undefined = mem + while (cursor) { + const children = this.childrenMap.get(cursor.id) + if (!children || children.length === 0) break + const firstChildId = children[0] + if (!firstChildId) break + const child = this.memoryMap.get(firstChildId) + if (!child || visited.has(child.id)) break + visited.add(child.id) + forward.push(child) + cursor = child + } + + // Combine: backward (root..selected) + forward (selected+1..latest) + const all = [...backward, ...forward] + + // A single-entry chain (standalone v1 with no children) is not useful + if (all.length <= 1) return null + + const chain: ChainEntry[] = all.map((m) => ({ + id: m.id, + version: m.version, + memory: m.memory, + isForgotten: m.isForgotten, + isLatest: m.isLatest, + })) + + for (const entry of chain) { + this.cache.set(entry.id, chain) + } + + return chain + } +} diff --git a/apps/web/components/memory-graph/canvas/viewport.ts b/packages/memory-graph/src/canvas/viewport.ts similarity index 97% rename from apps/web/components/memory-graph/canvas/viewport.ts rename to packages/memory-graph/src/canvas/viewport.ts index ec2715a66..b741097ba 100644 --- a/apps/web/components/memory-graph/canvas/viewport.ts +++ b/packages/memory-graph/src/canvas/viewport.ts @@ -43,7 +43,6 @@ export class ViewportState { pan(dx: number, dy: number): void { this.panX += dx this.panY += dy - // Cancel any target pan animation when user drags this.targetPanX = null this.targetPanY = null } @@ -125,7 +124,6 @@ export class ViewportState { tick(): boolean { let moving = false - // Momentum panning if (Math.abs(this.velocityX) > 0.5 || Math.abs(this.velocityY) > 0.5) { this.panX += this.velocityX this.panY += this.velocityY @@ -137,7 +135,6 @@ export class ViewportState { this.velocityY = 0 } - // Spring zoom const zoomDiff = this.targetZoom - this.zoom if (Math.abs(zoomDiff) > 0.001) { const world = this.screenToWorld(this.zoomAnchorX, this.zoomAnchorY) @@ -147,7 +144,6 @@ export class ViewportState { moving = true } - // Lerp pan animation if (this.targetPanX !== null && this.targetPanY !== null) { const dx = this.targetPanX - this.panX const dy = this.targetPanY - this.panY diff --git a/packages/memory-graph/src/components/canvas-common.css.ts b/packages/memory-graph/src/components/canvas-common.css.ts deleted file mode 100644 index 4f4a35042..000000000 --- a/packages/memory-graph/src/components/canvas-common.css.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { style } from "@vanilla-extract/css" - -/** - * Canvas wrapper/container that fills its parent - * Used by both graph-canvas and graph-webgl-canvas - */ -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 f4cede9f8..f195e0ba8 100644 --- a/packages/memory-graph/src/components/graph-canvas.tsx +++ b/packages/memory-graph/src/components/graph-canvas.tsx @@ -1,1053 +1,315 @@ -"use client" - -import { - memo, - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, -} from "react" -import { colors, ANIMATION } from "@/constants" -import type { - DocumentWithMemories, - GraphCanvasProps, - GraphNode, - MemoryEntry, -} from "@/types" -import { drawDocumentIcon } from "@/utils/document-icons" -import { canvasWrapper } from "./canvas-common.css" - -export const GraphCanvas = memo( - ({ +import { memo, useEffect, useLayoutEffect, useRef } from "react" +import { SpatialIndex } from "../canvas/hit-test" +import { InputHandler } from "../canvas/input-handler" +import { renderFrame } from "../canvas/renderer" +import { ViewportState } from "../canvas/viewport" +import { GRAPH_SETTINGS } from "../constants" +import type { GraphCanvasProps, GraphNode, GraphThemeColors } from "../types" +import type { ForceSimulation } from "../canvas/simulation" + +export interface ExtendedGraphCanvasProps extends GraphCanvasProps { + showFps?: boolean + variant?: "console" | "consumer" + colors: GraphThemeColors +} + +export const GraphCanvas = memo(function GraphCanvas({ + nodes, + edges, + width, + height, + colors, + highlightDocumentIds, + selectedNodeId = null, + onNodeHover, + onNodeClick, + onNodeDragStart, + onNodeDragEnd, + onViewportChange, + canvasRef: externalCanvasRef, + simulation, + viewportRef: externalViewportRef, + showFps = false, + variant = "console", +}) { + const internalCanvasRef = useRef(null) + const canvasRef = externalCanvasRef || internalCanvasRef + + // Engine instances — mutable, never trigger re-renders + const viewportRef = useRef(null) + const spatialRef = useRef(new SpatialIndex()) + const inputRef = useRef(null) + const rafRef = useRef(0) + const renderNeeded = useRef(true) + const nodeMapRef = useRef(new Map()) + + // FPS tracking refs + const showFpsRef = useRef(showFps) + showFpsRef.current = showFps + const fpsFrames = useRef(0) + const fpsLastTime = useRef(performance.now()) + const fpsValue = useRef(0) + + // DPR ref for FPS overlay + const dprRef = useRef( + typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1, + ) + + // All mutable render state in a single ref — the rAF loop reads from here + const s = useRef({ nodes, edges, - panX, - panY, - zoom, width, height, + colors, + selectedNodeId, + hoveredNodeId: null as string | null, + highlightIds: new Set(highlightDocumentIds ?? []), + dimProgress: 0, + dimTarget: selectedNodeId ? 1 : 0, + }) + + // Sync incoming props to mutable state (no re-renders) + s.current.nodes = nodes + s.current.edges = edges + s.current.width = width + s.current.height = height + s.current.colors = colors + + // Stable callback refs so InputHandler never needs recreation + const cb = useRef({ onNodeHover, onNodeClick, onNodeDragStart, - onNodeDragMove, onNodeDragEnd, - onPanStart, - onPanMove, - onPanEnd, - onWheel, - onDoubleClick, - onTouchStart, - onTouchMove, - onTouchEnd, - draggingNodeId, - highlightDocumentIds, - isSimulationActive = false, - selectedNodeId = null, - }) => { - const canvasRef = useRef(null) - const animationRef = useRef(0) - const startTimeRef = useRef(Date.now()) - const mousePos = useRef<{ x: number; y: number }>({ x: 0, y: 0 }) - const currentHoveredNode = useRef(null) - const dimProgress = useRef(selectedNodeId ? 1 : 0) - const dimAnimationRef = useRef(0) - const [, forceRender] = useState(0) - - // Initialize start time once - useEffect(() => { - startTimeRef.current = Date.now() - }, []) - - // Initialize canvas quality settings once - useLayoutEffect(() => { - const canvas = canvasRef.current - if (!canvas) return - const ctx = canvas.getContext("2d") - if (!ctx) return - - // Set high quality rendering once instead of every frame - ctx.imageSmoothingEnabled = true - ctx.imageSmoothingQuality = "high" - }, []) - - // Smooth dimming animation - useEffect(() => { - const targetDim = selectedNodeId ? 1 : 0 - const duration = ANIMATION.dimDuration // Match physics settling time - const startDim = dimProgress.current - const startTime = Date.now() - - const animate = () => { - const elapsed = Date.now() - startTime - const progress = Math.min(elapsed / duration, 1) - - // Ease-out cubic easing for smooth deceleration - const eased = 1 - (1 - progress) ** 3 - dimProgress.current = startDim + (targetDim - startDim) * eased - - // Force re-render to update canvas during animation - forceRender((prev) => prev + 1) - - if (progress < 1) { - dimAnimationRef.current = requestAnimationFrame(animate) - } - } - - if (dimAnimationRef.current) { - cancelAnimationFrame(dimAnimationRef.current) - } - animate() - - return () => { - if (dimAnimationRef.current) { - cancelAnimationFrame(dimAnimationRef.current) - } - } - }, [selectedNodeId]) - - // Spatial grid for optimized hit detection (20-25% FPS improvement for large graphs) - const spatialGrid = useMemo(() => { - const GRID_CELL_SIZE = 150 // Grid cell size in screen pixels - const grid = new Map() - - // Build spatial grid - nodes.forEach((node) => { - const screenX = node.x * zoom + panX - const screenY = node.y * zoom + panY - - // Calculate which grid cell this node belongs to - const cellX = Math.floor(screenX / GRID_CELL_SIZE) - const cellY = Math.floor(screenY / GRID_CELL_SIZE) - const cellKey = `${cellX},${cellY}` - - // Add node to grid cell - if (!grid.has(cellKey)) { - grid.set(cellKey, []) - } - grid.get(cellKey)!.push(node) - }) - - return { grid, cellSize: GRID_CELL_SIZE } - }, [nodes, panX, panY, zoom]) - - // Efficient hit detection using spatial grid - const getNodeAtPosition = useCallback( - (x: number, y: number): string | null => { - const { grid, cellSize } = spatialGrid - - // Determine which grid cell the click is in - const cellX = Math.floor(x / cellSize) - const cellY = Math.floor(y / cellSize) - const cellKey = `${cellX},${cellY}` - - // Only check nodes in the clicked cell (and neighboring cells for edge cases) - const cellsToCheck = [ - cellKey, - `${cellX - 1},${cellY}`, - `${cellX + 1},${cellY}`, - `${cellX},${cellY - 1}`, - `${cellX},${cellY + 1}`, - ] - - // Check from top-most to bottom-most: memory nodes are drawn after documents - for (const key of cellsToCheck) { - const cellNodes = grid.get(key) - if (!cellNodes) continue - - // Iterate backwards (top-most first) - for (let i = cellNodes.length - 1; i >= 0; i--) { - const node = cellNodes[i]! - const screenX = node.x * zoom + panX - const screenY = node.y * zoom + panY - const nodeSize = node.size * zoom - - if (node.type === "document") { - // Rectangular hit detection for documents (matches visual size) - const docWidth = nodeSize * 1.4 - const docHeight = nodeSize * 0.9 - const halfW = docWidth / 2 - const halfH = docHeight / 2 - - if ( - x >= screenX - halfW && - x <= screenX + halfW && - y >= screenY - halfH && - y <= screenY + halfH - ) { - return node.id - } - } else { - // Circular hit detection for memory nodes - const dx = x - screenX - const dy = y - screenY - const distance = Math.sqrt(dx * dx + dy * dy) - - if (distance <= nodeSize / 2) { - return node.id - } - } - } - } - return null - }, - [spatialGrid, panX, panY, zoom], + onViewportChange, + simulation: simulation as ForceSimulation | undefined, + }) + cb.current = { + onNodeHover, + onNodeClick, + onNodeDragStart, + onNodeDragEnd, + onViewportChange, + simulation, + } + + // Rebuild nodeMap + spatial index when nodes change + useEffect(() => { + const map = nodeMapRef.current + map.clear() + for (const n of nodes) map.set(n.id, n) + spatialRef.current.rebuild(nodes) + renderNeeded.current = true + }, [nodes]) + + useEffect(() => { + s.current.highlightIds = new Set(highlightDocumentIds ?? []) + renderNeeded.current = true + }, [highlightDocumentIds]) + + useEffect(() => { + s.current.selectedNodeId = selectedNodeId + s.current.dimTarget = selectedNodeId ? 1 : 0 + renderNeeded.current = true + }, [selectedNodeId]) + + // Create viewport + input handler (once per variant) + // biome-ignore lint/correctness/useExhaustiveDependencies: canvasRef and externalViewportRef are refs — mutations do not trigger re-renders, intentionally omitted + useLayoutEffect(() => { + const canvas = canvasRef.current + if (!canvas) return + + const cfg = GRAPH_SETTINGS[variant] + const vp = new ViewportState( + cfg.initialPanX, + cfg.initialPanY, + cfg.initialZoom, ) - - // Handle mouse events - const handleMouseMove = useCallback( - (e: React.MouseEvent) => { - const canvas = canvasRef.current - if (!canvas) return - - const rect = canvas.getBoundingClientRect() - const x = e.clientX - rect.left - const y = e.clientY - rect.top - - mousePos.current = { x, y } - - const nodeId = getNodeAtPosition(x, y) - if (nodeId !== currentHoveredNode.current) { - currentHoveredNode.current = nodeId - onNodeHover(nodeId) - } - - // Handle node dragging - if (draggingNodeId) { - onNodeDragMove(e) - } + viewportRef.current = vp + if (externalViewportRef) { + ;( + externalViewportRef as React.MutableRefObject + ).current = vp + } + + const handler = new InputHandler(canvas, vp, spatialRef.current, { + onNodeHover: (id) => { + s.current.hoveredNodeId = id + cb.current.onNodeHover(id) + renderNeeded.current = true }, - [getNodeAtPosition, onNodeHover, draggingNodeId, onNodeDragMove], - ) - - const handleMouseDown = useCallback( - (e: React.MouseEvent) => { - const canvas = canvasRef.current - if (!canvas) return - - const rect = canvas.getBoundingClientRect() - const x = e.clientX - rect.left - const y = e.clientY - rect.top - - const nodeId = getNodeAtPosition(x, y) - if (nodeId) { - // When starting a node drag, prevent initiating pan - e.stopPropagation() - onNodeDragStart(nodeId, e) - return - } - onPanStart(e) + onNodeClick: (id) => cb.current.onNodeClick(id), + onNodeDragStart: (id, _node) => { + cb.current.onNodeDragStart(id) + cb.current.simulation?.reheat() }, - [getNodeAtPosition, onNodeDragStart, onPanStart], - ) - - const handleClick = useCallback( - (e: React.MouseEvent) => { - const canvas = canvasRef.current - if (!canvas) return - - const rect = canvas.getBoundingClientRect() - const x = e.clientX - rect.left - const y = e.clientY - rect.top - - const nodeId = getNodeAtPosition(x, y) - if (nodeId) { - onNodeClick(nodeId) - } + onNodeDragEnd: () => { + cb.current.onNodeDragEnd() + cb.current.simulation?.coolDown() }, - [getNodeAtPosition, onNodeClick], - ) + onRequestRender: () => { + renderNeeded.current = true + }, + }) + inputRef.current = handler + + return () => handler.destroy() + }, [variant]) + + // High-DPI canvas sizing + const dpr = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1 + dprRef.current = dpr + + // biome-ignore lint/correctness/useExhaustiveDependencies: canvasRef is a ref — mutations do not trigger re-renders, intentionally omitted + useLayoutEffect(() => { + const canvas = canvasRef.current + if (!canvas || width === 0 || height === 0) return + + const MAX = 16384 + const d = Math.min(MAX / width, MAX / height, dpr) + canvas.style.width = `${width}px` + canvas.style.height = `${height}px` + canvas.width = Math.min(width * d, MAX) + canvas.height = Math.min(height * d, MAX) + + const ctx = canvas.getContext("2d") + if (ctx) { + ctx.scale(d, d) + ctx.imageSmoothingEnabled = true + ctx.imageSmoothingQuality = "high" + } + renderNeeded.current = true + }, [width, height, dpr]) - // Memoize nodeMap to avoid rebuilding every frame - const nodeMap = useMemo(() => { - return new Map(nodes.map((node) => [node.id, node])) - }, [nodes]) + // Single render loop — runs for component lifetime, reads everything from refs + // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally empty deps — all state read via refs inside tick(), not reactive props + useEffect(() => { + let prevVpX = 0 + let prevVpY = 0 + let prevVpZoom = 0 - // Professional rendering function with LOD - const render = useCallback(() => { + const tick = () => { + rafRef.current = requestAnimationFrame(tick) + + const vp = viewportRef.current const canvas = canvasRef.current - if (!canvas) return + if (!vp || !canvas) return const ctx = canvas.getContext("2d") if (!ctx) return - const currentTime = Date.now() - const _elapsed = currentTime - startTimeRef.current - - // Level-of-detail optimization based on zoom - const useSimplifiedRendering = zoom < 0.3 - - // Clear canvas - ctx.clearRect(0, 0, width, height) - - // Draw minimal background grid - ctx.strokeStyle = "rgba(148, 163, 184, 0.03)" // Very subtle grid - ctx.lineWidth = 1 - const gridSpacing = 100 * zoom - const offsetX = panX % gridSpacing - const offsetY = panY % gridSpacing - - // Simple, clean grid lines - for (let x = offsetX; x < width; x += gridSpacing) { - ctx.beginPath() - ctx.moveTo(x, 0) - ctx.lineTo(x, height) - ctx.stroke() - } - for (let y = offsetY; y < height; y += gridSpacing) { - ctx.beginPath() - ctx.moveTo(0, y) - ctx.lineTo(width, y) - ctx.stroke() - } - - // Draw enhanced edges with sophisticated styling - BATCHED BY TYPE for performance - ctx.lineCap = "round" - - // Group edges by type for batch rendering (reduces canvas state changes) - const docMemoryEdges: typeof edges = [] - const docDocEdges: typeof edges = [] - const versionEdges: typeof edges = [] - - // Categorize edges (single pass) with viewport culling - edges.forEach((edge) => { - // Handle both string IDs and node references (d3-force mutates these) - const sourceNode = - typeof edge.source === "string" - ? nodeMap.get(edge.source) - : edge.source - const targetNode = - typeof edge.target === "string" - ? nodeMap.get(edge.target) - : edge.target + const cur = s.current - 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 + // 1. Viewport momentum / spring zoom / lerp pan + const vpMoving = vp.tick() - // Enhanced viewport culling with proper X and Y axis bounds checking - // Only cull edges when BOTH endpoints are off-screen in the same direction - const edgeMargin = 100 - if ( - (sourceX < -edgeMargin && targetX < -edgeMargin) || - (sourceX > width + edgeMargin && targetX > width + edgeMargin) || - (sourceY < -edgeMargin && targetY < -edgeMargin) || - (sourceY > height + edgeMargin && targetY > height + edgeMargin) - ) { - return - } - - // Skip very weak connections when zoomed out for performance - if (useSimplifiedRendering) { - if ( - edge.edgeType === "doc-memory" && - edge.visualProps.opacity < 0.3 - ) { - return // Skip very weak doc-memory edges when zoomed out - } - } - - // Sort into appropriate batch based on edge type - if (edge.edgeType === "doc-memory") { - docMemoryEdges.push(edge) - } else if (edge.edgeType === "doc-doc") { - docDocEdges.push(edge) - } else if (edge.edgeType === "version") { - versionEdges.push(edge) - } - } - }) - - // Helper function to draw a single edge path - const drawEdgePath = ( - edge: (typeof edges)[0], - sourceNode: GraphNode, - targetNode: GraphNode, - edgeShouldDim: boolean, - ) => { - const sourceX = sourceNode.x * zoom + panX - const sourceY = sourceNode.y * zoom + panY - const targetX = targetNode.x * zoom + panX - const targetY = targetNode.y * zoom + panY - - // Simplified lines when zoomed out, curved when zoomed in - if (useSimplifiedRendering) { - // Straight lines for performance - ctx.beginPath() - ctx.moveTo(sourceX, sourceY) - ctx.lineTo(targetX, targetY) - ctx.stroke() - } else { - // Regular curved line for doc-memory and doc-doc - const midX = (sourceX + targetX) / 2 - const midY = (sourceY + targetY) / 2 - const dx = targetX - sourceX - const dy = targetY - sourceY - const distance = Math.sqrt(dx * dx + dy * dy) - const controlOffset = - edge.edgeType === "doc-memory" ? 15 : Math.min(30, distance * 0.2) - - ctx.beginPath() - ctx.moveTo(sourceX, sourceY) - ctx.quadraticCurveTo( - midX + controlOffset * (dy / distance), - midY - controlOffset * (dx / distance), - targetX, - targetY, - ) - ctx.stroke() - } + // 2. Dim animation (ease toward target) + const dd = cur.dimTarget - cur.dimProgress + let dimming = false + if (Math.abs(dd) > 0.01) { + cur.dimProgress += dd * 0.1 + dimming = true + } else { + cur.dimProgress = cur.dimTarget } - // Smooth edge opacity: interpolate between full and 0.05 (dimmed) - const edgeDimOpacity = 1 - dimProgress.current * 0.95 - - // BATCH 1: Draw all doc-memory edges together - if (docMemoryEdges.length > 0) { - ctx.strokeStyle = colors.connection.memory - ctx.lineWidth = 1 - ctx.setLineDash([]) - - docMemoryEdges.forEach((edge) => { - const sourceNode = - typeof edge.source === "string" - ? nodeMap.get(edge.source) - : edge.source - const targetNode = - typeof edge.target === "string" - ? nodeMap.get(edge.target) - : edge.target - - if (sourceNode && targetNode) { - const edgeShouldDim = - selectedNodeId !== null && - sourceNode.id !== selectedNodeId && - targetNode.id !== selectedNodeId - const opacity = edgeShouldDim ? edgeDimOpacity : 0.9 - - ctx.globalAlpha = opacity - drawEdgePath(edge, sourceNode, targetNode, edgeShouldDim) - } - }) - } - - // BATCH 2: Draw all doc-doc edges together (grouped by similarity strength) - if (docDocEdges.length > 0) { - const dashPattern = useSimplifiedRendering ? [] : [10, 5] - ctx.setLineDash(dashPattern) - - docDocEdges.forEach((edge) => { - const sourceNode = - typeof edge.source === "string" - ? nodeMap.get(edge.source) - : edge.source - const targetNode = - typeof edge.target === "string" - ? nodeMap.get(edge.target) - : edge.target - - if (sourceNode && targetNode) { - const edgeShouldDim = - selectedNodeId !== null && - sourceNode.id !== selectedNodeId && - targetNode.id !== selectedNodeId - const opacity = edgeShouldDim - ? edgeDimOpacity - : Math.max(0, edge.similarity * 0.5) - const lineWidth = Math.max(1, edge.similarity * 2) - - // Set color based on similarity strength - let connectionColor = colors.connection.weak - if (edge.similarity > 0.85) - connectionColor = colors.connection.strong - else if (edge.similarity > 0.725) - connectionColor = colors.connection.medium - - ctx.strokeStyle = connectionColor - ctx.lineWidth = lineWidth - ctx.globalAlpha = opacity - drawEdgePath(edge, sourceNode, targetNode, edgeShouldDim) - } - }) + // 3. Simulation physics + const simActive = cb.current.simulation?.isActive() ?? false + + // 4. Spatial index rebuild (only when positions actually move) + const spatialChanged = + simActive || inputRef.current?.getDraggingNode() + ? spatialRef.current.rebuild(cur.nodes) + : false + + // Skip frame if nothing changed + if ( + !vpMoving && + !simActive && + !dimming && + !spatialChanged && + !renderNeeded.current + ) + return + renderNeeded.current = false + + // Report viewport changes so zoom display and popover positions update. + const vpChanged = + vp.panX !== prevVpX || vp.panY !== prevVpY || vp.zoom !== prevVpZoom + if ((vpChanged || simActive) && cb.current.onViewportChange) { + cb.current.onViewportChange( + vp.zoom, + !!(cur.selectedNodeId || cur.hoveredNodeId), + ) } - - // BATCH 3: Draw all version edges together - if (versionEdges.length > 0) { - ctx.setLineDash([]) - - versionEdges.forEach((edge) => { - const sourceNode = - typeof edge.source === "string" - ? nodeMap.get(edge.source) - : edge.source - const targetNode = - typeof edge.target === "string" - ? nodeMap.get(edge.target) - : edge.target - - if (sourceNode && targetNode) { - const edgeShouldDim = - selectedNodeId !== null && - sourceNode.id !== selectedNodeId && - targetNode.id !== selectedNodeId - const opacity = edgeShouldDim ? edgeDimOpacity : 0.8 - const connectionColor = edge.color || colors.relations.updates - - const sourceX = sourceNode.x * zoom + panX - const sourceY = sourceNode.y * zoom + panY - const targetX = targetNode.x * zoom + panX - const targetY = targetNode.y * zoom + panY - - // Special double-line rendering for version chains - ctx.strokeStyle = connectionColor - - // First line (outer) - ctx.lineWidth = 3 - ctx.globalAlpha = opacity * 0.3 - ctx.beginPath() - ctx.moveTo(sourceX, sourceY) - ctx.lineTo(targetX, targetY) - ctx.stroke() - - // Second line (inner) - ctx.lineWidth = 1 - ctx.globalAlpha = opacity - ctx.beginPath() - ctx.moveTo(sourceX, sourceY) - ctx.lineTo(targetX, targetY) - ctx.stroke() - - // Subtle arrow head - const angle = Math.atan2(targetY - sourceY, targetX - sourceX) - const arrowLength = Math.max(6, 8 * zoom) - const arrowWidth = Math.max(8, 12 * zoom) - - 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.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() - } - }) + if (vpChanged) { + prevVpX = vp.panX + prevVpY = vp.panY + prevVpZoom = vp.zoom } - ctx.globalAlpha = 1 - ctx.setLineDash([]) - - // Prepare highlight set from provided document IDs (customId or internal) - const highlightSet = new Set(highlightDocumentIds ?? []) - - // Draw nodes with enhanced styling and LOD optimization - nodes.forEach((node) => { - const screenX = node.x * zoom + panX - const screenY = node.y * zoom + panY - const nodeSize = node.size * zoom - - // Enhanced viewport culling - const margin = nodeSize + 50 - if ( - screenX < -margin || - screenX > width + margin || - screenY < -margin || - screenY > height + margin - ) { - return - } - - const isHovered = currentHoveredNode.current === node.id - const isDragging = node.isDragging - const isSelected = selectedNodeId === node.id - const shouldDim = selectedNodeId !== null && !isSelected - // Smooth opacity: interpolate between 1 (full) and 0.1 (dimmed) based on animation progress - const nodeOpacity = shouldDim ? 1 - dimProgress.current * 0.9 : 1 - const isHighlightedDocument = (() => { - if (node.type !== "document" || highlightSet.size === 0) return false - const doc = node.data as DocumentWithMemories - if (doc.customId && highlightSet.has(doc.customId)) return true - return highlightSet.has(doc.id) - })() - - if (node.type === "document") { - // Enhanced glassmorphism document styling - const docWidth = nodeSize * 1.4 - const docHeight = nodeSize * 0.9 - - // Multi-layer glass effect - ctx.fillStyle = isDragging - ? colors.document.accent - : isHovered - ? colors.document.secondary - : colors.document.primary - ctx.globalAlpha = nodeOpacity - - // Enhanced border with subtle glow - ctx.strokeStyle = isDragging - ? colors.document.glow - : isHovered - ? colors.document.accent - : colors.document.border - ctx.lineWidth = isDragging ? 3 : isHovered ? 2 : 1 - - // Rounded rectangle with enhanced styling - const radius = useSimplifiedRendering ? 6 : 12 - ctx.beginPath() - ctx.roundRect( - screenX - docWidth / 2, - screenY - docHeight / 2, - docWidth, - docHeight, - radius, - ) - ctx.fill() - ctx.stroke() - - // Subtle inner highlight for glass effect (skip when zoomed out) - if (!useSimplifiedRendering && (isHovered || isDragging)) { - ctx.strokeStyle = "rgba(255, 255, 255, 0.1)" - ctx.lineWidth = 1 - ctx.beginPath() - ctx.roundRect( - screenX - docWidth / 2 + 1, - screenY - docHeight / 2 + 1, - docWidth - 2, - docHeight - 2, - radius - 1, - ) - ctx.stroke() - } - - // Highlight ring for search hits - if (isHighlightedDocument) { - ctx.save() - ctx.globalAlpha = 0.9 - ctx.strokeStyle = colors.accent.primary - ctx.lineWidth = 3 - ctx.setLineDash([6, 4]) - // Add equal padding on all sides (15% of average dimension) - const avgDimension = (docWidth + docHeight) / 2 - const ringPadding = avgDimension * 0.1 - 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() - } - - // Draw document type icon (centered) - if (!useSimplifiedRendering) { - const doc = node.data as DocumentWithMemories - const iconSize = docHeight * 0.4 // Icon size relative to card height - - drawDocumentIcon( - ctx, - screenX, - screenY, - iconSize, - doc.type || "text", - "rgba(255, 255, 255, 0.8)", - ) - } - } else { - // Enhanced memory styling with status indicators - const mem = node.data as MemoryEntry - const isForgotten = - mem.isForgotten || - (mem.forgetAfter && - new Date(mem.forgetAfter).getTime() < Date.now()) - const isLatest = mem.isLatest - - // Check if memory is expiring soon (within 7 days) - const expiringSoon = - mem.forgetAfter && - !isForgotten && - new Date(mem.forgetAfter).getTime() - Date.now() < - 1000 * 60 * 60 * 24 * 7 - - // Check if memory is new (created within last 24 hours) - const isNew = - !isForgotten && - new Date(mem.createdAt).getTime() > Date.now() - 1000 * 60 * 60 * 24 - - // Determine colors based on status - let fillColor = colors.memory.primary - let borderColor = colors.memory.border - let glowColor = colors.memory.glow - - if (isForgotten) { - fillColor = colors.status.forgotten - borderColor = "rgba(220,38,38,0.3)" - glowColor = "rgba(220,38,38,0.2)" - } else if (expiringSoon) { - borderColor = colors.status.expiring - glowColor = colors.accent.amber - } else if (isNew) { - borderColor = colors.status.new - glowColor = colors.accent.emerald - } - - if (isDragging) { - fillColor = colors.memory.accent - borderColor = glowColor - } else if (isHovered) { - fillColor = colors.memory.secondary - } - - const radius = nodeSize / 2 - - ctx.fillStyle = fillColor - ctx.globalAlpha = shouldDim ? nodeOpacity : isLatest ? 1 : 0.4 - ctx.strokeStyle = borderColor - ctx.lineWidth = isDragging ? 3 : isHovered ? 2 : 1.5 - - if (useSimplifiedRendering) { - // Simple circles when zoomed out for performance - ctx.beginPath() - ctx.arc(screenX, screenY, radius, 0, 2 * Math.PI) - ctx.fill() - ctx.stroke() - } else { - // HEXAGONAL memory nodes when zoomed in - const sides = 6 - ctx.beginPath() - for (let i = 0; i < sides; i++) { - const angle = (i * 2 * Math.PI) / sides - Math.PI / 2 // Start from top - const x = screenX + radius * Math.cos(angle) - const y = screenY + radius * Math.sin(angle) - if (i === 0) { - ctx.moveTo(x, y) - } else { - ctx.lineTo(x, y) - } - } - ctx.closePath() - ctx.fill() - ctx.stroke() - - // Inner highlight for glass effect - if (isHovered || isDragging) { - ctx.strokeStyle = "rgba(147, 197, 253, 0.3)" - ctx.lineWidth = 1 - const innerRadius = radius - 2 - ctx.beginPath() - for (let i = 0; i < sides; i++) { - const angle = (i * 2 * Math.PI) / sides - Math.PI / 2 - const x = screenX + innerRadius * Math.cos(angle) - const y = screenY + innerRadius * Math.sin(angle) - if (i === 0) { - ctx.moveTo(x, y) - } else { - ctx.lineTo(x, y) - } - } - ctx.closePath() - ctx.stroke() - } - } - - // Status indicators overlay (always preserve these as required) - if (isForgotten) { - // Cross for forgotten memories - ctx.strokeStyle = "rgba(220,38,38,0.4)" - ctx.lineWidth = 2 - const r = nodeSize * 0.25 - ctx.beginPath() - ctx.moveTo(screenX - r, screenY - r) - ctx.lineTo(screenX + r, screenY + r) - ctx.moveTo(screenX + r, screenY - r) - ctx.lineTo(screenX - r, screenY + r) - ctx.stroke() - } else if (isNew) { - // Small dot for new memories - ctx.fillStyle = colors.status.new - ctx.beginPath() - ctx.arc( - screenX + nodeSize * 0.25, - screenY - nodeSize * 0.25, - Math.max(2, nodeSize * 0.15), // Scale with node size, minimum 2px - 0, - 2 * Math.PI, - ) - ctx.fill() - } - } - - // Enhanced hover glow effect (skip when zoomed out for performance) - if (!useSimplifiedRendering && (isHovered || isDragging)) { - const glowColor = - node.type === "document" ? colors.document.glow : colors.memory.glow - - ctx.strokeStyle = glowColor - ctx.lineWidth = 1 - ctx.setLineDash([3, 3]) - ctx.globalAlpha = 0.6 - - ctx.beginPath() - if (node.type === "document") { - // Use actual document dimensions for glow - const docWidth = nodeSize * 1.4 - const docHeight = nodeSize * 0.9 - // Make glow 10% larger than document - const avgDimension = (docWidth + docHeight) / 2 - const glowPadding = avgDimension * 0.1 - ctx.roundRect( - screenX - docWidth / 2 - glowPadding, - screenY - docHeight / 2 - glowPadding, - docWidth + glowPadding * 2, - docHeight + glowPadding * 2, - 15, - ) - } else { - // Hexagonal glow for memory nodes - const glowRadius = nodeSize * 0.7 - const sides = 6 - for (let i = 0; i < sides; i++) { - const angle = (i * 2 * Math.PI) / sides - Math.PI / 2 - const x = screenX + glowRadius * Math.cos(angle) - const y = screenY + glowRadius * Math.sin(angle) - if (i === 0) { - ctx.moveTo(x, y) - } else { - ctx.lineTo(x, y) - } - } - ctx.closePath() - } - ctx.stroke() - ctx.setLineDash([]) - } - }) - - ctx.globalAlpha = 1 - }, [ - nodes, - edges, - panX, - panY, - zoom, - width, - height, - highlightDocumentIds, - nodeMap, - ]) - - // Hybrid rendering: continuous when simulation active, change-based when idle - const lastRenderParams = useRef(0) - - // Create a render key that changes when visual state changes - // Optimized: use cheap hash instead of building long strings - const renderKey = useMemo(() => { - // Hash node positions to a single number (cheaper than string concatenation) - const positionHash = nodes.reduce((hash, n) => { - // Round to 1 decimal to avoid unnecessary re-renders from tiny movements - const x = Math.round(n.x * 10) - const y = Math.round(n.y * 10) - const dragging = n.isDragging ? 1 : 0 - const hovered = currentHoveredNode.current === n.id ? 1 : 0 - // Simple XOR hash (fast and sufficient for change detection) - return hash ^ (x + y + dragging + hovered) - }, 0) - - const highlightHash = (highlightDocumentIds ?? []).reduce((hash, id) => { - return hash ^ id.length - }, 0) - - // Combine all factors into a single number - return ( - positionHash ^ - edges.length ^ - Math.round(panX) ^ - Math.round(panY) ^ - Math.round(zoom * 100) ^ - width ^ - height ^ - highlightHash + renderFrame( + ctx, + cur.nodes, + cur.edges, + vp, + cur.width, + cur.height, + { + selectedNodeId: cur.selectedNodeId, + hoveredNodeId: cur.hoveredNodeId, + highlightIds: cur.highlightIds, + dimProgress: cur.dimProgress, + }, + nodeMapRef.current, + cur.colors, ) - }, [ - nodes, - edges.length, - panX, - panY, - zoom, - width, - height, - highlightDocumentIds, - ]) - - // Render based on simulation state - useEffect(() => { - if (isSimulationActive) { - // Continuous rendering during physics simulation - const renderLoop = () => { - render() - animationRef.current = requestAnimationFrame(renderLoop) - } - renderLoop() - - return () => { - if (animationRef.current) { - cancelAnimationFrame(animationRef.current) - } - } - } - // Change-based rendering when simulation is idle - if (renderKey !== lastRenderParams.current) { - lastRenderParams.current = renderKey - render() - } - }, [isSimulationActive, renderKey, render]) - // Cleanup any existing animation frames - useEffect(() => { - return () => { - if (animationRef.current) { - cancelAnimationFrame(animationRef.current) + // FPS counter overlay + if (showFpsRef.current) { + const now = performance.now() + fpsFrames.current++ + if (now - fpsLastTime.current >= 1000) { + fpsValue.current = fpsFrames.current + fpsFrames.current = 0 + fpsLastTime.current = now } + ctx.save() + ctx.resetTransform() + // Scale for DPR + const d = Math.min( + 16384 / cur.width, + 16384 / cur.height, + dprRef.current, + ) + ctx.scale(d, d) + ctx.fillStyle = "rgba(0,0,0,0.7)" + ctx.fillRect(8, 8, 140, 52) + ctx.fillStyle = "#00ff00" + ctx.font = "bold 14px monospace" + ctx.fillText(`FPS: ${fpsValue.current}`, 16, 28) + ctx.fillStyle = "#ffffff" + ctx.font = "11px monospace" + ctx.fillText(`Nodes: ${cur.nodes.length}`, 16, 44) + ctx.fillText(`Edges: ${cur.edges.length}`, 16, 56) + ctx.restore() } - }, []) - - // Add native wheel event listener to prevent browser zoom - useEffect(() => { - const canvas = canvasRef.current - if (!canvas) return - - const handleNativeWheel = (e: WheelEvent) => { - e.preventDefault() - e.stopPropagation() - - // Call the onWheel handler with a synthetic-like event - // @ts-expect-error - partial WheelEvent object - onWheel({ - deltaY: e.deltaY, - deltaX: e.deltaX, - clientX: e.clientX, - clientY: e.clientY, - currentTarget: canvas, - nativeEvent: e, - preventDefault: () => {}, - stopPropagation: () => {}, - } as React.WheelEvent) - } - - // Add listener with passive: false to ensure preventDefault works - canvas.addEventListener("wheel", handleNativeWheel, { passive: false }) - - // Also prevent gesture events for touch devices - const handleGesture = (e: Event) => { - e.preventDefault() - } - - canvas.addEventListener("gesturestart", handleGesture, { - passive: false, - }) - canvas.addEventListener("gesturechange", handleGesture, { - passive: false, - }) - canvas.addEventListener("gestureend", handleGesture, { passive: false }) - - return () => { - canvas.removeEventListener("wheel", handleNativeWheel) - canvas.removeEventListener("gesturestart", handleGesture) - canvas.removeEventListener("gesturechange", handleGesture) - canvas.removeEventListener("gestureend", handleGesture) - } - }, [onWheel]) - - // High-DPI handling -------------------------------------------------- - const dpr = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1 - - useLayoutEffect(() => { - const canvas = canvasRef.current - if (!canvas) return - - // Maximum safe canvas size (most browsers support up to 16384px) - const MAX_CANVAS_SIZE = 16384 - - // Calculate effective DPR that keeps us within safe limits - // Prevent division by zero by checking for valid dimensions - const maxDpr = - width > 0 && height > 0 - ? Math.min(MAX_CANVAS_SIZE / width, MAX_CANVAS_SIZE / height, dpr) - : dpr - - // upscale backing store with clamped dimensions - canvas.style.width = `${width}px` - canvas.style.height = `${height}px` - canvas.width = Math.min(width * maxDpr, MAX_CANVAS_SIZE) - canvas.height = Math.min(height * maxDpr, MAX_CANVAS_SIZE) - - const ctx = canvas.getContext("2d") - ctx?.scale(maxDpr, maxDpr) - }, [width, height, dpr]) - // ----------------------------------------------------------------------- - - return ( - { - if (draggingNodeId) { - onNodeDragEnd() - } else { - onPanEnd() - } - }} - onMouseMove={(e) => { - handleMouseMove(e) - if (!draggingNodeId) { - onPanMove(e) - } - }} - onMouseUp={() => { - if (draggingNodeId) { - onNodeDragEnd() - } else { - onPanEnd() - } - }} - onTouchStart={onTouchStart} - onTouchMove={onTouchMove} - onTouchEnd={onTouchEnd} - ref={canvasRef} - style={{ - cursor: draggingNodeId - ? "grabbing" - : currentHoveredNode.current - ? "grab" - : "move", - touchAction: "none", - userSelect: "none", - WebkitUserSelect: "none", - }} - /> - ) - }, -) - -GraphCanvas.displayName = "GraphCanvas" + } + + rafRef.current = requestAnimationFrame(tick) + return () => cancelAnimationFrame(rafRef.current) + }, []) + + const canvasStyle: React.CSSProperties = { + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: 0, + touchAction: "none", + userSelect: "none", + } + + return +}) diff --git a/packages/memory-graph/src/components/legend.css.ts b/packages/memory-graph/src/components/legend.css.ts deleted file mode 100644 index 120afa49d..000000000 --- a/packages/memory-graph/src/components/legend.css.ts +++ /dev/null @@ -1,342 +0,0 @@ -import { style, styleVariants, globalStyle } from "@vanilla-extract/css" -import { themeContract } from "../styles/theme.css" - -/** - * Legend container base - */ -const legendContainerBase = style({ - position: "absolute", - zIndex: 20, // Above most elements but below node detail panel - borderRadius: themeContract.radii.xl, - overflow: "hidden", - width: "fit-content", - height: "fit-content", - maxHeight: "calc(100vh - 2rem)", // Prevent overflow -}) - -/** - * Legend container variants for positioning - * Console: Bottom-right (doesn't conflict with anything) - * Consumer: Bottom-right (moved from top to avoid conflicts) - */ -export const legendContainer = styleVariants({ - consoleDesktop: [ - legendContainerBase, - { - bottom: themeContract.space[4], - right: themeContract.space[4], - }, - ], - consoleMobile: [ - legendContainerBase, - { - bottom: themeContract.space[4], - right: themeContract.space[4], - "@media": { - "screen and (max-width: 767px)": { - display: "none", - }, - }, - }, - ], - consumerDesktop: [ - legendContainerBase, - { - // Changed from top to bottom to avoid overlap with node detail panel - bottom: themeContract.space[4], - right: themeContract.space[4], - }, - ], - consumerMobile: [ - legendContainerBase, - { - bottom: themeContract.space[4], - right: themeContract.space[4], - "@media": { - "screen and (max-width: 767px)": { - display: "none", - }, - }, - }, - ], -}) - -/** - * Mobile size variants - */ -export const mobileSize = styleVariants({ - expanded: { - maxWidth: "20rem", // max-w-xs - }, - collapsed: { - width: "4rem", // w-16 - height: "3rem", // h-12 - }, -}) - -/** - * Legend content wrapper - */ -export const legendContent = style({ - position: "relative", - zIndex: 10, -}) - -/** - * Collapsed trigger button - */ -export const collapsedTrigger = style({ - width: "100%", - height: "100%", - padding: themeContract.space[2], - display: "flex", - alignItems: "center", - justifyContent: "center", - transition: themeContract.transitions.normal, - - selectors: { - "&:hover": { - 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 - */ -export const legendHeader = style({ - display: "flex", - alignItems: "center", - justifyContent: "space-between", - paddingLeft: themeContract.space[4], - paddingRight: themeContract.space[4], - 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], - borderRadius: themeContract.radii.sm, - transition: themeContract.transitions.normal, - - selectors: { - "&:hover": { - backgroundColor: "rgba(255, 255, 255, 0.1)", - }, - }, -}) - -export const headerIcon = style({ - width: "1rem", - height: "1rem", - color: themeContract.colors.text.muted, -}) - -/** - * Content sections - */ -export const sectionsContainer = style({ - fontSize: themeContract.typography.fontSize.xs, - color: themeContract.colors.text.secondary, - paddingLeft: themeContract.space[4], - paddingRight: themeContract.space[4], - paddingTop: themeContract.space[3], - paddingBottom: themeContract.space[3], -}) - -export const sectionWrapper = style({ - marginTop: themeContract.space[3], - selectors: { - "&:first-child": { - marginTop: 0, - }, - }, -}) - -export const sectionTitle = style({ - fontSize: themeContract.typography.fontSize.xs, - fontWeight: themeContract.typography.fontWeight.medium, - color: themeContract.colors.text.secondary, - 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 documentNode = style({ - width: "1rem", - height: "0.75rem", - background: "rgba(255, 255, 255, 0.21)", - border: "1px solid rgba(255, 255, 255, 0.6)", - borderRadius: themeContract.radii.sm, - flexShrink: 0, -}) - -// Hexagon shapes using SVG background (matching graph's flat-top hexagon) -// Points calculated: angle = (i * 2π / 6) - π/2, center (6,6), radius 4.5 -const hexagonPoints = "6,1.5 10.4,3.75 10.4,8.25 6,10.5 1.6,8.25 1.6,3.75" - -export const memoryNode = style({ - width: "1rem", - height: "1rem", - flexShrink: 0, - backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 12 12' xmlns='http://www.w3.org/2000/svg'%3E%3Cpolygon points='${hexagonPoints}' fill='rgba(147,197,253,0.21)' stroke='rgba(147,196,253,0.6)' stroke-width='1'/%3E%3C/svg%3E")`, - backgroundSize: "contain", - backgroundRepeat: "no-repeat", -}) - -export const memoryNodeOlder = style({ - opacity: 0.4, - width: "1rem", - height: "1rem", - flexShrink: 0, - backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 12 12' xmlns='http://www.w3.org/2000/svg'%3E%3Cpolygon points='${hexagonPoints}' fill='rgba(147,197,253,0.21)' stroke='rgba(147,196,253,0.6)' stroke-width='1'/%3E%3C/svg%3E")`, - backgroundSize: "contain", - backgroundRepeat: "no-repeat", -}) - -export const forgottenNode = style({ - width: "1rem", - height: "1rem", - flexShrink: 0, - position: "relative", - backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 12 12' xmlns='http://www.w3.org/2000/svg'%3E%3Cpolygon points='${hexagonPoints}' fill='rgba(239,68,68,0.3)' stroke='rgba(239,68,68,0.8)' stroke-width='1'/%3E%3C/svg%3E")`, - backgroundSize: "contain", - backgroundRepeat: "no-repeat", -}) - -export const expiringNode = style({ - width: "1rem", - height: "1rem", - flexShrink: 0, - backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 12 12' xmlns='http://www.w3.org/2000/svg'%3E%3Cpolygon points='${hexagonPoints}' fill='rgba(147,197,253,0.1)' stroke='rgb(245,158,11)' stroke-width='1.5'/%3E%3C/svg%3E")`, - backgroundSize: "contain", - backgroundRepeat: "no-repeat", -}) - -export const newNode = style({ - width: "1rem", - height: "1rem", - flexShrink: 0, - position: "relative", - backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 12 12' xmlns='http://www.w3.org/2000/svg'%3E%3Cpolygon points='${hexagonPoints}' fill='rgba(147,197,253,0.1)' stroke='rgb(16,185,129)' stroke-width='1.5'/%3E%3C/svg%3E")`, - backgroundSize: "contain", - backgroundRepeat: "no-repeat", -}) - -export const forgottenIcon = style({ - position: "absolute", - inset: 0, - display: "flex", - alignItems: "center", - justifyContent: "center", - color: "rgb(248, 113, 113)", - fontSize: themeContract.typography.fontSize.xs, - lineHeight: "1", - pointerEvents: "none", -}) - -export const newBadge = style({ - position: "absolute", - top: "-0.25rem", - right: "-0.25rem", - width: "0.5rem", - 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, 0.5)", - flexShrink: 0, -}) - -export const similarityLine = style({ - width: "1rem", - height: 0, - borderTop: "2px dashed rgba(35, 189, 255, 0.6)", - flexShrink: 0, -}) - -export const relationLine = style({ - width: "1rem", - height: 0, - borderTop: "2px solid", - flexShrink: 0, -}) - -export const weakSimilarity = style({ - width: "0.75rem", - height: "0.75rem", - borderRadius: themeContract.radii.full, - background: "rgba(79, 255, 226, 0.3)", - flexShrink: 0, -}) - -export const strongSimilarity = style({ - width: "0.75rem", - height: "0.75rem", - borderRadius: themeContract.radii.full, - background: "rgba(79, 255, 226, 0.7)", - 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))", - borderRadius: themeContract.radii.full, -}) diff --git a/packages/memory-graph/src/components/legend.tsx b/packages/memory-graph/src/components/legend.tsx index db06da10f..0290226e7 100644 --- a/packages/memory-graph/src/components/legend.tsx +++ b/packages/memory-graph/src/components/legend.tsx @@ -1,276 +1,442 @@ -"use client" +import { memo, useState } from "react" +import type { GraphEdge, GraphNode, GraphThemeColors } from "../types" -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" +interface LegendProps { + nodes?: GraphNode[] + edges?: GraphEdge[] + isLoading?: boolean + colors: GraphThemeColors +} -// 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=/` +function HexagonIcon({ + fill, + stroke, + size = 12, +}: { + fill: string + stroke: string + size?: number +}) { + return ( + + ) } -const getCookie = (name: string): string | null => { - if (typeof document === "undefined") return null - const nameEQ = `${name}=` - const ca = document.cookie.split(";") - for (let i = 0; i < ca.length; i++) { - let c = ca[i] - if (!c) continue - while (c.charAt(0) === " ") c = c.substring(1, c.length) - if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length) - } - return null +function LineIcon({ + color, + dashed = false, +}: { + color: string + dashed?: boolean +}) { + return ( +
+
+
+ ) } -interface ExtendedLegendProps extends LegendProps { - id?: string - nodes?: GraphNode[] - edges?: GraphEdge[] - isLoading?: boolean +function ChevronDownIcon({ color }: { color: string }) { + return ( + + ) +} + +function ChevronRightIcon({ color }: { color: string }) { + return ( + + ) +} + +function StatRow({ + icon, + label, + count, + expandable = false, + expanded = false, + onToggle, + children, + colors, +}: { + icon: React.ReactNode + label: string + count: number + expandable?: boolean + expanded?: boolean + onToggle?: () => void + children?: React.ReactNode + colors: GraphThemeColors +}) { + const buttonStyle: React.CSSProperties = { + display: "flex", + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + width: "100%", + padding: 0, + outline: "none", + background: "none", + border: "none", + cursor: expandable ? "pointer" : "default", + } + + const leftStyle: React.CSSProperties = { + display: "flex", + flexDirection: "row", + alignItems: "center", + gap: 8, + } + + const labelStyle: React.CSSProperties = { + fontSize: 12, + color: colors.textPrimary, + fontWeight: 400, + } + + const countStyle: React.CSSProperties = { + fontSize: 12, + color: colors.textMuted, + } + + const childrenContainerStyle: React.CSSProperties = { + paddingLeft: 10, + paddingTop: 6, + display: "flex", + flexDirection: "column", + gap: 6, + } + + return ( +
+ + {expandable && expanded && children && ( +
{children}
+ )} +
+ ) } export const Legend = memo(function Legend({ - variant = "console", - id, nodes = [], edges = [], - isLoading = false, -}: ExtendedLegendProps) { - const isMobile = useIsMobile() - const [isExpanded, setIsExpanded] = useState(true) - const [isInitialized, setIsInitialized] = useState(false) + isLoading: _isLoading = false, + colors, +}: LegendProps) { + const [isExpanded, setIsExpanded] = useState(false) + const [connectionsExpanded, setConnectionsExpanded] = useState(true) - // Load saved preference on client side - useEffect(() => { - if (!isInitialized) { - const savedState = getCookie("legendCollapsed") - if (savedState === "true") { - setIsExpanded(false) - } else if (savedState === "false") { - setIsExpanded(true) - } else { - // Default: collapsed on mobile, expanded on desktop - setIsExpanded(!isMobile) - } - setIsInitialized(true) - } - }, [isInitialized, isMobile]) + const memoryCount = nodes.filter((n) => n.type === "memory").length + const documentCount = nodes.filter((n) => n.type === "document").length + const connectionCount = edges.length - // Save to cookie when state changes - const handleToggleExpanded = (expanded: boolean) => { - setIsExpanded(expanded) - setCookie("legendCollapsed", expanded ? "false" : "true") + const outerStyle: React.CSSProperties = { + position: "absolute", + zIndex: 20, + overflow: "hidden", + bottom: 16, + left: 16, + width: 214, } - // 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.consumerMobile - : styles.legendContainer.consumerDesktop + const cardStyle: React.CSSProperties = { + borderRadius: 12, + backgroundColor: colors.controlBg, + border: `1px solid ${colors.controlBorder}`, + boxShadow: "0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1)", } - // Calculate stats - const memoryCount = nodes.filter((n) => n.type === "memory").length - const documentCount = nodes.filter((n) => n.type === "document").length + const headerBtnStyle: React.CSSProperties = { + display: "flex", + flexDirection: "row", + alignItems: "center", + gap: 6, + width: "100%", + cursor: "pointer", + outline: "none", + background: "none", + border: "none", + padding: 0, + } - const containerClass = - isMobile && !isExpanded - ? `${getContainerClass()} ${styles.mobileSize.collapsed}` - : isMobile - ? `${getContainerClass()} ${styles.mobileSize.expanded}` - : getContainerClass() + const headerTextStyle: React.CSSProperties = { + fontSize: 14, + color: colors.textPrimary, + fontWeight: 400, + } - return ( -
- - {/* Glass effect background */} - + const sectionLabelStyle: React.CSSProperties = { + fontSize: 12, + color: colors.textMuted, + fontWeight: 400, + textTransform: "uppercase", + letterSpacing: "0.05em", + } -
- {/* Mobile and Desktop collapsed state */} - {!isExpanded && ( - -
-
?
- -
-
- )} + const rowStyle: React.CSSProperties = { + display: "flex", + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + } - {/* Expanded state */} - {isExpanded && ( - <> - {/* Header with toggle */} -
-
Legend
- - - -
+ const rowLeftStyle: React.CSSProperties = { + display: "flex", + alignItems: "center", + gap: 8, + } - -
- {/* Stats Section */} - {!isLoading && ( -
-
Statistics
-
-
- - - {memoryCount} memories - -
-
- - - {documentCount} documents - -
-
-
- - {edges.length} connections - -
-
-
- )} + const edgeLabelStyle: React.CSSProperties = { + fontSize: 12, + color: colors.textPrimary, + } - {/* Node Types */} -
-
Nodes
-
-
-
- Document -
-
-
- - Memory (latest) - -
-
-
- - Memory (older) - -
-
-
+ const statusRowStyle: React.CSSProperties = { + display: "flex", + flexDirection: "row", + alignItems: "center", + gap: 8, + } + + return ( +
+
+
+ - {/* Status Indicators */} -
-
Status
-
-
-
-
+ {isExpanded && ( +
+ {/* Statistics section */} +
+ Statistics +
+ + } + label="Memories" + colors={colors} + /> + + } + label="Documents" + colors={colors} + /> +