"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.filter((n) => n.type === "document").length === 0 && children}
{containerSize.width > 0 && containerSize.height > 0 && ( )} {activeNodeData && activePopoverPosition && ( )}
{containerSize.width > 0 && ( )}
) }