Files
wehub-resource-sync e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

990 lines
31 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<Layer, string> = {
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<Layer, string> = {
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<MemoryGraph | null>(null);
const [loading, setLoading] = useState(true);
const [layerOn, setLayerOn] = useState<Record<Layer, boolean>>({
L1: true,
L2: true,
L3: true,
});
const [hover, setHover] = useState<HoverState | null>(null);
const [selected, setSelected] = useState<string | null>(null);
const [view, setView] = useState<ViewState>(INITIAL_VIEW);
const containerRef = useRef<HTMLDivElement | null>(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<string>([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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
// 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<HTMLDivElement>) => {
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 (
<div className="flex h-full min-h-0 flex-col">
<Header onRefresh={() => void load()} loading={loading} />
<div className="relative flex-1 min-h-0 overflow-hidden">
<div
ref={containerRef}
onWheel={onWheel}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
className="absolute inset-0 cursor-grab touch-none select-none active:cursor-grabbing"
style={{
background:
"radial-gradient(ellipse at center, color-mix(in srgb, var(--primary) 7%, var(--background)) 0%, var(--background) 65%)",
}}
>
{graph && (
<GraphView
graph={graph}
view={view}
layerOn={layerOn}
hover={hover}
showNodeHover={showNodeHover}
setHover={setHover}
selected={selected}
setSelected={setSelected}
highlight={highlight}
/>
)}
{loading && (
<div className="pointer-events-none absolute inset-0 grid place-items-center">
<div className="inline-flex items-center gap-2 rounded-full border border-[var(--border)] bg-[var(--card)]/80 px-3 py-1.5 text-[12px] text-[var(--muted-foreground)] shadow-sm backdrop-blur">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{t("Composing memory graph…")}
</div>
</div>
)}
{graph && !loading && (
<Controls
view={view}
layerOn={layerOn}
setLayerOn={setLayerOn}
zoomBy={zoomBy}
fit={fit}
/>
)}
{hover && <HoverCard hover={hover} view={view} />}
</div>
{graph && !loading && <Legend graph={graph} />}
</div>
</div>
);
}
// ───────────────────────────────────────────────────────────── Header
function Header({
onRefresh,
loading,
}: {
onRefresh: () => void;
loading: boolean;
}) {
const { t } = useTranslation();
return (
<div className="flex items-center justify-between border-b border-[var(--border)] bg-[var(--background)]/80 px-6 py-3 backdrop-blur md:px-10">
<div className="flex items-center gap-3">
<Link
href="/memory"
className="inline-flex items-center gap-1.5 rounded-md border border-[var(--border)] bg-[var(--background)] px-2.5 py-1 text-[12px] text-[var(--muted-foreground)] transition hover:bg-[var(--muted)]"
>
<ArrowLeft className="h-3.5 w-3.5" />
{t("Memory")}
</Link>
<div className="flex flex-col">
<h1 className="font-serif text-[16px] font-semibold tracking-tight text-[var(--foreground)]">
{t("Memory graph")}
</h1>
<p className="text-[11.5px] text-[var(--muted-foreground)]">
{t(
"L3 synthesis at the centre, L2 facts in the middle ring, L1 traces on the outside.",
)}
</p>
</div>
</div>
<button
type="button"
onClick={onRefresh}
className="inline-flex items-center gap-1.5 rounded-md border border-[var(--border)] bg-[var(--background)] px-2.5 py-1 text-[12px] text-[var(--muted-foreground)] transition hover:bg-[var(--muted)] disabled:opacity-50"
disabled={loading}
>
<RefreshCw
className={loading ? "h-3.5 w-3.5 animate-spin" : "h-3.5 w-3.5"}
/>
{t("Refresh")}
</button>
</div>
);
}
// ───────────────────────────────────────────────────────── GraphView
interface GraphViewProps {
graph: MemoryGraph;
view: ViewState;
layerOn: Record<Layer, boolean>;
hover: HoverState | null;
showNodeHover: (node: GraphNode) => void;
setHover: Dispatch<SetStateAction<HoverState | null>>;
selected: string | null;
setSelected: Dispatch<SetStateAction<string | null>>;
highlight: Set<string> | 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<string, GraphNode>();
for (const n of graph.nodes) m.set(n.id, n);
return m;
}, [graph.nodes]);
return (
<svg
width="100%"
height="100%"
style={{ position: "absolute", inset: 0 }}
aria-label={t("Memory graph")}
>
<defs>
<radialGradient id="halo-l3" cx="50%" cy="50%" r="50%">
<stop offset="0%" stopColor="var(--primary)" stopOpacity="0.20" />
<stop offset="100%" stopColor="var(--primary)" stopOpacity="0.02" />
</radialGradient>
<radialGradient id="halo-l2" cx="50%" cy="50%" r="50%">
<stop offset="0%" stopColor="var(--primary)" stopOpacity="0.12" />
<stop offset="100%" stopColor="var(--primary)" stopOpacity="0.02" />
</radialGradient>
<radialGradient id="halo-l1" cx="50%" cy="50%" r="50%">
<stop offset="0%" stopColor="var(--foreground)" stopOpacity="0.08" />
<stop
offset="100%"
stopColor="var(--foreground)"
stopOpacity="0.01"
/>
</radialGradient>
</defs>
<g transform={`translate(${view.tx} ${view.ty}) scale(${view.scale})`}>
{/* Concentric ring guides (faint). */}
<g pointerEvents="none">
{(["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 (
<g key={`ring-${layer}`}>
<circle
cx={cx}
cy={cy}
r={inner}
fill="none"
stroke={RING_LINE[layer]}
strokeWidth={0.6}
strokeDasharray="2 3"
/>
<circle
cx={cx}
cy={cy}
r={outer}
fill="none"
stroke={RING_LINE[layer]}
strokeWidth={0.6}
strokeDasharray="2 3"
/>
</g>
);
})}
</g>
{/* Cluster halos. */}
<g pointerEvents="none">
{graph.clusters.map((c, i) => {
if (!layerOn[c.layer]) return null;
const fill = `url(#halo-${c.layer.toLowerCase()})`;
return (
<path
key={c.id}
d={haloPaths[i]}
fill={fill}
stroke="color-mix(in srgb, var(--primary) 8%, transparent)"
strokeWidth={0.6}
/>
);
})}
</g>
{/* 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). */}
<g pointerEvents="none">
{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 (
<g key={`label-${c.id}`}>
<text
x={x}
y={y - 12}
fontSize={c.layer === "L2" ? 20 : 22}
fontWeight={600}
fill="var(--foreground)"
textAnchor="middle"
dominantBaseline="central"
style={{ userSelect: "none" }}
>
{t(c.label)}
</text>
<text
x={x}
y={y + 14}
fontSize={15}
fontWeight={500}
fill="var(--muted-foreground)"
textAnchor="middle"
dominantBaseline="central"
style={{ userSelect: "none" }}
>
{c.count.toLocaleString()}{" "}
{c.layer === "L2" ? t("facts") : t("traces")}
</text>
</g>
);
})}
</g>
{/* 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 && (
<g pointerEvents="none">
<circle
cx={DEFAULT_LAYOUT.width / 2}
cy={DEFAULT_LAYOUT.height / 2}
r={DEFAULT_LAYOUT.l3InnerRadius - 6}
fill="color-mix(in srgb, var(--primary) 12%, var(--background))"
stroke="color-mix(in srgb, var(--primary) 22%, transparent)"
strokeWidth={1}
/>
<text
x={DEFAULT_LAYOUT.width / 2}
y={DEFAULT_LAYOUT.height / 2 - 10}
fontSize={22}
fontWeight={700}
fill="var(--primary)"
textAnchor="middle"
dominantBaseline="central"
style={{ letterSpacing: "0.08em", userSelect: "none" }}
>
L3
</text>
<text
x={DEFAULT_LAYOUT.width / 2}
y={DEFAULT_LAYOUT.height / 2 + 16}
fontSize={14}
fontWeight={500}
fill="var(--muted-foreground)"
textAnchor="middle"
dominantBaseline="central"
style={{ letterSpacing: "0.04em", userSelect: "none" }}
>
{t("synthesis")}
</text>
{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 (
<text
key={`l3-label-${c.id}`}
x={x}
y={y}
fontSize={18}
fontWeight={600}
fill="var(--primary)"
textAnchor="middle"
dominantBaseline="central"
style={{ letterSpacing: "0.04em", userSelect: "none" }}
>
{t(c.label)}
</text>
);
})}
</g>
)}
{/* Edges. */}
<g pointerEvents="none">
{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 (
<EdgePath
key={`${e.source}->${e.target}`}
edge={e}
s={s}
t={t2}
opacity={opacity}
stroke={stroke}
focused={focused}
/>
);
})}
</g>
{/* Nodes. */}
<g>
{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 (
<NodeDot
key={n.id}
node={n}
isActive={isActive}
focused={focused}
dim={dim}
onEnter={showNodeHover}
onLeave={(node) =>
setHover((cur) => (cur?.node.id === node.id ? null : cur))
}
onClick={() =>
setSelected((cur) => (cur === n.id ? null : n.id))
}
/>
);
})}
</g>
</g>
</svg>
);
}
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 (
<path
d={`M ${s.x} ${s.y} Q ${cpx} ${cpy} ${t.x} ${t.y}`}
stroke={stroke}
strokeWidth={focused ? 0.9 : edge.kind === "strong" ? 0.5 : 0.35}
strokeOpacity={opacity}
fill="none"
strokeLinecap="round"
/>
);
}
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 (
<g data-node={node.id} style={{ cursor: "pointer" }}>
<circle
cx={node.x}
cy={node.y}
r={hitR}
fill="transparent"
pointerEvents="all"
onPointerEnter={() => onEnter(node)}
onPointerLeave={() => onLeave(node)}
onClick={onClick}
/>
{isActive && (
<circle
cx={node.x}
cy={node.y}
r={r * 2.5}
fill={fill}
opacity={0.18}
pointerEvents="none"
/>
)}
<circle
cx={node.x}
cy={node.y}
r={r}
fill={fill}
opacity={opacity}
stroke={isActive ? "var(--background)" : "transparent"}
strokeWidth={isActive ? 1.5 : 0}
pointerEvents="none"
/>
</g>
);
}
// 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<Layer, boolean>;
setLayerOn: (next: Record<Layer, boolean>) => void;
zoomBy: (factor: number) => void;
fit: () => void;
}) {
const { t } = useTranslation();
return (
<div className="pointer-events-none absolute right-4 top-4 flex flex-col items-end gap-2">
<div className="pointer-events-auto inline-flex items-center overflow-hidden rounded-lg border border-[var(--border)] bg-[var(--card)]/95 shadow-sm backdrop-blur">
<IconButton onClick={() => zoomBy(1.25)} title={t("Zoom in")}>
<ZoomIn className="h-3.5 w-3.5" />
</IconButton>
<IconButton onClick={() => zoomBy(1 / 1.25)} title={t("Zoom out")}>
<ZoomOut className="h-3.5 w-3.5" />
</IconButton>
<IconButton onClick={fit} title={t("Fit")}>
<Maximize2 className="h-3.5 w-3.5" />
</IconButton>
<span className="px-2 text-[11px] tabular-nums text-[var(--muted-foreground)]">
{(view.scale * 100).toFixed(0)}%
</span>
</div>
<div className="pointer-events-auto flex flex-col gap-1 rounded-lg border border-[var(--border)] bg-[var(--card)]/95 p-1.5 shadow-sm backdrop-blur">
{(["L3", "L2", "L1"] as const).map((layer) => (
<button
key={layer}
type="button"
onClick={() => setLayerOn({ ...layerOn, [layer]: !layerOn[layer] })}
className="group inline-flex items-center gap-2 rounded-md px-2 py-1 text-[11.5px] text-[var(--foreground)] transition hover:bg-[var(--muted)]"
>
<span
className="inline-block h-2 w-2 rounded-full"
style={{ background: LAYER_COLOR[layer] }}
/>
<span className="font-medium">{layer}</span>
<span className="text-[var(--muted-foreground)]">
{layer === "L3"
? t("Synthesis")
: layer === "L2"
? t("Per-surface")
: t("Raw traces")}
</span>
{layerOn[layer] ? (
<Eye className="ml-1 h-3 w-3 text-[var(--muted-foreground)]" />
) : (
<EyeOff className="ml-1 h-3 w-3 text-[var(--muted-foreground)]/60" />
)}
</button>
))}
</div>
</div>
);
}
function IconButton({
onClick,
title,
children,
}: {
onClick: () => void;
title: string;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
title={title}
className="grid h-8 w-8 place-items-center text-[var(--muted-foreground)] transition hover:bg-[var(--muted)] hover:text-[var(--foreground)]"
>
{children}
</button>
);
}
// ──────────────────────────────────────────────────────────── Legend
function Legend({ graph }: { graph: MemoryGraph }) {
const { t } = useTranslation();
// Aggregate counts.
const byLayer = useMemo(() => {
const out: Record<Layer, number> = { 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 (
<div className="pointer-events-none absolute bottom-4 left-4 max-w-md rounded-lg border border-[var(--border)] bg-[var(--card)]/92 p-3 text-[11.5px] shadow-sm backdrop-blur">
<div className="mb-2 flex items-center gap-3 text-[var(--muted-foreground)]">
{(["L3", "L2", "L1"] as const).map((layer) => (
<span key={layer} className="inline-flex items-center gap-1.5">
<span
className="inline-block h-2 w-2 rounded-full"
style={{ background: LAYER_COLOR[layer] }}
/>
<span className="font-medium text-[var(--foreground)]">
{layer}
</span>
<span>{byLayer[layer]}</span>
</span>
))}
</div>
<p className="leading-relaxed text-[var(--muted-foreground)]">
{t(
"Hover a node to preview the memory. Click to lock the highlight and trace its references inward (L1 → L2 → L3) or outward.",
)}
</p>
</div>
);
}
// ─────────────────────────────────────────────────────── 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 (
<div
className="pointer-events-none fixed z-50 max-w-sm rounded-lg border border-[var(--border)] bg-[var(--card)] p-3 text-[12px] shadow-lg"
style={style}
>
<div className="mb-1 flex items-center gap-2 text-[10.5px] uppercase tracking-wider text-[var(--muted-foreground)]">
<span
className="inline-block h-2 w-2 rounded-full"
style={{ background: LAYER_COLOR[node.layer] }}
/>
<span>{meta.layerLabel}</span>
<span className="text-[var(--border)]">·</span>
<span>{meta.clusterLabel}</span>
{node.section && (
<>
<span className="text-[var(--border)]">·</span>
<span className="text-[var(--foreground)]/80">{node.section}</span>
</>
)}
</div>
<p className="leading-relaxed text-[var(--foreground)] line-clamp-6">
{node.preview || node.label}
</p>
<p className="mt-2 text-[10.5px] text-[var(--muted-foreground)]">
{t("Click to lock · open in workbench")}
</p>
</div>
);
}
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 };