diff --git a/apps/memory-graph-playground/src/app/page.tsx b/apps/memory-graph-playground/src/app/page.tsx index 0682905a..d43a27f5 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,61 @@ interface DocumentsResponse { } } +/** Convert the external API format to the internal graph format */ +function toGraphDocuments(docs: DocumentWithMemories[]): GraphApiDocument[] { + // Use a seeded random for deterministic positions + let seed = 42 + const rand = () => { + seed = (seed * 16807 + 0) % 2147483647 + return seed / 2147483647 + } + + return docs.map((doc) => ({ + id: doc.id, + title: doc.title, + summary: doc.summary ?? null, + documentType: doc.documentType, + createdAt: doc.createdAt, + updatedAt: doc.updatedAt, + x: rand() * 1000, + y: rand() * 1000, + 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 +83,6 @@ export default function Home() { if (page === 1) { setIsLoading(true) - } else { - setIsLoadingMore(true) } setError(null) @@ -79,40 +117,38 @@ export default function Home() { 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], + similarityEdgeRatio: 0.05, + seed: 12345, + }) + setMockData({ documents: data.documents }) + setDocuments([]) + setStressTestCount(count) + setShowGraph(true) + setError(null) } // Toggle slideshow @@ -122,16 +158,24 @@ 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 +209,66 @@ 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) => ( -
- -
+ {isSlideshowActive ? ( + + ) : ( + + )} + + Slideshow +
- )} +
{/* Main content */}
@@ -252,21 +294,20 @@ export default function Home() { Get Started

- 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 +315,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 180ce642..44961717 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 0ebc86ee..00000000 --- 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/constants.ts b/apps/web/components/memory-graph/constants.ts deleted file mode 100644 index b879bf1d..00000000 --- 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 06b7bc4c..00000000 --- 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/hooks/use-graph-api.ts b/apps/web/components/memory-graph/hooks/use-graph-api.ts index f2ced9cb..88fc573c 100644 --- a/apps/web/components/memory-graph/hooks/use-graph-api.ts +++ b/apps/web/components/memory-graph/hooks/use-graph-api.ts @@ -7,7 +7,7 @@ import type { GraphViewportResponse, GraphBoundsResponse, GraphStatsResponse, -} from "../types" +} from "@supermemory/memory-graph" interface ViewportParams { minX: number diff --git a/apps/web/components/memory-graph/hooks/use-graph-data.ts b/apps/web/components/memory-graph/hooks/use-graph-data.ts deleted file mode 100644 index 1c5b0a10..00000000 --- a/apps/web/components/memory-graph/hooks/use-graph-data.ts +++ /dev/null @@ -1,289 +0,0 @@ -"use client" - -import { useMemo, useRef, useEffect } from "react" -import { MEMORY_BORDER, EDGE_COLORS } from "../constants" -import type { - GraphNode, - GraphEdge, - GraphApiDocument, - GraphApiMemory, - GraphApiEdge, - DocumentNodeData, - MemoryNodeData, -} from "../types" - -const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000 -const ONE_DAY_MS = 24 * 60 * 60 * 1000 -const MEMORY_CLUSTER_SPREAD = 150 - -function getMemoryBorderColor(mem: GraphApiMemory): string { - if (mem.isForgotten) return MEMORY_BORDER.forgotten - if (mem.forgetAfter) { - const msLeft = new Date(mem.forgetAfter).getTime() - Date.now() - if (msLeft < SEVEN_DAYS_MS) return MEMORY_BORDER.expiring - } - const age = Date.now() - new Date(mem.createdAt).getTime() - if (age < ONE_DAY_MS) return MEMORY_BORDER.recent - return MEMORY_BORDER.default -} - -function getEdgeVisualProps(similarity: number) { - return { - opacity: 0.3 + similarity * 0.5, - thickness: 1 + similarity * 1.5, - } -} - -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) - } - - const rangeX = maxX - minX || 1 - const rangeY = maxY - minY || 1 - const PAD = 100 - - return documents.map((doc) => ({ - ...doc, - x: PAD + ((doc.x - minX) / rangeX) * (1000 - 2 * PAD), - y: PAD + ((doc.y - minY) / rangeY) * (1000 - 2 * PAD), - })) -} - -export function useGraphData( - documents: GraphApiDocument[], - apiEdges: GraphApiEdge[], - draggingNodeId: string | null, - canvasWidth: number, - canvasHeight: number, -) { - const nodeCache = useRef>(new Map()) - - useEffect(() => { - if (!documents || documents.length === 0) return - - const currentIds = new Set() - for (const doc of documents) { - currentIds.add(doc.id) - for (const mem of doc.memories) currentIds.add(mem.id) - } - - for (const [id] of nodeCache.current.entries()) { - if (!currentIds.has(id)) nodeCache.current.delete(id) - } - }, [documents]) - - const { scale, offsetX, offsetY } = useMemo(() => { - if (canvasWidth === 0 || canvasHeight === 0) { - return { scale: 1, offsetX: 0, offsetY: 0 } - } - const paddingFactor = 0.8 - const s = (Math.min(canvasWidth, canvasHeight) * paddingFactor) / 1000 - const ox = (canvasWidth - 1000 * s) / 2 - const oy = (canvasHeight - 1000 * s) / 2 - return { scale: s, offsetX: ox, offsetY: oy } - }, [canvasWidth, canvasHeight]) - - const normalizedDocs = useMemo( - () => normalizeDocCoordinates(documents), - [documents], - ) - - const nodes = useMemo(() => { - if (!normalizedDocs || normalizedDocs.length === 0) return [] - - const result: GraphNode[] = [] - - for (const doc of normalizedDocs) { - const initialX = doc.x * scale + offsetX - const initialY = doc.y * scale + offsetY - - let docNode = nodeCache.current.get(doc.id) - const docData: DocumentNodeData = { - id: doc.id, - title: doc.title, - summary: doc.summary, - type: doc.documentType, - createdAt: doc.createdAt, - updatedAt: doc.updatedAt, - memories: doc.memories, - } - - if (docNode) { - docNode.data = docData - docNode.isDragging = draggingNodeId === doc.id - } else { - docNode = { - id: doc.id, - type: "document", - x: initialX, - y: initialY, - data: docData, - size: 50, - borderColor: "#2A2F36", - isHovered: false, - isDragging: false, - } - nodeCache.current.set(doc.id, docNode) - } - result.push(docNode) - - const memCount = doc.memories.length - for (let i = 0; i < memCount; i++) { - const mem = doc.memories[i]! - let memNode = nodeCache.current.get(mem.id) - const memData: MemoryNodeData = { - ...mem, - documentId: doc.id, - content: mem.memory, - } - - if (memNode) { - memNode.data = memData - memNode.borderColor = getMemoryBorderColor(mem) - memNode.isDragging = draggingNodeId === mem.id - } else { - const angle = (i / memCount) * 2 * Math.PI - memNode = { - id: mem.id, - type: "memory", - x: docNode.x + Math.cos(angle) * MEMORY_CLUSTER_SPREAD, - y: docNode.y + Math.sin(angle) * MEMORY_CLUSTER_SPREAD, - data: memData, - size: 36, - borderColor: getMemoryBorderColor(mem), - isHovered: false, - isDragging: false, - } - nodeCache.current.set(mem.id, memNode) - } - result.push(memNode) - } - } - - return result - }, [normalizedDocs, scale, offsetX, offsetY, draggingNodeId]) - - const edges = useMemo(() => { - if (!normalizedDocs || normalizedDocs.length === 0) return [] - - const result: GraphEdge[] = [] - const allNodeIds = new Set(nodes.map((n) => n.id)) - - for (const doc of normalizedDocs) { - for (const mem of doc.memories) { - result.push({ - id: `dm-${doc.id}-${mem.id}`, - source: doc.id, - target: mem.id, - similarity: 1, - visualProps: { opacity: 0.3, thickness: 1.5 }, - edgeType: "doc-memory", - }) - } - } - - for (const doc of normalizedDocs) { - for (const mem of doc.memories) { - if (mem.parentMemoryId && allNodeIds.has(mem.parentMemoryId)) { - result.push({ - id: `ver-${mem.parentMemoryId}-${mem.id}`, - source: mem.parentMemoryId, - target: mem.id, - similarity: 1, - visualProps: { opacity: 0.6, thickness: 2 }, - edgeType: "version", - }) - } - } - } - - for (const apiEdge of apiEdges) { - if (!allNodeIds.has(apiEdge.source) || !allNodeIds.has(apiEdge.target)) { - continue - } - - result.push({ - id: `sim-${apiEdge.source}-${apiEdge.target}`, - source: apiEdge.source, - target: apiEdge.target, - similarity: apiEdge.similarity, - visualProps: getEdgeVisualProps(apiEdge.similarity), - edgeType: "similarity", - }) - } - - return result - }, [normalizedDocs, apiEdges, nodes]) - - return { nodes, edges, scale, offsetX, offsetY } -} - -export function screenToBackendCoords( - screenX: number, - screenY: number, - panX: number, - panY: number, - zoom: number, - canvasWidth: number, - canvasHeight: number, -): { x: number; y: number } { - const canvasX = (screenX - panX) / zoom - const canvasY = (screenY - panY) / zoom - - const paddingFactor = 0.8 - const s = (Math.min(canvasWidth, canvasHeight) * paddingFactor) / 1000 - const ox = (canvasWidth - 1000 * s) / 2 - const oy = (canvasHeight - 1000 * s) / 2 - - return { - x: (canvasX - ox) / s, - y: (canvasY - oy) / s, - } -} - -export function calculateBackendViewport( - panX: number, - panY: number, - zoom: number, - canvasWidth: number, - canvasHeight: number, -): { minX: number; maxX: number; minY: number; maxY: number } { - const topLeft = screenToBackendCoords( - 0, - 0, - panX, - panY, - zoom, - canvasWidth, - canvasHeight, - ) - const bottomRight = screenToBackendCoords( - canvasWidth, - canvasHeight, - panX, - panY, - zoom, - canvasWidth, - canvasHeight, - ) - - return { - minX: Math.max(0, Math.min(topLeft.x, bottomRight.x)), - maxX: Math.max(topLeft.x, bottomRight.x), - minY: Math.max(0, Math.min(topLeft.y, bottomRight.y)), - maxY: Math.max(topLeft.y, bottomRight.y), - } -} diff --git a/apps/web/components/memory-graph/index.ts b/apps/web/components/memory-graph/index.ts index cb3827a0..ae2c30f1 100644 --- a/apps/web/components/memory-graph/index.ts +++ b/apps/web/components/memory-graph/index.ts @@ -1,33 +1,19 @@ -// Memory Graph components -export { MemoryGraph } from "./memory-graph" -export type { MemoryGraphProps } from "./memory-graph" +// Re-export the wrapper as MemoryGraph (same name, drop-in replacement) +export { MemoryGraph } from "./memory-graph-wrapper" +export type { MemoryGraphWrapperProps as MemoryGraphProps } from "./memory-graph-wrapper" + +// Keep GraphCard (app-specific) export { GraphCard } from "./graph-card" export type { GraphCardProps } from "./graph-card" -// Hooks -export { useGraphApi } from "./hooks/use-graph-api" -export { - useGraphData, - calculateBackendViewport, - screenToBackendCoords, -} from "./hooks/use-graph-data" - -// Canvas engine -export { ViewportState } from "./canvas/viewport" -export { ForceSimulation } from "./canvas/simulation" - -// Types +// Re-export useful types from the package export type { GraphNode, GraphEdge, GraphApiDocument, GraphApiMemory, GraphApiEdge, - GraphViewportResponse, - GraphBoundsResponse, - GraphStatsResponse, - DocumentNodeData, - MemoryNodeData, - DocumentWithMemories, - MemoryEntry, -} from "./types" +} from "@supermemory/memory-graph" + +// Keep the API hook export +export { useGraphApi } from "./hooks/use-graph-api" diff --git a/apps/web/components/memory-graph/legend.tsx b/apps/web/components/memory-graph/legend.tsx deleted file mode 100644 index 6ec393d2..00000000 --- a/apps/web/components/memory-graph/legend.tsx +++ /dev/null @@ -1,599 +0,0 @@ -"use client" - -import { useIsMobile } from "@hooks/use-mobile" -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@ui/components/collapsible" -import { ChevronDown, ChevronRight } from "lucide-react" -import { memo, useEffect, useState } from "react" -import type { GraphEdge, GraphNode, LegendProps } from "./types" -import { cn } from "@lib/utils" - -// Cookie utility functions for legend state -const setCookie = (name: string, value: string, days = 365) => { - if (typeof document === "undefined") return - const expires = new Date() - expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000) - document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/` -} - -const getCookie = (name: string): string | null => { - if (typeof document === "undefined") return null - const nameEQ = `${name}=` - const ca = document.cookie.split(";") - for (let i = 0; i < ca.length; i++) { - let c = ca[i] - if (!c) continue - while (c.charAt(0) === " ") c = c.substring(1, c.length) - if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length) - } - return null -} - -interface ExtendedLegendProps extends LegendProps { - id?: string - nodes?: GraphNode[] - edges?: GraphEdge[] - isLoading?: boolean -} - -// Toggle switch component matching Figma design -const SmallToggle = memo(function SmallToggle({ - checked, - onChange, -}: { - checked: boolean - onChange: (checked: boolean) => void -}) { - 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 41026304..00000000 --- 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 00000000..b9919be9 --- /dev/null +++ b/apps/web/components/memory-graph/memory-graph-wrapper.tsx @@ -0,0 +1,78 @@ +"use client" + +import { useEffect, useRef, useState } from "react" +import { MemoryGraph as MemoryGraphBase } from "@supermemory/memory-graph" +import type { GraphApiDocument, GraphApiEdge } 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, + documentIds, + 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 { + data: apiData, + isLoading: apiIsLoading, + error: apiError, + } = useGraphApi({ + containerTags, + documentIds, + limit: maxNodes, + enabled: containerSize.width > 0 && containerSize.height > 0, + }) + + return ( +
+ + {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 27825787..00000000 --- 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 27d2a7e0..00000000 --- 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 a1e1deeb..00000000 --- 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 7a2a9c70..00000000 --- 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 f20243f6..00000000 --- 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/package.json b/apps/web/package.json index db7632c7..5551c607 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 b98246e1..a634cb4a 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,23 +295,14 @@ }, "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", @@ -2059,22 +2051,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 +2811,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 +3279,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 +4155,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 +5145,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 +5173,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 +6015,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 a0cb5bb4..9cbfd783 100644 --- a/packages/memory-graph/package.json +++ b/packages/memory-graph/package.json @@ -1,6 +1,6 @@ { "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", @@ -17,16 +17,18 @@ "default": "./dist/memory-graph.cjs" } }, - "./styles.css": "./dist/memory-graph.css", + "./mock-data": { + "import": { + "types": "./dist/mock-data.d.ts", + "default": "./dist/mock-data.js" + } + }, "./package.json": "./package.json" }, "files": [ "dist", "README.md" ], - "sideEffects": [ - "**/*.css" - ], "scripts": { "dev": "vite build --watch", "build": "vite build && tsc --emitDeclarationOnly", @@ -59,21 +61,12 @@ "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" diff --git a/packages/memory-graph/src/api-types.ts b/packages/memory-graph/src/api-types.ts index 0ebc86ee..48160d8f 100644 --- a/packages/memory-graph/src/api-types.ts +++ b/packages/memory-graph/src/api-types.ts @@ -1,71 +1,29 @@ -// 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 + content: string + createdAt: string + updatedAt: string + spaceId?: string + embedding?: number[] + isStatic?: boolean 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 + forgetAfter?: string | null + forgetReason?: string | null + version?: number parentMemoryId?: string | null + rootMemoryId?: string | null + isLatest?: boolean } 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 5eb38b42..00000000 --- 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/apps/web/components/memory-graph/canvas/hit-test.ts b/packages/memory-graph/src/canvas/hit-test.ts similarity index 84% rename from apps/web/components/memory-graph/canvas/hit-test.ts rename to packages/memory-graph/src/canvas/hit-test.ts index 88a40678..e71e4643 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,7 +58,6 @@ 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 } 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 a1a6fbf7..9f0afc19 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/apps/web/components/memory-graph/canvas/renderer.ts b/packages/memory-graph/src/canvas/renderer.ts similarity index 78% rename from apps/web/components/memory-graph/canvas/renderer.ts rename to packages/memory-graph/src/canvas/renderer.ts index 7d9cc3c3..5f5ce323 100644 --- a/apps/web/components/memory-graph/canvas/renderer.ts +++ b/packages/memory-graph/src/canvas/renderer.ts @@ -1,5 +1,10 @@ +import type { + DocumentNodeData, + GraphEdge, + GraphNode, + GraphThemeColors, +} from "../types" import type { ViewportState } from "./viewport" -import type { GraphNode, GraphEdge, DocumentNodeData } from "../types" export interface RenderState { selectedNodeId: string | null @@ -8,6 +13,9 @@ export interface RenderState { dimProgress: number } +// Module-level reusable batch map – cleared each frame instead of reallocating +const edgeBatches = new Map() + export function renderFrame( ctx: CanvasRenderingContext2D, nodes: GraphNode[], @@ -17,21 +25,23 @@ export function renderFrame( height: number, state: RenderState, nodeMap: Map, + colors: GraphThemeColors, ): 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) + drawDocDocLines(ctx, nodes, viewport, width, height, colors) + drawEdges(ctx, edges, viewport, width, height, state, nodeMap, colors) + drawNodes(ctx, nodes, viewport, width, height, state, colors) } -// Connect each visible doc to its 2 nearest neighbors function drawDocDocLines( ctx: CanvasRenderingContext2D, nodes: GraphNode[], viewport: ViewportState, width: number, height: number, + colors: GraphThemeColors, ): void { + // Collect visible docs with screen positions const docs: { x: number; y: number }[] = [] for (const n of nodes) { if (n.type !== "document") continue @@ -42,43 +52,68 @@ function drawDocDocLines( } if (docs.length < 2) return - ctx.strokeStyle = "#8DA3F4" + // Build spatial grid for O(n*k) nearest-neighbor instead of O(n^2) + const CELL = 200 + const grid = new Map() + for (let i = 0; i < docs.length; i++) { + const d = docs[i]! + const key = `${Math.floor(d.x / CELL)},${Math.floor(d.y / CELL)}` + let cell = grid.get(key) + if (!cell) { + cell = [] + grid.set(key, cell) + } + cell.push(i) + } + + // For each doc, find 2 nearest from neighboring cells only + ctx.strokeStyle = colors.edgeDocDoc 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]! + const cx = Math.floor(d.x / CELL) + const cy = Math.floor(d.y / CELL) let best1 = -1 let best2 = -1 - let dist1 = Number.POSITIVE_INFINITY - let dist2 = Number.POSITIVE_INFINITY + let dist1 = Infinity + let dist2 = 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 + for (let dx = -1; dx <= 1; dx++) { + for (let dy = -1; dy <= 1; dy++) { + const cell = grid.get(`${cx + dx},${cy + dy}`) + if (!cell) continue + for (const j of cell) { + if (j === i) continue + const other = docs[j]! + const ox = other.x - d.x + const oy = other.y - d.y + const dist = ox * ox + oy * oy + if (dist < dist1) { + best2 = best1 + dist2 = dist1 + best1 = j + dist1 = dist + } else if (dist < dist2) { + best2 = j + dist2 = dist + } + } } } if (best1 >= 0 && i < best1) { + const b1 = docs[best1]! ctx.moveTo(d.x, d.y) - ctx.lineTo(docs[best1]!.x, docs[best1]!.y) + ctx.lineTo(b1.x, b1.y) } if (best2 >= 0 && i < best2) { + const b2 = docs[best2]! ctx.moveTo(d.x, d.y) - ctx.lineTo(docs[best2]!.x, docs[best2]!.y) + ctx.lineTo(b2.x, b2.y) } } @@ -87,26 +122,19 @@ function drawDocDocLines( ctx.globalAlpha = 1 } -// --- Edges --- - -const EDGE_STYLE: Record = { - "doc-memory": { color: "#4A5568", width: 1.5 }, - version: { color: "#8B5CF6", width: 2 }, +function edgeStyle( + edge: GraphEdge, + colors: GraphThemeColors, +): { color: string; width: number } { + if (edge.edgeType === "doc-memory") + return { color: colors.edgeDocMemory, width: 1.5 } + if (edge.edgeType === "version") + return { color: colors.edgeVersion, width: 2 } + if (edge.similarity >= 0.9) return { color: colors.edgeSimStrong, width: 2 } + if (edge.similarity >= 0.8) return { color: colors.edgeSimMedium, width: 1.5 } + return { color: colors.edgeSimWeak, width: 1 } } -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}` } @@ -130,21 +158,26 @@ function drawEdges( height: number, state: RenderState, nodeMap: Map, + colors: GraphThemeColors, ): void { const margin = 100 const hasDim = state.selectedNodeId !== null && state.dimProgress > 0 - // Prepare all visible edges const prepared: PreparedEdge[] = [] for (const edge of edges) { + // Zoom-based edge culling for similarity edges + if (edge.edgeType === "similarity") { + if (viewport.zoom < 0.3) continue + if (viewport.zoom < 0.5 && edge.similarity < 0.9) 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 - // 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 @@ -187,30 +220,30 @@ function drawEdges( endX: t.x - ux * tr, endY: t.y - uy * tr, connected, - style: edgeStyle(edge), + style: edgeStyle(edge, colors), 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() + // Reuse module-level batch map + edgeBatches.clear() for (const e of prepared) { const dimKey = hasDim ? (e.connected ? "|c" : "|d") : "" const key = batchKey(e.style) + dimKey - let batch = batches.get(key) + let batch = edgeBatches.get(key) if (!batch) { batch = [] - batches.set(key, batch) + edgeBatches.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]! + for (const [key, batch] of edgeBatches) { + const first = batch[0] + if (!first) continue const isDimmed = key.endsWith("|d") ctx.globalAlpha = isDimmed ? 1 - state.dimProgress * 0.8 : 1 @@ -224,7 +257,6 @@ function drawEdges( } 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 @@ -260,8 +292,6 @@ function drawArrowHead( ctx.fill() } -// --- Nodes --- - function drawNodes( ctx: CanvasRenderingContext2D, nodes: GraphNode[], @@ -269,6 +299,7 @@ function drawNodes( width: number, height: number, state: RenderState, + colors: GraphThemeColors, ): void { const margin = 60 const memDots: { x: number; y: number; r: number; color: string }[] = [] @@ -278,7 +309,6 @@ function drawNodes( 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 || @@ -292,7 +322,6 @@ function drawNodes( 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) }) @@ -301,7 +330,7 @@ function drawNodes( x: screen.x, y: screen.y, r: Math.max(2, screenSize * 0.45), - color: node.borderColor || "#3B73B8", + color: node.borderColor || colors.memStrokeDefault, }) } continue @@ -323,6 +352,7 @@ function drawNodes( isSelected, isHovered, isHighlighted, + colors, ) } else { drawMemoryNode( @@ -334,11 +364,12 @@ function drawNodes( isSelected, isHovered, isHighlighted, + colors, ) } if (isSelected || isHighlighted) { - drawGlow(ctx, screen.x, screen.y, screenSize, node.type) + drawGlow(ctx, screen.x, screen.y, screenSize, node.type, colors) } } @@ -347,10 +378,9 @@ function drawNodes( ? 1 - state.dimProgress * 0.7 : 1 - // Batch: document dots as filled squares if (docDots.length > 0) { - ctx.fillStyle = "#1B1F24" - ctx.strokeStyle = "#2A2F36" + ctx.fillStyle = colors.docFill + ctx.strokeStyle = colors.docStroke ctx.lineWidth = 1 ctx.globalAlpha = dimAlpha for (const d of docDots) { @@ -360,12 +390,10 @@ function drawNodes( } } - // 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.fillStyle = colors.memFill ctx.beginPath() for (const d of memDots) { ctx.moveTo(d.x + d.r, d.y) @@ -373,7 +401,6 @@ function drawNodes( } ctx.fill() - // Pass 2: colored strokes grouped by border color ctx.lineWidth = 1.5 const byColor = new Map() for (const d of memDots) { @@ -407,32 +434,34 @@ function drawDocumentNode( isSelected: boolean, isHovered: boolean, isHighlighted: boolean, + colors: GraphThemeColors, ): void { const half = size * 0.5 const cornerR = 8 * (size / 50) - // Outer rect - ctx.fillStyle = "#1B1F24" + ctx.fillStyle = colors.docFill ctx.strokeStyle = - isSelected || isHighlighted ? "#3B73B8" : isHovered ? "#3B73B8" : "#2A2F36" + isSelected || isHighlighted + ? colors.accent + : isHovered + ? colors.accent + : colors.docStroke 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" + ctx.fillStyle = colors.docInnerFill 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") + drawDocIcon(ctx, sx, sy, iconSize, docType || "text", colors) } function drawMemoryNode( @@ -444,17 +473,16 @@ function drawMemoryNode( isSelected: boolean, isHovered: boolean, _isHighlighted: boolean, + colors: GraphThemeColors, ): void { const radius = size * 0.5 - // Fill - ctx.fillStyle = isHovered ? "#112840" : "#0D2034" + ctx.fillStyle = isHovered ? colors.memFillHover : colors.memFill drawHexagon(ctx, sx, sy, radius) ctx.fill() - // Stroke with time-based border color - const borderColor = node.borderColor || "#3B73B8" - ctx.strokeStyle = isSelected ? "#3B73B8" : borderColor + const borderColor = node.borderColor || colors.memStrokeDefault + ctx.strokeStyle = isSelected ? colors.accent : borderColor ctx.lineWidth = isHovered ? 2 : 1.5 ctx.stroke() } @@ -465,8 +493,9 @@ function drawGlow( sy: number, size: number, nodeType: "document" | "memory", + colors: GraphThemeColors, ): void { - ctx.strokeStyle = "#3B73B8" + ctx.strokeStyle = colors.glowColor ctx.lineWidth = 2 ctx.setLineDash([3, 3]) ctx.globalAlpha = 0.8 @@ -485,8 +514,6 @@ function drawGlow( ctx.globalAlpha = 1 } -// --- Shapes --- - function drawHexagon( ctx: CanvasRenderingContext2D, cx: number, @@ -524,20 +551,17 @@ function roundRect( ctx.closePath() } -// --- Document icons --- - -const ICON_COLOR = "#3B73B8" - function drawDocIcon( ctx: CanvasRenderingContext2D, x: number, y: number, size: number, type: string, + colors: GraphThemeColors, ): void { ctx.save() - ctx.fillStyle = ICON_COLOR - ctx.strokeStyle = ICON_COLOR + ctx.fillStyle = colors.iconColor + ctx.strokeStyle = colors.iconColor ctx.lineWidth = Math.max(1, size / 12) ctx.lineCap = "round" ctx.lineJoin = "round" diff --git a/apps/web/components/memory-graph/canvas/simulation.ts b/packages/memory-graph/src/canvas/simulation.ts similarity index 89% rename from apps/web/components/memory-graph/canvas/simulation.ts rename to packages/memory-graph/src/canvas/simulation.ts index f0a4088b..c0cfebd7 100644 --- a/apps/web/components/memory-graph/canvas/simulation.ts +++ b/packages/memory-graph/src/canvas/simulation.ts @@ -1,5 +1,5 @@ import * as d3 from "d3-force" -import type { GraphNode, GraphEdge } from "../types" +import type { GraphEdge, GraphNode } from "../types" export class ForceSimulation { private sim: d3.Simulation | null = null @@ -10,7 +10,7 @@ export class ForceSimulation { try { this.sim = d3 .forceSimulation(nodes) - .alphaDecay(0.03) + .alphaDecay(0.04) .alphaMin(0.001) .velocityDecay(0.6) @@ -40,10 +40,9 @@ export class ForceSimulation { 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() + for (let i = 0; i < 100; i++) this.sim.tick() this.sim.alphaTarget(0).restart() } catch (e) { console.error("ForceSimulation.init failed:", e) diff --git a/apps/web/components/memory-graph/canvas/version-chain.ts b/packages/memory-graph/src/canvas/version-chain.ts similarity index 94% rename from apps/web/components/memory-graph/canvas/version-chain.ts rename to packages/memory-graph/src/canvas/version-chain.ts index ec151701..26de0bd0 100644 --- a/apps/web/components/memory-graph/canvas/version-chain.ts +++ b/packages/memory-graph/src/canvas/version-chain.ts @@ -33,7 +33,6 @@ export class VersionChainIndex { 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 @@ -53,7 +52,6 @@ export class VersionChainIndex { chain.reverse() - // Cache for every member in the chain for (const entry of chain) { this.cache.set(entry.id, 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 ec2715a6..b741097b 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 4f4a3504..00000000 --- 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 f4cede9f..f389588f 100644 --- a/packages/memory-graph/src/components/graph-canvas.tsx +++ b/packages/memory-graph/src/components/graph-canvas.tsx @@ -1,1053 +1,313 @@ -"use client" +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" -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 interface ExtendedGraphCanvasProps extends GraphCanvasProps { + showFps?: boolean + variant?: "console" | "consumer" + colors: GraphThemeColors +} -export const GraphCanvas = memo( - ({ +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, + ) + + // Node ID tracking for smart re-init + const prevIdsRef = useRef("") + + // 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) + onViewportChange, + simulation: simulation as ForceSimulation | undefined, + }) + cb.current = { + onNodeHover, + onNodeClick, + onNodeDragStart, + onNodeDragEnd, + onViewportChange, + simulation, + } - // Initialize start time once - useEffect(() => { - startTimeRef.current = Date.now() - }, []) + // 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]) - // Initialize canvas quality settings once - useLayoutEffect(() => { - const canvas = canvasRef.current - if (!canvas) return - const ctx = canvas.getContext("2d") - if (!ctx) return + // Track node ID changes for smart simulation re-init + useEffect(() => { + const idKey = nodes.map((n) => n.id).sort().join(",") + if (idKey !== prevIdsRef.current) { + prevIdsRef.current = idKey + // IDs changed - full re-init needed (handled by parent) + } + }, [nodes]) - // Set high quality rendering once instead of every frame + 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 + dprRef.current = dpr + + 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]) - // 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() + // Single render loop — runs for component lifetime, reads everything from refs + useEffect(() => { + let lastReportedZoom = 0 - const animate = () => { - const elapsed = Date.now() - startTime - const progress = Math.min(elapsed / duration, 1) + const tick = () => { + rafRef.current = requestAnimationFrame(tick) - // 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], - ) - - // Handle mouse events - const handleMouseMove = useCallback( - (e: React.MouseEvent) => { - const canvas = canvasRef.current - if (!canvas) return - - const rect = canvas.getBoundingClientRect() - const x = e.clientX - rect.left - const y = e.clientY - rect.top - - mousePos.current = { x, y } - - const nodeId = getNodeAtPosition(x, y) - if (nodeId !== currentHoveredNode.current) { - currentHoveredNode.current = nodeId - onNodeHover(nodeId) - } - - // Handle node dragging - if (draggingNodeId) { - onNodeDragMove(e) - } - }, - [getNodeAtPosition, onNodeHover, draggingNodeId, onNodeDragMove], - ) - - const handleMouseDown = useCallback( - (e: React.MouseEvent) => { - const canvas = canvasRef.current - if (!canvas) return - - const rect = canvas.getBoundingClientRect() - const x = e.clientX - rect.left - const y = e.clientY - rect.top - - const nodeId = getNodeAtPosition(x, y) - if (nodeId) { - // When starting a node drag, prevent initiating pan - e.stopPropagation() - onNodeDragStart(nodeId, e) - return - } - onPanStart(e) - }, - [getNodeAtPosition, onNodeDragStart, onPanStart], - ) - - const handleClick = useCallback( - (e: React.MouseEvent) => { - const canvas = canvasRef.current - if (!canvas) return - - const rect = canvas.getBoundingClientRect() - const x = e.clientX - rect.left - const y = e.clientY - rect.top - - const nodeId = getNodeAtPosition(x, y) - if (nodeId) { - onNodeClick(nodeId) - } - }, - [getNodeAtPosition, onNodeClick], - ) - - // Memoize nodeMap to avoid rebuilding every frame - const nodeMap = useMemo(() => { - return new Map(nodes.map((node) => [node.id, node])) - }, [nodes]) - - // Professional rendering function with LOD - const render = useCallback(() => { + 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 + const cur = s.current - // Level-of-detail optimization based on zoom - const useSimplifiedRendering = zoom < 0.3 + // 1. Viewport momentum / spring zoom / lerp pan + const vpMoving = vp.tick() - // 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() + // 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 } - // Draw enhanced edges with sophisticated styling - BATCHED BY TYPE for performance - ctx.lineCap = "round" + // 3. Simulation physics + const simActive = cb.current.simulation?.isActive() ?? false - // Group edges by type for batch rendering (reduces canvas state changes) - const docMemoryEdges: typeof edges = [] - const docDocEdges: typeof edges = [] - const versionEdges: typeof edges = [] + // 4. Spatial index rebuild (only when positions actually move) + const spatialChanged = + simActive || inputRef.current?.getDraggingNode() + ? spatialRef.current.rebuild(cur.nodes) + : false - // 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 - - if (sourceNode && targetNode) { - const sourceX = sourceNode.x * zoom + panX - const sourceY = sourceNode.y * zoom + panY - const targetX = targetNode.x * zoom + panX - const targetY = targetNode.y * zoom + panY - - // Enhanced viewport culling with 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() - } - } - - // 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) - } - }) - } - - // 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() - } - }) - } - - 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 + // Skip frame if nothing changed + if ( + !vpMoving && + !simActive && + !dimming && + !spatialChanged && + !renderNeeded.current ) - }, [ - nodes, - edges.length, - panX, - panY, - zoom, - width, - height, - highlightDocumentIds, - ]) + return + renderNeeded.current = false - // 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) - } + // 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, + cur.colors, + ) + + // 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() } - // 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) - } - } - }, []) + rafRef.current = requestAnimationFrame(tick) + return () => cancelAnimationFrame(rafRef.current) + }, []) - // Add native wheel event listener to prevent browser zoom - useEffect(() => { - const canvas = canvasRef.current - if (!canvas) return + const canvasStyle: React.CSSProperties = { + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: 0, + touchAction: "none", + userSelect: "none", + } - 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" + 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 120afa49..00000000 --- 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 db06da10..f8f0721a 100644 --- a/packages/memory-graph/src/components/legend.tsx +++ b/packages/memory-graph/src/components/legend.tsx @@ -1,276 +1,427 @@ -"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" - -// Cookie utility functions for legend state -const setCookie = (name: string, value: string, days = 365) => { - if (typeof document === "undefined") return - const expires = new Date() - expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000) - document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/` -} - -const getCookie = (name: string): string | null => { - if (typeof document === "undefined") return null - const nameEQ = `${name}=` - const ca = document.cookie.split(";") - for (let i = 0; i < ca.length; i++) { - let c = ca[i] - if (!c) continue - while (c.charAt(0) === " ") c = c.substring(1, c.length) - if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length) - } - return null -} - -interface ExtendedLegendProps extends LegendProps { - id?: string +interface LegendProps { nodes?: GraphNode[] edges?: GraphEdge[] isLoading?: boolean + colors: GraphThemeColors +} + +function HexagonIcon({ + fill, + stroke, + size = 12, +}: { + fill: string + stroke: string + size?: number +}) { + return ( + + ) +} + +function LineIcon({ + color, + dashed = false, +}: { + color: string + dashed?: boolean +}) { + return ( +
+
+
+ ) +} + +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]) - - // Save to cookie when state changes - const handleToggleExpanded = (expanded: boolean) => { - setIsExpanded(expanded) - setCookie("legendCollapsed", expanded ? "false" : "true") - } - - // Get container class based on variant and mobile state - const getContainerClass = () => { - if (variant === "console") { - return isMobile - ? styles.legendContainer.consoleMobile - : styles.legendContainer.consoleDesktop - } - return isMobile - ? styles.legendContainer.consumerMobile - : styles.legendContainer.consumerDesktop - } - - // Calculate stats const memoryCount = nodes.filter((n) => n.type === "memory").length const documentCount = nodes.filter((n) => n.type === "document").length + const connectionCount = edges.length - const containerClass = - isMobile && !isExpanded - ? `${getContainerClass()} ${styles.mobileSize.collapsed}` - : isMobile - ? `${getContainerClass()} ${styles.mobileSize.expanded}` - : getContainerClass() + const outerStyle: React.CSSProperties = { + position: "absolute", + zIndex: 20, + overflow: "hidden", + bottom: 16, + left: 16, + width: 214, + } + + 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)", + } + + 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 headerTextStyle: React.CSSProperties = { + fontSize: 14, + color: colors.textPrimary, + fontWeight: 400, + } + + const sectionLabelStyle: React.CSSProperties = { + fontSize: 12, + color: colors.textMuted, + fontWeight: 400, + textTransform: "uppercase", + letterSpacing: "0.05em", + } + + const rowStyle: React.CSSProperties = { + display: "flex", + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + } + + const rowLeftStyle: React.CSSProperties = { + display: "flex", + alignItems: "center", + gap: 8, + } + + const edgeLabelStyle: React.CSSProperties = { + fontSize: 12, + color: colors.textPrimary, + } + + const statusRowStyle: React.CSSProperties = { + display: "flex", + flexDirection: "row", + alignItems: "center", + gap: 8, + } return ( -
- - {/* Glass effect background */} - +
+
+
+ -
- {/* Mobile and Desktop collapsed state */} - {!isExpanded && ( - -
-
?
- -
-
- )} - - {/* Expanded state */} {isExpanded && ( - <> - {/* Header with toggle */} -
-
Legend
- - - +
+ {/* Statistics section */} +
+ Statistics +
+ + } + label="Memories" + colors={colors} + /> + + } + label="Documents" + colors={colors} + /> + +
- -
- {/* Stats Section */} - {!isLoading && ( -
-
Statistics
-
-
- - - {memoryCount} memories - -
-
- - - {documentCount} documents - -
-
-
- - {edges.length} connections - -
-
-
- )} - - {/* Node Types */} -
-
Nodes
-
-
-
- Document -
-
-
- - Memory (latest) - -
-
-
- - Memory (older) - -
-
+ {/* Memory Status section */} +
+ Memory Status +
+
+ + + Recent (< 24h) +
- - {/* Status Indicators */} -
-
Status
-
-
-
-
✕
-
- Forgotten -
-
-
- Expiring soon -
-
-
-
-
- New memory -
-
+
+ + Expiring soon
- - {/* Connection Types */} -
-
Connections
-
-
-
- Doc → Memory -
-
-
- - Doc similarity - -
-
-
- - {/* Relation Types */} -
-
Relations
-
- {[ - ["updates", colors.relations.updates], - ["extends", colors.relations.extends], - ["derives", colors.relations.derives], - ].map(([label, color]) => ( -
-
- - {label} - -
- ))} -
-
- - {/* Similarity Strength */} -
-
Similarity
-
-
-
- Weak -
-
-
- Strong -
-
+
+ + Forgotten
- - +
+
)}
- +
) }) diff --git a/packages/memory-graph/src/components/loading-indicator.css.ts b/packages/memory-graph/src/components/loading-indicator.css.ts deleted file mode 100644 index 4aac7cfd..00000000 --- a/packages/memory-graph/src/components/loading-indicator.css.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { style } from "@vanilla-extract/css" -import { themeContract } from "../styles/theme.css" -import { animations } from "../styles" - -/** - * Loading indicator container - * Positioned top-left, below spaces dropdown - */ -export const loadingContainer = style({ - position: "absolute", - zIndex: 30, // High priority so it's visible when loading - borderRadius: themeContract.radii.xl, - overflow: "hidden", - top: "5.5rem", // Below spaces dropdown (~88px) - left: themeContract.space[4], -}) - -/** - * Content wrapper - */ -export const loadingContent = style({ - position: "relative", - zIndex: 10, - color: themeContract.colors.text.secondary, - paddingLeft: themeContract.space[4], - paddingRight: themeContract.space[4], - paddingTop: themeContract.space[3], - paddingBottom: themeContract.space[3], -}) - -/** - * Flex container for icon and text - */ -export const loadingFlex = style({ - display: "flex", - alignItems: "center", - gap: themeContract.space[2], -}) - -/** - * Spinning icon - */ -export const loadingIcon = style({ - width: "1rem", - height: "1rem", - animation: `${animations.spin} 1s linear infinite`, - color: themeContract.colors.memory.border, -}) - -/** - * Loading text - */ -export const loadingText = style({ - fontSize: themeContract.typography.fontSize.sm, -}) diff --git a/packages/memory-graph/src/components/loading-indicator.tsx b/packages/memory-graph/src/components/loading-indicator.tsx index bbb2312c..37ddc82e 100644 --- a/packages/memory-graph/src/components/loading-indicator.tsx +++ b/packages/memory-graph/src/components/loading-indicator.tsx @@ -1,40 +1,87 @@ -"use client" - -import { GlassMenuEffect } from "@/ui/glass-effect" -import { Sparkles } from "lucide-react" import { memo } from "react" -import type { LoadingIndicatorProps } from "@/types" -import { - loadingContainer, - loadingContent, - loadingFlex, - loadingIcon, - loadingText, -} from "./loading-indicator.css" +import type { GraphThemeColors, LoadingIndicatorProps } from "../types" -export const LoadingIndicator = memo( - ({ isLoading, isLoadingMore, totalLoaded, variant = "console" }) => { - if (!isLoading && !isLoadingMore) return null +const spinKeyframes = ` +@keyframes mg-spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} +` - return ( -
- {/* Glass effect background */} - +let styleInjected = false +function injectSpinStyle() { + if (styleInjected || typeof document === "undefined") return + const style = document.createElement("style") + style.textContent = spinKeyframes + document.head.appendChild(style) + styleInjected = true +} -
-
- {/*@ts-ignore */} - - - {isLoading - ? "Loading memory graph..." - : `Loading more documents... (${totalLoaded})`} - -
-
+export const LoadingIndicator = memo< + LoadingIndicatorProps & { colors?: GraphThemeColors } +>(({ isLoading, isLoadingMore, totalLoaded, colors }) => { + if (!isLoading && !isLoadingMore) return null + + injectSpinStyle() + + const containerStyle: React.CSSProperties = { + position: "absolute", + zIndex: 30, + overflow: "hidden", + top: 16, + left: 16, + borderRadius: 12, + border: `1px solid ${colors?.controlBorder ?? "#2A2F36"}`, + backgroundColor: colors?.controlBg ?? "#1a1f29", + paddingLeft: 16, + paddingRight: 16, + paddingTop: 12, + paddingBottom: 12, + boxShadow: "0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1)", + } + + const flexStyle: React.CSSProperties = { + display: "flex", + alignItems: "center", + gap: 8, + } + + const spinnerStyle: React.CSSProperties = { + width: 16, + height: 16, + animation: "mg-spin 1s linear infinite", + color: colors?.accent ?? "#3B73B8", + flexShrink: 0, + } + + const textStyle: React.CSSProperties = { + fontSize: 14, + color: colors?.textSecondary ?? "#e2e8f0", + } + + return ( +
+
+ + + {isLoading + ? "Loading memory graph..." + : `Loading more documents... (${totalLoaded})`} +
- ) - }, -) +
+ ) +}) LoadingIndicator.displayName = "LoadingIndicator" diff --git a/packages/memory-graph/src/components/memory-graph.css.ts b/packages/memory-graph/src/components/memory-graph.css.ts deleted file mode 100644 index 1726b741..00000000 --- a/packages/memory-graph/src/components/memory-graph.css.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { style } from "@vanilla-extract/css" -import { themeContract } from "../styles/theme.css" - -/** - * Error state container - */ -export const errorContainer = style({ - height: "100%", - display: "flex", - alignItems: "center", - justifyContent: "center", - backgroundColor: themeContract.colors.background.primary, -}) - -export const errorCard = style({ - borderRadius: themeContract.radii.xl, - overflow: "hidden", -}) - -export const errorContent = style({ - position: "relative", - zIndex: 10, - color: themeContract.colors.text.secondary, - paddingLeft: themeContract.space[6], - paddingRight: themeContract.space[6], - paddingTop: themeContract.space[4], - paddingBottom: themeContract.space[4], -}) - -/** - * Main graph container - * Position relative so absolutely positioned children position relative to this container - */ -export const mainContainer = style({ - position: "relative", - height: "100%", - borderRadius: themeContract.radii.xl, - overflow: "hidden", - backgroundColor: themeContract.colors.background.primary, -}) - -/** - * Spaces selector positioning - * Top-left corner, below most overlays - */ -export const spacesSelectorContainer = style({ - position: "absolute", - top: themeContract.space[4], - left: themeContract.space[4], - zIndex: 15, // Above base elements, below loading/panels -}) - -/** - * Graph canvas container - */ -export const graphContainer = style({ - width: "100%", - height: "100%", - position: "relative", - overflow: "hidden", - touchAction: "none", - userSelect: "none", - WebkitUserSelect: "none", -}) - -/** - * Navigation controls positioning - * Bottom-left corner - */ -export const navControlsContainer = style({ - position: "absolute", - bottom: themeContract.space[4], - left: themeContract.space[4], - zIndex: 15, // Same level as spaces dropdown -}) diff --git a/packages/memory-graph/src/components/memory-graph.tsx b/packages/memory-graph/src/components/memory-graph.tsx index a6c16a2e..7af2cfab 100644 --- a/packages/memory-graph/src/components/memory-graph.tsx +++ b/packages/memory-graph/src/components/memory-graph.tsx @@ -1,795 +1,604 @@ -"use client" - -import { GlassMenuEffect } from "@/ui/glass-effect" -import { AnimatePresence } from "motion/react" -import { - useCallback, - useEffect, - useMemo, - useReducer, - useRef, - useState, -} from "react" +import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { ForceSimulation } from "../canvas/simulation" +import { VersionChainIndex } from "../canvas/version-chain" +import type { ViewportState } from "../canvas/viewport" +import { useGraphData } from "../hooks/use-graph-data" +import { useGraphTheme } from "../hooks/use-graph-theme" +import type { + GraphApiDocument, + GraphApiEdge, + GraphNode, + GraphThemeColors, + MemoryGraphProps, +} from "../types" import { GraphCanvas } from "./graph-canvas" -import { useGraphData } from "@/hooks/use-graph-data" -import { useGraphInteractions } from "@/hooks/use-graph-interactions" -import { useForceSimulation } from "@/hooks/use-force-simulation" -import { injectStyles } from "@/lib/inject-styles" import { Legend } from "./legend" import { LoadingIndicator } from "./loading-indicator" import { NavigationControls } from "./navigation-controls" -import { NodeDetailPanel } from "./node-detail-panel" -import { NodePopover } from "./node-popover" -import { SpacesDropdown } from "./spaces-dropdown" -import * as styles from "./memory-graph.css" -import { defaultTheme } from "@/styles/theme.css" +import { NodeHoverPopover } from "./node-hover-popover" -import type { MemoryGraphProps } from "@/types" - -export const MemoryGraph = ({ +export function MemoryGraph({ + documents = [], + apiEdges = [], + isLoading: externalIsLoading = false, + error: externalError = null, children, - documents, - isLoading = false, - isLoadingMore = false, - error = null, - totalLoaded, - hasMore = false, - loadMoreDocuments, - showSpacesSelector, variant = "console", - legendId, highlightDocumentIds = [], highlightsVisible = true, - occludedRightPx = 0, - autoLoadOnViewport = true, - themeClassName, - selectedSpace: externalSelectedSpace, - onSpaceChange: externalOnSpaceChange, - memoryLimit, - maxNodes, - isExperimental, - // Slideshow control + showFps = false, isSlideshowActive = false, onSlideshowNodeChange, - onSlideshowStop, -}: MemoryGraphProps) => { - // Inject styles on first render (client-side only) - useEffect(() => { - injectStyles() - }, []) - - // Derive totalLoaded from documents if not provided - const effectiveTotalLoaded = totalLoaded ?? documents.length - // No-op for loadMoreDocuments if not provided - const effectiveLoadMoreDocuments = loadMoreDocuments ?? (async () => {}) - // Derive showSpacesSelector from variant if not explicitly provided - // console variant shows spaces selector, consumer variant hides it - const finalShowSpacesSelector = showSpacesSelector ?? variant === "console" - - // Internal state for controlled/uncontrolled pattern - const [internalSelectedSpace, setInternalSelectedSpace] = - useState("all") + onSlideshowStop: _onSlideshowStop, + canvasRef: externalCanvasRef, + colors: colorOverrides, + totalCount, +}: MemoryGraphProps) { + const resolvedColors = useGraphTheme(colorOverrides) + const colors: GraphThemeColors = colorOverrides + ? { ...resolvedColors, ...colorOverrides } + : resolvedColors 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()) - // Use external state if provided, otherwise use internal state - const selectedSpace = externalSelectedSpace ?? internalSelectedSpace + // React state only for things that affect DOM + const [hoveredNode, setHoveredNode] = useState(null) + const [selectedNode, setSelectedNode] = useState(null) + const [zoomDisplay, setZoomDisplay] = useState(50) - // Handle space change - const handleSpaceChange = useCallback( - (spaceId: string) => { - if (externalOnSpaceChange) { - externalOnSpaceChange(spaceId) - } else { - setInternalSelectedSpace(spaceId) - } - }, - [externalOnSpaceChange], - ) - - // Create data object with pagination to satisfy type requirements - const data = useMemo(() => { - return documents && documents.length > 0 - ? { - documents, - pagination: { - currentPage: 1, - limit: documents.length, - totalItems: documents.length, - totalPages: 1, - }, - } - : null - }, [documents]) - - // Graph interactions with variant-specific settings - const { - panX, - panY, - zoom, - /** hoveredNode currently unused within this component */ - hoveredNode: _hoveredNode, - selectedNode, - draggingNodeId, - nodePositions, - handlePanStart, - handlePanMove, - handlePanEnd, - handleWheel, - handleNodeHover, - handleNodeClick, - handleNodeDragStart, - handleNodeDragMove, - handleNodeDragEnd, - handleDoubleClick, - handleTouchStart, - handleTouchMove, - handleTouchEnd, - setSelectedNode, - autoFitToViewport, - centerViewportOn, - zoomIn, - zoomOut, - } = useGraphInteractions(variant) - - // Graph data const { nodes, edges } = useGraphData( - data, - selectedSpace, - nodePositions, - draggingNodeId, - memoryLimit, - maxNodes, - ) - - // State to trigger re-renders when simulation ticks - const [, forceRender] = useReducer((x: number) => x + 1, 0) - - // Track drag state for physics integration - const dragStateRef = useRef<{ - nodeId: string | null - startX: number - startY: number - nodeStartX: number - nodeStartY: number - }>({ nodeId: null, startX: 0, startY: 0, nodeStartX: 0, nodeStartY: 0 }) - - // Force simulation - only runs during interactions (drag) - const forceSimulation = useForceSimulation( - nodes, - edges, - () => { - // On each tick, trigger a re-render - // D3 directly mutates node.x and node.y - forceRender() - }, - true, // enabled - ) - - // Auto-fit once per unique highlight set to show the full graph for context - const lastFittedHighlightKeyRef = useRef("") - useEffect(() => { - const highlightKey = highlightsVisible ? highlightDocumentIds.join("|") : "" - if ( - highlightKey && - highlightKey !== lastFittedHighlightKeyRef.current && - containerSize.width > 0 && - containerSize.height > 0 && - nodes.length > 0 - ) { - autoFitToViewport(nodes, containerSize.width, containerSize.height, { - occludedRightPx, - animate: true, - }) - lastFittedHighlightKeyRef.current = highlightKey - } - }, [ - highlightsVisible, - highlightDocumentIds, + documents, + apiEdges, + null, containerSize.width, containerSize.height, - nodes.length, - occludedRightPx, - autoFitToViewport, - ]) + colors, + ) - // Auto-fit graph when component mounts or nodes change significantly + // Rebuild version chain index when documents change + useEffect(() => { + chainIndex.current.rebuild(documents) + }, [documents]) + + // Smart simulation re-init: track node ID set, only init() when IDs change + const prevSimIdsRef = useRef("") + useEffect(() => { + if (nodes.length === 0) { + simulationRef.current?.destroy() + simulationRef.current = null + prevSimIdsRef.current = "" + return + } + + const idKey = nodes + .map((n) => n.id) + .sort() + .join(",") + + if (!simulationRef.current) { + simulationRef.current = new ForceSimulation() + } + + if (idKey !== prevSimIdsRef.current) { + // IDs changed - full re-init + prevSimIdsRef.current = idKey + simulationRef.current.init(nodes, edges) + } else { + // Only metadata changed - update existing simulation + simulationRef.current.update(nodes, edges) + } + }, [nodes, edges]) + + // Cleanup simulation on unmount + useEffect(() => { + return () => { + simulationRef.current?.destroy() + simulationRef.current = null + } + }, []) + + // Auto-fit when data first loads const hasAutoFittedRef = useRef(false) useEffect(() => { - // Only auto-fit once when we have nodes and container size if ( !hasAutoFittedRef.current && nodes.length > 0 && - containerSize.width > 0 && - containerSize.height > 0 + viewportRef.current && + containerSize.width > 0 ) { - // Auto-fit to show all content for both variants - // Add a small delay to ensure the canvas is fully initialized const timer = setTimeout(() => { - autoFitToViewport(nodes, containerSize.width, containerSize.height) - hasAutoFittedRef.current = true - }, 100) - - return () => clearTimeout(timer) - } - }, [nodes, containerSize.width, containerSize.height, autoFitToViewport]) - - // Reset auto-fit flag when nodes array becomes empty (switching views) - useEffect(() => { - if (nodes.length === 0) { - hasAutoFittedRef.current = false - } - }, [nodes.length]) - - // Extract unique spaces from memories and calculate counts - const { availableSpaces, spaceMemoryCounts } = useMemo(() => { - if (!data?.documents) return { availableSpaces: [], spaceMemoryCounts: {} } - - const spaceSet = new Set() - const counts: Record = {} - - data.documents.forEach((doc) => { - doc.memoryEntries.forEach((memory) => { - const spaceId = memory.spaceContainerTag || memory.spaceId || "default" - spaceSet.add(spaceId) - counts[spaceId] = (counts[spaceId] || 0) + 1 - }) - }) - - return { - availableSpaces: Array.from(spaceSet).sort(), - spaceMemoryCounts: counts, - } - }, [data]) - - // Handle container resize - useEffect(() => { - const updateSize = () => { - if (containerRef.current) { - const newWidth = containerRef.current.clientWidth - const newHeight = containerRef.current.clientHeight - - // Only update if size actually changed and is valid - setContainerSize((prev) => { - if (prev.width !== newWidth || prev.height !== newHeight) { - return { width: newWidth, height: newHeight } - } - return prev - }) - } - } - - // Use a slight delay to ensure DOM is fully rendered - const timer = setTimeout(updateSize, 0) - updateSize() // Also call immediately - - window.addEventListener("resize", updateSize) - - // Use ResizeObserver for more accurate container size detection - const resizeObserver = new ResizeObserver(updateSize) - if (containerRef.current) { - resizeObserver.observe(containerRef.current) - } - - return () => { - clearTimeout(timer) - window.removeEventListener("resize", updateSize) - resizeObserver.disconnect() - } - }, []) - - // Physics-enabled node drag start - const handleNodeDragStartWithNodes = useCallback( - (nodeId: string, e: React.MouseEvent) => { - // Find the node being dragged - const node = nodes.find((n) => n.id === nodeId) - if (node) { - // Store drag start state - dragStateRef.current = { - nodeId, - startX: e.clientX, - startY: e.clientY, - nodeStartX: node.x, - nodeStartY: node.y, - } - - // Pin the node at its current position (d3-force pattern) - node.fx = node.x - node.fy = node.y - - // Reheat simulation immediately (like d3 reference code) - forceSimulation.reheat() - } - - // Set dragging state (still need this for visual feedback) - handleNodeDragStart(nodeId, e, nodes) - }, - [handleNodeDragStart, nodes, forceSimulation], - ) - - // Physics-enabled node drag move - const handleNodeDragMoveWithNodes = useCallback( - (e: React.MouseEvent) => { - if (draggingNodeId && dragStateRef.current.nodeId === draggingNodeId) { - // Update the fixed position during drag (this is what d3 uses) - const node = nodes.find((n) => n.id === draggingNodeId) - if (node) { - // Calculate new position based on drag delta - const deltaX = (e.clientX - dragStateRef.current.startX) / zoom - const deltaY = (e.clientY - dragStateRef.current.startY) / zoom - - // Update subject position (matches d3 reference code pattern) - // Only update fx/fy, let simulation handle x/y - node.fx = dragStateRef.current.nodeStartX + deltaX - node.fy = dragStateRef.current.nodeStartY + deltaY - } - } - }, - [nodes, draggingNodeId, zoom], - ) - - // Physics-enabled node drag end - const handleNodeDragEndWithPhysics = useCallback(() => { - if (draggingNodeId) { - // Unpin the node (allow physics to take over) - matches d3 reference code - const node = nodes.find((n) => n.id === draggingNodeId) - if (node) { - node.fx = null - node.fy = null - } - - // Cool down the simulation (restore target alpha to 0) - forceSimulation.coolDown() - - // Reset drag state - dragStateRef.current = { - nodeId: null, - startX: 0, - startY: 0, - nodeStartX: 0, - nodeStartY: 0, - } - } - - // Call original handler to clear dragging state - handleNodeDragEnd() - }, [draggingNodeId, nodes, forceSimulation, handleNodeDragEnd]) - - // Physics-aware node click - let simulation continue naturally - const handleNodeClickWithPhysics = useCallback( - (nodeId: string) => { - // Just call original handler to update selected node state - // Don't stop the simulation - let it cool down naturally - handleNodeClick(nodeId) - }, - [handleNodeClick], - ) - - // Navigation callbacks - const handleCenter = useCallback(() => { - if (nodes.length > 0) { - // Calculate center of all nodes - let sumX = 0 - let sumY = 0 - let count = 0 - - nodes.forEach((node) => { - sumX += node.x - sumY += node.y - count++ - }) - - if (count > 0) { - const centerX = sumX / count - const centerY = sumY / count - centerViewportOn( - centerX, - centerY, + viewportRef.current?.fitToNodes( + nodes, containerSize.width, containerSize.height, ) - } + hasAutoFittedRef.current = true + }, 100) + return () => clearTimeout(timer) } - }, [nodes, centerViewportOn, containerSize.width, containerSize.height]) + }, [nodes, containerSize.width, containerSize.height]) - const handleAutoFit = useCallback(() => { - if ( - nodes.length > 0 && - containerSize.width > 0 && - containerSize.height > 0 - ) { - autoFitToViewport(nodes, containerSize.width, containerSize.height, { - occludedRightPx, - animate: true, - }) - } - }, [ - nodes, - containerSize.width, - containerSize.height, - occludedRightPx, - autoFitToViewport, - ]) + useEffect(() => { + if (nodes.length === 0) hasAutoFittedRef.current = false + }, [nodes.length]) - // Get selected node data - const selectedNodeData = useMemo(() => { - if (!selectedNode) return null - return nodes.find((n) => n.id === selectedNode) || null - }, [selectedNode, nodes]) + // Container resize observer + useEffect(() => { + const el = containerRef.current + if (!el) return - // Calculate popover position (memoized for performance) - const popoverPosition = useMemo(() => { - if (!selectedNodeData) return null - - // Calculate screen position of the node - const screenX = selectedNodeData.x * zoom + panX - const screenY = selectedNodeData.y * zoom + panY - - // Popover dimensions (estimated) - const popoverWidth = 320 - const popoverHeight = 400 - const padding = 16 - - // Calculate node dimensions to position popover with proper gap - const nodeSize = selectedNodeData.size * zoom - const nodeWidth = - selectedNodeData.type === "document" ? nodeSize * 1.4 : nodeSize - const nodeHeight = - selectedNodeData.type === "document" ? nodeSize * 0.9 : nodeSize - const gap = 20 // Gap between node and popover - - // Smart positioning: flip to other side if would go off-screen - let popoverX = screenX + nodeWidth / 2 + gap - let popoverY = screenY - popoverHeight / 2 - - // Check right edge - if (popoverX + popoverWidth > containerSize.width - padding) { - // Flip to left side of node - popoverX = screenX - nodeWidth / 2 - gap - popoverWidth - } - - // Check left edge - if (popoverX < padding) { - popoverX = padding - } - - // Check bottom edge - if (popoverY + popoverHeight > containerSize.height - padding) { - // Move up - popoverY = containerSize.height - popoverHeight - padding - } - - // Check top edge - if (popoverY < padding) { - popoverY = padding - } - - return { x: popoverX, y: popoverY } - }, [ - selectedNodeData, - zoom, - panX, - panY, - containerSize.width, - containerSize.height, - ]) - - // Viewport-based loading: load more when most documents are visible (optional) - const checkAndLoadMore = useCallback(() => { - if ( - isLoadingMore || - !hasMore || - !data?.documents || - data.documents.length === 0 - ) - return - - // Calculate viewport bounds - const viewportBounds = { - left: -panX / zoom - 200, - right: (-panX + containerSize.width) / zoom + 200, - top: -panY / zoom - 200, - bottom: (-panY + containerSize.height) / zoom + 200, - } - - // Count visible documents - const visibleDocuments = data.documents.filter((doc) => { - const docNodes = nodes.filter( - (node) => node.type === "document" && node.data.id === doc.id, - ) - return docNodes.some( - (node) => - node.x >= viewportBounds.left && - node.x <= viewportBounds.right && - node.y >= viewportBounds.top && - node.y <= viewportBounds.bottom, - ) + 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()) - // If 80% or more of documents are visible, load more - const visibilityRatio = visibleDocuments.length / data.documents.length - if (visibilityRatio >= 0.8) { - effectiveLoadMoreDocuments() + 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 } - }, [ - isLoadingMore, - hasMore, - data, - panX, - panY, - zoom, - containerSize.width, - containerSize.height, - nodes, - effectiveLoadMoreDocuments, - ]) + viewportRef.current.centerOn( + sx / nodes.length, + sy / nodes.length, + containerSize.width, + containerSize.height, + ) + }, [nodes, containerSize.width, containerSize.height]) - // Throttled version to avoid excessive checks - const lastLoadCheckRef = useRef(0) - const throttledCheckAndLoadMore = useCallback(() => { - const now = Date.now() - if (now - lastLoadCheckRef.current > 1000) { - // Check at most once per second - lastLoadCheckRef.current = now - checkAndLoadMore() - } - }, [checkAndLoadMore]) + 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]) - // Monitor viewport changes to trigger loading + 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 — using useEffect with keydown listener useEffect(() => { - if (!autoLoadOnViewport) return - throttledCheckAndLoadMore() - }, [throttledCheckAndLoadMore, autoLoadOnViewport]) + const handler = (e: KeyboardEvent) => { + // Don't handle if user is typing in an input + const target = e.target as HTMLElement + if ( + target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.isContentEditable + ) + return - // Initial load trigger when graph is first rendered - useEffect(() => { - if (!autoLoadOnViewport) return - if (data?.documents && data.documents.length > 0 && hasMore) { - // Start loading more documents after initial render - setTimeout(() => { - throttledCheckAndLoadMore() - }, 500) // Small delay to allow initial layout - } - }, [data, hasMore, throttledCheckAndLoadMore, autoLoadOnViewport]) - - // Slideshow logic - simulate actual node clicks with physics - const slideshowIntervalRef = useRef(null) - const physicsTimeoutRef = useRef(null) - const lastSelectedIndexRef = useRef(-1) - const isSlideshowActiveRef = useRef(isSlideshowActive) - - // Update slideshow active ref - useEffect(() => { - isSlideshowActiveRef.current = isSlideshowActive - }, [isSlideshowActive]) - - // Use refs to store current values without triggering re-renders - const nodesRef = useRef(nodes) - const handleNodeClickRef = useRef(handleNodeClick) - const centerViewportOnRef = useRef(centerViewportOn) - const containerSizeRef = useRef(containerSize) - const onSlideshowNodeChangeRef = useRef(onSlideshowNodeChange) - const forceSimulationRef = useRef(forceSimulation) - - // Update refs when values change - useEffect(() => { - nodesRef.current = nodes - handleNodeClickRef.current = handleNodeClick - centerViewportOnRef.current = centerViewportOn - containerSizeRef.current = containerSize - onSlideshowNodeChangeRef.current = onSlideshowNodeChange - forceSimulationRef.current = forceSimulation - }, [ - nodes, - handleNodeClick, - centerViewportOn, - containerSize, - onSlideshowNodeChange, - forceSimulation, - ]) - - useEffect(() => { - // Clear any existing interval and timeout when isSlideshowActive changes - if (slideshowIntervalRef.current) { - clearInterval(slideshowIntervalRef.current) - slideshowIntervalRef.current = null - } - if (physicsTimeoutRef.current) { - clearTimeout(physicsTimeoutRef.current) - physicsTimeoutRef.current = null + switch (e.key) { + case "z": + case "Z": + handleAutoFit() + break + case "c": + case "C": + handleCenter() + break + case "=": + case "+": + handleZoomIn() + break + case "-": + case "_": + handleZoomOut() + break + case "Escape": + setSelectedNode(null) + break + } } - if (!isSlideshowActive) { - // Close the popover when stopping slideshow - setSelectedNode(null) - // Explicitly cool down physics simulation in case timeout hasn't fired yet - forceSimulation.coolDown() + window.addEventListener("keydown", handler) + return () => window.removeEventListener("keydown", handler) + }, [handleAutoFit, handleCenter, handleZoomIn, handleZoomOut]) + + // 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) + const prev = chain[idx - 1] + if (idx > 0 && prev) { + selectAndCenter(prev.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) + const next = chain[idx + 1] + if (idx >= 0 && idx < chain.length - 1 && next) { + selectAndCenter(next.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] + if (next) selectAndCenter(next.id) + } 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] + if (next) selectAndCenter(next.id) + } + }, [selectedNode, nodes, selectAndCenter]) + + 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] + if (prev) selectAndCenter(prev.id) + } 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] + if (prev) selectAndCenter(prev.id) + } + }, [selectedNode, nodes, selectAndCenter]) + + // Arrow key navigation + useEffect(() => { + const handler = (e: KeyboardEvent) => { + const target = e.target as HTMLElement + if ( + target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.isContentEditable + ) + return + + switch (e.key) { + case "ArrowUp": + e.preventDefault() + navigateUp() + break + case "ArrowDown": + e.preventDefault() + navigateDown() + break + case "ArrowRight": + e.preventDefault() + navigateNext() + break + case "ArrowLeft": + e.preventDefault() + navigatePrev() + break + } + } + + window.addEventListener("keydown", handler) + return () => window.removeEventListener("keydown", handler) + }, [navigateUp, navigateDown, navigateNext, navigatePrev]) + + // Slideshow + useEffect(() => { + if (!isSlideshowActive || nodes.length === 0) { + if (!isSlideshowActive) { + setSelectedNode(null) + simulationRef.current?.coolDown() + } return } - // Select a random node (avoid selecting the same one twice in a row) - const selectRandomNode = () => { - // Double-check slideshow is still active - if (!isSlideshowActiveRef.current) return - - const currentNodes = nodesRef.current - if (currentNodes.length === 0) return - - let randomIndex: number - // If we have more than one node, avoid selecting the same one - if (currentNodes.length > 1) { + let lastIdx = -1 + const pick = () => { + if (nodes.length === 0) return + let idx: number + if (nodes.length > 1) { do { - randomIndex = Math.floor(Math.random() * currentNodes.length) - } while (randomIndex === lastSelectedIndexRef.current) + idx = Math.floor(Math.random() * nodes.length) + } while (idx === lastIdx) } else { - randomIndex = 0 - } - - lastSelectedIndexRef.current = randomIndex - const randomNode = currentNodes[randomIndex] - - if (randomNode) { - // Smoothly pan to the node first - centerViewportOnRef.current( - randomNode.x, - randomNode.y, - containerSizeRef.current.width, - containerSizeRef.current.height, - ) - - // Simulate the actual node click (triggers dimming and popover) - handleNodeClickRef.current(randomNode.id) - - // Trigger physics animation briefly - forceSimulationRef.current.reheat() - - // Cool down physics after 1 second (cleanup old timeout first) - if (physicsTimeoutRef.current) { - clearTimeout(physicsTimeoutRef.current) - } - physicsTimeoutRef.current = setTimeout(() => { - // Only cool down if slideshow is still active or if this is cleanup - forceSimulationRef.current.coolDown() - physicsTimeoutRef.current = null - }, 1000) - - // Notify parent component - onSlideshowNodeChangeRef.current?.(randomNode.id) + 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) } - // Start immediately - selectRandomNode() + pick() + const interval = setInterval(pick, 3500) + return () => clearInterval(interval) + }, [ + isSlideshowActive, + nodes, + containerSize.width, + containerSize.height, + onSlideshowNodeChange, + ]) - // Set interval for subsequent selections (3.5 seconds) - slideshowIntervalRef.current = setInterval(() => { - selectRandomNode() - }, 3500) + // 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]) - return () => { - if (slideshowIntervalRef.current) { - clearInterval(slideshowIntervalRef.current) - slideshowIntervalRef.current = null - } - if (physicsTimeoutRef.current) { - clearTimeout(physicsTimeoutRef.current) - physicsTimeoutRef.current = null - } + 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 + + if (externalError) { + const errorContainerStyle: React.CSSProperties = { + height: "100%", + display: "flex", + alignItems: "center", + justifyContent: "center", + backgroundColor: colors.bg, + borderRadius: 12, + } + + const errorBoxStyle: React.CSSProperties = { + color: colors.textSecondary, + paddingLeft: 24, + paddingRight: 24, + paddingTop: 16, + paddingBottom: 16, } - }, [isSlideshowActive]) // Only depend on isSlideshowActive - if (error) { return ( -
-
- {/* Glass effect background */} - - -
- Error loading documents: {error.message} -
+
+
+ Error loading graph: {externalError.message}
) } - return ( -
- {/* Spaces selector - only shown for console variant */} - {variant === "console" && availableSpaces.length > 0 && ( -
- -
- )} + const wrapperStyle: React.CSSProperties = { + position: "relative", + height: "100%", + borderRadius: 12, + overflow: "hidden", + backgroundColor: colors.bg, + backgroundImage: `radial-gradient(circle, ${colors.textMuted} 0.5px, transparent 0.5px)`, + backgroundSize: "16px 16px", + } - {/* Loading indicator */} + const canvasContainerStyle: React.CSSProperties = { + width: "100%", + height: "100%", + position: "relative", + overflow: "hidden", + touchAction: "none", + userSelect: "none", + } + + const emptyStateStyle: React.CSSProperties = { + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: 0, + display: "flex", + alignItems: "center", + justifyContent: "center", + } + + const navControlsStyle: React.CSSProperties = { + position: "absolute", + bottom: 72, + left: 16, + zIndex: 15, + } + + return ( +
- {/* Legend */} - + {!isLoading && + !nodes.some((n) => n.type === "document") && + children && ( +
{children}
+ )} - {/* Node popover - positioned near clicked node */} - {selectedNodeData && popoverPosition && ( - setSelectedNode(null)} - containerBounds={containerRef.current?.getBoundingClientRect()} - onBackdropClick={isSlideshowActive ? onSlideshowStop : undefined} - /> - )} - - {/* Show welcome screen when no memories exist */} - {!isLoading && (!data || !nodes.some((n) => n.type === "document")) && ( - <>{children} - )} - - {/* Graph container */} -
+
{containerSize.width > 0 && containerSize.height > 0 && ( )} - {/* Navigation controls */} - {containerSize.width > 0 && ( - - zoomIn(containerSize.width / 2, containerSize.height / 2) - } - onZoomOut={() => - zoomOut(containerSize.width / 2, containerSize.height / 2) - } - onAutoFit={handleAutoFit} - nodes={nodes} - className={styles.navControlsContainer} + {activeNodeData && activePopoverPosition && ( + )} + +
+ {containerSize.width > 0 && ( +
+ +
+ )} + +
) diff --git a/packages/memory-graph/src/components/navigation-controls.css.ts b/packages/memory-graph/src/components/navigation-controls.css.ts deleted file mode 100644 index 318cf067..00000000 --- a/packages/memory-graph/src/components/navigation-controls.css.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { style } from "@vanilla-extract/css" -import { themeContract } from "../styles/theme.css" - -/** - * Navigation controls container - */ -export const navContainer = style({ - display: "flex", - flexDirection: "column", - gap: themeContract.space[1], -}) - -/** - * Base button styles for navigation controls - */ -const navButtonBase = style({ - backgroundColor: "rgba(0, 0, 0, 0.2)", - backdropFilter: "blur(8px)", - WebkitBackdropFilter: "blur(8px)", - border: "1px solid rgba(255, 255, 255, 0.1)", - borderRadius: themeContract.radii.lg, - padding: themeContract.space[2], - color: "rgba(255, 255, 255, 0.7)", - fontSize: themeContract.typography.fontSize.xs, - fontWeight: themeContract.typography.fontWeight.medium, - minWidth: "64px", - cursor: "pointer", - transition: themeContract.transitions.normal, - - selectors: { - "&:hover": { - backgroundColor: "rgba(0, 0, 0, 0.3)", - borderColor: "rgba(255, 255, 255, 0.2)", - color: "rgba(255, 255, 255, 1)", - }, - }, -}) - -/** - * Standard navigation button - */ -export const navButton = navButtonBase - -/** - * Zoom controls container - */ -export const zoomContainer = style({ - display: "flex", - flexDirection: "column", -}) - -/** - * Zoom in button (top rounded) - */ -export const zoomInButton = style([ - navButtonBase, - { - borderTopLeftRadius: themeContract.radii.lg, - borderTopRightRadius: themeContract.radii.lg, - borderBottomLeftRadius: 0, - borderBottomRightRadius: 0, - borderBottom: 0, - }, -]) - -/** - * Zoom out button (bottom rounded) - */ -export const zoomOutButton = style([ - navButtonBase, - { - borderTopLeftRadius: 0, - borderTopRightRadius: 0, - borderBottomLeftRadius: themeContract.radii.lg, - borderBottomRightRadius: themeContract.radii.lg, - }, -]) diff --git a/packages/memory-graph/src/components/navigation-controls.tsx b/packages/memory-graph/src/components/navigation-controls.tsx index ce25aa5b..d697deb2 100644 --- a/packages/memory-graph/src/components/navigation-controls.tsx +++ b/packages/memory-graph/src/components/navigation-controls.tsx @@ -1,14 +1,5 @@ -"use client" - import { memo } from "react" -import type { GraphNode } from "@/types" -import { - navContainer, - navButton, - zoomContainer, - zoomInButton, - zoomOutButton, -} from "./navigation-controls.css" +import type { GraphNode, GraphThemeColors } from "../types" interface NavigationControlsProps { onCenter: () => void @@ -17,53 +8,183 @@ interface NavigationControlsProps { onAutoFit: () => void nodes: GraphNode[] className?: string + zoomLevel: number + colors: GraphThemeColors +} + +function KeyBadge({ + keys, + colors, +}: { + keys: string + colors: GraphThemeColors +}) { + const style: React.CSSProperties = { + display: "inline-flex", + alignItems: "center", + padding: "2px 6px", + borderRadius: 4, + fontSize: 10, + fontWeight: 500, + color: colors.textMuted, + backgroundColor: colors.controlBg, + border: `1px solid ${colors.controlBorder}`, + lineHeight: 1, + } + + return {keys} +} + +function NavBtn({ + onClick, + children, + colors, +}: { + onClick: () => void + children: React.ReactNode + colors: GraphThemeColors +}) { + const style: React.CSSProperties = { + display: "flex", + width: "fit-content", + gap: 12, + alignItems: "center", + justifyContent: "space-between", + paddingLeft: 12, + paddingRight: 12, + paddingTop: 8, + paddingBottom: 8, + borderRadius: 9999, + cursor: "pointer", + backgroundColor: colors.controlBg, + border: `1px solid ${colors.controlBorder}`, + boxShadow: "0 1px 2px 0 rgba(0,0,0,0.05)", + transition: "background-color 0.15s", + } + + return ( + + ) } export const NavigationControls = memo( - ({ onCenter, onZoomIn, onZoomOut, onAutoFit, nodes, className = "" }) => { - if (nodes.length === 0) { - return null + ({ + onCenter, + onZoomIn, + onZoomOut, + onAutoFit, + nodes, + className = "", + zoomLevel, + colors, + }) => { + if (nodes.length === 0) return null + + const containerStyle: React.CSSProperties = { + display: "flex", + flexDirection: "column", + gap: 4, } - const containerClassName = className - ? `${navContainer} ${className}` - : navContainer + const labelStyle: React.CSSProperties = { + fontSize: 12, + fontWeight: 500, + color: colors.textPrimary, + } + + const zoomRowStyle: React.CSSProperties = { + display: "flex", + width: "fit-content", + gap: 12, + alignItems: "center", + justifyContent: "space-between", + paddingLeft: 12, + paddingRight: 12, + paddingTop: 8, + paddingBottom: 8, + borderRadius: 9999, + backgroundColor: colors.controlBg, + border: `1px solid ${colors.controlBorder}`, + boxShadow: "0 1px 2px 0 rgba(0,0,0,0.05)", + } + + const zoomBtnGroupStyle: React.CSSProperties = { + display: "flex", + flexDirection: "row", + alignItems: "center", + gap: 2, + } + + const zoomBtnStyle: React.CSSProperties = { + width: 20, + height: 20, + display: "flex", + alignItems: "center", + justifyContent: "center", + borderRadius: 4, + backgroundColor: colors.controlBg, + border: `1px solid ${colors.controlBorder}`, + color: colors.textSecondary, + cursor: "pointer", + fontSize: 12, + padding: 0, + transition: "background-color 0.15s, color 0.15s", + } return ( -
- - -
- - +
+ + Fit + + + + + Center + + + +
+ {zoomLevel}% +
+ + +
) diff --git a/packages/memory-graph/src/components/node-detail-panel.css.ts b/packages/memory-graph/src/components/node-detail-panel.css.ts deleted file mode 100644 index 5429e2bd..00000000 --- a/packages/memory-graph/src/components/node-detail-panel.css.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { style } from "@vanilla-extract/css" -import { themeContract } from "../styles/theme.css" - -/** - * Main container (positioned absolutely) - * Highest z-index so it appears above everything when open - */ -export const container = style({ - position: "absolute", - width: "20rem", // w-80 = 320px = 20rem - borderRadius: themeContract.radii.xl, - overflow: "hidden", - zIndex: 40, // Highest priority - always on top when open - maxHeight: "calc(100vh - 2rem)", // Leave some breathing room - top: themeContract.space[4], - right: themeContract.space[4], - - // Add shadow for depth - boxShadow: - "0 20px 25px -5px rgb(0 0 0 / 0.3), 0 8px 10px -6px rgb(0 0 0 / 0.3)", -}) - -/** - * Content wrapper with scrolling - */ -export const content = style({ - position: "relative", - zIndex: 10, - padding: themeContract.space[4], - overflowY: "auto", - maxHeight: "80vh", -}) - -/** - * Header section - */ -export const header = style({ - display: "flex", - alignItems: "center", - justifyContent: "space-between", - marginBottom: themeContract.space[3], -}) - -export const headerLeft = style({ - display: "flex", - alignItems: "center", - gap: themeContract.space[2], -}) - -export const headerIcon = style({ - width: "1.25rem", - height: "1.25rem", - color: themeContract.colors.text.secondary, -}) - -export const headerIconMemory = style({ - width: "1.25rem", - height: "1.25rem", - color: "rgb(96, 165, 250)", // blue-400 -}) - -export const closeButton = style({ - height: "32px", - width: "32px", - padding: 0, - color: themeContract.colors.text.secondary, - - selectors: { - "&:hover": { - color: themeContract.colors.text.primary, - }, - }, -}) - -export const closeIcon = style({ - width: "1rem", - height: "1rem", -}) - -/** - * Content sections - */ -export const sections = style({ - display: "flex", - flexDirection: "column", - gap: themeContract.space[3], -}) - -export const section = style({}) - -export const sectionLabel = style({ - fontSize: themeContract.typography.fontSize.xs, - color: themeContract.colors.text.muted, - textTransform: "uppercase", - letterSpacing: "0.05em", -}) - -export const sectionValue = style({ - fontSize: themeContract.typography.fontSize.sm, - color: themeContract.colors.text.secondary, - marginTop: themeContract.space[1], -}) - -export const sectionValueTruncated = style({ - fontSize: themeContract.typography.fontSize.sm, - color: themeContract.colors.text.secondary, - marginTop: themeContract.space[1], - overflow: "hidden", - display: "-webkit-box", - WebkitLineClamp: 3, - WebkitBoxOrient: "vertical", -}) - -export const link = style({ - fontSize: themeContract.typography.fontSize.sm, - color: "rgb(129, 140, 248)", // indigo-400 - marginTop: themeContract.space[1], - display: "flex", - alignItems: "center", - gap: themeContract.space[1], - textDecoration: "none", - transition: themeContract.transitions.normal, - - selectors: { - "&:hover": { - color: "rgb(165, 180, 252)", // indigo-300 - }, - }, -}) - -export const linkIcon = style({ - width: "0.75rem", - height: "0.75rem", -}) - -export const badge = style({ - marginTop: themeContract.space[2], -}) - -export const expiryText = style({ - fontSize: themeContract.typography.fontSize.xs, - color: themeContract.colors.text.muted, - marginTop: themeContract.space[1], -}) - -/** - * Footer section (metadata) - */ -export const footer = style({ - paddingTop: themeContract.space[2], - borderTop: "1px solid rgba(71, 85, 105, 0.5)", // slate-700/50 -}) - -export const metadata = style({ - display: "flex", - alignItems: "center", - gap: themeContract.space[4], - fontSize: themeContract.typography.fontSize.xs, - color: themeContract.colors.text.muted, -}) - -export const metadataItem = style({ - display: "flex", - alignItems: "center", - gap: themeContract.space[1], -}) - -export const metadataIcon = style({ - width: "0.75rem", - height: "0.75rem", -}) diff --git a/packages/memory-graph/src/components/node-detail-panel.tsx b/packages/memory-graph/src/components/node-detail-panel.tsx deleted file mode 100644 index e3920be2..00000000 --- a/packages/memory-graph/src/components/node-detail-panel.tsx +++ /dev/null @@ -1,248 +0,0 @@ -"use client" - -import { Badge } from "@/ui/badge" -import { Button } from "@/ui/button" -import { GlassMenuEffect } from "@/ui/glass-effect" -import { Brain, Calendar, ExternalLink, FileText, Hash, X } from "lucide-react" -import { motion } from "motion/react" -import { memo } from "react" -import { - GoogleDocs, - GoogleDrive, - GoogleSheets, - GoogleSlides, - MicrosoftExcel, - MicrosoftOneNote, - MicrosoftPowerpoint, - MicrosoftWord, - NotionDoc, - OneDrive, - PDF, -} from "@/assets/icons" -import { HeadingH3Bold } from "@/ui/heading" -import type { DocumentWithMemories, MemoryEntry } from "@/types" -import type { NodeDetailPanelProps } from "@/types" -import * as styles from "./node-detail-panel.css" - -const formatDocumentType = (type: string) => { - // Special case for PDF - if (type.toLowerCase() === "pdf") return "PDF" - - // Replace underscores with spaces and capitalize each word - return type - .split("_") - .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) - .join(" ") -} - -const getDocumentIcon = (type: string) => { - const iconProps = { className: "w-5 h-5 text-slate-300" } - - switch (type) { - case "google_doc": - return - case "google_sheet": - return - case "google_slide": - return - case "google_drive": - return - case "notion": - case "notion_doc": - return - case "word": - case "microsoft_word": - return - case "excel": - case "microsoft_excel": - return - case "powerpoint": - case "microsoft_powerpoint": - return - case "onenote": - case "microsoft_onenote": - return - case "onedrive": - return - case "pdf": - return - default: - { - } - return - } -} - -export const NodeDetailPanel = memo(function NodeDetailPanel({ - node, - onClose, - variant = "console", -}: NodeDetailPanelProps) { - if (!node) return null - - const isDocument = node.type === "document" - const data = node.data - - return ( - - {/* Glass effect background */} - - - -
-
- {isDocument ? ( - getDocumentIcon((data as DocumentWithMemories).type ?? "") - ) : ( - - )} - {isDocument ? "Document" : "Memory"} -
- - - -
- -
- {isDocument ? ( - <> -
- Title -

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

-
- - {(data as DocumentWithMemories).summary && ( -
- Summary -

- {(data as DocumentWithMemories).summary} -

-
- )} - -
- Type -

- {formatDocumentType( - (data as DocumentWithMemories).type ?? "", - )} -

-
- -
- Memory Count -

- {(data as DocumentWithMemories).memoryEntries.length} memories -

-
- - {((data as DocumentWithMemories).url || - (data as DocumentWithMemories).customId) && ( - - )} - - ) : ( - <> -
- Memory -

- {(data as MemoryEntry).memory} -

- {(data as MemoryEntry).isForgotten && ( - - Forgotten - - )} - {(data as MemoryEntry).forgetAfter && ( -

- Expires:{" "} - {(data as MemoryEntry).forgetAfter - ? new Date( - (data as MemoryEntry).forgetAfter!, - ).toLocaleDateString() - : ""}{" "} - {"forgetReason" in data && (data as any).forgetReason - ? `- ${(data as any).forgetReason}` - : null} -

- )} -
- -
- Space -

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

-
- - )} - -
-
- - {/* @ts-ignore */} - - {new Date(data.createdAt).toLocaleDateString()} - - - {/* @ts-ignore */} - - {node.id} - -
-
-
-
-
- ) -}) - -NodeDetailPanel.displayName = "NodeDetailPanel" diff --git a/packages/memory-graph/src/components/node-hover-popover.tsx b/packages/memory-graph/src/components/node-hover-popover.tsx new file mode 100644 index 00000000..dc2a5af1 --- /dev/null +++ b/packages/memory-graph/src/components/node-hover-popover.tsx @@ -0,0 +1,637 @@ +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react" +import type { ChainEntry } from "../canvas/version-chain" +import type { + DocumentNodeData, + GraphNode, + GraphThemeColors, + MemoryNodeData, +} from "../types" + +export interface NodeHoverPopoverProps { + node: GraphNode + screenX: number + screenY: number + nodeRadius: number + containerBounds?: DOMRect + versionChain?: ChainEntry[] | null + colors: GraphThemeColors + onNavigateNext?: () => void + onNavigatePrev?: () => void + onNavigateUp?: () => void + onNavigateDown?: () => void + onSelectNode?: (nodeId: string) => void +} + +function useCopyToClipboard(timeout = 2000) { + const [copied, setCopied] = useState(false) + const timeoutRef = useRef>(null) + + useEffect(() => { + return () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current) + } + }, []) + + const copy = useCallback( + (value: string) => { + navigator.clipboard.writeText(value).then( + () => { + setCopied(true) + if (timeoutRef.current) clearTimeout(timeoutRef.current) + timeoutRef.current = setTimeout(() => setCopied(false), timeout) + }, + () => {}, + ) + }, + [timeout], + ) + + return { copied, copy } +} + +function KeyBadge({ + children, + colors, +}: { + children: React.ReactNode + colors: GraphThemeColors +}) { + const style: React.CSSProperties = { + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + width: 16, + height: 16, + borderRadius: 4, + fontSize: 10, + fontWeight: 500, + backgroundColor: colors.controlBg, + border: `1px solid ${colors.controlBorder}`, + color: colors.popoverTextMuted, + lineHeight: 1, + } + + return {children} +} + +function NavButton({ + icon, + label, + onClick, + colors, +}: { + icon: string + label: string + onClick?: () => void + colors: GraphThemeColors +}) { + const buttonStyle: React.CSSProperties = { + display: "flex", + alignItems: "center", + gap: 8, + cursor: "pointer", + background: "none", + border: "none", + padding: "2px 0", + transition: "opacity 0.15s", + } + + const labelStyle: React.CSSProperties = { + fontSize: 11, + whiteSpace: "nowrap", + color: colors.popoverTextSecondary, + } + + return ( + + ) +} + +function CopyableId({ + label, + value, + colors, +}: { + label: string + value: string + colors: GraphThemeColors +}) { + const { copied, copy } = useCopyToClipboard() + + const short = + value.length > 12 ? `${value.slice(0, 6)}...${value.slice(-4)}` : value + + const buttonStyle: React.CSSProperties = { + display: "flex", + alignItems: "center", + gap: 6, + cursor: "pointer", + background: "none", + border: "none", + padding: 0, + } + + const labelStyle: React.CSSProperties = { + fontSize: 10, + color: colors.popoverTextSecondary, + } + + const valueStyle: React.CSSProperties = { + fontSize: 10, + fontFamily: "monospace", + color: colors.popoverTextMuted, + transition: "color 0.15s", + } + + 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] ?? "right" +} + +function truncate(s: string, max: number) { + return s.length > max ? `${s.substring(0, max)}...` : s +} + +function VersionTimeline({ + chain, + currentId, + onSelect, + colors, +}: { + chain: ChainEntry[] + currentId: string + onSelect?: (id: string) => void + colors: GraphThemeColors +}) { + const containerStyle: React.CSSProperties = { + display: "flex", + flexDirection: "column", + gap: 0, + maxHeight: 120, + overflowY: "auto", + } + + return ( +
+ {chain.map((entry) => { + const isCurrent = entry.id === currentId + + const entryStyle: React.CSSProperties = { + display: "flex", + alignItems: "flex-start", + gap: 8, + paddingLeft: 12, + paddingRight: 12, + paddingTop: 6, + paddingBottom: 6, + textAlign: "left", + cursor: "pointer", + transition: "background-color 0.15s", + background: isCurrent ? `${colors.accent}15` : "transparent", + border: "none", + borderLeft: isCurrent + ? `2px solid ${colors.accent}` + : `2px solid ${colors.popoverBorder}`, + } + + const versionStyle: React.CSSProperties = { + fontSize: 10, + fontWeight: 600, + flexShrink: 0, + marginTop: 1, + color: entry.isForgotten + ? colors.memBorderForgotten + : isCurrent + ? colors.accent + : colors.popoverTextSecondary, + } + + const textStyle: React.CSSProperties = { + fontSize: 11, + lineHeight: 1.35, + color: isCurrent + ? colors.popoverTextPrimary + : colors.popoverTextMuted, + } + + return ( + + ) + })} +
+ ) +} + +export const NodeHoverPopover = memo( + function NodeHoverPopover({ + node, + screenX, + screenY, + nodeRadius, + containerBounds, + versionChain, + colors, + 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 + + const overlayStyle: React.CSSProperties = { + pointerEvents: "none", + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: 0, + zIndex: 100, + } + + const svgStyle: React.CSSProperties = { + position: "absolute", + top: 0, + left: 0, + width: "100%", + height: "100%", + overflow: "visible", + pointerEvents: "none", + } + + const popoverContainerStyle: React.CSSProperties = { + position: "absolute", + display: "flex", + gap: 12, + pointerEvents: "auto", + left: popoverX, + top: popoverY, + } + + const cardStyle: React.CSSProperties = { + display: "flex", + flexDirection: "column", + borderRadius: 12, + overflow: "hidden", + border: `1px solid ${colors.popoverBorder}`, + boxShadow: "0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -4px rgba(0,0,0,0.1)", + width: CARD_W, + backgroundColor: colors.popoverBg, + } + + const contentPadStyle: React.CSSProperties = { + padding: 12, + } + + const contentTextStyle: React.CSSProperties = { + margin: 0, + lineHeight: "135%", + fontSize: 12, + color: colors.popoverTextSecondary, + } + + const dividerStyle: React.CSSProperties = { + borderTop: `1px solid ${colors.popoverBorder}`, + } + + const footerStyle: React.CSSProperties = { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + paddingLeft: 12, + paddingRight: 12, + paddingTop: 8, + paddingBottom: 8, + ...dividerStyle, + } + + const idRowStyle: React.CSSProperties = { + paddingLeft: 12, + paddingRight: 12, + paddingTop: 6, + paddingBottom: 6, + display: "flex", + alignItems: "center", + ...dividerStyle, + } + + const shortcutsPanelStyle: React.CSSProperties = { + display: "flex", + flexDirection: "column", + justifyContent: "center", + gap: 6, + paddingLeft: 12, + paddingRight: 12, + paddingTop: 8, + paddingBottom: 8, + borderRadius: 12, + border: `1px solid ${colors.popoverBorder}`, + backgroundColor: colors.popoverBg, + } + + return ( +
+ + +
+
+ {hasChain ? ( + + ) : ( +
+

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

+
+ )} + + {memoryMeta && hasForgetInfo && ( +
+ {memoryMeta.forgetAfter && ( + + Expires:{" "} + {new Date(memoryMeta.forgetAfter).toLocaleDateString()} + + )} + {memoryMeta.forgetReason && ( + + Reason: {memoryMeta.forgetReason} + + )} + {memoryMeta.isForgotten && !memoryMeta.forgetReason && ( + + Forgotten + + )} +
+ )} + +
+ {memoryMeta ? ( + + v{memoryMeta.version}{" "} + {memoryMeta.isForgotten + ? "Forgotten" + : memoryMeta.isLatest + ? "Latest" + : "Superseded"} + + ) : ( + <> + + {docData?.type || "document"} + + + {docData?.memories?.length ?? 0} memories + + + )} +
+ +
+ {isMemory ? ( + + ) : ( + + )} +
+
+ +
+ {isMemory && ( + + )} + {(isMemory ? hasChain : true) && ( + + )} + + +
+
+
+ ) + }, +) diff --git a/packages/memory-graph/src/components/node-popover.css.ts b/packages/memory-graph/src/components/node-popover.css.ts deleted file mode 100644 index c758f4b5..00000000 --- a/packages/memory-graph/src/components/node-popover.css.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { style } from "@vanilla-extract/css" - -// Backdrop styles -export const backdrop = style({ - position: "fixed", - zIndex: 999, - pointerEvents: "auto", - backgroundColor: "transparent", -}) - -export const backdropFullscreen = style({ - inset: 0, -}) - -// Popover container -export const popoverContainer = style({ - position: "fixed", - background: "rgba(255, 255, 255, 0.05)", - backdropFilter: "blur(12px)", - WebkitBackdropFilter: "blur(12px)", - border: "1px solid rgba(255, 255, 255, 0.25)", - borderRadius: "12px", - padding: "16px", - width: "320px", - zIndex: 1000, - pointerEvents: "auto", - boxShadow: - "0 20px 25px -5px rgb(0 0 0 / 0.3), 0 8px 10px -6px rgb(0 0 0 / 0.3)", -}) - -// Layout -export const contentContainer = style({ - display: "flex", - flexDirection: "column", - gap: "12px", -}) - -export const header = style({ - display: "flex", - alignItems: "center", - justifyContent: "space-between", - marginBottom: "4px", -}) - -export const headerTitle = style({ - display: "flex", - alignItems: "center", - gap: "8px", -}) - -export const headerIcon = style({ - color: "rgba(148, 163, 184, 1)", -}) - -export const headerIconMemory = style({ - color: "rgb(96, 165, 250)", -}) - -export const title = style({ - fontSize: "16px", - fontWeight: "700", - color: "white", - margin: 0, -}) - -// Close button -export const closeButton = style({ - padding: "4px", - background: "transparent", - border: "none", - color: "rgba(148, 163, 184, 1)", - cursor: "pointer", - fontSize: "16px", - lineHeight: "1", - transition: "color 0.2s", - ":hover": { - color: "white", - }, -}) - -// Sections -export const sectionsContainer = style({ - display: "flex", - flexDirection: "column", - gap: "12px", -}) - -export const fieldLabel = style({ - fontSize: "11px", - color: "rgba(148, 163, 184, 0.8)", - textTransform: "uppercase", - letterSpacing: "0.05em", - marginBottom: "4px", -}) - -export const fieldValue = style({ - fontSize: "14px", - color: "rgba(203, 213, 225, 1)", - margin: 0, - lineHeight: "1.4", -}) - -export const summaryValue = style({ - fontSize: "14px", - color: "rgba(203, 213, 225, 1)", - margin: 0, - lineHeight: "1.4", - overflow: "hidden", - display: "-webkit-box", - WebkitLineClamp: 2, - WebkitBoxOrient: "vertical", -}) - -// Link -export const link = style({ - fontSize: "14px", - color: "rgb(129, 140, 248)", - textDecoration: "none", - display: "flex", - alignItems: "center", - gap: "4px", - transition: "color 0.2s", - ":hover": { - color: "rgb(165, 180, 252)", - }, -}) - -// Footer -export const footer = style({ - paddingTop: "12px", - borderTop: "1px solid rgba(71, 85, 105, 0.5)", - display: "flex", - alignItems: "center", - gap: "16px", - fontSize: "12px", - color: "rgba(148, 163, 184, 1)", -}) - -export const footerItem = style({ - display: "flex", - alignItems: "center", - gap: "4px", -}) - -export const footerItemId = style({ - display: "flex", - alignItems: "center", - gap: "4px", - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap", - flex: 1, -}) - -export const idText = style({ - overflow: "hidden", - textOverflow: "ellipsis", -}) - -// Memory-specific styles -export const forgottenBadge = style({ - marginTop: "8px", - padding: "4px 8px", - background: "rgba(220, 38, 38, 0.15)", - borderRadius: "4px", - fontSize: "12px", - color: "rgba(248, 113, 113, 1)", - display: "inline-block", -}) - -export const expiresText = style({ - fontSize: "12px", - color: "rgba(148, 163, 184, 1)", - margin: "8px 0 0 0", - lineHeight: "1.4", -}) diff --git a/packages/memory-graph/src/components/node-popover.tsx b/packages/memory-graph/src/components/node-popover.tsx deleted file mode 100644 index 5570038d..00000000 --- a/packages/memory-graph/src/components/node-popover.tsx +++ /dev/null @@ -1,343 +0,0 @@ -"use client" - -import { memo, useEffect } from "react" -import type { GraphNode } from "@/types" -import * as styles from "./node-popover.css" - -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 backdropClassName = containerBounds - ? styles.backdrop - : `${styles.backdrop} ${styles.backdropFullscreen}` - - const handleBackdropClick = () => { - onBackdropClick?.() - onClose() - } - - return ( - <> - {/* Invisible backdrop to catch clicks outside */} -
- - {/* Popover content */} -
e.stopPropagation()} // Prevent closing when clicking inside - className={styles.popoverContainer} - 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/packages/memory-graph/src/components/spaces-dropdown.css.ts b/packages/memory-graph/src/components/spaces-dropdown.css.ts deleted file mode 100644 index 58fa73e4..00000000 --- a/packages/memory-graph/src/components/spaces-dropdown.css.ts +++ /dev/null @@ -1,270 +0,0 @@ -import { style, keyframes } from "@vanilla-extract/css" -import { themeContract } from "../styles/theme.css" - -const spin = keyframes({ - "0%": { transform: "rotate(0deg)" }, - "100%": { transform: "rotate(360deg)" }, -}) - -/** - * Dropdown container - */ -export const container = style({ - position: "relative", -}) - -/** - * Main trigger button with gradient border effect - */ -export const trigger = style({ - display: "flex", - alignItems: "center", - gap: themeContract.space[3], - paddingLeft: themeContract.space[4], - paddingRight: themeContract.space[4], - paddingTop: themeContract.space[3], - paddingBottom: themeContract.space[3], - borderRadius: themeContract.radii.xl, - border: "2px solid transparent", - backgroundImage: - "linear-gradient(#1a1f29, #1a1f29), linear-gradient(150.262deg, #A4E8F5 0%, #267FFA 26%, #464646 49%, #747474 70%, #A4E8F5 100%)", - backgroundOrigin: "border-box", - backgroundClip: "padding-box, border-box", - boxShadow: "inset 0px 2px 1px rgba(84, 84, 84, 0.15)", - backdropFilter: "blur(12px)", - WebkitBackdropFilter: "blur(12px)", - transition: themeContract.transitions.normal, - cursor: "pointer", - minWidth: "15rem", // min-w-60 = 240px = 15rem - - selectors: { - "&:hover": { - boxShadow: "inset 0px 2px 1px rgba(84, 84, 84, 0.25)", - }, - }, -}) - -export const triggerIcon = style({ - width: "1rem", - height: "1rem", - color: themeContract.colors.text.secondary, -}) - -export const triggerContent = style({ - flex: 1, - textAlign: "left", -}) - -export const triggerLabel = style({ - fontSize: themeContract.typography.fontSize.sm, - color: themeContract.colors.text.secondary, - fontWeight: themeContract.typography.fontWeight.medium, -}) - -export const triggerSubtext = style({ - fontSize: themeContract.typography.fontSize.xs, - color: themeContract.colors.text.muted, -}) - -export const triggerChevron = style({ - width: "1rem", - height: "1rem", - color: themeContract.colors.text.secondary, - transition: "transform 200ms ease", -}) - -export const triggerChevronOpen = style({ - transform: "rotate(180deg)", -}) - -/** - * Dropdown menu - */ -export const dropdown = style({ - position: "absolute", - top: "100%", - left: 0, - right: 0, - marginTop: themeContract.space[2], - background: "rgba(15, 23, 42, 0.95)", // slate-900/95 - backdropFilter: "blur(12px)", - WebkitBackdropFilter: "blur(12px)", - border: "1px solid rgba(71, 85, 105, 0.4)", // slate-700/40 - borderRadius: themeContract.radii.xl, - boxShadow: - "0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)", // shadow-xl - zIndex: 20, - overflow: "hidden", -}) - -export const dropdownInner = style({ - padding: themeContract.space[1], -}) - -/** - * Search container and form - */ -export const searchContainer = style({ - display: "flex", - alignItems: "center", - gap: themeContract.space[2], - padding: themeContract.space[2], - borderBottom: "1px solid rgba(71, 85, 105, 0.4)", // slate-700/40 -}) - -export const searchForm = style({ - flex: 1, - display: "flex", - alignItems: "center", - gap: themeContract.space[2], -}) - -export const searchButton = style({ - color: themeContract.colors.text.muted, - padding: themeContract.space[1], - cursor: "pointer", - border: "none", - background: "transparent", - transition: themeContract.transitions.normal, - - selectors: { - "&:hover:not(:disabled)": { - color: themeContract.colors.text.secondary, - }, - "&:disabled": { - opacity: 0.5, - cursor: "not-allowed", - }, - }, -}) - -export const searchIcon = style({ - width: "1rem", - height: "1rem", -}) - -export const searchInput = style({ - flex: 1, - backgroundColor: "transparent", - fontSize: themeContract.typography.fontSize.sm, - color: themeContract.colors.text.secondary, - border: "none", - outline: "none", - - "::placeholder": { - color: themeContract.colors.text.muted, - }, -}) - -export const searchSpinner = style({ - width: "1rem", - height: "1rem", - borderRadius: "50%", - border: "2px solid rgba(148, 163, 184, 0.3)", // slate-400 with opacity - borderTopColor: "rgb(148, 163, 184)", // slate-400 - animation: `${spin} 1s linear infinite`, -}) - -export const searchClearButton = style({ - color: themeContract.colors.text.muted, - cursor: "pointer", - border: "none", - background: "transparent", - transition: themeContract.transitions.normal, - - selectors: { - "&:hover": { - color: themeContract.colors.text.secondary, - }, - }, -}) - -/** - * Dropdown list container - */ -export const dropdownList = style({ - maxHeight: "16rem", // max-h-64 - overflowY: "auto", -}) - -/** - * Dropdown items - */ -const dropdownItemBase = style({ - width: "100%", - display: "flex", - alignItems: "center", - justifyContent: "space-between", - paddingLeft: themeContract.space[3], - paddingRight: themeContract.space[3], - paddingTop: themeContract.space[2], - paddingBottom: themeContract.space[2], - borderRadius: themeContract.radii.lg, - textAlign: "left", - transition: themeContract.transitions.normal, - cursor: "pointer", - border: "none", - background: "transparent", -}) - -export const dropdownItem = style([ - dropdownItemBase, - { - color: themeContract.colors.text.secondary, - - selectors: { - "&:hover": { - backgroundColor: "rgba(51, 65, 85, 0.5)", // slate-700/50 - }, - }, - }, -]) - -export const dropdownItemActive = style([ - dropdownItemBase, - { - backgroundColor: "rgba(59, 130, 246, 0.2)", // blue-500/20 - color: "rgb(147, 197, 253)", // blue-300 - }, -]) - -export const dropdownItemHighlighted = style([ - dropdownItemBase, - { - backgroundColor: "rgba(51, 65, 85, 0.7)", // slate-700/70 - color: themeContract.colors.text.secondary, - }, -]) - -export const dropdownItemLabel = style({ - fontSize: themeContract.typography.fontSize.sm, - flex: 1, -}) - -export const dropdownItemLabelTruncate = style({ - fontSize: themeContract.typography.fontSize.sm, - flex: 1, - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap", -}) - -export const dropdownItemBadge = style({ - backgroundColor: "rgba(51, 65, 85, 0.5)", // slate-700/50 - color: themeContract.colors.text.secondary, - fontSize: themeContract.typography.fontSize.xs, - marginLeft: themeContract.space[2], -}) - -/** - * Empty state message - */ -export const emptyState = style({ - paddingLeft: themeContract.space[3], - paddingRight: themeContract.space[3], - paddingTop: themeContract.space[2], - paddingBottom: themeContract.space[2], - fontSize: themeContract.typography.fontSize.sm, - color: themeContract.colors.text.muted, - textAlign: "center", -}) diff --git a/packages/memory-graph/src/components/spaces-dropdown.tsx b/packages/memory-graph/src/components/spaces-dropdown.tsx deleted file mode 100644 index d8a56fe3..00000000 --- a/packages/memory-graph/src/components/spaces-dropdown.tsx +++ /dev/null @@ -1,247 +0,0 @@ -"use client" - -import { Badge } from "@/ui/badge" -import { ChevronDown, Eye, Search, X } from "lucide-react" -import { memo, useEffect, useRef, useState } from "react" -import type { SpacesDropdownProps } from "@/types" -import * as styles from "./spaces-dropdown.css" - -export const SpacesDropdown = memo( - ({ selectedSpace, availableSpaces, spaceMemoryCounts, onSpaceChange }) => { - const [isOpen, setIsOpen] = useState(false) - const [searchQuery, setSearchQuery] = useState("") - const [highlightedIndex, setHighlightedIndex] = useState(-1) - const dropdownRef = useRef(null) - const searchInputRef = useRef(null) - const itemRefs = useRef>(new Map()) - - // Close dropdown when clicking outside - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if ( - dropdownRef.current && - !dropdownRef.current.contains(event.target as Node) - ) { - setIsOpen(false) - } - } - - document.addEventListener("mousedown", handleClickOutside) - return () => document.removeEventListener("mousedown", handleClickOutside) - }, []) - - // Focus search input when dropdown opens - useEffect(() => { - if (isOpen && searchInputRef.current) { - searchInputRef.current.focus() - } - }, [isOpen]) - - // Clear search query and reset highlighted index when dropdown closes - useEffect(() => { - if (!isOpen) { - setSearchQuery("") - setHighlightedIndex(-1) - } - }, [isOpen]) - - // Filter spaces based on search query (client-side) - const filteredSpaces = searchQuery - ? availableSpaces.filter((space) => - space.toLowerCase().includes(searchQuery.toLowerCase()), - ) - : availableSpaces - - const totalMemories = Object.values(spaceMemoryCounts).reduce( - (sum, count) => sum + count, - 0, - ) - - // Total items including "Latest" option - const totalItems = filteredSpaces.length + 1 - - // Scroll highlighted item into view - useEffect(() => { - if (highlightedIndex >= 0 && highlightedIndex < totalItems) { - const element = itemRefs.current.get(highlightedIndex) - if (element) { - element.scrollIntoView({ - block: "nearest", - behavior: "smooth", - }) - } - } - }, [highlightedIndex, totalItems]) - - // Handle keyboard navigation - const handleKeyDown = (e: React.KeyboardEvent) => { - if (!isOpen) return - - switch (e.key) { - case "ArrowDown": - e.preventDefault() - setHighlightedIndex((prev) => (prev < totalItems - 1 ? prev + 1 : 0)) - break - case "ArrowUp": - e.preventDefault() - setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : totalItems - 1)) - break - case "Enter": - e.preventDefault() - if (highlightedIndex === 0) { - onSpaceChange("all") - setIsOpen(false) - } else if ( - highlightedIndex > 0 && - highlightedIndex <= filteredSpaces.length - ) { - const selectedSpace = filteredSpaces[highlightedIndex - 1] - if (selectedSpace) { - onSpaceChange(selectedSpace) - setIsOpen(false) - } - } - break - case "Escape": - e.preventDefault() - setIsOpen(false) - break - } - } - - return ( -
- - - {isOpen && ( -
-
- {/* Search Input - Always show for filtering */} -
-
- {/*@ts-ignore */} - - setSearchQuery(e.target.value)} - placeholder="Search spaces..." - ref={searchInputRef} - type="text" - value={searchQuery} - /> - {searchQuery && ( - - )} -
-
- - {/* Spaces List */} -
- {/* Always show "Latest" option */} - - - {/* Show all spaces, filtered by search query */} - {filteredSpaces.length > 0 - ? filteredSpaces.map((space, index) => { - const itemIndex = index + 1 - return ( - - ) - }) - : searchQuery && ( -
- No spaces found matching "{searchQuery}" -
- )} -
-
-
- )} -
- ) - }, -) - -SpacesDropdown.displayName = "SpacesDropdown" diff --git a/packages/memory-graph/src/constants.ts b/packages/memory-graph/src/constants.ts index e6ab4a26..49b2c667 100644 --- a/packages/memory-graph/src/constants.ts +++ b/packages/memory-graph/src/constants.ts @@ -1,138 +1,66 @@ -// Enhanced glass-morphism color palette -export const colors = { - background: { - primary: "#0f1419", // Deep dark blue-gray - secondary: "#1a1f29", // Slightly lighter - accent: "#252a35", // Card backgrounds - }, - document: { - primary: "rgba(255, 255, 255, 0.21)", // Subtle glass white - secondary: "rgba(255, 255, 255, 0.31)", // More visible - accent: "rgba(255, 255, 255, 0.31)", // Hover state - border: "rgba(255, 255, 255, 0.6)", // Sharp borders - glow: "rgba(147, 197, 253, 0.4)", // Blue glow for interaction - }, - memory: { - primary: "rgba(147, 196, 253, 0.21)", // Subtle glass blue - secondary: "rgba(147, 196, 253, 0.31)", // More visible - accent: "rgba(147, 197, 253, 0.31)", // Hover state - border: "rgba(147, 196, 253, 0.6)", // Sharp borders - glow: "rgba(147, 197, 253, 0.5)", // Blue glow for interaction - }, - connection: { - weak: "rgba(35, 189, 255, 0.3)", // subtle - memory: "rgba(148, 163, 184, 0.35)", // Very subtle - medium: "rgba(35, 189, 255, 0.6)", // Medium visibility - strong: "rgba(35, 189, 255, 0.9)", // Strong connection - }, - text: { - primary: "#ffffff", // Pure white - secondary: "#e2e8f0", // Light gray - muted: "#94a3b8", // Medium gray - }, - accent: { - primary: "rgba(59, 130, 246, 0.7)", // Clean blue - secondary: "rgba(99, 102, 241, 0.6)", // Clean purple - glow: "rgba(147, 197, 253, 0.6)", // Subtle glow - amber: "rgba(251, 165, 36, 0.8)", // Amber for expiring - emerald: "rgba(16, 185, 129, 0.4)", // Emerald for new - }, - status: { - forgotten: "rgba(220, 38, 38, 0.15)", // Red for forgotten - expiring: "rgba(251, 165, 36, 0.8)", // Amber for expiring soon - new: "rgba(16, 185, 129, 0.4)", // Emerald for new memories - }, - relations: { - updates: "rgba(147, 77, 253, 0.5)", // purple - extends: "rgba(16, 185, 129, 0.5)", // green - derives: "rgba(147, 197, 253, 0.5)", // blue - }, -} +import type { GraphThemeColors } from "./types" -export const LAYOUT_CONSTANTS = { - centerX: 400, - centerY: 300, - clusterRadius: 300, // Memory "bubble" size around a doc - smaller bubble - spaceSpacing: 1600, // How far apart the *spaces* (groups of docs) sit - push spaces way out - documentSpacing: 1000, // How far the first doc in a space sits from its space-centre - push docs way out - minDocDist: 900, // Minimum distance two documents in the **same space** are allowed to be - sets repulsion radius - memoryClusterRadius: 300, -} +export const MEMORY_BORDER_KEYS = { + forgotten: "memBorderForgotten", + expiring: "memBorderExpiring", + recent: "memBorderRecent", + default: "memStrokeDefault", +} as const -// Similarity calculation configuration -export const SIMILARITY_CONFIG = { - threshold: 0.725, // Minimum similarity (72.5%) to create edge - maxComparisonsPerDoc: 10, // k-NN: each doc compares with 10 neighbors (optimized for performance) -} - -// D3-Force simulation configuration export const FORCE_CONFIG = { - // Link force (spring between connected nodes)simil linkStrength: { - docMemory: 0.8, // Strong for doc-memory connections - version: 1.0, // Strongest for version chains - docDocBase: 0.3, // Base for doc-doc similarity + docMemory: 0.8, + version: 1.0, + docDocBase: 0.3, }, - linkDistance: 300, // Desired spring length - - // Charge force (repulsion between nodes) - chargeStrength: -1000, // Negative = repulsion, higher magnitude = stronger push - - // Collision force (prevents node overlap) - collisionRadius: { - document: 80, // Collision radius for document nodes - memory: 40, // Collision radius for memory nodes - }, - - // Simulation behavior - alphaDecay: 0.03, // How fast simulation cools down (higher = faster cooldown) - alphaMin: 0.001, // Threshold to stop simulation (when alpha drops below this) - velocityDecay: 0.6, // Friction/damping (0 = no friction, 1 = instant stop) - increased for less movement - alphaTarget: 0.3, // Target alpha when reheating (on drag start) + linkDistance: 300, + docMemoryDistance: 150, + chargeStrength: -1000, + collisionRadius: { document: 80, memory: 40 }, + alphaDecay: 0.04, + alphaMin: 0.001, + velocityDecay: 0.6, + alphaTarget: 0.3, + preSettleTicks: 100, } -// Graph view settings export const GRAPH_SETTINGS = { - console: { - initialZoom: 0.8, // Higher zoom for console - better overview - initialPanX: 0, - initialPanY: 0, - }, - consumer: { - initialZoom: 0.5, // Changed from 0.1 to 0.5 for better initial visibility - initialPanX: 400, // Pan towards center to compensate for larger layout - initialPanY: 300, // Pan towards center to compensate for larger layout - }, + console: { initialZoom: 0.8, initialPanX: 0, initialPanY: 0 }, + consumer: { initialZoom: 0.5, initialPanX: 400, initialPanY: 300 }, } -// Animation settings export const ANIMATION = { - // Dim effect duration - shortened for better UX - dimDuration: 1500, // milliseconds + dimDuration: 1500, } -// Responsive positioning for different app variants -export const POSITIONING = { - console: { - legend: { - desktop: "bottom-4 right-4", - mobile: "bottom-4 right-4", - }, - loadingIndicator: "top-20 right-4", - - spacesSelector: "top-4 left-4", - viewToggle: "", // Not used in console - nodeDetail: "top-4 right-4", - }, - consumer: { - legend: { - desktop: "top-18 right-4", - mobile: "bottom-[180px] left-4", - }, - loadingIndicator: "top-20 right-4", - - spacesSelector: "", // Hidden in consumer - viewToggle: "top-4 right-4", // Consumer has view toggle - nodeDetail: "top-4 right-4", - }, +export const DEFAULT_COLORS: GraphThemeColors = { + bg: "#0f1419", + docFill: "#1B1F24", + docStroke: "#2A2F36", + docInnerFill: "#13161A", + memFill: "#0D2034", + memFillHover: "#112840", + memStrokeDefault: "#3B73B8", + accent: "#3B73B8", + textPrimary: "#ffffff", + textSecondary: "#e2e8f0", + textMuted: "#94a3b8", + edgeDocMemory: "#4A5568", + edgeVersion: "#8B5CF6", + edgeSimStrong: "#00D4B8", + edgeSimMedium: "#6B8FBF", + edgeSimWeak: "#4A6A8A", + edgeDocDoc: "#8DA3F4", + memBorderForgotten: "#EF4444", + memBorderExpiring: "#F59E0B", + memBorderRecent: "#10B981", + glowColor: "#3B73B8", + iconColor: "#3B73B8", + popoverBg: "#1a1f29", + popoverBorder: "#2A2F36", + popoverTextPrimary: "#ffffff", + popoverTextSecondary: "#e2e8f0", + popoverTextMuted: "#94a3b8", + controlBg: "#1a1f29", + controlBorder: "#2A2F36", } diff --git a/packages/memory-graph/src/hooks/use-force-simulation.ts b/packages/memory-graph/src/hooks/use-force-simulation.ts deleted file mode 100644 index 32c38962..00000000 --- a/packages/memory-graph/src/hooks/use-force-simulation.ts +++ /dev/null @@ -1,179 +0,0 @@ -"use client" - -import { useEffect, useRef, useCallback } from "react" -import * as d3 from "d3-force" -import { FORCE_CONFIG } from "@/constants" -import type { GraphNode, GraphEdge } from "@/types" - -export interface ForceSimulationControls { - /** The d3 simulation instance */ - simulation: d3.Simulation | null - /** Reheat the simulation (call on drag start) */ - reheat: () => void - /** Cool down the simulation (call on drag end) */ - coolDown: () => void - /** Check if simulation is currently active */ - isActive: () => boolean - /** Stop the simulation completely */ - stop: () => void - /** Get current alpha value */ - getAlpha: () => number -} - -/** - * Custom hook to manage d3-force simulation lifecycle - * Simulation only runs during interactions (drag) for performance - */ -export function useForceSimulation( - nodes: GraphNode[], - edges: GraphEdge[], - onTick: () => void, - enabled = true, -): ForceSimulationControls { - const simulationRef = useRef | null>(null) - - // Initialize simulation ONCE - useEffect(() => { - if (!enabled || nodes.length === 0) { - return - } - - // Only create simulation once - if (!simulationRef.current) { - const simulation = d3 - .forceSimulation(nodes) - .alphaDecay(FORCE_CONFIG.alphaDecay) - .alphaMin(FORCE_CONFIG.alphaMin) - .velocityDecay(FORCE_CONFIG.velocityDecay) - .on("tick", () => { - // Trigger re-render by calling onTick - // D3 has already mutated node.x and node.y - onTick() - }) - - // Configure forces - // 1. Link force - spring connections between nodes - simulation.force( - "link", - d3 - .forceLink(edges) - .id((d) => d.id) - .distance(FORCE_CONFIG.linkDistance) - .strength((link) => { - // Different strength based on edge type - if (link.edgeType === "doc-memory") { - return FORCE_CONFIG.linkStrength.docMemory - } - if (link.edgeType === "version") { - return FORCE_CONFIG.linkStrength.version - } - // doc-doc: variable strength based on similarity - return link.similarity * FORCE_CONFIG.linkStrength.docDocBase - }), - ) - - // 2. Charge force - repulsion between nodes - simulation.force( - "charge", - d3.forceManyBody().strength(FORCE_CONFIG.chargeStrength), - ) - - // 3. Collision force - prevent node overlap - simulation.force( - "collide", - d3 - .forceCollide() - .radius((d) => - d.type === "document" - ? FORCE_CONFIG.collisionRadius.document - : FORCE_CONFIG.collisionRadius.memory, - ) - .strength(0.7), - ) - - // 4. forceX and forceY - weak centering forces (like reference code) - simulation.force("x", d3.forceX().strength(0.05)) - simulation.force("y", d3.forceY().strength(0.05)) - - // Store reference - simulationRef.current = simulation - - // Quick pre-settle to avoid initial chaos, then animate the rest - // This gives best of both worlds: fast initial render + smooth settling - simulation.alpha(1) - for (let i = 0; i < 50; ++i) simulation.tick() // Just 50 ticks = ~5-10ms - simulation.alphaTarget(0).restart() // Continue animating to full stability - } - - // Cleanup on unmount - return () => { - if (simulationRef.current) { - simulationRef.current.stop() - simulationRef.current = null - } - } - // Only run on mount/unmount, not when nodes/edges/onTick change - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [enabled]) - - // Update simulation nodes and edges together to prevent race conditions - useEffect(() => { - if (!simulationRef.current) return - - // Update nodes - if (nodes.length > 0) { - simulationRef.current.nodes(nodes) - } - - // Update edges - if (edges.length > 0) { - const linkForce = - simulationRef.current.force>("link") - if (linkForce) { - linkForce.links(edges) - } - } - }, [nodes, edges]) - - // Reheat simulation (called on drag start) - const reheat = useCallback(() => { - if (simulationRef.current) { - simulationRef.current.alphaTarget(FORCE_CONFIG.alphaTarget).restart() - } - }, []) - - // Cool down simulation (called on drag end) - const coolDown = useCallback(() => { - if (simulationRef.current) { - simulationRef.current.alphaTarget(0) - } - }, []) - - // Check if simulation is active - const isActive = useCallback(() => { - if (!simulationRef.current) return false - return simulationRef.current.alpha() > FORCE_CONFIG.alphaMin - }, []) - - // Stop simulation completely - const stop = useCallback(() => { - if (simulationRef.current) { - simulationRef.current.stop() - } - }, []) - - // Get current alpha - const getAlpha = useCallback(() => { - if (!simulationRef.current) return 0 - return simulationRef.current.alpha() - }, []) - - return { - simulation: simulationRef.current, - reheat, - coolDown, - isActive, - stop, - getAlpha, - } -} diff --git a/packages/memory-graph/src/hooks/use-graph-data.ts b/packages/memory-graph/src/hooks/use-graph-data.ts index a94bebdc..675c7601 100644 --- a/packages/memory-graph/src/hooks/use-graph-data.ts +++ b/packages/memory-graph/src/hooks/use-graph-data.ts @@ -1,471 +1,292 @@ -"use client" - -import { - calculateSemanticSimilarity, - getConnectionVisualProps, - getMagicalConnectionColor, -} from "@/lib/similarity" -import { useMemo, useRef, useEffect } from "react" -import { colors, LAYOUT_CONSTANTS, SIMILARITY_CONFIG } from "@/constants" +import { useEffect, useMemo, useRef } from "react" import type { - DocumentsResponse, - DocumentWithMemories, + DocumentNodeData, + GraphApiDocument, + GraphApiEdge, + GraphApiMemory, GraphEdge, GraphNode, - MemoryEntry, - MemoryRelation, -} from "@/types" + GraphThemeColors, + MemoryNodeData, +} from "../types" + +const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000 +const ONE_DAY_MS = 24 * 60 * 60 * 1000 +const MEMORY_CLUSTER_SPREAD = 150 + +function getMemoryBorderColor( + mem: GraphApiMemory, + colors: GraphThemeColors, +): string { + if (mem.isForgotten) return colors.memBorderForgotten + if (mem.forgetAfter) { + const msLeft = new Date(mem.forgetAfter).getTime() - Date.now() + if (msLeft < SEVEN_DAYS_MS) return colors.memBorderExpiring + } + const age = Date.now() - new Date(mem.createdAt).getTime() + if (age < ONE_DAY_MS) return colors.memBorderRecent + return colors.memStrokeDefault +} + +function getEdgeVisualProps(similarity: number) { + return { + opacity: 0.3 + similarity * 0.5, + thickness: 1 + similarity * 1.5, + } +} + +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) + } + + const rangeX = maxX - minX || 1 + const rangeY = maxY - minY || 1 + const PAD = 100 + + return documents.map((doc) => ({ + ...doc, + x: PAD + ((doc.x - minX) / rangeX) * (1000 - 2 * PAD), + y: PAD + ((doc.y - minY) / rangeY) * (1000 - 2 * PAD), + })) +} export function useGraphData( - data: DocumentsResponse | null, - selectedSpace: string, - nodePositions: Map< - string, - { - x: number - y: number - parentDocId?: string - offsetX?: number - offsetY?: number - } - >, + documents: GraphApiDocument[], + apiEdges: GraphApiEdge[], draggingNodeId: string | null, - memoryLimit?: number, - maxNodes?: number, + canvasWidth: number, + canvasHeight: number, + colors: GraphThemeColors, ) { - // Cache nodes to preserve d3-force mutations (x, y, vx, vy, fx, fy) const nodeCache = useRef>(new Map()) - // Cleanup nodeCache to prevent memory leak useEffect(() => { - if (!data?.documents) return + if (!documents || documents.length === 0) return - // Build set of current node IDs - const currentNodeIds = new Set() - data.documents.forEach((doc) => { - currentNodeIds.add(doc.id) - doc.memoryEntries.forEach((mem) => { - currentNodeIds.add(`${mem.id}`) - }) - }) + const currentIds = new Set() + for (const doc of documents) { + currentIds.add(doc.id) + for (const mem of doc.memories) currentIds.add(mem.id) + } - // Remove stale nodes from cache for (const [id] of nodeCache.current.entries()) { - if (!currentNodeIds.has(id)) { - nodeCache.current.delete(id) - } + if (!currentIds.has(id)) nodeCache.current.delete(id) } - }, [data, selectedSpace]) + }, [documents]) - // Memo 1: Filter documents by selected space and apply node limits - const filteredDocuments = useMemo(() => { - if (!data?.documents) return [] + const { scale, offsetX, offsetY } = useMemo(() => { + if (canvasWidth === 0 || canvasHeight === 0) { + return { scale: 1, offsetX: 0, offsetY: 0 } + } + const paddingFactor = 0.8 + const s = (Math.min(canvasWidth, canvasHeight) * paddingFactor) / 1000 + const ox = (canvasWidth - 1000 * s) / 2 + const oy = (canvasHeight - 1000 * s) / 2 + return { scale: s, offsetX: ox, offsetY: oy } + }, [canvasWidth, canvasHeight]) - // Sort documents by most recent first - const sortedDocs = [...data.documents].sort((a, b) => { - const dateA = new Date(a.updatedAt || a.createdAt).getTime() - const dateB = new Date(b.updatedAt || b.createdAt).getTime() - return dateB - dateA // Most recent first - }) + const normalizedDocs = useMemo( + () => normalizeDocCoordinates(documents), + [documents], + ) - // Filter by space and prepare documents - let processedDocs = sortedDocs.map((doc) => { - let memories = - selectedSpace === "all" - ? doc.memoryEntries - : doc.memoryEntries.filter( - (memory) => - (memory.spaceContainerTag ?? memory.spaceId ?? "default") === - selectedSpace, - ) + const nodes = useMemo(() => { + if (!normalizedDocs || normalizedDocs.length === 0) return [] - // Sort memories by relevance score (if available) or recency - memories = memories.sort((a, b) => { - // Prioritize sourceRelevanceScore if available - if (a.sourceRelevanceScore != null && b.sourceRelevanceScore != null) { - return b.sourceRelevanceScore - a.sourceRelevanceScore // Higher score first - } - // Fall back to most recent - const dateA = new Date(a.updatedAt || a.createdAt).getTime() - const dateB = new Date(b.updatedAt || b.createdAt).getTime() - return dateB - dateA // Most recent first - }) + const result: GraphNode[] = [] - return { - ...doc, - memoryEntries: memories, + for (const doc of normalizedDocs) { + const initialX = doc.x * scale + offsetX + const initialY = doc.y * scale + offsetY + + let docNode = nodeCache.current.get(doc.id) + const docData: DocumentNodeData = { + id: doc.id, + title: doc.title, + summary: doc.summary, + type: doc.documentType, + createdAt: doc.createdAt, + updatedAt: doc.updatedAt, + memories: doc.memories, } - }) - // Apply maxNodes limit using Option B (dynamic cap per document) - if (maxNodes && maxNodes > 0) { - const totalDocs = processedDocs.length - if (totalDocs > 0) { - // Calculate memories per document to stay within maxNodes budget - const memoriesPerDoc = Math.floor(maxNodes / totalDocs) + if (docNode) { + docNode.data = docData + docNode.isDragging = draggingNodeId === doc.id + } else { + docNode = { + id: doc.id, + type: "document", + x: initialX, + y: initialY, + data: docData, + size: 50, + borderColor: colors.docStroke, + isHovered: false, + isDragging: false, + } + nodeCache.current.set(doc.id, docNode) + } + result.push(docNode) - // If we need to limit, slice memories for each document - if (memoriesPerDoc > 0) { - let totalNodes = 0 - processedDocs = processedDocs.map((doc) => { - // Limit memories to calculated amount per doc - const limitedMemories = doc.memoryEntries.slice(0, memoriesPerDoc) - totalNodes += limitedMemories.length - return { - ...doc, - memoryEntries: limitedMemories, - } - }) + const memCount = doc.memories.length + for (let i = 0; i < memCount; i++) { + const mem = doc.memories[i] + if (!mem) continue + let memNode = nodeCache.current.get(mem.id) + const memData: MemoryNodeData = { + ...mem, + documentId: doc.id, + content: mem.memory, + } - // If we still have budget left, distribute remaining nodes to first docs - let remainingBudget = maxNodes - totalNodes - if (remainingBudget > 0) { - for ( - let i = 0; - i < processedDocs.length && remainingBudget > 0; - i++ - ) { - const doc = processedDocs[i] - if (!doc) continue - const originalDoc = sortedDocs.find((d) => d.id === doc.id) - if (!originalDoc) continue - - const currentMemCount = doc.memoryEntries.length - const originalMemCount = originalDoc.memoryEntries.filter( - (m) => - selectedSpace === "all" || - (m.spaceContainerTag ?? m.spaceId ?? "default") === - selectedSpace, - ).length - - // Can we add more memories to this doc? - const canAdd = originalMemCount - currentMemCount - if (canAdd > 0) { - const toAdd = Math.min(canAdd, remainingBudget) - const additionalMems = doc.memoryEntries.slice( - 0, - currentMemCount + toAdd, - ) - processedDocs[i] = { - ...doc, - memoryEntries: originalDoc.memoryEntries - .filter( - (m) => - selectedSpace === "all" || - (m.spaceContainerTag ?? m.spaceId ?? "default") === - selectedSpace, - ) - .sort((a, b) => { - if ( - a.sourceRelevanceScore != null && - b.sourceRelevanceScore != null - ) { - return b.sourceRelevanceScore - a.sourceRelevanceScore - } - const dateA = new Date( - a.updatedAt || a.createdAt, - ).getTime() - const dateB = new Date( - b.updatedAt || b.createdAt, - ).getTime() - return dateB - dateA - }) - .slice(0, currentMemCount + toAdd), - } - remainingBudget -= toAdd - } - } - } + if (memNode) { + memNode.data = memData + memNode.borderColor = getMemoryBorderColor(mem, colors) + memNode.isDragging = draggingNodeId === mem.id } else { - // If memoriesPerDoc is 0, we need to limit the number of documents shown - // Show at least 1 memory per document, up to maxNodes documents - processedDocs = processedDocs.slice(0, maxNodes).map((doc) => ({ - ...doc, - memoryEntries: doc.memoryEntries.slice(0, 1), - })) - } - } - } - // Apply legacy memoryLimit if provided and a specific space is selected - else if (selectedSpace !== "all" && memoryLimit && memoryLimit > 0) { - processedDocs = processedDocs.map((doc) => ({ - ...doc, - memoryEntries: doc.memoryEntries.slice(0, memoryLimit), - })) - } - - return processedDocs - }, [data, selectedSpace, memoryLimit, maxNodes]) - - // Memo 2: Calculate similarity edges using k-NN approach - const similarityEdges = useMemo(() => { - const edges: GraphEdge[] = [] - - // k-NN: Each document compares with k neighbors (configurable) - const { maxComparisonsPerDoc, threshold } = SIMILARITY_CONFIG - - for (let i = 0; i < filteredDocuments.length; i++) { - const docI = filteredDocuments[i] - if (!docI) continue - - // Only compare with next k documents (k-nearest neighbors approach) - const endIdx = Math.min( - i + maxComparisonsPerDoc + 1, - filteredDocuments.length, - ) - - for (let j = i + 1; j < endIdx; j++) { - const docJ = filteredDocuments[j] - if (!docJ) continue - - const sim = calculateSemanticSimilarity( - docI.summaryEmbedding ? Array.from(docI.summaryEmbedding) : null, - docJ.summaryEmbedding ? Array.from(docJ.summaryEmbedding) : null, - ) - - if (sim > threshold) { - edges.push({ - id: `doc-doc-${docI.id}-${docJ.id}`, - source: docI.id, - target: docJ.id, - similarity: sim, - visualProps: getConnectionVisualProps(sim), - color: getMagicalConnectionColor(sim, 200), - edgeType: "doc-doc", - }) - } - } - } - - return edges - }, [filteredDocuments]) - - // Memo 3: Build full graph data (nodes + edges) - return useMemo(() => { - if (!data?.documents || filteredDocuments.length === 0) { - return { nodes: [], edges: [] } - } - - const allNodes: GraphNode[] = [] - const allEdges: GraphEdge[] = [] - - // Group documents by space for better clustering - const documentsBySpace = new Map() - filteredDocuments.forEach((doc) => { - const docSpace = - doc.memoryEntries[0]?.spaceContainerTag ?? - doc.memoryEntries[0]?.spaceId ?? - "default" - if (!documentsBySpace.has(docSpace)) { - documentsBySpace.set(docSpace, []) - } - const spaceDocsArr = documentsBySpace.get(docSpace) - if (spaceDocsArr) { - spaceDocsArr.push(doc) - } - }) - - // Enhanced Layout with Space Separation - const { centerX, centerY, clusterRadius } = LAYOUT_CONSTANTS - - /* 1. Build DOCUMENT nodes with space-aware clustering */ - const documentNodes: GraphNode[] = [] - let spaceIndex = 0 - - documentsBySpace.forEach((spaceDocs) => { - spaceDocs.forEach((doc, docIndex) => { - // Simple grid-like layout that physics will naturally organize - // Start documents near the center with some random offset - const gridSize = Math.ceil(Math.sqrt(spaceDocs.length)) - const row = Math.floor(docIndex / gridSize) - const col = docIndex % gridSize - - // Loose grid spacing - physics will organize it better - const spacing = 200 - const defaultX = - centerX + (col - gridSize / 2) * spacing + (Math.random() - 0.5) * 50 - const defaultY = - centerY + (row - gridSize / 2) * spacing + (Math.random() - 0.5) * 50 - - const customPos = nodePositions.get(doc.id) - - // Check if node exists in cache (preserves d3-force mutations) - let node = nodeCache.current.get(doc.id) - if (node) { - // Update existing node's data, preserve physics properties (x, y, vx, vy, fx, fy) - node.data = doc - node.isDragging = draggingNodeId === doc.id - // Don't reset x/y - they're managed by d3-force - } else { - // Create new node with initial position - node = { - id: doc.id, - type: "document", - x: customPos?.x ?? defaultX, - y: customPos?.y ?? defaultY, - data: doc, - size: 58, - color: colors.document.primary, + const angle = (i / memCount) * 2 * Math.PI + memNode = { + id: mem.id, + type: "memory", + x: docNode.x + Math.cos(angle) * MEMORY_CLUSTER_SPREAD, + y: docNode.y + Math.sin(angle) * MEMORY_CLUSTER_SPREAD, + data: memData, + size: 36, + borderColor: getMemoryBorderColor(mem, colors), isHovered: false, - isDragging: draggingNodeId === doc.id, - } satisfies GraphNode - nodeCache.current.set(doc.id, node) - } - - documentNodes.push(node) - }) - - spaceIndex++ - }) - - /* 2. Manual collision avoidance removed - now handled by d3-force simulation */ - // The initial circular layout provides good starting positions - // D3-force will handle collision avoidance and spacing dynamically - - allNodes.push(...documentNodes) - - /* 3. Add memories around documents WITH doc-memory connections */ - documentNodes.forEach((docNode) => { - const memoryNodeMap = new Map() - const doc = docNode.data as DocumentWithMemories - - doc.memoryEntries.forEach((memory, memIndex) => { - const memoryId = `${memory.id}` - const customMemPos = nodePositions.get(memoryId) - - // Simple circular positioning around parent doc - // Physics will naturally cluster them better - const angle = (memIndex / doc.memoryEntries.length) * Math.PI * 2 - const distance = clusterRadius * 1 // Closer to parent, let physics separate - - const defaultMemX = docNode.x + Math.cos(angle) * distance - const defaultMemY = docNode.y + Math.sin(angle) * distance - - // Calculate final position - let finalMemX = defaultMemX - let finalMemY = defaultMemY - - if (customMemPos) { - // If memory was manually positioned and has stored offset relative to parent - if ( - customMemPos.parentDocId === docNode.id && - customMemPos.offsetX !== undefined && - customMemPos.offsetY !== undefined - ) { - // Apply the stored offset to the current document position - finalMemX = docNode.x + customMemPos.offsetX - finalMemY = docNode.y + customMemPos.offsetY - } else { - // Fallback: use absolute position (for backward compatibility or if parent changed) - finalMemX = customMemPos.x - finalMemY = customMemPos.y + isDragging: false, } + nodeCache.current.set(mem.id, memNode) } + result.push(memNode) + } + } - if (!memoryNodeMap.has(memoryId)) { - // Check if memory node exists in cache (preserves d3-force mutations) - let memoryNode = nodeCache.current.get(memoryId) - if (memoryNode) { - // Update existing node's data, preserve physics properties - memoryNode.data = memory - memoryNode.isDragging = draggingNodeId === memoryId - // Don't reset x/y - they're managed by d3-force - } else { - // Create new node with initial position - memoryNode = { - id: memoryId, - type: "memory", - x: finalMemX, - y: finalMemY, - data: memory, - size: Math.max( - 32, - Math.min(48, (memory.memory?.length || 50) * 0.5), - ), - color: colors.memory.primary, - isHovered: false, - isDragging: draggingNodeId === memoryId, - } - nodeCache.current.set(memoryId, memoryNode) - } - memoryNodeMap.set(memoryId, memoryNode) - allNodes.push(memoryNode) - } + return result + }, [normalizedDocs, scale, offsetX, offsetY, draggingNodeId, colors]) - // Create doc-memory edge with similarity - allEdges.push({ - id: `edge-${docNode.id}-${memory.id}`, - source: docNode.id, - target: memoryId, + const edges = useMemo(() => { + if (!normalizedDocs || normalizedDocs.length === 0) return [] + + const result: GraphEdge[] = [] + const allNodeIds = new Set(nodes.map((n) => n.id)) + + for (const doc of normalizedDocs) { + for (const mem of doc.memories) { + result.push({ + id: `dm-${doc.id}-${mem.id}`, + source: doc.id, + target: mem.id, similarity: 1, - visualProps: getConnectionVisualProps(1), - color: colors.connection.memory, + visualProps: { opacity: 0.3, thickness: 1.5 }, edgeType: "doc-memory", }) - }) - }) - - // Build mapping of memoryId -> nodeId for version chains - const memNodeIdMap = new Map() - allNodes.forEach((n) => { - if (n.type === "memory") { - memNodeIdMap.set((n.data as MemoryEntry).id, n.id) } - }) + } - // Add version-chain edges (old -> new) - data.documents.forEach((doc) => { - doc.memoryEntries.forEach((mem: MemoryEntry) => { - // Support both new object structure and legacy array/single parent fields - let parentRelations: Record = - (mem.memoryRelations ?? {}) as Record - - if ( - mem.memoryRelations && - Array.isArray(mem.memoryRelations) && - mem.memoryRelations.length > 0 - ) { - // Convert array to Record - parentRelations = mem.memoryRelations.reduce( - (acc, rel) => { - acc[rel.targetMemoryId] = rel.relationType - return acc - }, - {} as Record, - ) - } else if (mem.parentMemoryId) { - parentRelations = { - [mem.parentMemoryId]: "updates" as MemoryRelation, - } + for (const doc of normalizedDocs) { + for (const mem of doc.memories) { + if (mem.parentMemoryId && allNodeIds.has(mem.parentMemoryId)) { + result.push({ + id: `ver-${mem.parentMemoryId}-${mem.id}`, + source: mem.parentMemoryId, + target: mem.id, + similarity: 1, + visualProps: { opacity: 0.6, thickness: 2 }, + edgeType: "version", + }) } - Object.entries(parentRelations).forEach(([pid, relationType]) => { - const fromId = memNodeIdMap.get(pid) - const toId = memNodeIdMap.get(mem.id) - if (fromId && toId) { - allEdges.push({ - id: `version-${fromId}-${toId}`, - source: fromId, - target: toId, - similarity: 1, - visualProps: { - opacity: 0.8, - thickness: 1, - glow: 0, - pulseDuration: 3000, - }, - // choose color based on relation type - color: colors.relations[relationType] ?? colors.relations.updates, - edgeType: "version", - relationType: relationType as MemoryRelation, - }) - } - }) + } + } + + for (const apiEdge of apiEdges) { + if (!allNodeIds.has(apiEdge.source) || !allNodeIds.has(apiEdge.target)) { + continue + } + + result.push({ + id: `sim-${apiEdge.source}-${apiEdge.target}`, + source: apiEdge.source, + target: apiEdge.target, + similarity: apiEdge.similarity, + visualProps: getEdgeVisualProps(apiEdge.similarity), + edgeType: "similarity", }) - }) + } - // Append similarity edges (calculated in separate memo) - allEdges.push(...similarityEdges) + return result + }, [normalizedDocs, apiEdges, nodes]) - return { nodes: allNodes, edges: allEdges } - }, [data, filteredDocuments, nodePositions, draggingNodeId, similarityEdges]) + return { nodes, edges, scale, offsetX, offsetY } +} + +export function screenToBackendCoords( + screenX: number, + screenY: number, + panX: number, + panY: number, + zoom: number, + canvasWidth: number, + canvasHeight: number, +): { x: number; y: number } { + const canvasX = (screenX - panX) / zoom + const canvasY = (screenY - panY) / zoom + + const paddingFactor = 0.8 + const s = (Math.min(canvasWidth, canvasHeight) * paddingFactor) / 1000 + const ox = (canvasWidth - 1000 * s) / 2 + const oy = (canvasHeight - 1000 * s) / 2 + + return { + x: (canvasX - ox) / s, + y: (canvasY - oy) / s, + } +} + +export function calculateBackendViewport( + panX: number, + panY: number, + zoom: number, + canvasWidth: number, + canvasHeight: number, +): { minX: number; maxX: number; minY: number; maxY: number } { + const topLeft = screenToBackendCoords( + 0, + 0, + panX, + panY, + zoom, + canvasWidth, + canvasHeight, + ) + const bottomRight = screenToBackendCoords( + canvasWidth, + canvasHeight, + panX, + panY, + zoom, + canvasWidth, + canvasHeight, + ) + + return { + minX: Math.max(0, Math.min(topLeft.x, bottomRight.x)), + maxX: Math.max(topLeft.x, bottomRight.x), + minY: Math.max(0, Math.min(topLeft.y, bottomRight.y)), + maxY: Math.max(topLeft.y, bottomRight.y), + } } diff --git a/packages/memory-graph/src/hooks/use-graph-interactions.ts b/packages/memory-graph/src/hooks/use-graph-interactions.ts deleted file mode 100644 index d9044ad3..00000000 --- a/packages/memory-graph/src/hooks/use-graph-interactions.ts +++ /dev/null @@ -1,606 +0,0 @@ -"use client" - -import { useCallback, useRef, useState } from "react" -import { GRAPH_SETTINGS } from "@/constants" -import type { GraphNode } from "@/types" - -export function useGraphInteractions( - variant: "console" | "consumer" = "console", -) { - const settings = GRAPH_SETTINGS[variant] - - const [panX, setPanX] = useState(settings.initialPanX) - const [panY, setPanY] = useState(settings.initialPanY) - const [zoom, setZoom] = useState(settings.initialZoom) - const [isPanning, setIsPanning] = useState(false) - const [panStart, setPanStart] = useState({ x: 0, y: 0 }) - const [hoveredNode, setHoveredNode] = useState(null) - const [selectedNode, setSelectedNode] = useState(null) - const [draggingNodeId, setDraggingNodeId] = useState(null) - const [dragStart, setDragStart] = useState({ - x: 0, - y: 0, - nodeX: 0, - nodeY: 0, - }) - const [nodePositions, setNodePositions] = useState< - Map< - string, - { - x: number - y: number - parentDocId?: string - offsetX?: number - offsetY?: number - } - > - >(new Map()) - - // Touch gesture state - const [touchState, setTouchState] = useState<{ - touches: { id: number; x: number; y: number }[] - lastDistance: number - lastCenter: { x: number; y: number } - isGesturing: boolean - }>({ - touches: [], - lastDistance: 0, - lastCenter: { x: 0, y: 0 }, - isGesturing: false, - }) - - // Animation state for smooth transitions - const animationRef = useRef(null) - const [isAnimating, setIsAnimating] = useState(false) - - // Smooth animation helper - const animateToViewState = useCallback( - ( - targetPanX: number, - targetPanY: number, - targetZoom: number, - duration = 300, - ) => { - if (animationRef.current) { - cancelAnimationFrame(animationRef.current) - } - - const startPanX = panX - const startPanY = panY - const startZoom = zoom - const startTime = Date.now() - - setIsAnimating(true) - - const animate = () => { - const elapsed = Date.now() - startTime - const progress = Math.min(elapsed / duration, 1) - - // Ease out cubic function for smooth transitions - const easeOut = 1 - (1 - progress) ** 3 - - const currentPanX = startPanX + (targetPanX - startPanX) * easeOut - const currentPanY = startPanY + (targetPanY - startPanY) * easeOut - const currentZoom = startZoom + (targetZoom - startZoom) * easeOut - - setPanX(currentPanX) - setPanY(currentPanY) - setZoom(currentZoom) - - if (progress < 1) { - animationRef.current = requestAnimationFrame(animate) - } else { - setIsAnimating(false) - animationRef.current = null - } - } - - animate() - }, - [panX, panY, zoom], - ) - - // Node drag handlers - const handleNodeDragStart = useCallback( - (nodeId: string, e: React.MouseEvent, nodes?: GraphNode[]) => { - const node = nodes?.find((n) => n.id === nodeId) - if (!node) return - - setDraggingNodeId(nodeId) - setDragStart({ - x: e.clientX, - y: e.clientY, - nodeX: node.x, - nodeY: node.y, - }) - }, - [], - ) - - const handleNodeDragMove = useCallback( - (e: React.MouseEvent, nodes?: GraphNode[]) => { - if (!draggingNodeId) return - - const deltaX = (e.clientX - dragStart.x) / zoom - const deltaY = (e.clientY - dragStart.y) / zoom - - const newX = dragStart.nodeX + deltaX - const newY = dragStart.nodeY + deltaY - - // Find the node being dragged to determine if it's a memory - const draggedNode = nodes?.find((n) => n.id === draggingNodeId) - - if (draggedNode?.type === "memory") { - // For memory nodes, find the parent document and store relative offset - const memoryData = draggedNode.data as any // MemoryEntry type - const parentDoc = nodes?.find( - (n) => - n.type === "document" && - (n.data as any).memoryEntries?.some( - (m: any) => m.id === memoryData.id, - ), - ) - - if (parentDoc) { - // Store the offset from the parent document - const offsetX = newX - parentDoc.x - const offsetY = newY - parentDoc.y - - setNodePositions((prev) => - new Map(prev).set(draggingNodeId, { - x: newX, - y: newY, - parentDocId: parentDoc.id, - offsetX, - offsetY, - }), - ) - return - } - } - - // For document nodes or if parent not found, just store absolute position - setNodePositions((prev) => - new Map(prev).set(draggingNodeId, { x: newX, y: newY }), - ) - }, - [draggingNodeId, dragStart, zoom], - ) - - const handleNodeDragEnd = useCallback(() => { - setDraggingNodeId(null) - }, []) - - // Pan handlers - const handlePanStart = useCallback( - (e: React.MouseEvent) => { - setIsPanning(true) - setPanStart({ x: e.clientX - panX, y: e.clientY - panY }) - }, - [panX, panY], - ) - - const handlePanMove = useCallback( - (e: React.MouseEvent) => { - if (!isPanning || draggingNodeId) return - - const newPanX = e.clientX - panStart.x - const newPanY = e.clientY - panStart.y - setPanX(newPanX) - setPanY(newPanY) - }, - [isPanning, panStart, draggingNodeId], - ) - - const handlePanEnd = useCallback(() => { - setIsPanning(false) - }, []) - - // Zoom handlers - const handleWheel = useCallback( - (e: React.WheelEvent) => { - // Always prevent default to stop browser navigation - e.preventDefault() - e.stopPropagation() - - // Handle horizontal scrolling (trackpad swipe) by converting to pan - if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) { - // Horizontal scroll - pan the graph instead of zooming - const panDelta = e.deltaX * 0.5 - setPanX((prev) => prev - panDelta) - return - } - - // Vertical scroll - zoom behavior - const delta = e.deltaY > 0 ? 0.97 : 1.03 - const newZoom = Math.max(0.05, Math.min(3, zoom * delta)) - - // Get mouse position relative to the viewport - let mouseX = e.clientX - let mouseY = e.clientY - - // Try to get the container bounds to make coordinates relative to the graph container - const target = e.currentTarget - if (target && "getBoundingClientRect" in target) { - const rect = target.getBoundingClientRect() - mouseX = e.clientX - rect.left - mouseY = e.clientY - rect.top - } - - // Calculate the world position of the mouse cursor - const worldX = (mouseX - panX) / zoom - const worldY = (mouseY - panY) / zoom - - // Calculate new pan to keep the mouse position stationary - const newPanX = mouseX - worldX * newZoom - const newPanY = mouseY - worldY * newZoom - - setZoom(newZoom) - setPanX(newPanX) - setPanY(newPanY) - }, - [zoom, panX, panY], - ) - - const zoomIn = useCallback( - (centerX?: number, centerY?: number, animate = true) => { - const zoomFactor = 1.2 - const newZoom = Math.min(3, zoom * zoomFactor) // Increased max zoom to 3x - - if (centerX !== undefined && centerY !== undefined) { - // Mouse-centered zoom for programmatic zoom in - const worldX = (centerX - panX) / zoom - const worldY = (centerY - panY) / zoom - const newPanX = centerX - worldX * newZoom - const newPanY = centerY - worldY * newZoom - - if (animate && !isAnimating) { - animateToViewState(newPanX, newPanY, newZoom, 200) - } else { - setZoom(newZoom) - setPanX(newPanX) - setPanY(newPanY) - } - } else { - if (animate && !isAnimating) { - animateToViewState(panX, panY, newZoom, 200) - } else { - setZoom(newZoom) - } - } - }, - [zoom, panX, panY, isAnimating, animateToViewState], - ) - - const zoomOut = useCallback( - (centerX?: number, centerY?: number, animate = true) => { - const zoomFactor = 0.8 - const newZoom = Math.max(0.05, zoom * zoomFactor) // Decreased min zoom to 0.05x - - if (centerX !== undefined && centerY !== undefined) { - // Mouse-centered zoom for programmatic zoom out - const worldX = (centerX - panX) / zoom - const worldY = (centerY - panY) / zoom - const newPanX = centerX - worldX * newZoom - const newPanY = centerY - worldY * newZoom - - if (animate && !isAnimating) { - animateToViewState(newPanX, newPanY, newZoom, 200) - } else { - setZoom(newZoom) - setPanX(newPanX) - setPanY(newPanY) - } - } else { - if (animate && !isAnimating) { - animateToViewState(panX, panY, newZoom, 200) - } else { - setZoom(newZoom) - } - } - }, - [zoom, panX, panY, isAnimating, animateToViewState], - ) - - const resetView = useCallback(() => { - setPanX(settings.initialPanX) - setPanY(settings.initialPanY) - setZoom(settings.initialZoom) - setNodePositions(new Map()) - }, [settings]) - - // Auto-fit graph to viewport - const autoFitToViewport = useCallback( - ( - nodes: GraphNode[], - viewportWidth: number, - viewportHeight: number, - options?: { occludedRightPx?: number; animate?: boolean }, - ) => { - if (nodes.length === 0) return - - // Find the bounds of all nodes - let minX = Number.POSITIVE_INFINITY - let maxX = Number.NEGATIVE_INFINITY - let minY = Number.POSITIVE_INFINITY - let maxY = Number.NEGATIVE_INFINITY - - nodes.forEach((node) => { - minX = Math.min(minX, node.x - node.size / 2) - maxX = Math.max(maxX, node.x + node.size / 2) - minY = Math.min(minY, node.y - node.size / 2) - maxY = Math.max(maxY, node.y + node.size / 2) - }) - - // Calculate the center of the content - const contentCenterX = (minX + maxX) / 2 - const contentCenterY = (minY + maxY) / 2 - - // Calculate the size of the content - const contentWidth = maxX - minX - const contentHeight = maxY - minY - - // Add padding (20% on each side) - const paddingFactor = 1.4 - const paddedWidth = contentWidth * paddingFactor - const paddedHeight = contentHeight * paddingFactor - - // Account for occluded area on the right (e.g., chat panel) - const occludedRightPx = Math.max(0, options?.occludedRightPx ?? 0) - const availableWidth = Math.max(1, viewportWidth - occludedRightPx) - - // Calculate the zoom needed to fit the content within available width - const zoomX = availableWidth / paddedWidth - const zoomY = viewportHeight / paddedHeight - const newZoom = Math.min(Math.max(0.05, Math.min(zoomX, zoomY)), 3) - - // Calculate pan to center the content within available area - const availableCenterX = availableWidth / 2 - const newPanX = availableCenterX - contentCenterX * newZoom - const newPanY = viewportHeight / 2 - contentCenterY * newZoom - - // Apply the new view (optional animation) - if (options?.animate) { - const steps = 8 - const durationMs = 160 // snappy - const intervalMs = Math.max(1, Math.floor(durationMs / steps)) - const startZoom = zoom - const startPanX = panX - const startPanY = panY - let i = 0 - const ease = (t: number) => 1 - (1 - t) ** 2 // ease-out quad - const timer = setInterval(() => { - i++ - const t = ease(i / steps) - setZoom(startZoom + (newZoom - startZoom) * t) - setPanX(startPanX + (newPanX - startPanX) * t) - setPanY(startPanY + (newPanY - startPanY) * t) - if (i >= steps) clearInterval(timer) - }, intervalMs) - } else { - setZoom(newZoom) - setPanX(newPanX) - setPanY(newPanY) - } - }, - [zoom, panX, panY], - ) - - // Touch gesture handlers for mobile pinch-to-zoom - const handleTouchStart = useCallback((e: React.TouchEvent) => { - const touches = Array.from(e.touches).map((touch) => ({ - id: touch.identifier, - x: touch.clientX, - y: touch.clientY, - })) - - if (touches.length >= 2) { - // Start gesture with two or more fingers - const touch1 = touches[0]! - const touch2 = touches[1]! - - const distance = Math.sqrt( - (touch2.x - touch1.x) ** 2 + (touch2.y - touch1.y) ** 2, - ) - - const center = { - x: (touch1.x + touch2.x) / 2, - y: (touch1.y + touch2.y) / 2, - } - - setTouchState({ - touches, - lastDistance: distance, - lastCenter: center, - isGesturing: true, - }) - } else { - setTouchState((prev) => ({ ...prev, touches, isGesturing: false })) - } - }, []) - - const handleTouchMove = useCallback( - (e: React.TouchEvent) => { - e.preventDefault() - - const touches = Array.from(e.touches).map((touch) => ({ - id: touch.identifier, - x: touch.clientX, - y: touch.clientY, - })) - - if (touches.length >= 2 && touchState.isGesturing) { - const touch1 = touches[0]! - const touch2 = touches[1]! - - const distance = Math.sqrt( - (touch2.x - touch1.x) ** 2 + (touch2.y - touch1.y) ** 2, - ) - - const center = { - x: (touch1.x + touch2.x) / 2, - y: (touch1.y + touch2.y) / 2, - } - - // Calculate zoom change based on pinch distance change - const distanceChange = distance / touchState.lastDistance - const newZoom = Math.max(0.05, Math.min(3, zoom * distanceChange)) - - // Get canvas bounds for center calculation - const canvas = e.currentTarget as HTMLElement - const rect = canvas.getBoundingClientRect() - const centerX = center.x - rect.left - const centerY = center.y - rect.top - - // Calculate the world position of the pinch center - const worldX = (centerX - panX) / zoom - const worldY = (centerY - panY) / zoom - - // Calculate new pan to keep the pinch center stationary - const newPanX = centerX - worldX * newZoom - const newPanY = centerY - worldY * newZoom - - // Calculate pan change based on center movement - const centerDx = center.x - touchState.lastCenter.x - const centerDy = center.y - touchState.lastCenter.y - - setZoom(newZoom) - setPanX(newPanX + centerDx) - setPanY(newPanY + centerDy) - - setTouchState({ - touches, - lastDistance: distance, - lastCenter: center, - isGesturing: true, - }) - } else if (touches.length === 1 && !touchState.isGesturing && isPanning) { - // Single finger pan (only if not in gesture mode) - const touch = touches[0]! - const newPanX = touch.x - panStart.x - const newPanY = touch.y - panStart.y - setPanX(newPanX) - setPanY(newPanY) - } - }, - [touchState, zoom, panX, panY, isPanning, panStart], - ) - - const handleTouchEnd = useCallback((e: React.TouchEvent) => { - const touches = Array.from(e.touches).map((touch) => ({ - id: touch.identifier, - x: touch.clientX, - y: touch.clientY, - })) - - if (touches.length < 2) { - setTouchState((prev) => ({ ...prev, touches, isGesturing: false })) - } else { - setTouchState((prev) => ({ ...prev, touches })) - } - - if (touches.length === 0) { - setIsPanning(false) - } - }, []) - - // Center viewport on a specific world position (with animation) - const centerViewportOn = useCallback( - ( - worldX: number, - worldY: number, - viewportWidth: number, - viewportHeight: number, - animate = true, - ) => { - const newPanX = viewportWidth / 2 - worldX * zoom - const newPanY = viewportHeight / 2 - worldY * zoom - - if (animate && !isAnimating) { - animateToViewState(newPanX, newPanY, zoom, 400) - } else { - setPanX(newPanX) - setPanY(newPanY) - } - }, - [zoom, isAnimating, animateToViewState], - ) - - // Node interaction handlers - const handleNodeHover = useCallback((nodeId: string | null) => { - setHoveredNode(nodeId) - }, []) - - const handleNodeClick = useCallback( - (nodeId: string) => { - setSelectedNode(selectedNode === nodeId ? null : nodeId) - }, - [selectedNode], - ) - - const handleDoubleClick = useCallback( - (e: React.MouseEvent) => { - // Calculate new zoom (zoom in by 1.5x) - const zoomFactor = 1.5 - const newZoom = Math.min(3, zoom * zoomFactor) - - // Get mouse position relative to the container - let mouseX = e.clientX - let mouseY = e.clientY - - // Try to get the container bounds to make coordinates relative to the graph container - const target = e.currentTarget - if (target && "getBoundingClientRect" in target) { - const rect = target.getBoundingClientRect() - mouseX = e.clientX - rect.left - mouseY = e.clientY - rect.top - } - - // Calculate the world position of the clicked point - const worldX = (mouseX - panX) / zoom - const worldY = (mouseY - panY) / zoom - - // Calculate new pan to keep the clicked point in the same screen position - const newPanX = mouseX - worldX * newZoom - const newPanY = mouseY - worldY * newZoom - - setZoom(newZoom) - setPanX(newPanX) - setPanY(newPanY) - }, - [zoom, panX, panY], - ) - - return { - // State - panX, - panY, - zoom, - hoveredNode, - selectedNode, - draggingNodeId, - nodePositions, - // Handlers - handlePanStart, - handlePanMove, - handlePanEnd, - handleWheel, - handleNodeHover, - handleNodeClick, - handleNodeDragStart, - handleNodeDragMove, - handleNodeDragEnd, - handleDoubleClick, - // Touch handlers - handleTouchStart, - handleTouchMove, - handleTouchEnd, - // Controls - zoomIn, - zoomOut, - resetView, - autoFitToViewport, - centerViewportOn, - setSelectedNode, - } -} diff --git a/packages/memory-graph/src/hooks/use-graph-theme.ts b/packages/memory-graph/src/hooks/use-graph-theme.ts new file mode 100644 index 00000000..8bc37893 --- /dev/null +++ b/packages/memory-graph/src/hooks/use-graph-theme.ts @@ -0,0 +1,78 @@ +import { useEffect, useState } from "react" +import type { GraphThemeColors } from "../types" +import { DEFAULT_COLORS } from "../constants" + +function readCssVar(name: string, fallback: string): string { + if (typeof document === "undefined") return fallback + const val = getComputedStyle(document.documentElement) + .getPropertyValue(name) + .trim() + return val || fallback +} + +function resolveColors(): GraphThemeColors { + return { + bg: readCssVar("--graph-bg", DEFAULT_COLORS.bg), + docFill: readCssVar("--graph-doc-fill", DEFAULT_COLORS.docFill), + docStroke: readCssVar("--graph-doc-stroke", DEFAULT_COLORS.docStroke), + docInnerFill: readCssVar("--graph-doc-inner", DEFAULT_COLORS.docInnerFill), + memFill: readCssVar("--graph-mem-fill", DEFAULT_COLORS.memFill), + memFillHover: readCssVar("--graph-mem-fill-hover", DEFAULT_COLORS.memFillHover), + memStrokeDefault: readCssVar("--graph-mem-stroke", DEFAULT_COLORS.memStrokeDefault), + accent: readCssVar("--graph-accent", DEFAULT_COLORS.accent), + textPrimary: readCssVar("--graph-text-primary", DEFAULT_COLORS.textPrimary), + textSecondary: readCssVar("--graph-text-secondary", DEFAULT_COLORS.textSecondary), + textMuted: readCssVar("--graph-text-muted", DEFAULT_COLORS.textMuted), + edgeDocMemory: readCssVar("--graph-edge-doc-mem", DEFAULT_COLORS.edgeDocMemory), + edgeVersion: readCssVar("--graph-edge-version", DEFAULT_COLORS.edgeVersion), + edgeSimStrong: readCssVar("--graph-edge-sim-strong", DEFAULT_COLORS.edgeSimStrong), + edgeSimMedium: readCssVar("--graph-edge-sim-medium", DEFAULT_COLORS.edgeSimMedium), + edgeSimWeak: readCssVar("--graph-edge-sim-weak", DEFAULT_COLORS.edgeSimWeak), + edgeDocDoc: readCssVar("--graph-edge-doc-doc", DEFAULT_COLORS.edgeDocDoc), + memBorderForgotten: readCssVar("--graph-mem-border-forgotten", DEFAULT_COLORS.memBorderForgotten), + memBorderExpiring: readCssVar("--graph-mem-border-expiring", DEFAULT_COLORS.memBorderExpiring), + memBorderRecent: readCssVar("--graph-mem-border-recent", DEFAULT_COLORS.memBorderRecent), + glowColor: readCssVar("--graph-glow", DEFAULT_COLORS.glowColor), + iconColor: readCssVar("--graph-icon", DEFAULT_COLORS.iconColor), + popoverBg: readCssVar("--graph-popover-bg", DEFAULT_COLORS.popoverBg), + popoverBorder: readCssVar("--graph-popover-border", DEFAULT_COLORS.popoverBorder), + popoverTextPrimary: readCssVar("--graph-popover-text-primary", DEFAULT_COLORS.popoverTextPrimary), + popoverTextSecondary: readCssVar("--graph-popover-text-secondary", DEFAULT_COLORS.popoverTextSecondary), + popoverTextMuted: readCssVar("--graph-popover-text-muted", DEFAULT_COLORS.popoverTextMuted), + controlBg: readCssVar("--graph-control-bg", DEFAULT_COLORS.controlBg), + controlBorder: readCssVar("--graph-control-border", DEFAULT_COLORS.controlBorder), + } +} + +export function useGraphTheme(overrides?: Partial): GraphThemeColors { + const [colors, setColors] = useState(() => resolveColors()) + + useEffect(() => { + const update = () => setColors(resolveColors()) + + // Re-read on theme class change + const observer = new MutationObserver((mutations) => { + for (const m of mutations) { + if (m.type === "attributes" && m.attributeName === "class") { + update() + } + } + }) + observer.observe(document.documentElement, { attributes: true }) + + // Also listen for media query changes (system theme) + const mq = window.matchMedia("(prefers-color-scheme: dark)") + mq.addEventListener("change", update) + + return () => { + observer.disconnect() + mq.removeEventListener("change", update) + } + }, []) + + // Apply overrides if provided + if (overrides) { + return { ...colors, ...overrides } + } + return colors +} diff --git a/packages/memory-graph/src/hooks/use-mobile.ts b/packages/memory-graph/src/hooks/use-mobile.ts deleted file mode 100644 index 283bbb4c..00000000 --- a/packages/memory-graph/src/hooks/use-mobile.ts +++ /dev/null @@ -1,19 +0,0 @@ -import * as React from "react" - -const MOBILE_BREAKPOINT = 768 - -export function useIsMobile() { - const [isMobile, setIsMobile] = React.useState(undefined) - - React.useEffect(() => { - const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) - const onChange = () => { - setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) - } - mql.addEventListener("change", onChange) - setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) - return () => mql.removeEventListener("change", onChange) - }, []) - - return !!isMobile -} diff --git a/packages/memory-graph/src/index.tsx b/packages/memory-graph/src/index.tsx index f32f29bf..7db8714d 100644 --- a/packages/memory-graph/src/index.tsx +++ b/packages/memory-graph/src/index.tsx @@ -1,25 +1,41 @@ -// Export the main component +// Components export { MemoryGraph } from "./components/memory-graph" +export { GraphCanvas } from "./components/graph-canvas" -// Export style injector for manual use if needed -export { injectStyles } from "./lib/inject-styles" +// Hooks +export { useGraphData } from "./hooks/use-graph-data" +export { useGraphTheme } from "./hooks/use-graph-theme" -// Export types for consumers -export type { MemoryGraphProps } from "./types" +// Engine classes (for advanced usage) +export { ForceSimulation } from "./canvas/simulation" +export { ViewportState } from "./canvas/viewport" +export { SpatialIndex } from "./canvas/hit-test" +export { VersionChainIndex } from "./canvas/version-chain" +// Constants +export { DEFAULT_COLORS, FORCE_CONFIG, GRAPH_SETTINGS } from "./constants" + +// Types +export type { + MemoryGraphProps, + GraphNode, + GraphEdge, + GraphThemeColors, + GraphCanvasProps, + GraphApiDocument, + GraphApiMemory, + GraphApiEdge, + GraphViewportResponse, + GraphBoundsResponse, + GraphStatsResponse, + DocumentNodeData, + MemoryNodeData, + ChainEntry, +} from "./types" + +// Backward-compatible API types export type { DocumentWithMemories, MemoryEntry, DocumentsResponse, } from "./api-types" - -export type { - GraphNode, - GraphEdge, - MemoryRelation, -} from "./types" - -// Export theme system for custom theming -export { themeContract, defaultTheme } from "./styles/theme.css" -export { sprinkles } from "./styles/sprinkles.css" -export type { Sprinkles } from "./styles/sprinkles.css" diff --git a/packages/memory-graph/src/lib/inject-styles.ts b/packages/memory-graph/src/lib/inject-styles.ts deleted file mode 100644 index e50b4f5e..00000000 --- a/packages/memory-graph/src/lib/inject-styles.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Runtime CSS injection for universal bundler support - * The CSS content is injected by the build plugin - */ - -// This will be replaced by the build plugin with the actual CSS content -declare const __MEMORY_GRAPH_CSS__: string - -// Track injection state -let injected = false - -/** - * Inject memory-graph styles into the document head. - * Safe to call multiple times - will only inject once. - */ -export function injectStyles(): void { - // Only run in browser - if (typeof document === "undefined") return - - // Only inject once - if (injected) return - - // Check if already injected (e.g., by another instance) - if (document.querySelector("style[data-memory-graph]")) { - injected = true - return - } - - injected = true - - // Create and inject style element - const style = document.createElement("style") - style.setAttribute("data-memory-graph", "") - style.textContent = __MEMORY_GRAPH_CSS__ - document.head.appendChild(style) -} diff --git a/packages/memory-graph/src/lib/similarity.ts b/packages/memory-graph/src/lib/similarity.ts deleted file mode 100644 index 09d3a2cc..00000000 --- a/packages/memory-graph/src/lib/similarity.ts +++ /dev/null @@ -1,115 +0,0 @@ -// Utility functions for calculating semantic similarity between documents and memories - -/** - * Calculate cosine similarity between two normalized vectors (unit vectors) - * Since all embeddings in this system are normalized using normalizeEmbeddingFast, - * cosine similarity equals dot product for unit vectors. - */ -export const cosineSimilarity = ( - vectorA: number[], - vectorB: number[], -): number => { - if (vectorA.length !== vectorB.length) { - throw new Error("Vectors must have the same length") - } - - let dotProduct = 0 - - for (let i = 0; i < vectorA.length; i++) { - const vectorAi = vectorA[i] - const vectorBi = vectorB[i] - if ( - typeof vectorAi !== "number" || - typeof vectorBi !== "number" || - isNaN(vectorAi) || - isNaN(vectorBi) - ) { - throw new Error("Vectors must contain only numbers") - } - dotProduct += vectorAi * vectorBi - } - - return dotProduct -} - -/** - * Calculate semantic similarity between two documents - * Returns a value between 0 and 1, where 1 is most similar - */ -export const calculateSemanticSimilarity = ( - document1Embedding: number[] | null, - document2Embedding: number[] | null, -): number => { - // If we have both embeddings, use cosine similarity - if ( - document1Embedding && - document2Embedding && - document1Embedding.length > 0 && - document2Embedding.length > 0 - ) { - const similarity = cosineSimilarity(document1Embedding, document2Embedding) - // Convert from [-1, 1] to [0, 1] range - return similarity >= 0 ? similarity : 0 - } - - return 0 -} - -/** - * Calculate semantic similarity between a document and memory entry - * Returns a value between 0 and 1, where 1 is most similar - */ -export const calculateDocumentMemorySimilarity = ( - documentEmbedding: number[] | null, - memoryEmbedding: number[] | null, - relevanceScore?: number | null, -): number => { - // If we have both embeddings, use cosine similarity - if ( - documentEmbedding && - memoryEmbedding && - documentEmbedding.length > 0 && - memoryEmbedding.length > 0 - ) { - const similarity = cosineSimilarity(documentEmbedding, memoryEmbedding) - // Convert from [-1, 1] to [0, 1] range - return similarity >= 0 ? similarity : 0 - } - - // Fall back to relevance score from database (0-100 scale) - if (relevanceScore !== null && relevanceScore !== undefined) { - return Math.max(0, Math.min(1, relevanceScore / 100)) - } - - // Default similarity for connections without embeddings or relevance scores - return 0.5 -} - -/** - * Get visual properties for connection based on similarity - */ -export const getConnectionVisualProps = (similarity: number) => { - // Ensure similarity is between 0 and 1 - const normalizedSimilarity = Math.max(0, Math.min(1, similarity)) - - return { - opacity: Math.max(0, normalizedSimilarity), // 0 to 1 range - thickness: Math.max(1, normalizedSimilarity * 4), // 1 to 4 pixels - glow: normalizedSimilarity * 0.6, // Glow intensity - pulseDuration: 2000 + (1 - normalizedSimilarity) * 3000, // Faster pulse for higher similarity - } -} - -/** - * Generate magical color based on similarity and connection type - */ -export const getMagicalConnectionColor = ( - similarity: number, - hue = 220, -): string => { - const normalizedSimilarity = Math.max(0, Math.min(1, similarity)) - const saturation = 60 + normalizedSimilarity * 40 // 60% to 100% - const lightness = 40 + normalizedSimilarity * 30 // 40% to 70% - - return `hsl(${hue}, ${saturation}%, ${lightness}%)` -} diff --git a/packages/memory-graph/src/mock-data.ts b/packages/memory-graph/src/mock-data.ts new file mode 100644 index 00000000..c408b304 --- /dev/null +++ b/packages/memory-graph/src/mock-data.ts @@ -0,0 +1,341 @@ +import type { GraphApiDocument, GraphApiEdge, GraphApiMemory } from "./types" + +export interface MockGraphOptions { + documentCount?: number + memoriesPerDoc?: number | [number, number] + similarityEdgeRatio?: number + seed?: number +} + +/** + * Simple seeded PRNG (Mulberry32). + * Returns a function that produces values in [0, 1). + */ +function createSeededRandom(seed: number): () => number { + let s = seed | 0 + return () => { + s = (s + 0x6d2b79f5) | 0 + let t = Math.imul(s ^ (s >>> 15), 1 | s) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +const DOCUMENT_TYPES = [ + "webpage", + "pdf", + "md", + "doc", + "csv", + "json", + "notion", + "text", +] as const + +const TITLE_PREFIXES = [ + "Project Planning", + "API Documentation", + "Meeting Minutes", + "Architecture Design", + "Sprint Retrospective", + "User Research", + "Product Roadmap", + "Technical Spec", + "Onboarding Guide", + "Release Notes", + "Performance Report", + "Security Audit", + "Database Schema", + "Deployment Guide", + "Team Standup", + "Bug Triage", + "Feature Request", + "Code Review", + "Integration Test", + "Data Pipeline", +] + +const TITLE_SUFFIXES = [ + "Notes", + "Q3", + "Q4", + "v2", + "v3", + "Final", + "Draft", + "2024", + "2025", + "Summary", + "Overview", + "Detailed", + "Analysis", + "Report", + "Update", +] + +const MEMORY_TEMPLATES = [ + "The user prefers {preference} when working with {topic}", + "Key decision: {topic} will be implemented using {approach}", + "Important constraint: {topic} must handle {requirement}", + "The {component} module depends on {dependency} for {purpose}", + "Performance target: {metric} should be under {threshold}", + "The team agreed to {action} before {deadline}", + "User feedback indicates {observation} about {feature}", + "Best practice: always {action} when {condition}", + "Known issue: {component} has {issue} under {condition}", + "Migration plan: move from {oldThing} to {newThing} by {deadline}", +] + +const TEMPLATE_FILLS: Record = { + preference: ["TypeScript", "dark mode", "keyboard shortcuts", "minimal UI", "detailed logs", "async patterns"], + topic: ["authentication", "caching", "data sync", "graph rendering", "search indexing", "memory management"], + approach: ["event sourcing", "CQRS", "microservices", "edge functions", "WebSocket streams", "batch processing"], + requirement: ["10k concurrent users", "sub-100ms latency", "offline mode", "real-time updates", "GDPR compliance"], + component: ["auth", "graph", "search", "storage", "notification", "analytics"], + dependency: ["Redis", "PostgreSQL", "S3", "Cloudflare Workers", "D3-force", "WebGL"], + purpose: ["caching", "persistence", "rendering", "indexing", "scheduling", "routing"], + metric: ["p99 latency", "time to interactive", "memory usage", "CPU utilization", "bundle size"], + threshold: ["200ms", "500ms", "50MB", "3 seconds", "100KB", "1GB"], + action: ["run benchmarks", "update dependencies", "write migration scripts", "review PRs", "deploy to staging"], + deadline: ["end of sprint", "next release", "Q4", "Friday", "the demo"], + observation: ["confusion", "delight", "frustration", "efficiency gains", "unexpected usage patterns"], + feature: ["the graph view", "search filters", "memory chains", "document upload", "sharing"], + condition: ["high load", "cold start", "large datasets", "slow networks", "concurrent edits"], + issue: ["memory leaks", "race conditions", "stale data", "layout thrashing", "connection drops"], + oldThing: ["REST API", "MongoDB", "class components", "Webpack", "manual testing"], + newThing: ["GraphQL", "PostgreSQL", "hooks", "Vite", "automated CI"], +} + +const SUMMARY_TEMPLATES = [ + "Documentation covering {topic} implementation details and best practices for the team.", + "Notes from the recent discussion about {topic} and the decisions made regarding {approach}.", + "Technical overview of the {component} system, including architecture and key design choices.", + "Analysis of {metric} performance data, with recommendations for optimization.", + "Guide for setting up and configuring {component} in development and production environments.", + "Collection of user feedback and research findings related to {feature}.", +] + +function fillTemplate(template: string, random: () => number): string { + return template.replace(/{(\w+)}/g, (_match, key: string) => { + const options = TEMPLATE_FILLS[key] + if (!options || options.length === 0) return key + return options[Math.floor(random() * options.length)] + }) +} + +function generateTitle(random: () => number): string { + const prefix = TITLE_PREFIXES[Math.floor(random() * TITLE_PREFIXES.length)] + if (random() > 0.5) { + const suffix = TITLE_SUFFIXES[Math.floor(random() * TITLE_SUFFIXES.length)] + return `${prefix} ${suffix}` + } + return prefix +} + +function generateSummary(random: () => number): string | null { + if (random() < 0.15) return null + const template = SUMMARY_TEMPLATES[Math.floor(random() * SUMMARY_TEMPLATES.length)] + return fillTemplate(template, random) +} + +function generateMemoryContent(random: () => number): string { + const template = MEMORY_TEMPLATES[Math.floor(random() * MEMORY_TEMPLATES.length)] + return fillTemplate(template, random) +} + +function generateISODate(random: () => number, baseMs: number, rangeMs: number): string { + const ms = baseMs + Math.floor(random() * rangeMs) + return new Date(ms).toISOString() +} + +export function generateMockGraphData(options: MockGraphOptions = {}): { + documents: GraphApiDocument[] + edges: GraphApiEdge[] +} { + const { + documentCount = 100, + memoriesPerDoc = [2, 6] as [number, number], + similarityEdgeRatio = 0.1, + seed = 42, + } = options + + const random = createSeededRandom(seed) + + // Time range: roughly Jan 2024 to Jun 2025 + const baseTime = new Date("2024-01-01T00:00:00Z").getTime() + const timeRange = 1000 * 60 * 60 * 24 * 540 // ~540 days + + const spaceIds = ["space-default", "space-work", "space-personal", "space-research"] + + const documents: GraphApiDocument[] = [] + + for (let d = 0; d < documentCount; d++) { + const docId = `doc-${String(d).padStart(4, "0")}` + const docCreatedAt = generateISODate(random, baseTime, timeRange) + const docUpdatedAt = generateISODate( + random, + new Date(docCreatedAt).getTime(), + 1000 * 60 * 60 * 24 * 30, // up to 30 days after creation + ) + + // Determine memory count + let memCount: number + if (typeof memoriesPerDoc === "number") { + memCount = memoriesPerDoc + } else { + const [min, max] = memoriesPerDoc + memCount = min + Math.floor(random() * (max - min + 1)) + } + + const spaceId = spaceIds[Math.floor(random() * spaceIds.length)] + const memories: GraphApiMemory[] = [] + + // Decide if this document has a version chain (30% chance) + const hasVersionChain = random() < 0.3 && memCount >= 3 + let chainRootId: string | null = null + let chainPrevId: string | null = null + let chainVersion = 1 + + for (let m = 0; m < memCount; m++) { + const memId = `mem-${docId}-${String(m).padStart(3, "0")}` + const memCreatedAt = generateISODate(random, new Date(docCreatedAt).getTime(), 1000 * 60 * 60 * 24 * 14) + const memUpdatedAt = generateISODate( + random, + new Date(memCreatedAt).getTime(), + 1000 * 60 * 60 * 24 * 7, + ) + + let parentMemoryId: string | null = null + let rootMemoryId: string | null = null + let version = 1 + let isLatest = true + let isForgotten = false + + // Build version chain for first few memories if applicable + if (hasVersionChain && m < 3) { + if (m === 0) { + // Root of the chain + chainRootId = memId + chainPrevId = memId + chainVersion = 1 + version = 1 + isLatest = false + isForgotten = random() < 0.3 + } else { + parentMemoryId = chainPrevId + rootMemoryId = chainRootId + chainVersion++ + version = chainVersion + chainPrevId = memId + isLatest = m === 2 // last in the 3-memory chain + isForgotten = !isLatest && random() < 0.2 + } + } else { + // Standalone memory + isForgotten = random() < 0.1 + isLatest = true + version = 1 + } + + // Determine forgetAfter (for some non-forgotten memories, set a future expiry) + let forgetAfter: string | null = null + let forgetReason: string | null = null + if (isForgotten) { + forgetReason = random() < 0.5 ? "superseded" : "user-requested" + } else if (random() < 0.1) { + // Expiring memory + const expiryMs = Date.now() + Math.floor(random() * 1000 * 60 * 60 * 24 * 30) + forgetAfter = new Date(expiryMs).toISOString() + } + + memories.push({ + id: memId, + memory: generateMemoryContent(random), + isStatic: random() < 0.15, + spaceId, + isLatest, + isForgotten, + forgetAfter, + forgetReason, + version, + parentMemoryId, + rootMemoryId, + createdAt: memCreatedAt, + updatedAt: memUpdatedAt, + }) + } + + // Position documents spread across a 1000x1000 space + const x = random() * 1000 + const y = random() * 1000 + + documents.push({ + id: docId, + title: generateTitle(random), + summary: generateSummary(random), + documentType: DOCUMENT_TYPES[Math.floor(random() * DOCUMENT_TYPES.length)], + createdAt: docCreatedAt, + updatedAt: docUpdatedAt, + x, + y, + memories, + }) + } + + // Generate similarity edges between random document pairs + const edges: GraphApiEdge[] = [] + const totalPossiblePairs = (documentCount * (documentCount - 1)) / 2 + const targetEdgeCount = Math.max(0, Math.floor(totalPossiblePairs * similarityEdgeRatio)) + + // Use a set to avoid duplicate pairs + const edgeSet = new Set() + + // For small document counts, iterate all pairs; for large, sample randomly + if (documentCount <= 50 || targetEdgeCount > totalPossiblePairs * 0.5) { + // Iterate all pairs and include based on probability + for (let i = 0; i < documentCount; i++) { + for (let j = i + 1; j < documentCount; j++) { + if (random() < similarityEdgeRatio) { + const sourceId = documents[i].id + const targetId = documents[j].id + const key = `${sourceId}:${targetId}` + if (!edgeSet.has(key)) { + edgeSet.add(key) + // Similarity weighted towards medium-high values + const similarity = 0.3 + random() * 0.7 + edges.push({ + source: sourceId, + target: targetId, + similarity: Math.round(similarity * 1000) / 1000, + }) + } + } + } + } + } else { + // Random sampling for large document counts + let attempts = 0 + const maxAttempts = targetEdgeCount * 5 + while (edges.length < targetEdgeCount && attempts < maxAttempts) { + attempts++ + const i = Math.floor(random() * documentCount) + let j = Math.floor(random() * documentCount) + if (i === j) continue + const sourceIdx = Math.min(i, j) + const targetIdx = Math.max(i, j) + const sourceId = documents[sourceIdx].id + const targetId = documents[targetIdx].id + const key = `${sourceId}:${targetId}` + if (edgeSet.has(key)) continue + edgeSet.add(key) + const similarity = 0.3 + random() * 0.7 + edges.push({ + source: sourceId, + target: targetId, + similarity: Math.round(similarity * 1000) / 1000, + }) + } + } + + return { documents, edges } +} diff --git a/packages/memory-graph/src/styles/animations.css.ts b/packages/memory-graph/src/styles/animations.css.ts deleted file mode 100644 index 53444052..00000000 --- a/packages/memory-graph/src/styles/animations.css.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { keyframes } from "@vanilla-extract/css" - -/** - * Animation keyframes - * Used throughout the component library for consistent motion - */ - -export const fadeIn = keyframes({ - from: { opacity: 0 }, - to: { opacity: 1 }, -}) - -export const fadeOut = keyframes({ - from: { opacity: 1 }, - to: { opacity: 0 }, -}) - -export const slideInFromRight = keyframes({ - from: { - transform: "translateX(100%)", - opacity: 0, - }, - to: { - transform: "translateX(0)", - opacity: 1, - }, -}) - -export const slideInFromLeft = keyframes({ - from: { - transform: "translateX(-100%)", - opacity: 0, - }, - to: { - transform: "translateX(0)", - opacity: 1, - }, -}) - -export const slideInFromTop = keyframes({ - from: { - transform: "translateY(-100%)", - opacity: 0, - }, - to: { - transform: "translateY(0)", - opacity: 1, - }, -}) - -export const slideInFromBottom = keyframes({ - from: { - transform: "translateY(100%)", - opacity: 0, - }, - to: { - transform: "translateY(0)", - opacity: 1, - }, -}) - -export const spin = keyframes({ - from: { transform: "rotate(0deg)" }, - to: { transform: "rotate(360deg)" }, -}) - -export const pulse = keyframes({ - "0%, 100%": { - opacity: 1, - }, - "50%": { - opacity: 0.5, - }, -}) - -export const bounce = keyframes({ - "0%, 100%": { - transform: "translateY(-25%)", - animationTimingFunction: "cubic-bezier(0.8, 0, 1, 1)", - }, - "50%": { - transform: "translateY(0)", - animationTimingFunction: "cubic-bezier(0, 0, 0.2, 1)", - }, -}) - -export const scaleIn = keyframes({ - from: { - transform: "scale(0.95)", - opacity: 0, - }, - to: { - transform: "scale(1)", - opacity: 1, - }, -}) - -export const scaleOut = keyframes({ - from: { - transform: "scale(1)", - opacity: 1, - }, - to: { - transform: "scale(0.95)", - opacity: 0, - }, -}) - -export const shimmer = keyframes({ - "0%": { - backgroundPosition: "-1000px 0", - }, - "100%": { - backgroundPosition: "1000px 0", - }, -}) diff --git a/packages/memory-graph/src/styles/effects.css.ts b/packages/memory-graph/src/styles/effects.css.ts deleted file mode 100644 index 306f912c..00000000 --- a/packages/memory-graph/src/styles/effects.css.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { style, styleVariants } from "@vanilla-extract/css" -import { themeContract } from "./theme.css" - -/** - * Base glass-morphism effect - * Provides the signature frosted glass look - */ -const glassBase = style({ - backdropFilter: "blur(12px)", - WebkitBackdropFilter: "blur(12px)", - border: `1px solid ${themeContract.colors.document.border}`, - borderRadius: themeContract.radii.lg, -}) - -/** - * Glass effect variants - */ -export const glass = styleVariants({ - /** - * Light glass effect - subtle background - */ - light: [ - glassBase, - { - background: "rgba(255, 255, 255, 0.05)", - }, - ], - - /** - * Medium glass effect - more visible - */ - medium: [ - glassBase, - { - background: "rgba(255, 255, 255, 0.08)", - }, - ], - - /** - * Dark glass effect - prominent - */ - dark: [ - glassBase, - { - background: "rgba(15, 20, 25, 0.8)", - backdropFilter: "blur(20px)", - WebkitBackdropFilter: "blur(20px)", - }, - ], -}) - -/** - * Glass panel styles for larger containers - */ -export const glassPanel = styleVariants({ - default: { - background: "rgba(15, 20, 25, 0.8)", - backdropFilter: "blur(20px)", - WebkitBackdropFilter: "blur(20px)", - border: `1px solid ${themeContract.colors.document.border}`, - borderRadius: themeContract.radii.xl, - }, - bordered: { - background: "rgba(15, 20, 25, 0.8)", - backdropFilter: "blur(20px)", - WebkitBackdropFilter: "blur(20px)", - border: `2px solid ${themeContract.colors.document.border}`, - borderRadius: themeContract.radii.xl, - }, -}) - -/** - * Focus ring styles for accessibility - */ -export const focusRing = style({ - outline: "none", - selectors: { - "&:focus-visible": { - outline: `2px solid ${themeContract.colors.accent.primary}`, - outlineOffset: "2px", - }, - }, -}) - -/** - * Transition presets - */ -export const transition = styleVariants({ - fast: { - transition: themeContract.transitions.fast, - }, - normal: { - transition: themeContract.transitions.normal, - }, - slow: { - transition: themeContract.transitions.slow, - }, - all: { - transition: `all ${themeContract.transitions.normal}`, - }, - colors: { - transition: `background-color ${themeContract.transitions.normal}, color ${themeContract.transitions.normal}, border-color ${themeContract.transitions.normal}`, - }, - transform: { - transition: `transform ${themeContract.transitions.normal}`, - }, -}) - -/** - * Hover glow effect - */ -export const hoverGlow = style({ - position: "relative", - transition: themeContract.transitions.normal, - selectors: { - "&:hover": { - boxShadow: `0 0 20px ${themeContract.colors.document.glow}`, - }, - }, -}) diff --git a/packages/memory-graph/src/styles/global.css.ts b/packages/memory-graph/src/styles/global.css.ts deleted file mode 100644 index ec08d959..00000000 --- a/packages/memory-graph/src/styles/global.css.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { globalStyle } from "@vanilla-extract/css" - -/** - * Global CSS reset and base styles - */ - -// Box sizing reset -globalStyle("*, *::before, *::after", { - boxSizing: "border-box", -}) - -// Remove default margins -globalStyle("body, h1, h2, h3, h4, h5, h6, p, figure, blockquote, dl, dd", { - margin: 0, -}) - -// Remove list styles -globalStyle("ul[role='list'], ol[role='list']", { - listStyle: "none", -}) - -// Core body defaults -globalStyle("html, body", { - height: "100%", -}) - -globalStyle("body", { - lineHeight: 1.5, - WebkitFontSmoothing: "antialiased", - MozOsxFontSmoothing: "grayscale", -}) - -// Typography defaults -globalStyle("h1, h2, h3, h4, h5, h6", { - fontWeight: 500, - lineHeight: 1.25, -}) - -// Inherit fonts for inputs and buttons -globalStyle("input, button, textarea, select", { - font: "inherit", -}) - -// Remove default button styles -globalStyle("button", { - background: "none", - border: "none", - padding: 0, - cursor: "pointer", -}) - -// Improve media defaults -globalStyle("img, picture, video, canvas, svg", { - display: "block", - maxWidth: "100%", -}) - -// Remove built-in form typography styles -globalStyle("input, button, textarea, select", { - font: "inherit", -}) - -// Avoid text overflows -globalStyle("p, h1, h2, h3, h4, h5, h6", { - overflowWrap: "break-word", -}) - -// Improve text rendering -globalStyle("#root, #__next", { - isolation: "isolate", -}) diff --git a/packages/memory-graph/src/styles/index.ts b/packages/memory-graph/src/styles/index.ts deleted file mode 100644 index 15dbd1e9..00000000 --- a/packages/memory-graph/src/styles/index.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Style system exports - * Provides theme, sprinkles, animations, and effects for the memory-graph package - */ - -// Import global styles (side effect) -import "./global.css" - -// Theme -export { themeContract, defaultTheme } from "./theme.css" - -// Sprinkles utilities -export { sprinkles } from "./sprinkles.css" -export type { Sprinkles } from "./sprinkles.css" - -// Animations -export * as animations from "./animations.css" - -// Glass-morphism effects -export { - glass, - glassPanel, - focusRing, - transition, - hoverGlow, -} from "./effects.css" diff --git a/packages/memory-graph/src/styles/sprinkles.css.ts b/packages/memory-graph/src/styles/sprinkles.css.ts deleted file mode 100644 index 6cb06261..00000000 --- a/packages/memory-graph/src/styles/sprinkles.css.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { defineProperties, createSprinkles } from "@vanilla-extract/sprinkles" -import { themeContract } from "./theme.css" - -/** - * Responsive conditions for mobile-first design - */ -const responsiveProperties = defineProperties({ - conditions: { - mobile: {}, - tablet: { "@media": "screen and (min-width: 768px)" }, - desktop: { "@media": "screen and (min-width: 1024px)" }, - }, - defaultCondition: "mobile", - properties: { - // Display - display: ["none", "flex", "block", "inline", "inline-flex", "grid"], - - // Flexbox - flexDirection: ["row", "column", "row-reverse", "column-reverse"], - justifyContent: [ - "stretch", - "flex-start", - "center", - "flex-end", - "space-between", - "space-around", - "space-evenly", - ], - alignItems: ["stretch", "flex-start", "center", "flex-end", "baseline"], - flexWrap: ["nowrap", "wrap", "wrap-reverse"], - gap: themeContract.space, - - // Spacing - padding: themeContract.space, - paddingTop: themeContract.space, - paddingBottom: themeContract.space, - paddingLeft: themeContract.space, - paddingRight: themeContract.space, - margin: themeContract.space, - marginTop: themeContract.space, - marginBottom: themeContract.space, - marginLeft: themeContract.space, - marginRight: themeContract.space, - - // Sizing - width: { - auto: "auto", - full: "100%", - screen: "100vw", - min: "min-content", - max: "max-content", - fit: "fit-content", - }, - height: { - auto: "auto", - full: "100%", - screen: "100vh", - min: "min-content", - max: "max-content", - fit: "fit-content", - }, - minWidth: { - 0: "0", - full: "100%", - min: "min-content", - max: "max-content", - fit: "fit-content", - }, - minHeight: { - 0: "0", - full: "100%", - screen: "100vh", - }, - maxWidth: { - none: "none", - full: "100%", - min: "min-content", - max: "max-content", - fit: "fit-content", - }, - maxHeight: { - none: "none", - full: "100%", - screen: "100vh", - }, - - // Position - position: ["static", "relative", "absolute", "fixed", "sticky"], - top: themeContract.space, - bottom: themeContract.space, - left: themeContract.space, - right: themeContract.space, - inset: themeContract.space, - - // Border radius - borderRadius: themeContract.radii, - borderTopLeftRadius: themeContract.radii, - borderTopRightRadius: themeContract.radii, - borderBottomLeftRadius: themeContract.radii, - borderBottomRightRadius: themeContract.radii, - - // Text - fontSize: themeContract.typography.fontSize, - fontWeight: themeContract.typography.fontWeight, - lineHeight: themeContract.typography.lineHeight, - textAlign: ["left", "center", "right", "justify"], - - // Overflow - overflow: ["visible", "hidden", "scroll", "auto"], - overflowX: ["visible", "hidden", "scroll", "auto"], - overflowY: ["visible", "hidden", "scroll", "auto"], - - // Z-index - zIndex: themeContract.zIndex, - - // Cursor - cursor: ["auto", "pointer", "not-allowed", "grab", "grabbing"], - - // Pointer events - pointerEvents: ["auto", "none"], - - // User select - userSelect: ["auto", "none", "text", "all"], - }, -}) - -/** - * Color properties (non-responsive) - */ -const colorProperties = defineProperties({ - properties: { - color: { - primary: themeContract.colors.text.primary, - secondary: themeContract.colors.text.secondary, - muted: themeContract.colors.text.muted, - }, - backgroundColor: { - transparent: "transparent", - primary: themeContract.colors.background.primary, - secondary: themeContract.colors.background.secondary, - accent: themeContract.colors.background.accent, - documentPrimary: themeContract.colors.document.primary, - documentSecondary: themeContract.colors.document.secondary, - documentAccent: themeContract.colors.document.accent, - memoryPrimary: themeContract.colors.memory.primary, - memorySecondary: themeContract.colors.memory.secondary, - memoryAccent: themeContract.colors.memory.accent, - }, - borderColor: { - transparent: "transparent", - documentBorder: themeContract.colors.document.border, - memoryBorder: themeContract.colors.memory.border, - }, - }, -}) - -/** - * Border properties - */ -const borderProperties = defineProperties({ - properties: { - borderWidth: { - 0: "0", - 1: "1px", - 2: "2px", - 4: "4px", - }, - borderStyle: ["none", "solid", "dashed", "dotted"], - }, -}) - -/** - * Opacity properties - */ -const opacityProperties = defineProperties({ - properties: { - opacity: { - 0: "0", - 10: "0.1", - 20: "0.2", - 30: "0.3", - 40: "0.4", - 50: "0.5", - 60: "0.6", - 70: "0.7", - 80: "0.8", - 90: "0.9", - 100: "1", - }, - }, -}) - -/** - * Combined sprinkles system - * Provides Tailwind-like utility classes with full type safety - */ -export const sprinkles = createSprinkles( - responsiveProperties, - colorProperties, - borderProperties, - opacityProperties, -) - -export type Sprinkles = Parameters[0] diff --git a/packages/memory-graph/src/styles/theme.css.ts b/packages/memory-graph/src/styles/theme.css.ts deleted file mode 100644 index 0f3f5fea..00000000 --- a/packages/memory-graph/src/styles/theme.css.ts +++ /dev/null @@ -1,245 +0,0 @@ -import { createTheme, createThemeContract } from "@vanilla-extract/css" - -/** - * Theme contract defines the structure of the design system. - * Consumers can provide custom themes that match this contract. - */ -export const themeContract = createThemeContract({ - colors: { - // Background colors - background: { - primary: null, - secondary: null, - accent: null, - }, - // Document node colors - document: { - primary: null, - secondary: null, - accent: null, - border: null, - glow: null, - }, - // Memory node colors - memory: { - primary: null, - secondary: null, - accent: null, - border: null, - glow: null, - }, - // Connection strengths - connection: { - weak: null, - memory: null, - medium: null, - strong: null, - }, - // Text colors - text: { - primary: null, - secondary: null, - muted: null, - }, - // Accent colors - accent: { - primary: null, - secondary: null, - glow: null, - amber: null, - emerald: null, - }, - // Status indicators - status: { - forgotten: null, - expiring: null, - new: null, - }, - // Relation types - relations: { - updates: null, - extends: null, - derives: null, - }, - }, - space: { - 0: null, - 1: null, - 2: null, - 3: null, - 4: null, - 5: null, - 6: null, - 8: null, - 10: null, - 12: null, - 16: null, - 20: null, - 24: null, - 32: null, - 40: null, - 48: null, - 64: null, - }, - radii: { - none: null, - sm: null, - md: null, - lg: null, - xl: null, - "2xl": null, - full: null, - }, - typography: { - fontSize: { - xs: null, - sm: null, - base: null, - lg: null, - xl: null, - "2xl": null, - "3xl": null, - }, - fontWeight: { - normal: null, - medium: null, - semibold: null, - bold: null, - }, - lineHeight: { - tight: null, - normal: null, - relaxed: null, - }, - }, - transitions: { - fast: null, - normal: null, - slow: null, - }, - zIndex: { - base: null, - dropdown: null, - overlay: null, - modal: null, - tooltip: null, - }, -}) - -/** - * Default theme implementation based on the original constants.ts colors - * This provides the glass-morphism dark theme used throughout the app. - */ -export const defaultTheme = createTheme(themeContract, { - colors: { - background: { - primary: "#0f1419", // Deep dark blue-gray - secondary: "#1a1f29", // Slightly lighter - accent: "#252a35", // Card backgrounds - }, - document: { - primary: "rgba(255, 255, 255, 0.06)", // Subtle glass white - secondary: "rgba(255, 255, 255, 0.12)", // More visible - accent: "rgba(255, 255, 255, 0.18)", // Hover state - border: "rgba(255, 255, 255, 0.25)", // Sharp borders - glow: "rgba(147, 197, 253, 0.4)", // Blue glow for interaction - }, - memory: { - primary: "rgba(147, 197, 253, 0.08)", // Subtle glass blue - secondary: "rgba(147, 197, 253, 0.16)", // More visible - accent: "rgba(147, 197, 253, 0.24)", // Hover state - border: "rgba(147, 197, 253, 0.35)", // Sharp borders - glow: "rgba(147, 197, 253, 0.5)", // Blue glow for interaction - }, - connection: { - weak: "rgba(148, 163, 184, 0)", // Very subtle - memory: "rgba(148, 163, 184, 0.3)", // Very subtle - medium: "rgba(148, 163, 184, 0.125)", // Medium visibility - strong: "rgba(148, 163, 184, 0.4)", // Strong connection - }, - text: { - primary: "#ffffff", // Pure white - secondary: "#e2e8f0", // Light gray - muted: "#94a3b8", // Medium gray - }, - accent: { - primary: "rgba(59, 130, 246, 0.7)", // Clean blue - secondary: "rgba(99, 102, 241, 0.6)", // Clean purple - glow: "rgba(147, 197, 253, 0.6)", // Subtle glow - amber: "rgba(251, 165, 36, 0.8)", // Amber for expiring - emerald: "rgba(16, 185, 129, 0.4)", // Emerald for new - }, - status: { - forgotten: "rgba(220, 38, 38, 0.15)", // Red for forgotten - expiring: "rgba(251, 165, 36, 0.8)", // Amber for expiring soon - new: "rgba(16, 185, 129, 0.4)", // Emerald for new memories - }, - relations: { - updates: "rgba(147, 77, 253, 0.5)", // purple - extends: "rgba(16, 185, 129, 0.5)", // green - derives: "rgba(147, 197, 253, 0.5)", // blue - }, - }, - space: { - 0: "0", - 1: "0.25rem", // 4px - 2: "0.5rem", // 8px - 3: "0.75rem", // 12px - 4: "1rem", // 16px - 5: "1.25rem", // 20px - 6: "1.5rem", // 24px - 8: "2rem", // 32px - 10: "2.5rem", // 40px - 12: "3rem", // 48px - 16: "4rem", // 64px - 20: "5rem", // 80px - 24: "6rem", // 96px - 32: "8rem", // 128px - 40: "10rem", // 160px - 48: "12rem", // 192px - 64: "16rem", // 256px - }, - radii: { - none: "0", - sm: "0.125rem", // 2px - md: "0.375rem", // 6px - lg: "0.5rem", // 8px - xl: "0.75rem", // 12px - "2xl": "1rem", // 16px - full: "9999px", - }, - typography: { - fontSize: { - xs: "0.75rem", // 12px - sm: "0.875rem", // 14px - base: "1rem", // 16px - lg: "1.125rem", // 18px - xl: "1.25rem", // 20px - "2xl": "1.5rem", // 24px - "3xl": "1.875rem", // 30px - }, - fontWeight: { - normal: "400", - medium: "500", - semibold: "600", - bold: "700", - }, - lineHeight: { - tight: "1.25", - normal: "1.5", - relaxed: "1.75", - }, - }, - transitions: { - fast: "150ms ease-in-out", - normal: "200ms ease-in-out", - slow: "300ms ease-in-out", - }, - zIndex: { - base: "0", - dropdown: "10", - overlay: "20", - modal: "30", - tooltip: "40", - }, -}) diff --git a/packages/memory-graph/src/types.ts b/packages/memory-graph/src/types.ts index f470223b..17f85ef9 100644 --- a/packages/memory-graph/src/types.ts +++ b/packages/memory-graph/src/types.ts @@ -1,148 +1,221 @@ -import type { - DocumentsResponse, - DocumentWithMemories, - MemoryEntry, -} from "./api-types" +// Graph API types matching backend response -// Re-export for convenience -export type { DocumentsResponse, DocumentWithMemories, MemoryEntry } +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 + y: number + memories: GraphApiMemory[] +} + +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 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: DocumentWithMemories | MemoryEntry + data: DocumentNodeData | MemoryNodeData size: number - color: string + borderColor: string isHovered: boolean isDragging: boolean // D3-force simulation properties - vx?: number // velocity x - vy?: number // velocity y - fx?: number | null // fixed x position (for pinning during drag) - fy?: number | null // fixed y position (for pinning during drag) + vx?: number + vy?: number + fx?: number | null + fy?: number | null } -export type MemoryRelation = "updates" | "extends" | "derives" - export interface GraphEdge { id: string - // D3-force mutates source/target from string IDs to node references during simulation source: string | GraphNode target: string | GraphNode similarity: number visualProps: { opacity: number thickness: number - glow: number - pulseDuration: number } - color: string - edgeType: "doc-memory" | "doc-doc" | "version" - relationType?: MemoryRelation + edgeType: "doc-memory" | "similarity" | "version" } -export interface SpacesDropdownProps { - selectedSpace: string - availableSpaces: string[] - spaceMemoryCounts: Record - onSpaceChange: (space: string) => void -} - -export interface NodeDetailPanelProps { - node: GraphNode | null - onClose: () => void - variant?: "console" | "consumer" +export interface GraphThemeColors { + bg: string + docFill: string + docStroke: string + docInnerFill: string + memFill: string + memFillHover: string + memStrokeDefault: string + accent: string + textPrimary: string + textSecondary: string + textMuted: string + edgeDocMemory: string + edgeVersion: string + edgeSimStrong: string + edgeSimMedium: string + edgeSimWeak: string + edgeDocDoc: string + memBorderForgotten: string + memBorderExpiring: string + memBorderRecent: string + glowColor: string + iconColor: string + popoverBg: string + popoverBorder: string + popoverTextPrimary: string + popoverTextSecondary: string + popoverTextMuted: string + controlBg: string + controlBorder: string } export interface GraphCanvasProps { nodes: GraphNode[] edges: GraphEdge[] - panX: number - panY: number - zoom: number width: number height: number - onNodeHover: (nodeId: string | null) => void - onNodeClick: (nodeId: string) => void - onNodeDragStart: (nodeId: string, e: React.MouseEvent) => void - onNodeDragMove: (e: React.MouseEvent) => void - onNodeDragEnd: () => void - onPanStart: (e: React.MouseEvent) => void - onPanMove: (e: React.MouseEvent) => void - onPanEnd: () => void - onWheel: (e: React.WheelEvent) => void - onDoubleClick: (e: React.MouseEvent) => void - onTouchStart?: (e: React.TouchEvent) => void - onTouchMove?: (e: React.TouchEvent) => void - onTouchEnd?: (e: React.TouchEvent) => void - draggingNodeId: string | null - // Optional list of document IDs (customId or internal id) to highlight + colors: GraphThemeColors highlightDocumentIds?: string[] - // Physics simulation state - isSimulationActive?: boolean - // Selected node ID - dims all other nodes and edges 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 + simulation?: import("./canvas/simulation").ForceSimulation + viewportRef?: React.RefObject< + import("./canvas/viewport").ViewportState | null + > } export interface MemoryGraphProps { - /** The documents to display in the graph */ - documents: DocumentWithMemories[] - /** Whether the initial data is loading */ + /** Documents to display - pass this for direct data mode */ + documents?: GraphApiDocument[] + /** API edges between documents */ + apiEdges?: GraphApiEdge[] + /** Whether data is loading */ isLoading?: boolean - /** Error that occurred during data fetching */ + /** Error from data fetching */ error?: Error | null - /** Optional children to render when no documents exist */ + /** Children to render when no documents */ children?: React.ReactNode - /** Whether more data is being loaded (for pagination) */ - isLoadingMore?: boolean - /** Total number of documents loaded */ - totalLoaded?: number - /** Whether there are more documents to load */ - hasMore?: boolean - /** Callback to load more documents (for pagination) */ - loadMoreDocuments?: () => Promise - /** Show/hide the spaces filter dropdown */ - showSpacesSelector?: boolean - /** Visual variant - "console" for full view, "consumer" for embedded */ + /** Visual variant */ variant?: "console" | "consumer" - /** Optional ID for the legend component */ + /** Optional legend ID */ legendId?: string - /** Document IDs to highlight in the graph */ + /** Document IDs to highlight */ highlightDocumentIds?: string[] - /** Whether highlights are currently visible */ + /** Whether highlights are visible */ highlightsVisible?: boolean - /** Pixels occluded on the right side of the viewport */ - occludedRightPx?: number - /** Whether to auto-load more documents based on viewport visibility */ - autoLoadOnViewport?: boolean - /** Theme class name to apply */ - themeClassName?: string - - // External space control - /** Currently selected space (for controlled component) */ - selectedSpace?: string - /** Callback when space selection changes (for controlled component) */ - onSpaceChange?: (spaceId: string) => void - - // Memory limit control - /** Maximum number of memories to display per document when a space is selected */ - memoryLimit?: number - /** Maximum total number of memory nodes to display across all documents (default: unlimited) */ + /** Container tags for filtering (used by apps with their own API hooks) */ + containerTags?: string[] + /** Specific document IDs to show */ + documentIds?: string[] + /** Max nodes to display */ maxNodes?: number - - // Feature flags - /** Enable experimental features */ - isExperimental?: boolean - - // Slideshow control - /** Whether slideshow mode is currently active */ + /** Show FPS counter overlay */ + showFps?: boolean + /** Slideshow mode */ isSlideshowActive?: boolean - /** Callback when slideshow selects a new node (provides node ID) */ onSlideshowNodeChange?: (nodeId: string | null) => void - /** Callback when user clicks outside during slideshow (to stop it) */ onSlideshowStop?: () => void + /** Canvas ref for external access (e.g. screenshot export) */ + canvasRef?: React.RefObject + /** Custom theme colors - if not provided, reads from CSS variables */ + colors?: GraphThemeColors + /** Total count for loading indicator */ + totalCount?: number +} + +export interface ChainEntry { + id: string + version: number + memory: string + isForgotten: boolean + isLatest: boolean } export interface LegendProps { @@ -160,9 +233,5 @@ export interface LoadingIndicatorProps { variant?: "console" | "consumer" } -export interface ControlsProps { - onZoomIn: () => void - onZoomOut: () => void - onResetView: () => void - variant?: "console" | "consumer" -} +// Re-export api-types for backward compatibility +export type { DocumentWithMemories, MemoryEntry, DocumentsResponse } from "./api-types" diff --git a/packages/memory-graph/src/ui/badge.css.ts b/packages/memory-graph/src/ui/badge.css.ts deleted file mode 100644 index 0f8cbc92..00000000 --- a/packages/memory-graph/src/ui/badge.css.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { recipe, type RecipeVariants } from "@vanilla-extract/recipes" -import { style, globalStyle } from "@vanilla-extract/css" -import { themeContract } from "../styles/theme.css" - -/** - * Base styles for SVG icons inside badges - */ -export const badgeIcon = style({ - width: "0.75rem", - height: "0.75rem", - pointerEvents: "none", -}) - -/** - * Badge recipe with variants - * Replaces CVA-based badge variants with vanilla-extract recipes - */ -const badgeBase = style({ - display: "inline-flex", - alignItems: "center", - justifyContent: "center", - borderRadius: themeContract.radii.md, - border: "1px solid", - paddingLeft: themeContract.space[2], - paddingRight: themeContract.space[2], - paddingTop: "0.125rem", - paddingBottom: "0.125rem", - fontSize: themeContract.typography.fontSize.xs, - fontWeight: themeContract.typography.fontWeight.medium, - width: "fit-content", - whiteSpace: "nowrap", - flexShrink: 0, - gap: themeContract.space[1], - transition: "color 200ms ease-in-out, box-shadow 200ms ease-in-out", - overflow: "hidden", - - selectors: { - "&:focus-visible": { - borderColor: themeContract.colors.accent.primary, - boxShadow: `0 0 0 2px ${themeContract.colors.accent.primary}33`, - }, - "&[aria-invalid='true']": { - boxShadow: `0 0 0 2px ${themeContract.colors.status.forgotten}33`, - borderColor: themeContract.colors.status.forgotten, - }, - }, -}) - -// Global style for SVG children -globalStyle(`${badgeBase} > svg`, { - width: "0.75rem", - height: "0.75rem", - pointerEvents: "none", -}) - -export const badge = recipe({ - base: badgeBase, - - variants: { - variant: { - default: { - borderColor: "transparent", - backgroundColor: themeContract.colors.accent.primary, - color: themeContract.colors.text.primary, - - selectors: { - "a&:hover": { - opacity: 0.9, - }, - }, - }, - - secondary: { - borderColor: "transparent", - backgroundColor: themeContract.colors.background.secondary, - color: themeContract.colors.text.secondary, - - selectors: { - "a&:hover": { - backgroundColor: themeContract.colors.background.accent, - }, - }, - }, - - destructive: { - borderColor: "transparent", - backgroundColor: themeContract.colors.status.forgotten, - color: themeContract.colors.text.primary, - - selectors: { - "a&:hover": { - opacity: 0.9, - }, - "&:focus-visible": { - boxShadow: `0 0 0 2px ${themeContract.colors.status.forgotten}33`, - }, - }, - }, - - outline: { - borderColor: themeContract.colors.document.border, - backgroundColor: "transparent", - color: themeContract.colors.text.primary, - - selectors: { - "a&:hover": { - backgroundColor: themeContract.colors.document.primary, - }, - }, - }, - }, - }, - - defaultVariants: { - variant: "default", - }, -}) - -export type BadgeVariants = RecipeVariants diff --git a/packages/memory-graph/src/ui/badge.tsx b/packages/memory-graph/src/ui/badge.tsx deleted file mode 100644 index a08578c3..00000000 --- a/packages/memory-graph/src/ui/badge.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { Slot } from "@radix-ui/react-slot" -import type * as React from "react" -import { badge, type BadgeVariants } from "./badge.css" - -function Badge({ - className, - variant, - asChild = false, - ...props -}: React.ComponentProps<"span"> & BadgeVariants & { asChild?: boolean }) { - const Comp = asChild ? Slot : "span" - - const combinedClassName = className - ? `${badge({ variant })} ${className}` - : badge({ variant }) - - return -} - -export { Badge, badge as badgeVariants } diff --git a/packages/memory-graph/src/ui/button.css.ts b/packages/memory-graph/src/ui/button.css.ts deleted file mode 100644 index 375d567b..00000000 --- a/packages/memory-graph/src/ui/button.css.ts +++ /dev/null @@ -1,210 +0,0 @@ -import { recipe, type RecipeVariants } from "@vanilla-extract/recipes" -import { style } from "@vanilla-extract/css" -import { themeContract } from "../styles/theme.css" - -/** - * Base styles for SVG icons inside buttons - */ -export const buttonIcon = style({ - pointerEvents: "none", - flexShrink: 0, - selectors: { - "&:not([class*='size-'])": { - width: "1rem", - height: "1rem", - }, - }, -}) - -/** - * Button recipe with variants - * Replaces CVA-based button variants with vanilla-extract recipes - */ -export const button = recipe({ - base: { - display: "inline-flex", - alignItems: "center", - justifyContent: "center", - gap: themeContract.space[2], - whiteSpace: "nowrap", - borderRadius: themeContract.radii.md, - fontSize: themeContract.typography.fontSize.sm, - fontWeight: themeContract.typography.fontWeight.medium, - transition: themeContract.transitions.normal, - flexShrink: 0, - outline: "none", - border: "1px solid transparent", - cursor: "pointer", - - // SVG sizing - selectors: { - [`&:has(${buttonIcon})`]: { - // Buttons with icons get adjusted padding - }, - "&:disabled": { - pointerEvents: "none", - opacity: 0.5, - }, - "&:focus-visible": { - borderColor: themeContract.colors.accent.primary, - boxShadow: `0 0 0 2px ${themeContract.colors.accent.primary}33`, - }, - "&[aria-invalid='true']": { - boxShadow: `0 0 0 2px ${themeContract.colors.status.forgotten}`, - borderColor: themeContract.colors.status.forgotten, - }, - }, - }, - - variants: { - variant: { - default: { - backgroundColor: themeContract.colors.accent.primary, - color: themeContract.colors.text.primary, - boxShadow: "0 1px 2px 0 rgb(0 0 0 / 0.05)", - - selectors: { - "&:hover:not(:disabled)": { - backgroundColor: themeContract.colors.accent.secondary, - }, - }, - }, - - destructive: { - backgroundColor: themeContract.colors.status.forgotten, - color: themeContract.colors.text.primary, - boxShadow: "0 1px 2px 0 rgb(0 0 0 / 0.05)", - - selectors: { - "&:hover:not(:disabled)": { - opacity: 0.9, - }, - "&:focus-visible": { - boxShadow: `0 0 0 2px ${themeContract.colors.status.forgotten}33`, - }, - }, - }, - - outline: { - backgroundColor: themeContract.colors.background.primary, - borderColor: themeContract.colors.document.border, - color: themeContract.colors.text.primary, - boxShadow: "0 1px 2px 0 rgb(0 0 0 / 0.05)", - - selectors: { - "&:hover:not(:disabled)": { - backgroundColor: themeContract.colors.document.primary, - }, - }, - }, - - secondary: { - backgroundColor: themeContract.colors.background.secondary, - color: themeContract.colors.text.secondary, - boxShadow: "0 1px 2px 0 rgb(0 0 0 / 0.05)", - - selectors: { - "&:hover:not(:disabled)": { - backgroundColor: themeContract.colors.background.accent, - }, - }, - }, - - ghost: { - backgroundColor: "transparent", - color: themeContract.colors.text.primary, - - selectors: { - "&:hover:not(:disabled)": { - backgroundColor: themeContract.colors.document.primary, - }, - }, - }, - - link: { - backgroundColor: "transparent", - color: themeContract.colors.accent.primary, - textDecoration: "underline", - textUnderlineOffset: "4px", - - selectors: { - "&:hover:not(:disabled)": { - textDecoration: "underline", - }, - }, - }, - - settingsNav: { - cursor: "pointer", - borderRadius: themeContract.radii.sm, - backgroundColor: "transparent", - color: themeContract.colors.text.primary, - }, - }, - - size: { - default: { - height: "36px", - paddingLeft: themeContract.space[4], - paddingRight: themeContract.space[4], - paddingTop: themeContract.space[2], - paddingBottom: themeContract.space[2], - - selectors: { - "&:has(svg)": { - paddingLeft: themeContract.space[3], - paddingRight: themeContract.space[3], - }, - }, - }, - - sm: { - height: "32px", - borderRadius: themeContract.radii.md, - gap: themeContract.space[1], - paddingLeft: themeContract.space[3], - paddingRight: themeContract.space[3], - - selectors: { - "&:has(svg)": { - paddingLeft: themeContract.space[2], - paddingRight: themeContract.space[2], - }, - }, - }, - - lg: { - height: "40px", - borderRadius: themeContract.radii.md, - paddingLeft: themeContract.space[6], - paddingRight: themeContract.space[6], - - selectors: { - "&:has(svg)": { - paddingLeft: themeContract.space[4], - paddingRight: themeContract.space[4], - }, - }, - }, - - icon: { - width: "36px", - height: "36px", - padding: 0, - }, - - settingsNav: { - height: "32px", - gap: 0, - padding: 0, - }, - }, - }, - - defaultVariants: { - variant: "default", - size: "default", - }, -}) - -export type ButtonVariants = RecipeVariants diff --git a/packages/memory-graph/src/ui/button.tsx b/packages/memory-graph/src/ui/button.tsx deleted file mode 100644 index 89015299..00000000 --- a/packages/memory-graph/src/ui/button.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { Slot } from "@radix-ui/react-slot" -import type * as React from "react" -import { button, type ButtonVariants } from "./button.css" - -function Button({ - className, - variant, - size, - asChild = false, - ...props -}: React.ComponentProps<"button"> & - ButtonVariants & { - asChild?: boolean - }) { - const Comp = asChild ? Slot : "button" - - const combinedClassName = className - ? `${button({ variant, size })} ${className}` - : button({ variant, size }) - - return -} - -export { Button, button as buttonVariants } diff --git a/packages/memory-graph/src/ui/collapsible.tsx b/packages/memory-graph/src/ui/collapsible.tsx deleted file mode 100644 index f8de4e4c..00000000 --- a/packages/memory-graph/src/ui/collapsible.tsx +++ /dev/null @@ -1,33 +0,0 @@ -"use client" - -import * as CollapsiblePrimitive from "@radix-ui/react-collapsible" - -function Collapsible({ - ...props -}: React.ComponentProps) { - return -} - -function CollapsibleTrigger({ - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function CollapsibleContent({ - ...props -}: React.ComponentProps) { - return ( - - ) -} - -export { Collapsible, CollapsibleTrigger, CollapsibleContent } diff --git a/packages/memory-graph/src/ui/glass-effect.css.ts b/packages/memory-graph/src/ui/glass-effect.css.ts deleted file mode 100644 index c7160d98..00000000 --- a/packages/memory-graph/src/ui/glass-effect.css.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { style } from "@vanilla-extract/css" -import { recipe } from "@vanilla-extract/recipes" -import { themeContract } from "../styles/theme.css" - -/** - * Glass menu effect container - */ -export const glassMenuContainer = style({ - position: "absolute", - inset: 0, -}) - -/** - * Glass menu effect with customizable border radius - */ -export const glassMenuEffect = recipe({ - base: { - position: "absolute", - inset: 0, - backdropFilter: "blur(12px)", - WebkitBackdropFilter: "blur(12px)", - background: "rgba(255, 255, 255, 0.05)", - border: `1px solid ${themeContract.colors.document.border}`, - }, - - variants: { - rounded: { - none: { - borderRadius: themeContract.radii.none, - }, - sm: { - borderRadius: themeContract.radii.sm, - }, - md: { - borderRadius: themeContract.radii.md, - }, - lg: { - borderRadius: themeContract.radii.lg, - }, - xl: { - borderRadius: themeContract.radii.xl, - }, - "2xl": { - borderRadius: themeContract.radii["2xl"], - }, - "3xl": { - borderRadius: "1.5rem", // Tailwind's rounded-3xl - }, - full: { - borderRadius: themeContract.radii.full, - }, - }, - }, - - defaultVariants: { - rounded: "3xl", - }, -}) diff --git a/packages/memory-graph/src/ui/glass-effect.tsx b/packages/memory-graph/src/ui/glass-effect.tsx deleted file mode 100644 index 5c072edf..00000000 --- a/packages/memory-graph/src/ui/glass-effect.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { glassMenuContainer, glassMenuEffect } from "./glass-effect.css" - -interface GlassMenuEffectProps { - rounded?: "none" | "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "full" - className?: string -} - -export function GlassMenuEffect({ - rounded = "3xl", - className = "", -}: GlassMenuEffectProps) { - return ( -
- {/* Frosted glass effect with translucent border */} -
-
- ) -} diff --git a/packages/memory-graph/src/ui/heading.css.ts b/packages/memory-graph/src/ui/heading.css.ts deleted file mode 100644 index 384e4fdb..00000000 --- a/packages/memory-graph/src/ui/heading.css.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { style } from "@vanilla-extract/css" -import { themeContract } from "../styles/theme.css" - -/** - * Responsive heading style with bold weight - */ -export const headingH3Bold = style({ - fontSize: "0.625rem", // 10px - fontWeight: themeContract.typography.fontWeight.bold, - lineHeight: "28px", - letterSpacing: "-0.4px", - - "@media": { - "screen and (min-width: 640px)": { - fontSize: themeContract.typography.fontSize.xs, // 12px - }, - "screen and (min-width: 768px)": { - fontSize: themeContract.typography.fontSize.sm, // 14px - }, - "screen and (min-width: 1024px)": { - fontSize: themeContract.typography.fontSize.base, // 16px - }, - }, -}) diff --git a/packages/memory-graph/src/ui/heading.tsx b/packages/memory-graph/src/ui/heading.tsx deleted file mode 100644 index ed9b80ef..00000000 --- a/packages/memory-graph/src/ui/heading.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { Root } from "@radix-ui/react-slot" -import { headingH3Bold } from "./heading.css" - -export function HeadingH3Bold({ - className, - asChild, - ...props -}: React.ComponentProps<"h3"> & { asChild?: boolean }) { - const Comp = asChild ? Root : "h3" - - const combinedClassName = className - ? `${headingH3Bold} ${className}` - : headingH3Bold - - return -} diff --git a/packages/memory-graph/src/utils/document-icons.ts b/packages/memory-graph/src/utils/document-icons.ts deleted file mode 100644 index 2e93c22a..00000000 --- a/packages/memory-graph/src/utils/document-icons.ts +++ /dev/null @@ -1,237 +0,0 @@ -/** - * Canvas-based document type icon rendering utilities - * Simplified to match supported file types: PDF, TXT, MD, DOCX, DOC, RTF, CSV, JSON - */ - -export type DocumentIconType = - | "text" - | "pdf" - | "md" - | "markdown" - | "docx" - | "doc" - | "rtf" - | "csv" - | "json" - -/** - * Draws a document type icon on canvas - * @param ctx - Canvas 2D rendering context - * @param x - X position (center of icon) - * @param y - Y position (center of icon) - * @param size - Icon size (width/height) - * @param type - Document type - * @param color - Icon color (default: white) - */ -export function drawDocumentIcon( - ctx: CanvasRenderingContext2D, - x: number, - y: number, - size: number, - type: string, - color = "rgba(255, 255, 255, 0.9)", -): void { - ctx.save() - ctx.fillStyle = color - ctx.strokeStyle = color - ctx.lineWidth = Math.max(1, size / 12) - ctx.lineCap = "round" - ctx.lineJoin = "round" - - switch (type) { - case "pdf": - drawPdfIcon(ctx, x, y, size) - break - case "md": - case "markdown": - drawMarkdownIcon(ctx, x, y, size) - break - case "doc": - case "docx": - drawWordIcon(ctx, x, y, size) - break - case "rtf": - drawRtfIcon(ctx, x, y, size) - break - case "csv": - drawCsvIcon(ctx, x, y, size) - break - case "json": - drawJsonIcon(ctx, x, y, size) - break - case "txt": - case "text": - default: - drawTextIcon(ctx, x, y, size) - break - } - - ctx.restore() -} - -// Individual icon drawing functions - -function drawTextIcon( - ctx: CanvasRenderingContext2D, - x: number, - y: number, - size: number, -): void { - // Simple document outline with lines - const w = size * 0.7 - const h = size * 0.85 - const cornerFold = size * 0.2 - - ctx.beginPath() - ctx.moveTo(x - w / 2, y - h / 2) - ctx.lineTo(x + w / 2 - cornerFold, y - h / 2) - ctx.lineTo(x + w / 2, y - h / 2 + cornerFold) - ctx.lineTo(x + w / 2, y + h / 2) - ctx.lineTo(x - w / 2, y + h / 2) - ctx.closePath() - ctx.stroke() - - // Text lines - const lineSpacing = size * 0.15 - const lineWidth = size * 0.4 - ctx.beginPath() - ctx.moveTo(x - lineWidth / 2, y - lineSpacing) - ctx.lineTo(x + lineWidth / 2, y - lineSpacing) - ctx.moveTo(x - lineWidth / 2, y) - ctx.lineTo(x + lineWidth / 2, y) - ctx.moveTo(x - lineWidth / 2, y + lineSpacing) - ctx.lineTo(x + lineWidth / 2, y + lineSpacing) - ctx.stroke() -} - -function drawPdfIcon( - ctx: CanvasRenderingContext2D, - x: number, - y: number, - size: number, -): void { - // Document with "PDF" text - const w = size * 0.7 - const h = size * 0.85 - - ctx.beginPath() - ctx.rect(x - w / 2, y - h / 2, w, h) - ctx.stroke() - - // "PDF" letters (simplified) - ctx.font = `bold ${size * 0.35}px sans-serif` - ctx.textAlign = "center" - ctx.textBaseline = "middle" - ctx.fillText("PDF", x, y) -} - -function drawMarkdownIcon( - ctx: CanvasRenderingContext2D, - x: number, - y: number, - size: number, -): void { - // Document with "MD" text - const w = size * 0.7 - const h = size * 0.85 - - ctx.beginPath() - ctx.rect(x - w / 2, y - h / 2, w, h) - ctx.stroke() - - // "MD" letters - ctx.font = `bold ${size * 0.3}px sans-serif` - ctx.textAlign = "center" - ctx.textBaseline = "middle" - ctx.fillText("MD", x, y) -} - -function drawWordIcon( - ctx: CanvasRenderingContext2D, - x: number, - y: number, - size: number, -): void { - // Document with "DOC" text - const w = size * 0.7 - const h = size * 0.85 - - ctx.beginPath() - ctx.rect(x - w / 2, y - h / 2, w, h) - ctx.stroke() - - // "DOC" letters - ctx.font = `bold ${size * 0.28}px sans-serif` - ctx.textAlign = "center" - ctx.textBaseline = "middle" - ctx.fillText("DOC", x, y) -} - -function drawRtfIcon( - ctx: CanvasRenderingContext2D, - x: number, - y: number, - size: number, -): void { - // Document with "RTF" text - const w = size * 0.7 - const h = size * 0.85 - - ctx.beginPath() - ctx.rect(x - w / 2, y - h / 2, w, h) - ctx.stroke() - - // "RTF" letters - ctx.font = `bold ${size * 0.3}px sans-serif` - ctx.textAlign = "center" - ctx.textBaseline = "middle" - ctx.fillText("RTF", x, y) -} - -function drawCsvIcon( - ctx: CanvasRenderingContext2D, - x: number, - y: number, - size: number, -): void { - // Grid table for CSV - const w = size * 0.7 - const h = size * 0.85 - - ctx.strokeRect(x - w / 2, y - h / 2, w, h) - - // Grid lines (2x2) - ctx.beginPath() - // Vertical line - ctx.moveTo(x, y - h / 2) - ctx.lineTo(x, y + h / 2) - // Horizontal line - ctx.moveTo(x - w / 2, y) - ctx.lineTo(x + w / 2, y) - ctx.stroke() -} - -function drawJsonIcon( - ctx: CanvasRenderingContext2D, - x: number, - y: number, - size: number, -): void { - // Curly braces for JSON - const w = size * 0.6 - const h = size * 0.8 - - // Left brace - 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() - - // Right brace - 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() -} diff --git a/packages/memory-graph/tsconfig.json b/packages/memory-graph/tsconfig.json index 4e7fc4ba..99298903 100644 --- a/packages/memory-graph/tsconfig.json +++ b/packages/memory-graph/tsconfig.json @@ -1,26 +1,22 @@ { - "extends": "@total-typescript/tsconfig/bundler/dom/library", - "compilerOptions": { - "jsx": "react-jsx", - "baseUrl": ".", - "paths": { - "@/*": ["./src/*"] - }, - "outDir": "./dist", - "declaration": true, - "declarationMap": true, - "emitDeclarationOnly": false, - "skipLibCheck": true, - "strict": true, - "esModuleInterop": true, - "moduleResolution": "bundler", - "resolveJsonModule": true, - "allowSyntheticDefaultImports": true, - "forceConsistentCasingInFileNames": true, - "noUnusedLocals": false, - "noUnusedParameters": false, - "noEmit": false - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "**/*.test.*", "**/*.spec.*"] + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationDir": "./dist", + "emitDeclarationOnly": true, + "outDir": "./dist", + "rootDir": "./src", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] } diff --git a/packages/memory-graph/vite.config.ts b/packages/memory-graph/vite.config.ts index 923952d4..fe7a4cd1 100644 --- a/packages/memory-graph/vite.config.ts +++ b/packages/memory-graph/vite.config.ts @@ -1,93 +1,32 @@ -import { defineConfig, type Plugin } from "vite" +import { defineConfig } from "vite" import react from "@vitejs/plugin-react" -import { resolve } from "path" -import { readFileSync } from "fs" -import { vanillaExtractPlugin } from "@vanilla-extract/vite-plugin" +import { resolve } from "node:path" -/** - * Custom plugin to embed CSS content into the JS bundle for runtime injection. - * This allows the package to work with any bundler (Vite, webpack, Next.js, etc.) - */ -function injectCssPlugin(): Plugin { - let cssContent = "" - - return { - name: "inject-css-content", - enforce: "post", - generateBundle(_, bundle) { - // Find the generated CSS file - for (const [fileName, chunk] of Object.entries(bundle)) { - if (fileName.endsWith(".css") && chunk.type === "asset") { - cssContent = chunk.source as string - break - } - } - - // Replace placeholder in JS files with actual CSS content - for (const [fileName, chunk] of Object.entries(bundle)) { - if ( - (fileName.endsWith(".js") || fileName.endsWith(".cjs")) && - chunk.type === "chunk" - ) { - // Escape the CSS for embedding in JS string - const escapedCss = JSON.stringify(cssContent) - chunk.code = chunk.code.replace(/__MEMORY_GRAPH_CSS__/g, escapedCss) - } - } - }, - } -} - -// https://vitejs.dev/config/ export default defineConfig({ - plugins: [react(), vanillaExtractPlugin(), injectCssPlugin()], - build: { - lib: { - entry: resolve(__dirname, "src/index.tsx"), - name: "MemoryGraph", - formats: ["es", "cjs"], - fileName: (format) => { - if (format === "es") return "memory-graph.js" - if (format === "cjs") return "memory-graph.cjs" - return "memory-graph.js" - }, - }, - rollupOptions: { - // Externalize only peer dependencies (React) - external: ["react", "react-dom", "react/jsx-runtime"], - output: { - // Provide global variables for UMD build (if needed later) - globals: { - react: "React", - "react-dom": "ReactDOM", - "react/jsx-runtime": "react/jsx-runtime", - }, - // Preserve CSS as separate file (for manual import fallback) - assetFileNames: (assetInfo) => { - // Vanilla-extract generates index.css, rename to memory-graph.css - if ( - assetInfo.name === "index.css" || - assetInfo.name === "style.css" - ) { - return "memory-graph.css" - } - return assetInfo.name || "asset" - }, - // Don't preserve modules - bundle everything except externals - preserveModules: false, - }, - }, - // Ensure CSS is extracted - cssCodeSplit: false, - // Generate sourcemaps for debugging - sourcemap: true, - // Optimize deps - minify: "esbuild", - target: "esnext", - }, + plugins: [react()], resolve: { alias: { "@": resolve(__dirname, "./src"), }, }, + build: { + lib: { + entry: { + "memory-graph": resolve(__dirname, "src/index.tsx"), + "mock-data": resolve(__dirname, "src/mock-data.ts"), + }, + formats: ["es", "cjs"], + }, + rollupOptions: { + external: ["react", "react-dom", "react/jsx-runtime"], + output: { + globals: { + react: "React", + "react-dom": "ReactDOM", + }, + }, + }, + sourcemap: true, + minify: false, + }, })