From becdb9472878424fb97942de2d9548d16262d74e Mon Sep 17 00:00:00 2001 From: Jimmy Kirk Date: Sun, 16 Aug 2026 15:11:49 -0500 Subject: [PATCH] feat(ui): focus search that dims everything outside a match's neighbourhood Whole-codebase diagrams stay saturated even once they are laid out legibly and zoomable: every node is drawn at full strength, so finding the part you care about means visually filtering hundreds of nodes by hand. This adds a search box above any rendered diagram. Typing a name fades everything outside the match's immediate neighbourhood to 7% opacity and outlines the matches. It applies to every diagram kind, with no server support and no extra request, because graphviz already writes the graph structure into the SVG it emits -- nodes carry their name in , edges carry "from->to" -- so adjacency is rebuilt from what is already on screen. Two deliberate choices: * One hop is kept, not just the match. A node alone with everything else dimmed shows where it is but not what it does; its neighbours are what make the match legible. * Non-matches are dimmed rather than hidden. The surrounding shape stays faintly visible, so you keep your bearings in the graph -- which matters most on exactly the dense diagrams that motivate this. Filtering would read cleaner and lose that. Verified in a browser against a real 3,045-object corpus: an exact node name dimmed 59 of 61 nodes and 60 of 61 edges, keeping the match and its one neighbour; a substring matched 8 and dimmed 51; a nonsense query left the diagram untouched and reported "no match"; clearing restored everything. Four tests cover neighbourhood dimming, restore-on-clear, the no-match case, and the box being absent before a diagram renders. --- cli/api/src/pb/api/static/style.css | 10 ++ .../src/views/features/diagrams/Diagrams.tsx | 99 +++++++++++++++++++ ui/tests/components/Diagrams.test.tsx | 63 +++++++++++- 3 files changed, 171 insertions(+), 1 deletion(-) diff --git a/cli/api/src/pb/api/static/style.css b/cli/api/src/pb/api/static/style.css index 36acb4a2..70c0cb68 100644 --- a/cli/api/src/pb/api/static/style.css +++ b/cli/api/src/pb/api/static/style.css @@ -284,6 +284,16 @@ body { /* Hasse-style diagrams are much taller than wide; the default viewport fits them down to an illegible ribbon. */ .diagram-viewport.tall { min-height: 840px; } + +/* Focus search: everything outside the match's neighbourhood fades back so the + relevant subgraph reads clearly, while the surrounding shape stays visible + as context. Filtering nodes out entirely would lose that. */ +.diagram-svg-wrap g.node, +.diagram-svg-wrap g.edge { transition: opacity .15s ease; } +.diagram-svg-wrap g.diagram-dim { opacity: 0.07; } +.diagram-svg-wrap g.diagram-hit ellipse, +.diagram-svg-wrap g.diagram-hit polygon, +.diagram-svg-wrap g.diagram-hit path { stroke: #FFD700; stroke-width: 2.5; } .diagram-viewport .diagram-svg-wrap { transform-origin: 0 0; display: inline-block; diff --git a/ui/app/src/views/features/diagrams/Diagrams.tsx b/ui/app/src/views/features/diagrams/Diagrams.tsx index 28c13c30..e7c338eb 100644 --- a/ui/app/src/views/features/diagrams/Diagrams.tsx +++ b/ui/app/src/views/features/diagrams/Diagrams.tsx @@ -22,6 +22,9 @@ export function Diagrams(props: { store: Store<AppState, AppAction> }) { const [focalInput, setFocalInput] = createSignal(""); const [depthInput, setDepthInput] = createSignal("2"); const [tableInput, setTableInput] = createSignal(""); + const [focusQuery, setFocusQuery] = createSignal(""); + const [focusHits, setFocusHits] = createSignal<number | null>(null); + let svgWrapEl: HTMLDivElement | undefined; // Hover tooltip state const [tooltip, setTooltip] = createSignal<{ x: number; y: number; kind: "object" | "table"; name: string; meta: Record<string, string> } | null>(null); @@ -155,6 +158,78 @@ export function Diagrams(props: { store: Store<AppState, AppAction> }) { navigator.clipboard.writeText(svg); } + // --- Focus search: dim everything not adjacent to the match --------------- + // + // Graphviz writes the graph's structure into the SVG it emits: every node is + // a <g class="node"> whose <title> is the node name, and every edge a + // <g class="edge"> whose <title> is "from->to". That is enough to rebuild + // adjacency in the browser, with no extra request and no server support. + // + // At the sizes these diagrams reach, reading them is limited by everything + // that is NOT relevant being drawn at full strength. Dimming is preferred to + // filtering because the surrounding shape stays visible as context. + + const titleOf = (g: Element) => g.querySelector("title")?.textContent?.trim() ?? ""; + + // Digraph edges render as "a->b", undirected as "a--b". + const edgeEnds = (g: Element): [string, string] | null => { + const parts = titleOf(g).split(/->|--/); + const [from, to] = parts; + if (parts.length !== 2 || from === undefined || to === undefined) return null; + return [from.trim(), to.trim()]; + }; + + function applyFocus(query: string) { + const root = svgWrapEl; + if (!root) return; + const nodes = Array.from(root.querySelectorAll("g.node")); + const edges = Array.from(root.querySelectorAll("g.edge")); + const clear = () => { + for (const g of [...nodes, ...edges]) g.classList.remove("diagram-dim", "diagram-hit"); + }; + + const needle = query.trim().toLowerCase(); + if (!needle) { clear(); setFocusHits(null); return; } + + const matched = new Set<string>(); + for (const g of nodes) { + const name = titleOf(g); + if (name.toLowerCase().includes(needle)) matched.add(name); + } + setFocusHits(matched.size); + if (!matched.size) { clear(); return; } + + // Keep the matches plus anything one hop away, so a match is shown with + // the context that explains it rather than stranded on its own. + const keep = new Set(matched); + for (const g of edges) { + const ends = edgeEnds(g); + if (!ends) continue; + const [a, b] = ends; + if (matched.has(a)) keep.add(b); + if (matched.has(b)) keep.add(a); + } + + for (const g of nodes) { + const name = titleOf(g); + g.classList.toggle("diagram-dim", !keep.has(name)); + g.classList.toggle("diagram-hit", matched.has(name)); + } + for (const g of edges) { + const ends = edgeEnds(g); + const live = !!ends && (matched.has(ends[0]) || matched.has(ends[1])); + g.classList.toggle("diagram-dim", !live); + } + } + + // Re-apply whenever the query changes or a new diagram arrives. The SVG is + // replaced wholesale by innerHTML, so the classes have to be re-stamped. + createEffect(() => { + const q = focusQuery(); + dg().job.result; + queueMicrotask(() => applyFocus(q)); + }); + const needsGenerate = () => !AUTO_GENERATE.has(activeTab()); return ( @@ -196,6 +271,29 @@ export function Diagrams(props: { store: Store<AppState, AppAction> }) { </div> </Show> + <Show when={dg().job.result}> + <div class="card" style={{ padding: "10px 20px" }}> + <div style={{ display: "flex", gap: "8px", "align-items": "center" }}> + <input + class="search-input" + type="search" + placeholder="Focus\u2026 dim everything not connected" + value={focusQuery()} + style={{ flex: "1", "max-width": "26rem" }} + onInput={(e) => setFocusQuery(e.currentTarget.value)} + /> + <Show when={focusHits() !== null}> + <span style={{ color: "var(--text-secondary)", "font-size": ".85rem" }}> + {focusHits() === 0 ? "no match" : `${focusHits()} match${focusHits() === 1 ? "" : "es"} + neighbours`} + </span> + </Show> + <Show when={focusQuery()}> + <button class="filter-pill" onClick={() => setFocusQuery("")}>clear</button> + </Show> + </div> + </div> + </Show> + <div class="card"> <Show when={dg().job.status === "pending"}> <div class="diagram-container"> @@ -221,6 +319,7 @@ export function Diagrams(props: { store: Store<AppState, AppAction> }) { onDownload={downloadSvg} /> <div + ref={(el) => { svgWrapEl = el; }} class="diagram-svg-wrap" style={{ transform: `translate(${pan.state.offset().x}px, ${pan.state.offset().y}px) scale(${pan.state.scale()})` }} innerHTML={dg().job.result!} diff --git a/ui/tests/components/Diagrams.test.tsx b/ui/tests/components/Diagrams.test.tsx index bc91077a..374bb4fd 100644 --- a/ui/tests/components/Diagrams.test.tsx +++ b/ui/tests/components/Diagrams.test.tsx @@ -1,7 +1,7 @@ // tests/components/Diagrams.test.tsx — Tests for Diagrams component. import { describe, it, expect } from "vitest"; -import { screen } from "@solidjs/testing-library"; +import { fireEvent, screen, waitFor } from "@solidjs/testing-library"; import { initialJobPollState } from "@pb/core"; import { renderWithStore } from "../helpers.js"; import { Diagrams } from "../../app/src/views/features/diagrams/Diagrams.js"; @@ -121,4 +121,65 @@ describe("Diagrams component", () => { const selectActions = captured.filter((a) => a.tag === "diagrams" && a.action.tag === "select"); expect(selectActions.length).toBe(0); }); + + // A graphviz-shaped SVG: node <title> is the name, edge <title> is "a->b". + const graphSvg = `<svg viewBox="0 0 100 100"> + <g class="node"><title>w_alpha + w_beta + w_gamma + w_alpha->w_beta + `; + + const withGraph = (extra = {}) => ({ + ...callsDiagrams, + job: { ...initialJobPollState(), status: "done" as const, result: graphSvg }, + ...extra, + }); + + it("focus search dims everything outside the match's neighbourhood", async () => { + const { container } = renderWithStore(Diagrams, { diagrams: withGraph() }); + const box = container.querySelector('input[type="search"]') as HTMLInputElement; + expect(box).not.toBeNull(); + + fireEvent.input(box, { target: { value: "w_alpha" } }); + + await waitFor(() => { + // w_alpha matches; w_beta is one hop away and stays lit; w_gamma dims. + const dimmed = Array.from(container.querySelectorAll("g.node.diagram-dim")) + .map((g) => g.querySelector("title")?.textContent); + expect(dimmed).toEqual(["w_gamma"]); + }); + + const hit = Array.from(container.querySelectorAll("g.node.diagram-hit")) + .map((g) => g.querySelector("title")?.textContent); + expect(hit).toEqual(["w_alpha"]); + }); + + it("focus search restores everything when cleared", async () => { + const { container } = renderWithStore(Diagrams, { diagrams: withGraph() }); + const box = container.querySelector('input[type="search"]') as HTMLInputElement; + + fireEvent.input(box, { target: { value: "w_alpha" } }); + await waitFor(() => expect(container.querySelectorAll("g.diagram-dim").length).toBeGreaterThan(0)); + + fireEvent.input(box, { target: { value: "" } }); + await waitFor(() => { + expect(container.querySelectorAll("g.diagram-dim").length).toBe(0); + expect(container.querySelectorAll("g.diagram-hit").length).toBe(0); + }); + }); + + it("focus search with no match leaves the diagram untouched", async () => { + const { container } = renderWithStore(Diagrams, { diagrams: withGraph() }); + const box = container.querySelector('input[type="search"]') as HTMLInputElement; + + fireEvent.input(box, { target: { value: "nothing_matches_this" } }); + await waitFor(() => expect(screen.getByText("no match")).toBeDefined()); + expect(container.querySelectorAll("g.diagram-dim").length).toBe(0); + }); + + it("no focus search box before a diagram has rendered", () => { + const { container } = renderWithStore(Diagrams, { diagrams: { ...callsDiagrams } }); + expect(container.querySelector('input[type="search"]')).toBeNull(); + }); });