chore: import upstream snapshot with attribution
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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:00:43 +08:00
commit e4dcfc49aa
1668 changed files with 324490 additions and 0 deletions
+989
View File
@@ -0,0 +1,989 @@
"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 };