diff --git a/components/graph-view.tsx b/components/graph-view.tsx index 99666be..69aefc4 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -38,15 +38,22 @@ * no per-node size gating, so labels never pop in one by one. (The Obsidian * text-fade feel, applied to the whole graph.) * + * The legend/filters panel (top-right overlay) doubles as the state legend: + * a search box plus one row per node state (colored dot, label, count). + * Clicking a row toggles that state's visibility; search matches the + * name or statement text. Filtering flows through sigma's official + * nodeReducer/edgeReducer settings (`hidden`), so rendering, hover, + * picking, and labels all respect it; edges drop when either endpoint is + * hidden, and an open detail card closes if its node is filtered out. * Clicking a node opens the detail card anchored just below the node's * viewport position (flipping above near the bottom edge); any camera move * (pan/zoom) dismisses it. The `requires` edges render as arrows pointing * prerequisite → dependent. */ -import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; -import { WaypointsIcon } from "lucide-react"; +import { SearchIcon, WaypointsIcon } from "lucide-react"; import Graph from "graphology"; import forceAtlas2 from "graphology-layout-forceatlas2"; import type { NodeHoverDrawingFunction, NodeLabelDrawingFunction } from "sigma/rendering"; @@ -69,6 +76,26 @@ const STATE_LABELS: Record = { candidate: "candidate", }; +/** Legend row order (the "progress" reading of the states). */ +const STATES_ORDER: NodeStateClass[] = ["taught", "known", "missed", "dont-know", "candidate"]; + +/** + * Pure filter predicate for the legend/filters panel: a node is visible + * when its state is toggled on AND (no query OR name/statement matches). + * Exported for tests; the sigma reducers just consume its verdict. + */ +export function isNodeVisible( + node: Pick, + state: NodeStateClass, + hiddenStates: ReadonlySet, + query: string, +): boolean { + if (hiddenStates.has(state)) return false; + const q = query.trim().toLowerCase(); + if (!q) return true; + return node.name.toLowerCase().includes(q) || node.statement.toLowerCase().includes(q); +} + // ---- Label drawing (canvas) --------------------------------------------- // // sigma's defaults draw the label on ONE line to the right of the node with @@ -324,6 +351,50 @@ export function GraphView() { const cardRef = useRef(null); const [snapshot, setSnapshot] = useState(null); const [selected, setSelected] = useState(null); + // Legend/filters panel state: toggled-off states + search text. + const [hiddenStates, setHiddenStates] = useState>(new Set()); + const [query, setQuery] = useState(""); + // The sigma instance and its graphology graph live inside the mount + // effect; refs expose them to the filters effect, and `rendererEpoch` + // re-triggers it when a new renderer is constructed. + const rendererRef = useRef(null); + const graphRef = useRef(null); + const [rendererEpoch, setRendererEpoch] = useState(0); + + const toggleState = (state: NodeStateClass) => { + setHiddenStates((prev) => { + const next = new Set(prev); + if (next.has(state)) next.delete(state); + else next.add(state); + return next; + }); + }; + + // Per-state node counts (of ALL nodes, not filtered ones). + const stateCounts = useMemo(() => { + const counts: Record = { + taught: 0, + known: 0, + missed: 0, + "dont-know": 0, + candidate: 0, + }; + if (snapshot) + for (const state of Object.values(snapshot.nodeStates)) counts[state]++; + return counts; + }, [snapshot]); + + // The id set that survives the current filters — consumed by the sigma + // reducers and the "no matches" hint. + const visibleIds = useMemo(() => { + const set = new Set(); + if (snapshot) + for (const node of snapshot.nodes) { + if (isNodeVisible(node, snapshot.nodeStates[node.id] ?? "candidate", hiddenStates, query)) + set.add(node.id); + } + return set; + }, [snapshot, hiddenStates, query]); // Fetch the derived graph (the route folds stale chats first). useEffect(() => { @@ -426,6 +497,9 @@ export function GraphView() { defaultDrawNodeLabel: drawLabel, defaultDrawNodeHover: drawHover, }); + graphRef.current = graph; + rendererRef.current = renderer; + setRendererEpoch((e) => e + 1); renderer.on("clickNode", ({ node }) => { const graphNode = snapshot.nodes.find((n) => n.id === node); if (!graphNode) return; @@ -496,10 +570,32 @@ export function GraphView() { return () => { killed = true; if (hoverAnimId !== null) cancelAnimationFrame(hoverAnimId); + rendererRef.current = null; + graphRef.current = null; renderer?.kill(); }; }, [snapshot]); + // Filtering through sigma's official reducers: hidden nodes drop out of + // rendering, picking, hover, AND labels; edges drop when either endpoint + // is hidden. Re-runs whenever filters change or a renderer is (re)built + // (epoch), so the reducers always close over current values. + useEffect(() => { + const renderer = rendererRef.current; + const graph = graphRef.current; + if (!renderer || !graph) return; + renderer.setSetting("nodeReducer", (key, attrs) => + visibleIds.has(key) ? attrs : { ...attrs, hidden: true }, + ); + renderer.setSetting("edgeReducer", (key, attrs) => { + const source = graph.source(key); + const target = graph.target(key); + return visibleIds.has(source) && visibleIds.has(target) ? attrs : { ...attrs, hidden: true }; + }); + // The open detail card cannot outlive its node's visibility. + setSelected((sel) => (sel && visibleIds.has(sel.node.id) ? sel : null)); + }, [rendererEpoch, visibleIds]); + // Position the detail card: below the clicked node, flipped above near // the bottom edge, clamped horizontally. Imperative (ref + layout effect, // before paint) because the flip needs the card's measured height. @@ -544,6 +640,52 @@ export function GraphView() {
)} + {/* Legend + filters: search box and per-state toggle rows. */} + {!isEmpty && ( +
+
+ + setQuery(e.target.value)} + placeholder="Search nodes…" + spellCheck={false} + className="h-8 w-full rounded-lg border bg-background pl-8 pr-2 text-xs outline-none placeholder:text-muted-foreground focus:border-ring" + /> +
+
+ {STATES_ORDER.map((state) => { + const active = !hiddenStates.has(state); + return ( + + ); + })} +
+ {snapshot !== null && visibleIds.size === 0 && (query.trim() !== "" || hiddenStates.size > 0) && ( +
+ No matching nodes. +
+ )} +
+ )} + {selected && !isEmpty && (
= new Set(); +const node = { name: "Money multiplier", statement: "The money multiplier is the maximum ratio of broad money to base money." }; + +describe("isNodeVisible (legend/filters predicate)", () => { + it("is visible with no filters", () => { + assert.equal(isNodeVisible(node, "known", none, ""), true); + }); + + it("is hidden when its state is toggled off, even if the query matches", () => { + const hidden = new Set(["known"]); + assert.equal(isNodeVisible(node, "known", hidden, "money"), false); + }); + + it("a toggled-off state does not affect other states", () => { + const hidden = new Set(["known"]); + assert.equal(isNodeVisible(node, "taught", hidden, ""), true); + }); + + it("search matches the name case-insensitively", () => { + assert.equal(isNodeVisible(node, "candidate", none, "MONEY multiplier"), true); + }); + + it("search matches the statement when the name does not", () => { + assert.equal(isNodeVisible(node, "candidate", none, "maximum ratio"), true); + assert.equal(isNodeVisible(node, "candidate", none, " velocity"), false); + }); + + it("whitespace-only query is no filter", () => { + assert.equal(isNodeVisible(node, "missed", none, " "), true); + }); + + it("a node with no state entry defaults to candidate", () => { + // candidate hidden → invisible; visible otherwise. + const hidden = new Set(["candidate"]); + assert.equal(isNodeVisible(node, "candidate", hidden, ""), false); + assert.equal(isNodeVisible(node, "candidate", none, ""), true); + }); +}); \ No newline at end of file