"use client"; import Link from "next/link"; import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type Dispatch, type SetStateAction, type WheelEvent as ReactWheelEvent, type PointerEvent as ReactPointerEvent, } from "react"; import { ArrowLeft, Eye, EyeOff, Loader2, Maximize2, RefreshCw, ZoomIn, ZoomOut, } from "lucide-react"; import { useTranslation } from "react-i18next"; import { buildGraph, DEFAULT_LAYOUT, fetchMemorySnapshot, L3_LABEL, SURFACE_LABEL, type ClusterMeta, type GraphEdge, type GraphNode, type Layer, type MemoryGraph, } from "@/lib/memory-graph"; interface ViewState { scale: number; tx: number; ty: number; } interface HoverState { node: GraphNode; containerLeft: number; containerTop: number; } const INITIAL_VIEW: ViewState = { scale: 1, tx: 0, ty: 0 }; // One canonical hue per layer; alpha is per-state (idle/hover/dim). // Drawn from DeepTutor's primary palette so dark mode stays warm too. const LAYER_COLOR: Record = { L3: "var(--primary)", L2: "color-mix(in srgb, var(--primary) 78%, var(--foreground) 22%)", L1: "color-mix(in srgb, var(--primary) 38%, var(--foreground) 62%)", }; const RING_LINE: Record = { L3: "color-mix(in srgb, var(--primary) 22%, transparent)", L2: "color-mix(in srgb, var(--primary) 14%, transparent)", L1: "color-mix(in srgb, var(--foreground) 10%, transparent)", }; export default function MemoryGraph() { const { t } = useTranslation(); const [graph, setGraph] = useState(null); const [loading, setLoading] = useState(true); const [layerOn, setLayerOn] = useState>({ L1: true, L2: true, L3: true, }); const [hover, setHover] = useState(null); const [selected, setSelected] = useState(null); const [view, setView] = useState(INITIAL_VIEW); const containerRef = useRef(null); const dragRef = useRef<{ pointerId: number; startX: number; startY: number; origTx: number; origTy: number; } | null>(null); const load = useCallback(async () => { setLoading(true); try { const snap = await fetchMemorySnapshot(); setGraph(buildGraph(snap)); } finally { setLoading(false); } }, []); useEffect(() => { void load(); }, [load]); // ── Derived: active node id used by hover *or* explicit selection. const activeId = selected ?? hover?.node.id ?? null; const highlight = useMemo(() => { if (!graph || !activeId) return null; const neighbours = new Set([activeId]); const queue = [activeId]; // BFS limited to 2 hops so we get e.g. L3 → L2 → L1 chains. for (let depth = 0; depth < 2 && queue.length; depth++) { const next: string[] = []; for (const id of queue) { const adj = graph.adjacency.get(id) ?? []; for (const n of adj) { if (!neighbours.has(n)) { neighbours.add(n); next.push(n); } } } queue.length = 0; queue.push(...next); } return neighbours; }, [graph, activeId]); // ── Pan / zoom handlers. const onWheel = useCallback((e: ReactWheelEvent) => { e.preventDefault(); const rect = containerRef.current?.getBoundingClientRect(); if (!rect) return; setView((v) => { const factor = Math.exp(-e.deltaY * 0.001); const next = Math.min(4, Math.max(0.35, v.scale * factor)); // Zoom centered on cursor: keep the world point under the cursor stationary. const px = e.clientX - rect.left; const py = e.clientY - rect.top; const k = next / v.scale; const tx = px - k * (px - v.tx); const ty = py - k * (py - v.ty); return { scale: next, tx, ty }; }); }, []); const onPointerDown = useCallback( (e: ReactPointerEvent) => { // Only start a drag on background clicks — let node clicks bubble. if ((e.target as HTMLElement).closest("[data-node]")) return; (e.target as HTMLElement).setPointerCapture?.(e.pointerId); dragRef.current = { pointerId: e.pointerId, startX: e.clientX, startY: e.clientY, origTx: view.tx, origTy: view.ty, }; setSelected(null); }, [view], ); const onPointerMove = useCallback((e: ReactPointerEvent) => { const d = dragRef.current; if (!d || d.pointerId !== e.pointerId) return; setView((v) => ({ ...v, tx: d.origTx + (e.clientX - d.startX), ty: d.origTy + (e.clientY - d.startY), })); }, []); const endDrag = useCallback(() => { dragRef.current = null; }, []); const showNodeHover = useCallback((node: GraphNode) => { const rect = containerRef.current?.getBoundingClientRect(); setHover({ node, containerLeft: rect?.left ?? 0, containerTop: rect?.top ?? 0, }); }, []); // ── Center / fit the graph to the container on first paint. useEffect(() => { if (!graph) return; const el = containerRef.current; if (!el) return; const rect = el.getBoundingClientRect(); const target = DEFAULT_LAYOUT.width; const scale = Math.min(rect.width / target, rect.height / target) * 0.95; setView({ scale, tx: (rect.width - target * scale) / 2, ty: (rect.height - target * scale) / 2, }); }, [graph]); const fit = useCallback(() => { if (!graph) return; const el = containerRef.current; if (!el) return; const rect = el.getBoundingClientRect(); const target = DEFAULT_LAYOUT.width; const scale = Math.min(rect.width / target, rect.height / target) * 0.95; setView({ scale, tx: (rect.width - target * scale) / 2, ty: (rect.height - target * scale) / 2, }); }, [graph]); const zoomBy = useCallback((factor: number) => { const el = containerRef.current; const rect = el?.getBoundingClientRect(); if (!rect) return; setView((v) => { const next = Math.min(4, Math.max(0.35, v.scale * factor)); const px = rect.width / 2; const py = rect.height / 2; const k = next / v.scale; return { scale: next, tx: px - k * (px - v.tx), ty: py - k * (py - v.ty), }; }); }, []); return (
void load()} loading={loading} />
{graph && ( )} {loading && (
{t("Composing memory graph…")}
)} {graph && !loading && ( )} {hover && }
{graph && !loading && }
); } // ───────────────────────────────────────────────────────────── Header function Header({ onRefresh, loading, }: { onRefresh: () => void; loading: boolean; }) { const { t } = useTranslation(); return (
{t("Memory")}

{t("Memory graph")}

{t( "L3 synthesis at the centre, L2 facts in the middle ring, L1 traces on the outside.", )}

); } // ───────────────────────────────────────────────────────── GraphView interface GraphViewProps { graph: MemoryGraph; view: ViewState; layerOn: Record; hover: HoverState | null; showNodeHover: (node: GraphNode) => void; setHover: Dispatch>; selected: string | null; setSelected: Dispatch>; highlight: Set | null; } function GraphView({ graph, view, layerOn, hover, showNodeHover, setHover, selected, setSelected, highlight, }: GraphViewProps) { const { t } = useTranslation(); // ── Cluster halos: large blurred translucent ellipses behind each // slice so users see the cluster outlines before they read labels. const haloPaths = useMemo(() => { return graph.clusters.map((c) => buildClusterArc(c)); }, [graph.clusters]); const visibleEdges = useMemo(() => { return graph.edges.filter((e) => { const sLayer = e.source.startsWith("L1") ? "L1" : e.source.startsWith("L2") ? "L2" : "L3"; const tLayer = e.target.startsWith("L1") ? "L1" : e.target.startsWith("L2") ? "L2" : "L3"; return layerOn[sLayer as Layer] && layerOn[tLayer as Layer]; }); }, [graph.edges, layerOn]); // Pre-index nodes by id for fast endpoint lookup when drawing edges. const nodeIdx = useMemo(() => { const m = new Map(); for (const n of graph.nodes) m.set(n.id, n); return m; }, [graph.nodes]); return ( {/* Concentric ring guides (faint). */} {(["L1", "L2", "L3"] as const).map((layer) => { if (!layerOn[layer]) return null; const opt = DEFAULT_LAYOUT; const inner = layer === "L1" ? opt.l1InnerRadius : layer === "L2" ? opt.l2InnerRadius : opt.l3InnerRadius; const outer = layer === "L1" ? opt.l1OuterRadius : layer === "L2" ? opt.l2OuterRadius : opt.l3OuterRadius; const cx = opt.width / 2; const cy = opt.height / 2; return ( ); })} {/* Cluster halos. */} {graph.clusters.map((c, i) => { if (!layerOn[c.layer]) return null; const fill = `url(#halo-${c.layer.toLowerCase()})`; return ( ); })} {/* Cluster labels. L1 labels orbit *outside* the outer ring, L2 labels sit just outside the L2 ring, L3 labels are rendered as a single core badge (handled below). */} {graph.clusters.map((c) => { if (!layerOn[c.layer]) return null; if (c.layer === "L3") return null; const mid = (c.startAngle + c.endAngle) / 2; const cx = DEFAULT_LAYOUT.width / 2; const cy = DEFAULT_LAYOUT.height / 2; const rOut = c.outerRadius + (c.layer === "L1" ? 30 : 18); const x = cx + Math.cos(mid) * rOut; const y = cy + Math.sin(mid) * rOut; return ( {t(c.label)} {c.count.toLocaleString()}{" "} {c.layer === "L2" ? t("facts") : t("traces")} ); })} {/* L3 core: a soft "core" plate at the centre with the three slot labels placed at their slice mid-angles inside the inner radius. */} {layerOn.L3 && ( L3 {t("synthesis")} {graph.clusters .filter((c) => c.layer === "L3") .map((c) => { const mid = (c.startAngle + c.endAngle) / 2; const cx = DEFAULT_LAYOUT.width / 2; const cy = DEFAULT_LAYOUT.height / 2; const r = c.outerRadius + 14; const x = cx + Math.cos(mid) * r; const y = cy + Math.sin(mid) * r; return ( {t(c.label)} ); })} )} {/* Edges. */} {visibleEdges.map((e) => { const s = nodeIdx.get(e.source); const t2 = nodeIdx.get(e.target); if (!s || !t2) return null; const dim = highlight !== null && !highlight.has(e.source) && !highlight.has(e.target); const focused = highlight !== null && highlight.has(e.source) && highlight.has(e.target); const opacity = dim ? 0.04 : focused ? e.kind === "strong" ? 0.85 : 0.55 : e.kind === "strong" ? 0.16 : 0.08; const stroke = focused ? "var(--primary)" : LAYER_COLOR[layerOf(e)]; return ( ${e.target}`} edge={e} s={s} t={t2} opacity={opacity} stroke={stroke} focused={focused} /> ); })} {/* Nodes. */} {graph.nodes.map((n) => { if (!layerOn[n.layer]) return null; if (n.r === 0) return null; // synthetic anchor const dim = highlight !== null && !highlight.has(n.id); const focused = highlight !== null && highlight.has(n.id); const isActive = n.id === (selected ?? hover?.node.id); return ( setHover((cur) => (cur?.node.id === node.id ? null : cur)) } onClick={() => setSelected((cur) => (cur === n.id ? null : n.id)) } /> ); })} ); } function layerOf(e: GraphEdge): Layer { // Pick the more synthetic layer for edge colour: L3 dominates, // then L2. if (e.source.startsWith("L3") || e.target.startsWith("L3")) return "L3"; if (e.source.startsWith("L2") || e.target.startsWith("L2")) return "L2"; return "L1"; } function EdgePath({ edge, s, t, opacity, stroke, focused, }: { edge: GraphEdge; s: GraphNode; t: GraphNode; opacity: number; stroke: string; focused: boolean; }) { // Quadratic curve: control point pulled toward the canvas center // gives the bundle a hub-and-spoke feel rather than a noisy mesh. const cx = DEFAULT_LAYOUT.width / 2; const cy = DEFAULT_LAYOUT.height / 2; const mx = (s.x + t.x) / 2; const my = (s.y + t.y) / 2; const k = edge.kind === "soft" ? 0.45 : 0.25; const cpx = mx + (cx - mx) * k; const cpy = my + (cy - my) * k; return ( ); } function NodeDot({ node, isActive, focused, dim, onEnter, onLeave, onClick, }: { node: GraphNode; isActive: boolean; focused: boolean; dim: boolean; onEnter: (node: GraphNode) => void; onLeave: (node: GraphNode) => void; onClick: () => void; }) { const baseR = node.r; const r = isActive ? baseR * 2.1 : focused ? baseR * 1.35 : baseR; const fill = LAYER_COLOR[node.layer]; const opacity = dim ? 0.18 : 1; // Always offer at least a 10-pixel-radius invisible target so the // mouse doesn't need pixel-perfect aim. Larger of (3×base radius, // 10px) gives small L1 dots a generous hit halo without making // dense clusters fight over hover. const hitR = Math.max(baseR * 3, 10); return ( onEnter(node)} onPointerLeave={() => onLeave(node)} onClick={onClick} /> {isActive && ( )} ); } // Pie-slice "arc" path between two radii. function buildClusterArc(c: ClusterMeta): string { const cx = DEFAULT_LAYOUT.width / 2; const cy = DEFAULT_LAYOUT.height / 2; const { startAngle, endAngle, innerRadius, outerRadius } = c; const large = endAngle - startAngle > Math.PI ? 1 : 0; const x1 = cx + Math.cos(startAngle) * outerRadius; const y1 = cy + Math.sin(startAngle) * outerRadius; const x2 = cx + Math.cos(endAngle) * outerRadius; const y2 = cy + Math.sin(endAngle) * outerRadius; const x3 = cx + Math.cos(endAngle) * innerRadius; const y3 = cy + Math.sin(endAngle) * innerRadius; const x4 = cx + Math.cos(startAngle) * innerRadius; const y4 = cy + Math.sin(startAngle) * innerRadius; return [ `M ${x1} ${y1}`, `A ${outerRadius} ${outerRadius} 0 ${large} 1 ${x2} ${y2}`, `L ${x3} ${y3}`, `A ${innerRadius} ${innerRadius} 0 ${large} 0 ${x4} ${y4}`, "Z", ].join(" "); } // ─────────────────────────────────────────────────────────── Controls function Controls({ view, layerOn, setLayerOn, zoomBy, fit, }: { view: ViewState; layerOn: Record; setLayerOn: (next: Record) => void; zoomBy: (factor: number) => void; fit: () => void; }) { const { t } = useTranslation(); return (
zoomBy(1.25)} title={t("Zoom in")}> zoomBy(1 / 1.25)} title={t("Zoom out")}> {(view.scale * 100).toFixed(0)}%
{(["L3", "L2", "L1"] as const).map((layer) => ( ))}
); } function IconButton({ onClick, title, children, }: { onClick: () => void; title: string; children: React.ReactNode; }) { return ( ); } // ──────────────────────────────────────────────────────────── Legend function Legend({ graph }: { graph: MemoryGraph }) { const { t } = useTranslation(); // Aggregate counts. const byLayer = useMemo(() => { const out: Record = { L1: 0, L2: 0, L3: 0 }; for (const n of graph.nodes) { if (n.r === 0) continue; // ignore synthetic anchors out[n.layer] += 1; } return out; }, [graph.nodes]); return (
{(["L3", "L2", "L1"] as const).map((layer) => ( {layer} {byLayer[layer]} ))}

{t( "Hover a node to preview the memory. Click to lock the highlight and trace its references inward (L1 → L2 → L3) or outward.", )}

); } // ─────────────────────────────────────────────────────── HoverCard function HoverCard({ hover, view }: { hover: HoverState; view: ViewState }) { const { t } = useTranslation(); const { node } = hover; // Anchor the tooltip to the *node's* screen position (not the // cursor), so we don't have to handle mousemove for every dot. The // card is offset down-right of the node so the user can still read // the dot underneath the cursor. const screenX = hover.containerLeft + node.x * view.scale + view.tx; const screenY = hover.containerTop + node.y * view.scale + view.ty; const offset = Math.max(node.r * view.scale + 14, 18); const style: CSSProperties = { left: screenX + offset, top: screenY + offset, }; const meta = nodeMeta(node, t); return (
{meta.layerLabel} · {meta.clusterLabel} {node.section && ( <> · {node.section} )}

{node.preview || node.label}

{t("Click to lock · open in workbench")}

); } function nodeMeta( n: GraphNode, t: (key: string) => string, ): { layerLabel: string; clusterLabel: string } { const [, cluster] = n.cluster.split(":"); if (n.layer === "L3") { return { layerLabel: t("L3 · synthesis"), clusterLabel: t(L3_LABEL[cluster as keyof typeof L3_LABEL] || cluster), }; } if (n.layer === "L2") { return { layerLabel: t("L2 · curated"), clusterLabel: t( SURFACE_LABEL[cluster as keyof typeof SURFACE_LABEL] || cluster, ), }; } return { layerLabel: t("L1 · raw trace"), clusterLabel: t( SURFACE_LABEL[cluster as keyof typeof SURFACE_LABEL] || cluster, ), }; } export type { GraphNode, GraphEdge, MemoryGraph };