From 13c2e8d611337ab2c7760aab418805cb31cfa36b Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Thu, 25 Jun 2026 15:02:01 +0200 Subject: [PATCH 1/3] feat(explain): lay out the pipeline graph with dagre (injected seam) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the hand-rolled layered layout with @dagrejs/dagre — full Sugiyama (network-simplex ranking, crossing-minimization, Brandes–Köpf coordinates) with routed edge bend points. dagre is injected like app.Chart (env.Dagre || win.dagre); our DOT parser (core/dot.js) and SVG drawer (ui/explain-graph.js) are unchanged. The pure wrapper core/dot-layout.js takes the injected dagre and returns the same {nodes,edges,width,height} shape the drawer consumes. On the antalya/ontime fact-dim-join pipeline this turns a 4654×534 strip (19 nodes in one row, long diagonal edges) into a balanced ~1300×800 DAG (~12 ranks, ≤5 wide) with cleanly routed edges. Bundle: +39 KB (dagre inlined). - core/dot.js: drop layoutGraph (now dagre); keep parseDot. - core/dot-layout.js (new): dagreLayout(dagre, graph) + nodeWidth, 100% covered. - ui/explain-graph.js: buildPipelineSvg(rawText, dagre); renderExplainGraph(app, r). - app.js: Dagre seam; main.js: import + inject; fake-app: inject real dagre. - e2e: pipeline.html loads dagre's ESM build from node_modules. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QGBS74oUsXarGkCRQKEFLu --- package.json | 1 + src/core/dot-layout.js | 52 ++++++++++++++++ src/core/dot.js | 102 ++----------------------------- src/main.js | 3 +- src/ui/app.js | 3 + src/ui/explain-graph.js | 18 +++--- src/ui/results.js | 2 +- tests/e2e/pipeline.html | 8 ++- tests/helpers/fake-app.js | 2 + tests/unit/dot-layout.test.js | 58 ++++++++++++++++++ tests/unit/dot.test.js | 49 +-------------- tests/unit/explain-graph.test.js | 30 +++++---- 12 files changed, 157 insertions(+), 171 deletions(-) create mode 100644 src/core/dot-layout.js create mode 100644 tests/unit/dot-layout.test.js diff --git a/package.json b/package.json index eff8f1b..d5a33c4 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "vitest": "^2.1.8" }, "dependencies": { + "@dagrejs/dagre": "^3.0.0", "chart.js": "^4.5.1" } } diff --git a/src/core/dot-layout.js b/src/core/dot-layout.js new file mode 100644 index 0000000..dd77269 --- /dev/null +++ b/src/core/dot-layout.js @@ -0,0 +1,52 @@ +// Lay out a parsed pipeline graph with dagre — a proven layered-graph engine +// (network-simplex ranking, crossing-minimization, Brandes–Köpf coordinate +// assignment, routed edge bend points). dagre is *injected* (the same seam +// pattern as app.Chart) so this module stays pure: no import of the library, no +// DOM, no globals. Returns the same shape the SVG drawer consumes: +// { nodes:[{id,label,x,y,w,h}], edges:[{from,to,points}], width, height } +// with node x/y as top-left (dagre reports centres) and edge points as the +// routed polyline. + +const NODE_H = 30; +const CHAR_W = 7; +const PAD_X = 18; +const MIN_W = 64; +const NODESEP = 26; // gap between processors in the same rank +const RANKSEP = 38; // gap between ranks (top→bottom) +const MARGIN = 12; + +/** Box width for a node label (monospace estimate, floored at MIN_W). */ +export function nodeWidth(label) { + return Math.max(MIN_W, String(label).length * CHAR_W + PAD_X); +} + +/** + * @param dagre the injected dagre module (`{ graphlib, layout }`) + * @param graph parsed `{ nodes:[{id,label}], edges:[{from,to}] }` + */ +export function dagreLayout(dagre, graph) { + const nodes = graph.nodes || []; + if (!nodes.length) return { nodes: [], edges: [], width: 0, height: 0 }; + const ids = new Set(nodes.map((n) => n.id)); + // Keep edges between declared processors; drop self-loops (a Resize feedback + // would just loop onto its own box). + const edges = (graph.edges || []).filter((e) => ids.has(e.from) && ids.has(e.to) && e.from !== e.to); + + const g = new dagre.graphlib.Graph(); + g.setGraph({ rankdir: 'TB', nodesep: NODESEP, ranksep: RANKSEP, marginx: MARGIN, marginy: MARGIN }); + g.setDefaultEdgeLabel(() => ({})); + for (const n of nodes) g.setNode(n.id, { width: nodeWidth(n.label), height: NODE_H }); + for (const e of edges) g.setEdge(e.from, e.to); + dagre.layout(g); + + const outNodes = nodes.map((n) => { + const dn = g.node(n.id); + return { id: n.id, label: n.label, x: dn.x - dn.width / 2, y: dn.y - dn.height / 2, w: dn.width, h: dn.height }; + }); + const outEdges = edges.map((e) => ({ + from: e.from, to: e.to, + points: g.edge(e.from, e.to).points.map((p) => ({ x: p.x, y: p.y })), + })); + const gg = g.graph(); + return { nodes: outNodes, edges: outEdges, width: gg.width, height: gg.height }; +} diff --git a/src/core/dot.js b/src/core/dot.js index 94efe17..e9462bf 100644 --- a/src/core/dot.js +++ b/src/core/dot.js @@ -1,19 +1,10 @@ -// Pure Graphviz-DOT parsing + layered-DAG layout for the Pipeline view -// (`EXPLAIN PIPELINE graph = 1` returns DOT). No DOM, no globals — the SVG -// drawing lives in src/ui/explain-graph.js. Kept deliberately small: a lenient -// regex parse (we only need nodes + edges) and a Sugiyama-lite left→right layout. +// Pure Graphviz-DOT parsing for the Pipeline view (`EXPLAIN PIPELINE graph = 1` +// returns DOT). No DOM, no globals. The graph *layout* lives in +// src/core/dot-layout.js (dagre seam); the SVG drawing in src/ui/explain-graph.js. +// Kept deliberately small: a lenient regex parse (we only need nodes + edges). const DOT_KEYWORDS = new Set(['node', 'edge', 'graph', 'subgraph', 'digraph']); -// Layout constants (px). Tuned for ClickHouse pipeline graphs (short labels). -const NODE_H = 30; -const V_GAP = 30; // vertical gap between sequential stages (room for the arrow) -const H_GAP = 28; // horizontal gap between parallel processors in a stage -const CHAR_W = 7; -const PAD_X = 18; -const MIN_W = 64; -const MARGIN = 12; - function unescapeDot(s) { // Labels may carry escaped quotes and `\n` line breaks; collapse to one line. return String(s).replace(/\\n/g, ' ').replace(/\\(.)/g, '$1').trim(); @@ -54,88 +45,3 @@ export function parseDot(text) { } return { nodes, edges }; } - -/** - * Lay a parsed graph out top→bottom by layer: sequential stages stack - * vertically, parallel processors in the same stage sit side-by-side. Returns - * positioned nodes (`{id,label,x,y,w,h}`), edges as 2-point polylines - * (`{from,to,points}`, bottom-centre → top-centre), and the overall - * `width`/`height` for the SVG viewBox. Longest-path layering (Kahn topo; - * cycle-safe — leftover nodes stay in layer 0), each row centred. Pure. - */ -export function layoutGraph(graph) { - const nodes = (graph.nodes || []).map((n) => ({ ...n })); - if (!nodes.length) return { nodes: [], edges: [], width: 0, height: 0 }; - const byId = new Map(nodes.map((n) => [n.id, n])); - // Drop edges to unknown nodes and self-loops (a self-loop would just draw a - // line over its own node and can stall the topological layering). - const edges = (graph.edges || []).filter((e) => byId.has(e.from) && byId.has(e.to) && e.from !== e.to); - - // Longest-path layering via Kahn topological order. - const succ = new Map(nodes.map((n) => [n.id, []])); - const indeg = new Map(nodes.map((n) => [n.id, 0])); - for (const e of edges) { - succ.get(e.from).push(e.to); - indeg.set(e.to, indeg.get(e.to) + 1); - } - const layer = new Map(nodes.map((n) => [n.id, 0])); - const queue = nodes.filter((n) => indeg.get(n.id) === 0).map((n) => n.id); - while (queue.length) { - const u = queue.shift(); - for (const v of succ.get(u)) { - if (layer.get(u) + 1 > layer.get(v)) layer.set(v, layer.get(u) + 1); - indeg.set(v, indeg.get(v) - 1); - if (indeg.get(v) === 0) queue.push(v); - } - } - - // Group node ids by layer (in discovery order). Longest-path layering yields - // contiguous layers 0..max, each non-empty, so a fixed-length array is safe. - const maxLayer = Math.max(...nodes.map((n) => layer.get(n.id))); - const layers = Array.from({ length: maxLayer + 1 }, () => []); - for (const n of nodes) layers[layer.get(n.id)].push(n.id); - - // Vertical layout: layers stack top→bottom (sequential stages), and nodes - // within a layer sit side-by-side left→right (parallel processors). Node widths - // vary with the label; each row is centred under the widest row. - for (const n of nodes) { - n.w = Math.max(MIN_W, n.label.length * CHAR_W + PAD_X); - n.h = NODE_H; - } - const rowWidth = (col) => - col.reduce((sum, id) => sum + byId.get(id).w, 0) + Math.max(0, col.length - 1) * H_GAP; - let maxRowW = 0; - for (const col of layers) maxRowW = Math.max(maxRowW, rowWidth(col)); - - for (let L = 0; L < layers.length; L++) { - const col = layers[L]; - const y = MARGIN + L * (NODE_H + V_GAP); - let x = MARGIN + (maxRowW - rowWidth(col)) / 2; - for (const id of col) { - const n = byId.get(id); - n.x = x; - n.y = y; - x += n.w + H_GAP; - } - } - - const laidEdges = edges.map((e) => { - const a = byId.get(e.from); - const b = byId.get(e.to); - return { - from: e.from, - to: e.to, - points: [ - { x: a.x + a.w / 2, y: a.y + a.h }, - { x: b.x + b.w / 2, y: b.y }, - ], - }; - }); - - return { - nodes, - edges: laidEdges, - width: maxRowW + MARGIN * 2, - height: layers.length * NODE_H + Math.max(0, layers.length - 1) * V_GAP + MARGIN * 2, - }; -} diff --git a/src/main.js b/src/main.js index ea0844a..945c35f 100644 --- a/src/main.js +++ b/src/main.js @@ -4,6 +4,7 @@ // the real side-effect that runs in the browser (and is coverage-ignored). import Chart from 'chart.js/auto'; +import Dagre from '@dagrejs/dagre'; import { createApp } from './ui/app.js'; import { handleKeydown } from './ui/shortcuts.js'; import { exchangeCodeForTokens, bearerFromTokens } from './net/oauth.js'; @@ -86,7 +87,7 @@ export async function bootstrap(app, env) { /* c8 ignore start -- browser entry side-effect, exercised via the live app */ if (typeof document !== 'undefined' && !globalThis.__ASB_NO_AUTOSTART__) { - const app = createApp({ Chart }); + const app = createApp({ Chart, Dagre }); document.addEventListener('keydown', (e) => handleKeydown(e, app)); bootstrap(app, { location: window.location, diff --git a/src/ui/app.js b/src/ui/app.js index 7a893f4..3aa4323 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -52,6 +52,9 @@ export function createApp(env = {}) { // CSS-custom-property reader (canvas needs real colors, not `var(--x)`). Chart: env.Chart || win.Chart, cssVar: env.cssVar || ((name) => win.getComputedStyle(doc.documentElement).getPropertyValue(name)), + // Pipeline-graph layout seam: dagre (injected like Chart). The DOT parser and + // SVG drawer are ours; dagre only computes node positions + edge bend points. + Dagre: env.Dagre || win.dagre, }; // Two ways to be signed in: OAuth (a JWT bearer, the default) or 'basic' — diff --git a/src/ui/explain-graph.js b/src/ui/explain-graph.js index 453ad30..4a9bc1d 100644 --- a/src/ui/explain-graph.js +++ b/src/ui/explain-graph.js @@ -6,17 +6,19 @@ import { h, s } from './dom.js'; import { Icon } from './icons.js'; -import { parseDot, layoutGraph } from '../core/dot.js'; +import { parseDot } from '../core/dot.js'; +import { dagreLayout } from '../core/dot-layout.js'; import { fitBox, zoomBox, panBox, viewBoxStr } from '../core/panzoom.js'; const ZOOM_STEP = 1.2; // per wheel notch / button press /** - * Build the pipeline SVG from a DOT document. Returns the `` element plus - * the graph's intrinsic size and node count (0 → caller shows a placeholder). + * Build the pipeline SVG from a DOT document, laying it out with the injected + * dagre engine. Returns the `` element plus the graph's intrinsic size and + * node count (0 → caller shows a placeholder). */ -export function buildPipelineSvg(rawText) { - const g = layoutGraph(parseDot(rawText || '')); +export function buildPipelineSvg(rawText, dagre) { + const g = dagreLayout(dagre, parseDot(rawText || '')); const svg = s('svg', { class: 'explain-graph', viewBox: `0 0 ${g.width} ${g.height}` }); if (!g.nodes.length) return { svg, width: g.width, height: g.height, nodeCount: 0 }; // A single reusable arrowhead marker. @@ -45,8 +47,8 @@ export function buildPipelineSvg(rawText) { * the pane scrolls; the fullscreen overlay (openPipelineFullscreen) is where * pan/zoom lives. */ -export function renderExplainGraph(r) { - const built = buildPipelineSvg(r.rawText || ''); +export function renderExplainGraph(app, r) { + const built = buildPipelineSvg(r.rawText || '', app.Dagre); if (!built.nodeCount) { return h('div', { class: 'placeholder' }, h('div', null, 'No pipeline graph to display.')); } @@ -61,7 +63,7 @@ export function renderExplainGraph(r) { */ export function openPipelineFullscreen(app, rawText) { const doc = (app && app.document) || document; - const built = buildPipelineSvg(rawText || ''); + const built = buildPipelineSvg(rawText || '', app && app.Dagre); const onKey = (e) => { if (e.key === 'Escape') { e.stopPropagation(); close(); } }; let backdrop; diff --git a/src/ui/results.js b/src/ui/results.js index 8c4bac4..a1fcabf 100644 --- a/src/ui/results.js +++ b/src/ui/results.js @@ -126,7 +126,7 @@ export function renderResults(app) { function renderExplainView(app, r) { const desc = EXPLAIN_VIEWS.find((v) => v.id === r.explainView); const kind = desc ? desc.kind : 'text'; - if (kind === 'graph') return renderExplainGraph(r); + if (kind === 'graph') return renderExplainGraph(app, r); if (kind === 'table') { return r.rows.length ? renderTable(app, r) diff --git a/tests/e2e/pipeline.html b/tests/e2e/pipeline.html index b77ae8c..086c1cf 100644 --- a/tests/e2e/pipeline.html +++ b/tests/e2e/pipeline.html @@ -16,14 +16,18 @@ diff --git a/tests/helpers/fake-app.js b/tests/helpers/fake-app.js index e3dadce..6552103 100644 --- a/tests/helpers/fake-app.js +++ b/tests/helpers/fake-app.js @@ -2,6 +2,7 @@ // render modules in isolation under happy-dom. Not under src/, so it does not // count toward coverage. import { vi } from 'vitest'; +import dagre from '@dagrejs/dagre'; import { createState, activeTab } from '../../src/state.js'; // A stand-in for the Chart.js constructor: records its canvas + config and @@ -26,6 +27,7 @@ export function makeApp(over = {}) { root, document, Chart: FakeChart, + Dagre: dagre, // real dagre — it's pure (no DOM), so tests use it directly cssVar: () => '', // blank → chartColors() uses its dark-theme fallbacks chart: null, host: () => 'test.host', diff --git a/tests/unit/dot-layout.test.js b/tests/unit/dot-layout.test.js new file mode 100644 index 0000000..bc78b6a --- /dev/null +++ b/tests/unit/dot-layout.test.js @@ -0,0 +1,58 @@ +import { describe, it, expect } from 'vitest'; +import dagre from '@dagrejs/dagre'; +import { parseDot } from '../../src/core/dot.js'; +import { dagreLayout, nodeWidth } from '../../src/core/dot-layout.js'; + +// dagre is pure (no DOM), so the tests drive it directly — the same library the +// app injects at runtime via the app.Dagre seam. +const lay = (dot) => dagreLayout(dagre, parseDot(dot)); + +describe('nodeWidth', () => { + it('floors at the minimum and grows with the label', () => { + expect(nodeWidth('')).toBe(64); + expect(nodeWidth('a very long processor label here')).toBeGreaterThan(64); + }); +}); + +describe('dagreLayout', () => { + it('returns an empty layout for no nodes', () => { + expect(dagreLayout(dagre, { nodes: [], edges: [] })).toEqual({ nodes: [], edges: [], width: 0, height: 0 }); + }); + + it('tolerates missing nodes/edges keys', () => { + expect(dagreLayout(dagre, {})).toEqual({ nodes: [], edges: [], width: 0, height: 0 }); + const g = dagreLayout(dagre, { nodes: [{ id: 'solo', label: 'Solo' }] }); // no edges key + expect(g.nodes).toHaveLength(1); + expect(g.edges).toEqual([]); + }); + + it('lays a chain out top→bottom with top-left node coords and routed edges', () => { + const g = lay('digraph { a [label="A"]; b [label="B"]; c [label="C"]; a -> b; b -> c; }'); + const by = Object.fromEntries(g.nodes.map((n) => [n.id, n])); + expect(by.a.y).toBeLessThan(by.b.y); + expect(by.b.y).toBeLessThan(by.c.y); + expect(by.a.w).toBeGreaterThanOrEqual(64); + expect(g.width).toBeGreaterThan(0); + expect(g.height).toBeGreaterThan(0); + expect(g.edges).toHaveLength(2); + expect(g.edges[0].points.length).toBeGreaterThanOrEqual(2); // a polyline + expect(g.edges[0].points[0]).toHaveProperty('x'); + }); + + it('puts parallel processors of one stage on the same rank (same y)', () => { + const g = lay('digraph { a[label="a"]; b[label="b"]; c[label="c"]; t[label="t"]; a->t; b->t; c->t; }'); + const by = Object.fromEntries(g.nodes.map((n) => [n.id, n])); + expect(by.a.y).toBe(by.b.y); + expect(by.b.y).toBe(by.c.y); + expect(by.t.y).toBeGreaterThan(by.a.y); + }); + + it('drops self-loops and edges to undeclared nodes before layout', () => { + const g = dagreLayout(dagre, { + nodes: [{ id: 'a', label: 'a' }, { id: 'b', label: 'b' }], + edges: [{ from: 'a', to: 'a' }, { from: 'a', to: 'ghost' }, { from: 'a', to: 'b' }], + }); + expect(g.nodes).toHaveLength(2); + expect(g.edges).toEqual([{ from: 'a', to: 'b', points: expect.any(Array) }]); + }); +}); diff --git a/tests/unit/dot.test.js b/tests/unit/dot.test.js index f8c9095..cd51811 100644 --- a/tests/unit/dot.test.js +++ b/tests/unit/dot.test.js @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { parseDot, layoutGraph } from '../../src/core/dot.js'; +import { parseDot } from '../../src/core/dot.js'; describe('parseDot', () => { it('pulls labelled nodes and edges from a digraph, skipping the preamble', () => { @@ -45,50 +45,3 @@ digraph expect(parseDot(null)).toEqual({ nodes: [], edges: [] }); }); }); - -describe('layoutGraph', () => { - it('returns an empty layout for no nodes', () => { - expect(layoutGraph({ nodes: [], edges: [] })).toEqual({ nodes: [], edges: [], width: 0, height: 0 }); - expect(layoutGraph({})).toEqual({ nodes: [], edges: [], width: 0, height: 0 }); - }); - it('lays a chain out top→bottom in increasing layers', () => { - const g = layoutGraph(parseDot('digraph { a [label="A"]; b [label="B"]; c [label="C"]; a -> b; b -> c; }')); - const by = Object.fromEntries(g.nodes.map((n) => [n.id, n])); - expect(by.a.y).toBeLessThan(by.b.y); - expect(by.b.y).toBeLessThan(by.c.y); - expect(g.width).toBeGreaterThan(0); - expect(g.height).toBeGreaterThan(0); - expect(g.edges).toHaveLength(2); - // each edge is a 2-point polyline from a bottom-centre to a top-centre - expect(g.edges[0].points).toHaveLength(2); - expect(g.edges[0].points[0]).toEqual({ x: by.a.x + by.a.w / 2, y: by.a.y + by.a.h }); - expect(g.edges[0].points[1]).toEqual({ x: by.b.x + by.b.w / 2, y: by.b.y }); - }); - it('uses the longest path for layering (diamond stacks vertically)', () => { - // a->b->d and a->d : d must sit a row below b, not beside a. - const g = layoutGraph(parseDot('digraph { a[label="a"]; b[label="b"]; d[label="d"]; a->b; b->d; a->d; }')); - const by = Object.fromEntries(g.nodes.map((n) => [n.id, n])); - expect(by.d.y).toBeGreaterThan(by.b.y); - }); - it('spreads parallel processors of one stage horizontally on the same row', () => { - const g = layoutGraph(parseDot('digraph { a[label="a"]; b[label="b"]; c[label="c"]; t[label="target"]; a->t; b->t; c->t; }')); - const by = Object.fromEntries(g.nodes.map((n) => [n.id, n])); - expect(by.a.y).toBe(by.b.y); // same stage → same row - expect(by.b.y).toBe(by.c.y); - expect(new Set([by.a.x, by.b.x, by.c.x]).size).toBe(3); // side-by-side, distinct columns - expect(by.t.y).toBeGreaterThan(by.a.y); // target below the parallel sources - }); - it('filters edges with an unknown endpoint and self-loops', () => { - const g = layoutGraph({ nodes: [{ id: 'a', label: 'a' }, { id: 'b', label: 'b' }], edges: [{ from: 'a', to: 'ghost' }, { from: 'a', to: 'a' }, { from: 'a', to: 'b' }] }); - expect(g.nodes).toHaveLength(2); - expect(g.edges).toHaveLength(1); // only a->b survives - }); - it('is cycle-safe (no infinite loop) and still positions every node', () => { - const g = layoutGraph(parseDot('digraph { a[label="a"]; b[label="b"]; a->b; b->a; }')); - expect(g.nodes).toHaveLength(2); - for (const n of g.nodes) { - expect(Number.isFinite(n.x)).toBe(true); - expect(Number.isFinite(n.y)).toBe(true); - } - }); -}); diff --git a/tests/unit/explain-graph.test.js b/tests/unit/explain-graph.test.js index 8db852a..0438d32 100644 --- a/tests/unit/explain-graph.test.js +++ b/tests/unit/explain-graph.test.js @@ -1,6 +1,9 @@ import { describe, it, expect, afterEach } from 'vitest'; +import dagre from '@dagrejs/dagre'; import { renderExplainGraph, openPipelineFullscreen } from '../../src/ui/explain-graph.js'; +const APP = { document, Dagre: dagre }; // app stub carrying the dagre layout seam + const DOT = `digraph { rankdir="LR"; @@ -13,7 +16,7 @@ const DOT = `digraph describe('renderExplainGraph', () => { it('draws an SVG with one rect+label per node and a path per edge', () => { - const el = renderExplainGraph({ rawText: DOT }); + const el = renderExplainGraph(APP, { rawText: DOT }); expect(el.className).toBe('explain-graph-view'); const svg = el.querySelector('svg.explain-graph'); expect(svg).not.toBeNull(); @@ -28,12 +31,12 @@ describe('renderExplainGraph', () => { .toEqual(['NumbersRange', 'Filter', 'Aggregating']); }); it('shows a placeholder when the DOT has no nodes', () => { - const el = renderExplainGraph({ rawText: 'digraph {}' }); + const el = renderExplainGraph(APP, { rawText: 'digraph {}' }); expect(el.className).toBe('placeholder'); expect(el.textContent).toMatch(/No pipeline graph/); }); it('tolerates a null rawText', () => { - const el = renderExplainGraph({ rawText: null }); + const el = renderExplainGraph(APP, { rawText: null }); expect(el.className).toBe('placeholder'); }); }); @@ -48,7 +51,7 @@ describe('openPipelineFullscreen', () => { const vbOf = (overlay) => overlay.querySelector('svg.explain-graph').getAttribute('viewBox').split(' ').map(Number); it('mounts a fullscreen overlay with the graph and an initial fitted viewBox', () => { - const overlay = openPipelineFullscreen({ document }, DOT); + const overlay = openPipelineFullscreen(APP, DOT); expect(document.body.contains(overlay)).toBe(true); expect(overlay.className).toBe('graph-overlay'); const svg = overlay.querySelector('svg.explain-graph'); @@ -59,7 +62,7 @@ describe('openPipelineFullscreen', () => { }); it('wheel zooms in (smaller viewBox) and out (larger) around the cursor', () => { - const overlay = openPipelineFullscreen({ document }, DOT); + const overlay = openPipelineFullscreen(APP, DOT); const canvas = overlay.querySelector('.graph-overlay-canvas'); stubRect(canvas); const w0 = vbOf(overlay)[2]; @@ -71,7 +74,7 @@ describe('openPipelineFullscreen', () => { }); it('drag pans the viewBox; a stray mousemove without a drag is a no-op', () => { - const overlay = openPipelineFullscreen({ document }, DOT); + const overlay = openPipelineFullscreen(APP, DOT); const canvas = overlay.querySelector('.graph-overlay-canvas'); stubRect(canvas); const [x0] = vbOf(overlay); @@ -87,7 +90,7 @@ describe('openPipelineFullscreen', () => { }); it('zoom buttons and Fit reframe the graph', () => { - const overlay = openPipelineFullscreen({ document }, DOT); + const overlay = openPipelineFullscreen(APP, DOT); const canvas = overlay.querySelector('.graph-overlay-canvas'); stubRect(canvas); const fitW = vbOf(overlay)[2]; @@ -101,25 +104,26 @@ describe('openPipelineFullscreen', () => { it('closes on Escape, the ✕ button, and a backdrop click (but not a panel click)', () => { // Escape - let overlay = openPipelineFullscreen({ document }, DOT); + let overlay = openPipelineFullscreen(APP, DOT); document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); // ignored expect(document.body.contains(overlay)).toBe(true); document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); expect(document.body.contains(overlay)).toBe(false); // panel click does NOT close; ✕ does - overlay = openPipelineFullscreen({ document }, DOT); + overlay = openPipelineFullscreen(APP, DOT); overlay.querySelector('.graph-overlay-panel').dispatchEvent(new Event('click', { bubbles: true })); expect(document.body.contains(overlay)).toBe(true); overlay.querySelector('.graph-overlay-close').dispatchEvent(new Event('click', { bubbles: true })); expect(document.body.contains(overlay)).toBe(false); - // backdrop click closes; app-less call uses the global document - overlay = openPipelineFullscreen(null, DOT); + // backdrop click closes + overlay = openPipelineFullscreen(APP, DOT); overlay.dispatchEvent(new Event('click', { bubbles: true })); expect(document.body.contains(overlay)).toBe(false); }); - it('shows a placeholder (no canvas svg / zoom controls) for an empty graph', () => { - const overlay = openPipelineFullscreen({ document }, 'digraph {}'); + it('shows a placeholder for an empty graph; an app-less call uses the global document', () => { + const overlay = openPipelineFullscreen(null, 'digraph {}'); // null app → global document seam + expect(document.body.contains(overlay)).toBe(true); expect(overlay.querySelector('svg.explain-graph')).toBeNull(); expect(overlay.querySelector('.graph-overlay-zoom')).toBeNull(); expect(overlay.textContent).toMatch(/No pipeline graph/); From 76c2642252dd37c6dc9cd63649ca2995a969011f Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Thu, 25 Jun 2026 15:13:52 +0200 Subject: [PATCH 2/3] docs(explain): record dagre as the second bundled dependency + tighten layout test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code review follow-ups for the dagre layout seam: - README, CLAUDE.md (hard-rule 4), and build.mjs header now state both bundled runtime deps (Chart.js + @dagrejs/dagre), and the Pipeline section reflects that layout is delegated to dagre via core/dot-layout.js (DOT parse stays pure in dot.js). - dot-layout.test.js now asserts the centre→top-left coordinate conversion and that edge points are finite {x,y} pairs, so a broken transform can't pass green. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QGBS74oUsXarGkCRQKEFLu --- CLAUDE.md | 18 ++++++++++-------- README.md | 10 ++++++---- build/build.mjs | 4 ++-- tests/unit/dot-layout.test.js | 7 ++++++- 4 files changed, 24 insertions(+), 15 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d8b4ec8..f77b944 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,14 +21,16 @@ ClickHouse. No framework, no runtime deps. Quality is held by tests. (see README "Configuring OAuth"). 4. **The build is esbuild only; runtime deps are rare and deliberate.** Source files are the tested files; esbuild bundles `src/main.js` → `dist/sql.html`. - The **one** bundled runtime dependency is **Chart.js** (the Chart result - view) — inlined into the artifact, so the page still makes zero third-party - requests. Adding *another* runtime dependency is a deliberate decision (it - grows the single served file) — don't do it casually. When a feature needs a - library, keep the testable logic pure in `src/core/` (chart axis/role/pivot - math lives in `src/core/chart-data.js`, 100%-covered) and make the library - call an **injected seam** (`app.Chart`, like the fetch/crypto seams) so the - DOM wrapper stays fully tested rather than dropping below the coverage gate. + There are **two** bundled runtime dependencies — **Chart.js** (the Chart + result view) and **@dagrejs/dagre** (the EXPLAIN pipeline-graph layout) — both + inlined into the artifact, so the page still makes zero third-party requests. + Adding *another* runtime dependency is a deliberate decision (it grows the + single served file) — don't do it casually. When a feature needs a library, + keep the testable logic pure in `src/core/` (chart axis/role/pivot math in + `src/core/chart-data.js`; DOT→positions in `src/core/dot-layout.js`, both + 100%-covered) and make the library call an **injected seam** (`app.Chart` / + `app.Dagre`, like the fetch/crypto seams) so the DOM wrapper stays fully tested + rather than dropping below the coverage gate. ## How to add a result view / panel / feature diff --git a/README.md b/README.md index c9a79ef..298593d 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,9 @@ schema-aware autocomplete, streaming results with table / JSON / chart views, saved queries, history, and shareable links. It ships as a **single self-contained HTML file served from ClickHouse itself** (no Node server, no CDN, no external fonts) — the page makes **zero third-party -requests** and renders in the OS's native UI font. Its only bundled runtime -dependency is **Chart.js** (the chart result view), inlined into that one file. +requests** and renders in the OS's native UI font. Its two bundled runtime +dependencies — **Chart.js** (the chart result view) and **@dagrejs/dagre** (the +EXPLAIN pipeline-graph layout) — are inlined into that one file. Refactored from a single-file SPA into a fully modular, test-first codebase held at **100% test coverage**. @@ -83,8 +84,9 @@ rewritten**: - **Indexes** / **Projections** — `EXPLAIN indexes = 1` / `projections = 1` of the inner query (used parts/granules, analyzed projections). Plan text. - **Pipeline** — `EXPLAIN PIPELINE graph = 1`, whose Graphviz DOT is drawn as a - boxes-and-arrows processor graph by a small self-contained SVG renderer (no new - dependency; DOT parse + layered layout are pure in `src/core/dot.js`). + boxes-and-arrows processor graph (with a fullscreen pan/zoom view). The DOT parse + is pure in `src/core/dot.js`; node/edge layout is delegated to **dagre** through + an injected seam (`src/core/dot-layout.js`), and our own SVG renderer draws it. - **Estimate** — `EXPLAIN ESTIMATE`, rendered as a real table (database, table, parts, rows, marks). diff --git a/build/build.mjs b/build/build.mjs index 3738537..a0acc37 100644 --- a/build/build.mjs +++ b/build/build.mjs @@ -1,8 +1,8 @@ // Build the single-file SPA: esbuild bundles src/main.js into one IIFE, which // is inlined (with the stylesheet) into build/template.html → dist/sql.html. // -// esbuild is the only build-time tool; the sole bundled runtime dependency is -// Chart.js (inlined, not fetched). The output is a self-contained HTML file +// esbuild is the only build-time tool; the bundled runtime dependencies are +// Chart.js and @dagrejs/dagre (inlined, not fetched). The output is a self-contained HTML file // that installs into any ClickHouse cluster's user_files and is served by an // static rule — it still makes zero third-party requests. diff --git a/tests/unit/dot-layout.test.js b/tests/unit/dot-layout.test.js index bc78b6a..0cd6a2e 100644 --- a/tests/unit/dot-layout.test.js +++ b/tests/unit/dot-layout.test.js @@ -24,6 +24,10 @@ describe('dagreLayout', () => { const g = dagreLayout(dagre, { nodes: [{ id: 'solo', label: 'Solo' }] }); // no edges key expect(g.nodes).toHaveLength(1); expect(g.edges).toEqual([]); + // x/y are the box TOP-LEFT (dagre reports centres → we subtract w/2, h/2): + // a corner sits at the margin, well left/above the centre. + expect(g.nodes[0].x).toBeLessThan(g.nodes[0].w / 2); + expect(g.nodes[0].y).toBeLessThan(g.nodes[0].h / 2); }); it('lays a chain out top→bottom with top-left node coords and routed edges', () => { @@ -36,7 +40,8 @@ describe('dagreLayout', () => { expect(g.height).toBeGreaterThan(0); expect(g.edges).toHaveLength(2); expect(g.edges[0].points.length).toBeGreaterThanOrEqual(2); // a polyline - expect(g.edges[0].points[0]).toHaveProperty('x'); + const p0 = g.edges[0].points[0]; + expect(Number.isFinite(p0.x) && Number.isFinite(p0.y)).toBe(true); // {x,y} pairs }); it('puts parallel processors of one stage on the same rank (same y)', () => { From 4ca0405d4ad639178127dce5d2316aad0ee23803 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Thu, 25 Jun 2026 15:32:37 +0200 Subject: [PATCH 3/3] =?UTF-8?q?feat(explain):=20unify=20inline=20+=20fulls?= =?UTF-8?q?creen=20graph=20interaction=20(drag-pan,=20=E2=8C=98/Ctrl-wheel?= =?UTF-8?q?-zoom)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inline pipeline pane used native scrollbars while fullscreen used drag/zoom — inconsistent. Extract a shared attachPanZoom() used by both: drag to pan (grab cursor), plain wheel to pan, ⌘/Ctrl+wheel to zoom at the cursor, double-click to fit, and fit-on-render. The inline pane drops scrollbars (overflow:hidden) and becomes the same pan/zoom surface as the overlay. - explain-graph.js: attachPanZoom() shared by renderExplainGraph (inline) and openPipelineFullscreen (overlay, which adds the −/+/Fit buttons). - styles.css: .explain-graph-view → overflow:hidden, cursor grab/grabbing, svg 100%. - tests: assert ⌘/Ctrl+wheel zoom vs plain-wheel pan + double-click fit; e2e holds Control for the fullscreen zoom; inline now fitted (width 100%, fitted viewBox). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QGBS74oUsXarGkCRQKEFLu --- src/styles.css | 8 +- src/ui/explain-graph.js | 132 +++++++++++++++---------------- tests/e2e/pipeline-graph.spec.js | 16 ++-- tests/unit/explain-graph.test.js | 41 ++++++++-- 4 files changed, 117 insertions(+), 80 deletions(-) diff --git a/src/styles.css b/src/styles.css index 107e3a0..fabbc46 100644 --- a/src/styles.css +++ b/src/styles.css @@ -623,11 +623,15 @@ body { .raw-text-view:focus, .json-view:focus { outline: none; } /* ------------ EXPLAIN pipeline graph view ------------ */ +/* Same pan/zoom surface as the fullscreen overlay: drag to pan (grab cursor), + wheel to pan, ⌘/Ctrl+wheel to zoom, double-click to fit. */ .explain-graph-view { - height: 100%; overflow: auto; padding: 12px 14px; - background: var(--bg-table); + height: 100%; overflow: hidden; + background: var(--bg-table); cursor: grab; } +.explain-graph-view.grabbing { cursor: grabbing; } .explain-graph-view:focus { outline: none; } +.explain-graph-view > svg.explain-graph { width: 100%; height: 100%; } /* `color` drives the arrowhead (fill:currentColor) and edge stroke. */ .explain-graph { color: var(--fg-faint); display: block; } .explain-graph .eg-node { diff --git a/src/ui/explain-graph.js b/src/ui/explain-graph.js index 4a9bc1d..6e8060d 100644 --- a/src/ui/explain-graph.js +++ b/src/ui/explain-graph.js @@ -1,8 +1,9 @@ // The Pipeline result view: draw the `EXPLAIN PIPELINE graph = 1` DOT output as -// an SVG boxes-and-arrows graph, plus a fullscreen pan/zoom overlay for big -// plans. All graph math (parse + layout) is pure in src/core/dot.js and the -// viewBox algebra in src/core/panzoom.js; this module only does SVG + DOM. -// Zero runtime deps — built with the `s()`/`h()` hyperscript. +// an SVG boxes-and-arrows graph. Both the inline pane and the fullscreen overlay +// use the SAME interaction model (attachPanZoom): drag to pan (grab cursor), +// wheel to pan, ⌘/Ctrl+wheel to zoom at the cursor, double-click to fit. Graph +// math (parse + layout) is pure in src/core/dot.js + dot-layout.js (dagre seam) +// and the viewBox algebra in src/core/panzoom.js; this module only does SVG + DOM. import { h, s } from './dom.js'; import { Icon } from './icons.js'; @@ -12,6 +13,56 @@ import { fitBox, zoomBox, panBox, viewBoxStr } from '../core/panzoom.js'; const ZOOM_STEP = 1.2; // per wheel notch / button press +/** + * Wire pan/zoom onto a container holding the graph `svg` (sized to fill it). The + * viewBox starts fitted to the `dims` graph. Returns `{ fit, zoomIn, zoomOut }` + * for external controls (the overlay buttons). Shared by the inline pane and the + * fullscreen overlay so both behave identically. + */ +function attachPanZoom(container, svg, dims) { + svg.setAttribute('width', '100%'); + svg.setAttribute('height', '100%'); + svg.setAttribute('preserveAspectRatio', 'xMidYMid meet'); + const minW = dims.width / 8; + const maxW = dims.width * 3; + let vb = fitBox(dims.width, dims.height); + const apply = () => svg.setAttribute('viewBox', viewBoxStr(vb)); + const fit = () => { vb = fitBox(dims.width, dims.height); apply(); }; + const toSvg = (cx, cy) => { + const r = container.getBoundingClientRect(); + return { x: vb.x + ((cx - r.left) / r.width) * vb.w, y: vb.y + ((cy - r.top) / r.height) * vb.h }; + }; + const zoomAt = (factor, cx, cy) => { const p = toSvg(cx, cy); vb = zoomBox(vb, factor, p.x, p.y, minW, maxW); apply(); }; + // Pan by pixel deltas (drag grabs the content; wheel scrolls the viewport — the + // caller passes the appropriate sign). + const panBy = (dxPx, dyPx) => { + const r = container.getBoundingClientRect(); + vb = panBox(vb, dxPx * (vb.w / r.width), dyPx * (vb.h / r.height)); + apply(); + }; + const centre = () => { const r = container.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; }; + + container.addEventListener('wheel', (e) => { + e.preventDefault(); + if (e.ctrlKey || e.metaKey) zoomAt(e.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP, e.clientX, e.clientY); + else panBy(-e.deltaX, -e.deltaY); + }); + let drag = null; + container.addEventListener('mousedown', (e) => { drag = { x: e.clientX, y: e.clientY }; container.classList.add('grabbing'); }); + container.addEventListener('mousemove', (e) => { + if (!drag) return; + panBy(e.clientX - drag.x, e.clientY - drag.y); + drag = { x: e.clientX, y: e.clientY }; + }); + const end = () => { drag = null; container.classList.remove('grabbing'); }; + container.addEventListener('mouseup', end); + container.addEventListener('mouseleave', end); + container.addEventListener('dblclick', fit); + + apply(); + return { fit, zoomIn: () => { const c = centre(); zoomAt(ZOOM_STEP, c.x, c.y); }, zoomOut: () => { const c = centre(); zoomAt(1 / ZOOM_STEP, c.x, c.y); } }; +} + /** * Build the pipeline SVG from a DOT document, laying it out with the injected * dagre engine. Returns the `` element plus the graph's intrinsic size and @@ -42,19 +93,18 @@ export function buildPipelineSvg(rawText, dagre) { } /** - * Render `r.rawText` as the inline (scrollable) pipeline graph. Falls back to a - * placeholder when the DOT has no nodes. The SVG is sized to its intrinsic px so - * the pane scrolls; the fullscreen overlay (openPipelineFullscreen) is where - * pan/zoom lives. + * Render `r.rawText` as the inline pipeline graph: fitted to the pane, with the + * shared drag/wheel pan-zoom. Falls back to a placeholder when the DOT has no + * nodes. The fullscreen overlay (openPipelineFullscreen) adds zoom buttons. */ export function renderExplainGraph(app, r) { const built = buildPipelineSvg(r.rawText || '', app.Dagre); if (!built.nodeCount) { return h('div', { class: 'placeholder' }, h('div', null, 'No pipeline graph to display.')); } - built.svg.setAttribute('width', built.width); - built.svg.setAttribute('height', built.height); - return h('div', { class: 'explain-graph-view', tabindex: '0' }, built.svg); + const view = h('div', { class: 'explain-graph-view', tabindex: '0' }, built.svg); + attachPanZoom(view, built.svg, built); + return view; } /** @@ -80,62 +130,12 @@ export function openPipelineFullscreen(app, rawText) { if (!built.nodeCount) { canvas.appendChild(h('div', { class: 'placeholder' }, h('div', null, 'No pipeline graph to display.'))); } else { - const svg = built.svg; - svg.setAttribute('width', '100%'); - svg.setAttribute('height', '100%'); - svg.setAttribute('preserveAspectRatio', 'xMidYMid meet'); - const minW = built.width / 8; - const maxW = built.width * 3; - let vb = fitBox(built.width, built.height); - const apply = () => svg.setAttribute('viewBox', viewBoxStr(vb)); - apply(); - - // Cursor px → svg-space coords, using the live canvas rect. - const toSvg = (clientX, clientY) => { - const rc = canvas.getBoundingClientRect(); - return { - x: vb.x + ((clientX - rc.left) / rc.width) * vb.w, - y: vb.y + ((clientY - rc.top) / rc.height) * vb.h, - }; - }; - const zoomAt = (factor, clientX, clientY) => { - const p = toSvg(clientX, clientY); - vb = zoomBox(vb, factor, p.x, p.y, minW, maxW); - apply(); - }; - const centre = () => { - const rc = canvas.getBoundingClientRect(); - return { x: rc.left + rc.width / 2, y: rc.top + rc.height / 2 }; - }; - - canvas.addEventListener('wheel', (e) => { - e.preventDefault(); - zoomAt(e.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP, e.clientX, e.clientY); - }); - - let drag = null; - canvas.addEventListener('mousedown', (e) => { - drag = { x: e.clientX, y: e.clientY }; - canvas.classList.add('grabbing'); - }); - const onMove = (e) => { - if (!drag) return; - const rc = canvas.getBoundingClientRect(); - const scale = vb.w / rc.width; - vb = panBox(vb, (e.clientX - drag.x) * scale, (e.clientY - drag.y) * scale); - drag = { x: e.clientX, y: e.clientY }; - apply(); - }; - const onUp = () => { drag = null; canvas.classList.remove('grabbing'); }; - canvas.addEventListener('mousemove', onMove); - canvas.addEventListener('mouseup', onUp); - canvas.addEventListener('mouseleave', onUp); - - canvas.appendChild(svg); + canvas.appendChild(built.svg); + const pz = attachPanZoom(canvas, built.svg, built); bar.appendChild(h('div', { class: 'graph-overlay-zoom' }, - h('button', { class: 'res-act', title: 'Zoom out', onclick: () => { const c = centre(); zoomAt(1 / ZOOM_STEP, c.x, c.y); } }, Icon.minus()), - h('button', { class: 'res-act', title: 'Zoom in', onclick: () => { const c = centre(); zoomAt(ZOOM_STEP, c.x, c.y); } }, Icon.plus()), - h('button', { class: 'res-act', title: 'Fit to screen', onclick: () => { vb = fitBox(built.width, built.height); apply(); } }, 'Fit'))); + h('button', { class: 'res-act', title: 'Zoom out', onclick: pz.zoomOut }, Icon.minus()), + h('button', { class: 'res-act', title: 'Zoom in', onclick: pz.zoomIn }, Icon.plus()), + h('button', { class: 'res-act', title: 'Fit to screen', onclick: pz.fit }, 'Fit'))); } bar.appendChild(h('button', { class: 'graph-overlay-close', title: 'Close (Esc)', onclick: close }, Icon.close())); diff --git a/tests/e2e/pipeline-graph.spec.js b/tests/e2e/pipeline-graph.spec.js index 5ab38fc..56fd68f 100644 --- a/tests/e2e/pipeline-graph.spec.js +++ b/tests/e2e/pipeline-graph.spec.js @@ -65,9 +65,11 @@ test.describe('EXPLAIN PIPELINE graph (antalya ontime fact/dim join)', () => { const vb = () => svg.getAttribute('viewBox').then((s) => s.split(' ').map(Number)); const [, , w0] = await vb(); - // wheel over the canvas → zoom in (smaller viewBox width) + // ⌘/Ctrl+wheel over the canvas → zoom in (smaller viewBox width) await page.locator('.graph-overlay-canvas').hover(); + await page.keyboard.down('Control'); await page.mouse.wheel(0, -300); + await page.keyboard.up('Control'); const [, , w1] = await vb(); expect(w1).toBeLessThan(w0); @@ -92,20 +94,22 @@ test.describe('EXPLAIN PIPELINE graph (antalya ontime fact/dim join)', () => { const rects = [...document.querySelectorAll('rect.eg-node')]; const rows = {}; for (const r of rects) { - const y = +r.getAttribute('y'); + const y = Math.round(+r.getAttribute('y')); rows[y] = (rows[y] || 0) + 1; } const svg = document.querySelector('svg.explain-graph'); return { rowCount: Object.keys(rows).length, maxPerRow: Math.max(...Object.values(rows)), - width: +svg.getAttribute('width'), - height: +svg.getAttribute('height'), - viewBox: svg.getAttribute('viewBox'), + width: svg.getAttribute('width'), + vbNums: svg.getAttribute('viewBox').split(' ').map(Number), }; }); expect(m.rowCount).toBeGreaterThanOrEqual(5); // many sequential stages → vertical expect(m.maxPerRow).toBeGreaterThanOrEqual(2); // parallel processors → side-by-side - expect(m.viewBox).toBe(`0 0 ${m.width} ${m.height}`); + // inline svg fills the pane; the fitted viewBox is the window into the graph + expect(m.width).toBe('100%'); + expect(m.vbNums).toHaveLength(4); + expect(m.vbNums.every(Number.isFinite)).toBe(true); }); }); diff --git a/tests/unit/explain-graph.test.js b/tests/unit/explain-graph.test.js index 0438d32..fbedd8f 100644 --- a/tests/unit/explain-graph.test.js +++ b/tests/unit/explain-graph.test.js @@ -20,7 +20,8 @@ describe('renderExplainGraph', () => { expect(el.className).toBe('explain-graph-view'); const svg = el.querySelector('svg.explain-graph'); expect(svg).not.toBeNull(); - expect(svg.getAttribute('viewBox')).toMatch(/^0 0 \d+(\.\d+)? \d+(\.\d+)?$/); + expect(svg.getAttribute('width')).toBe('100%'); // fills the pane; viewBox is the window + expect(svg.getAttribute('viewBox').split(' ').map(Number).every(Number.isFinite)).toBe(true); expect(svg.querySelectorAll('rect.eg-node')).toHaveLength(3); expect(svg.querySelectorAll('text.eg-label')).toHaveLength(3); expect(svg.querySelectorAll('path.eg-edge')).toHaveLength(2); @@ -49,6 +50,16 @@ describe('openPipelineFullscreen', () => { canvas.getBoundingClientRect = () => ({ left: 0, top: 0, width: 400, height: 200, right: 400, bottom: 200 }); }; const vbOf = (overlay) => overlay.querySelector('svg.explain-graph').getAttribute('viewBox').split(' ').map(Number); + // happy-dom drops modifier keys AND clientX/clientY from the WheelEvent init + // dict (it keeps deltaX/deltaY), so force every field the handler reads. + const fireWheel = (canvas, opts = {}) => { + const e = new WheelEvent('wheel', { bubbles: true, cancelable: true, deltaX: opts.deltaX || 0, deltaY: opts.deltaY || 0 }); + Object.defineProperty(e, 'clientX', { value: opts.clientX ?? 200 }); + Object.defineProperty(e, 'clientY', { value: opts.clientY ?? 100 }); + if (opts.ctrlKey) Object.defineProperty(e, 'ctrlKey', { value: true }); + if (opts.metaKey) Object.defineProperty(e, 'metaKey', { value: true }); + canvas.dispatchEvent(e); + }; it('mounts a fullscreen overlay with the graph and an initial fitted viewBox', () => { const overlay = openPipelineFullscreen(APP, DOT); @@ -61,16 +72,34 @@ describe('openPipelineFullscreen', () => { expect(vbOf(overlay)[2]).toBeGreaterThan(0); // a real fitted width }); - it('wheel zooms in (smaller viewBox) and out (larger) around the cursor', () => { + it('⌘/Ctrl+wheel zooms around the cursor; plain wheel pans', () => { const overlay = openPipelineFullscreen(APP, DOT); const canvas = overlay.querySelector('.graph-overlay-canvas'); stubRect(canvas); const w0 = vbOf(overlay)[2]; - canvas.dispatchEvent(new WheelEvent('wheel', { deltaY: -1, clientX: 200, clientY: 100, bubbles: true, cancelable: true })); + fireWheel(canvas, { deltaY: -1, ctrlKey: true }); const w1 = vbOf(overlay)[2]; - expect(w1).toBeLessThan(w0); // zoomed in - canvas.dispatchEvent(new WheelEvent('wheel', { deltaY: 1, clientX: 200, clientY: 100, bubbles: true, cancelable: true })); - expect(vbOf(overlay)[2]).toBeGreaterThan(w1); // zoomed back out + expect(w1).toBeLessThan(w0); // Ctrl+wheel up → zoom in + fireWheel(canvas, { deltaY: 1, metaKey: true }); + expect(vbOf(overlay)[2]).toBeGreaterThan(w1); // ⌘+wheel down → zoom out + // plain wheel pans: viewBox origin moves, width unchanged (not a zoom) + const [x0, y0, pw] = vbOf(overlay); + fireWheel(canvas, { deltaX: 30, deltaY: 40 }); + const [x1, y1, pw2] = vbOf(overlay); + expect(pw2).toBe(pw); + expect(x1).not.toBe(x0); + expect(y1).not.toBe(y0); + }); + + it('double-click fits the graph', () => { + const overlay = openPipelineFullscreen(APP, DOT); + const canvas = overlay.querySelector('.graph-overlay-canvas'); + stubRect(canvas); + const fitW = vbOf(overlay)[2]; + fireWheel(canvas, { deltaY: -1, ctrlKey: true }); + expect(vbOf(overlay)[2]).toBeLessThan(fitW); + canvas.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); + expect(vbOf(overlay)[2]).toBeCloseTo(fitW); }); it('drag pans the viewBox; a stray mousemove without a drag is a no-op', () => {