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
18 changes: 10 additions & 8 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**.
Expand Down Expand Up @@ -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).

Expand Down
4 changes: 2 additions & 2 deletions build/build.mjs
Original file line number Diff line number Diff line change
@@ -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
// <http_handlers> static rule — it still makes zero third-party requests.

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"vitest": "^2.1.8"
},
"dependencies": {
"@dagrejs/dagre": "^3.0.0",
"chart.js": "^4.5.1"
}
}
52 changes: 52 additions & 0 deletions src/core/dot-layout.js
Original file line number Diff line number Diff line change
@@ -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 };
}
102 changes: 4 additions & 98 deletions src/core/dot.js
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -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,
};
}
3 changes: 2 additions & 1 deletion src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 6 additions & 2 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions src/ui/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' —
Expand Down
Loading
Loading