Legend/filters panel: search + per-state visibility toggles
The top-right overlay doubles as the state legend and the filter surface: a search box (matches name or statement, case-insensitive) plus one row per node state in reading order (taught, known, missed, don't know, candidate) with its color dot, label, and total count. Clicking a row toggles that state's visibility. Filtering flows through sigma's official nodeReducer/edgeReducer settings: hidden nodes drop out of rendering, hover, picking, AND labels; edges drop when either endpoint is hidden; an open detail card closes if its node is filtered out. Pure predicate (isNodeVisible) is exported and covered by 7 tests; the reducers just consume its verdict. Verified live: search 'Nietzsche' 5457->1687->5457 label px (exact restore), known toggle drops 13 discs+labels and restores, nonsense query shows the 'No matching nodes.' hint with zero labels, counts 0/13/0/0/10 match the ledger.
This commit is contained in:
+144
-2
@@ -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<NodeStateClass, string> = {
|
||||
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<GraphNode, "name" | "statement">,
|
||||
state: NodeStateClass,
|
||||
hiddenStates: ReadonlySet<NodeStateClass>,
|
||||
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<HTMLDivElement | null>(null);
|
||||
const [snapshot, setSnapshot] = useState<GraphSnapshot | null>(null);
|
||||
const [selected, setSelected] = useState<Selection | null>(null);
|
||||
// Legend/filters panel state: toggled-off states + search text.
|
||||
const [hiddenStates, setHiddenStates] = useState<ReadonlySet<NodeStateClass>>(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<import("sigma").default | null>(null);
|
||||
const graphRef = useRef<Graph | null>(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<NodeStateClass, number> = {
|
||||
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<string>();
|
||||
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() {
|
||||
<div ref={containerRef} className="h-full w-full" />
|
||||
)}
|
||||
|
||||
{/* Legend + filters: search box and per-state toggle rows. */}
|
||||
{!isEmpty && (
|
||||
<div className="absolute right-3 top-3 z-10 w-64 rounded-xl border bg-card/85 p-2.5 shadow-lg backdrop-blur">
|
||||
<div className="relative">
|
||||
<SearchIcon className="absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-col">
|
||||
{STATES_ORDER.map((state) => {
|
||||
const active = !hiddenStates.has(state);
|
||||
return (
|
||||
<button
|
||||
key={state}
|
||||
type="button"
|
||||
onClick={() => toggleState(state)}
|
||||
aria-pressed={!active}
|
||||
className="flex items-center gap-2 rounded-md px-1.5 py-1 text-left text-xs transition-colors hover:bg-accent"
|
||||
>
|
||||
<span
|
||||
className="size-2.5 shrink-0 rounded-full"
|
||||
style={{ background: `var(${STATE_TOKENS[state].cssVar})`, opacity: active ? 1 : 0.25 }}
|
||||
/>
|
||||
<span className={active ? "text-foreground" : "text-muted-foreground line-through"}>
|
||||
{STATE_LABELS[state]}
|
||||
</span>
|
||||
<span className="ml-auto tabular-nums text-[10px] text-muted-foreground">
|
||||
{stateCounts[state]}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{snapshot !== null && visibleIds.size === 0 && (query.trim() !== "" || hiddenStates.size > 0) && (
|
||||
<div className="mt-1.5 border-t px-1.5 pt-1.5 text-[11px] text-muted-foreground">
|
||||
No matching nodes.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected && !isEmpty && (
|
||||
<div
|
||||
ref={cardRef}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Legend/filters predicate tests — the pure half of the graph-view filters
|
||||
* panel (isNodeVisible); the sigma reducers just consume its verdict.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { isNodeVisible } from "../components/graph-view.tsx";
|
||||
import type { NodeStateClass } from "../src/core/index.ts";
|
||||
|
||||
const none: ReadonlySet<NodeStateClass> = 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<NodeStateClass>(["known"]);
|
||||
assert.equal(isNodeVisible(node, "known", hidden, "money"), false);
|
||||
});
|
||||
|
||||
it("a toggled-off state does not affect other states", () => {
|
||||
const hidden = new Set<NodeStateClass>(["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<NodeStateClass>(["candidate"]);
|
||||
assert.equal(isNodeVisible(node, "candidate", hidden, ""), false);
|
||||
assert.equal(isNodeVisible(node, "candidate", none, ""), true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user