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
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:
@@ -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 };
|
||||
Reference in New Issue
Block a user