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 }) { const [focalInput, setFocalInput] = createSignal(""); const [depthInput, setDepthInput] = createSignal("2"); const [tableInput, setTableInput] = createSignal(""); + const [focusQuery, setFocusQuery] = createSignal(""); + const [focusHits, setFocusHits] = createSignal(null); + let svgWrapEl: HTMLDivElement | undefined; // Hover tooltip state const [tooltip, setTooltip] = createSignal<{ x: number; y: number; kind: "object" | "table"; name: string; meta: Record } | null>(null); @@ -155,6 +158,78 @@ export function Diagrams(props: { store: Store }) { 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 whose 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(); + }); });