Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions cli/api/src/pb/api/static/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
99 changes: 99 additions & 0 deletions ui/app/src/views/features/diagrams/Diagrams.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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">
Expand All @@ -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!}
Expand Down
63 changes: 62 additions & 1 deletion ui/tests/components/Diagrams.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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</title><ellipse/></g>
<g class="node"><title>w_beta</title><ellipse/></g>
<g class="node"><title>w_gamma</title><ellipse/></g>
<g class="edge"><title>w_alpha&#45;&gt;w_beta</title><path/></g>
</svg>`;

const withGraph = (extra = {}) => ({
...callsDiagrams,
job: { ...initialJobPollState<string>(), 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();
});
});
Loading