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
|
* no per-node size gating, so labels never pop in one by one. (The Obsidian
|
||||||
* text-fade feel, applied to the whole graph.)
|
* 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
|
* Clicking a node opens the detail card anchored just below the node's
|
||||||
* viewport position (flipping above near the bottom edge); any camera move
|
* viewport position (flipping above near the bottom edge); any camera move
|
||||||
* (pan/zoom) dismisses it. The `requires` edges render as arrows pointing
|
* (pan/zoom) dismisses it. The `requires` edges render as arrows pointing
|
||||||
* prerequisite → dependent.
|
* prerequisite → dependent.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { WaypointsIcon } from "lucide-react";
|
import { SearchIcon, WaypointsIcon } from "lucide-react";
|
||||||
import Graph from "graphology";
|
import Graph from "graphology";
|
||||||
import forceAtlas2 from "graphology-layout-forceatlas2";
|
import forceAtlas2 from "graphology-layout-forceatlas2";
|
||||||
import type { NodeHoverDrawingFunction, NodeLabelDrawingFunction } from "sigma/rendering";
|
import type { NodeHoverDrawingFunction, NodeLabelDrawingFunction } from "sigma/rendering";
|
||||||
@@ -69,6 +76,26 @@ const STATE_LABELS: Record<NodeStateClass, string> = {
|
|||||||
candidate: "candidate",
|
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) ---------------------------------------------
|
// ---- Label drawing (canvas) ---------------------------------------------
|
||||||
//
|
//
|
||||||
// sigma's defaults draw the label on ONE line to the right of the node with
|
// 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 cardRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [snapshot, setSnapshot] = useState<GraphSnapshot | null>(null);
|
const [snapshot, setSnapshot] = useState<GraphSnapshot | null>(null);
|
||||||
const [selected, setSelected] = useState<Selection | 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).
|
// Fetch the derived graph (the route folds stale chats first).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -426,6 +497,9 @@ export function GraphView() {
|
|||||||
defaultDrawNodeLabel: drawLabel,
|
defaultDrawNodeLabel: drawLabel,
|
||||||
defaultDrawNodeHover: drawHover,
|
defaultDrawNodeHover: drawHover,
|
||||||
});
|
});
|
||||||
|
graphRef.current = graph;
|
||||||
|
rendererRef.current = renderer;
|
||||||
|
setRendererEpoch((e) => e + 1);
|
||||||
renderer.on("clickNode", ({ node }) => {
|
renderer.on("clickNode", ({ node }) => {
|
||||||
const graphNode = snapshot.nodes.find((n) => n.id === node);
|
const graphNode = snapshot.nodes.find((n) => n.id === node);
|
||||||
if (!graphNode) return;
|
if (!graphNode) return;
|
||||||
@@ -496,10 +570,32 @@ export function GraphView() {
|
|||||||
return () => {
|
return () => {
|
||||||
killed = true;
|
killed = true;
|
||||||
if (hoverAnimId !== null) cancelAnimationFrame(hoverAnimId);
|
if (hoverAnimId !== null) cancelAnimationFrame(hoverAnimId);
|
||||||
|
rendererRef.current = null;
|
||||||
|
graphRef.current = null;
|
||||||
renderer?.kill();
|
renderer?.kill();
|
||||||
};
|
};
|
||||||
}, [snapshot]);
|
}, [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
|
// Position the detail card: below the clicked node, flipped above near
|
||||||
// the bottom edge, clamped horizontally. Imperative (ref + layout effect,
|
// the bottom edge, clamped horizontally. Imperative (ref + layout effect,
|
||||||
// before paint) because the flip needs the card's measured height.
|
// 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" />
|
<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 && (
|
{selected && !isEmpty && (
|
||||||
<div
|
<div
|
||||||
ref={cardRef}
|
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