tablewalk 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +553 -0
  3. package/dist/adapters/adapter.js +372 -0
  4. package/dist/adapters/connect.js +33 -0
  5. package/dist/adapters/mysql.js +951 -0
  6. package/dist/adapters/postgres.js +1000 -0
  7. package/dist/adapters/sqlite.js +781 -0
  8. package/dist/client/agent.js +262 -0
  9. package/dist/client/app.js +973 -0
  10. package/dist/client/arrange.js +254 -0
  11. package/dist/client/ask.js +133 -0
  12. package/dist/client/breakdown.js +317 -0
  13. package/dist/client/clauses.js +390 -0
  14. package/dist/client/columns.js +98 -0
  15. package/dist/client/complete.js +437 -0
  16. package/dist/client/compose.js +166 -0
  17. package/dist/client/composer.css +495 -0
  18. package/dist/client/composer.js +1972 -0
  19. package/dist/client/connections.js +234 -0
  20. package/dist/client/connmanager.js +962 -0
  21. package/dist/client/connurl.js +188 -0
  22. package/dist/client/core.js +893 -0
  23. package/dist/client/deeplink.js +270 -0
  24. package/dist/client/delete.js +144 -0
  25. package/dist/client/diagram.js +885 -0
  26. package/dist/client/dropdown.js +279 -0
  27. package/dist/client/export.js +456 -0
  28. package/dist/client/features.css +524 -0
  29. package/dist/client/findvalue.js +169 -0
  30. package/dist/client/grid.js +205 -0
  31. package/dist/client/handoff.js +153 -0
  32. package/dist/client/help.css +145 -0
  33. package/dist/client/help.js +881 -0
  34. package/dist/client/history.js +222 -0
  35. package/dist/client/index.html +116 -0
  36. package/dist/client/insert.js +151 -0
  37. package/dist/client/menu.js +160 -0
  38. package/dist/client/nested.js +255 -0
  39. package/dist/client/page.css +713 -0
  40. package/dist/client/page.js +1345 -0
  41. package/dist/client/pagebuilder.js +1222 -0
  42. package/dist/client/pagemarks.js +95 -0
  43. package/dist/client/palette.js +374 -0
  44. package/dist/client/peek.js +254 -0
  45. package/dist/client/picker.js +139 -0
  46. package/dist/client/pins.js +140 -0
  47. package/dist/client/prompt.js +129 -0
  48. package/dist/client/record.js +707 -0
  49. package/dist/client/schemaexport.js +242 -0
  50. package/dist/client/schematext.js +125 -0
  51. package/dist/client/shape.js +178 -0
  52. package/dist/client/shapecheck.js +129 -0
  53. package/dist/client/skeleton.js +139 -0
  54. package/dist/client/sql.css +126 -0
  55. package/dist/client/sql.js +398 -0
  56. package/dist/client/sqlcomplete.js +163 -0
  57. package/dist/client/sqlsaved.js +107 -0
  58. package/dist/client/style.css +2711 -0
  59. package/dist/client/summary.js +259 -0
  60. package/dist/client/table.js +1035 -0
  61. package/dist/client/template.js +539 -0
  62. package/dist/client/theme.js +74 -0
  63. package/dist/client/tour.js +324 -0
  64. package/dist/client/undo.js +105 -0
  65. package/dist/client/url.js +166 -0
  66. package/dist/client/value.js +223 -0
  67. package/dist/client/views.js +215 -0
  68. package/dist/client/virtual.js +176 -0
  69. package/dist/client/welcome.js +170 -0
  70. package/dist/client/write.js +414 -0
  71. package/dist/server/changeimpact.js +195 -0
  72. package/dist/server/connections.js +615 -0
  73. package/dist/server/constraints.js +62 -0
  74. package/dist/server/credentials.js +230 -0
  75. package/dist/server/fixture.js +199 -0
  76. package/dist/server/graph.js +194 -0
  77. package/dist/server/impact.js +48 -0
  78. package/dist/server/index.js +2204 -0
  79. package/dist/server/journal.js +173 -0
  80. package/dist/server/layouts.js +128 -0
  81. package/dist/server/mcp.js +2840 -0
  82. package/dist/server/shapeonly.js +91 -0
  83. package/dist/shared/breakdown.js +231 -0
  84. package/dist/shared/breakdowntext.js +257 -0
  85. package/dist/shared/diff.js +130 -0
  86. package/dist/shared/like.js +29 -0
  87. package/dist/shared/lint.js +149 -0
  88. package/dist/shared/order.js +133 -0
  89. package/dist/shared/page.js +932 -0
  90. package/dist/shared/query.js +831 -0
  91. package/dist/shared/recordview.js +343 -0
  92. package/dist/shared/schema.js +377 -0
  93. package/dist/shared/sqlsaved.js +67 -0
  94. package/dist/shared/view.js +981 -0
  95. package/dist/shared/viewtext.js +273 -0
  96. package/dist/shared/vocabulary.js +164 -0
  97. package/package.json +57 -0
@@ -0,0 +1,885 @@
1
+ /**
2
+ * The entity diagram.
3
+ *
4
+ * Scoped to one table's neighbourhood rather than the whole schema, because a
5
+ * whole-schema diagram of anything real is a hairball nobody reads. The
6
+ * layout axis is direction, which is the same axis the tool navigates on:
7
+ * what this table points at on the left, the table itself in the middle, what
8
+ * points back at it on the right. That makes the picture answer the same
9
+ * question the record view answers, in one glance instead of one click.
10
+ */
11
+ import { state, el, svgEl, findTable, go, labelColumn, tableView } from './core.js';
12
+ import { picker } from './picker.js';
13
+ import { schemaExportControl } from './schemaexport.js';
14
+
15
+
16
+ const NODE_W = 216;
17
+ const HEAD_H = 30;
18
+ const FIELD_H = 17;
19
+ const NODE_PAD = 5;
20
+ const ROW_GAP = 18;
21
+ const COL_GAP = 156;
22
+
23
+ /**
24
+ * How many columns a node shows before it has to be asked for more.
25
+ *
26
+ * A node is a label with context, not a table definition. Five is enough to
27
+ * carry the key, the references and the column that says what the row is —
28
+ * which is what the diagram is being read for — while keeping a neighbour
29
+ * roughly the height of the focus table rather than a column of forty names
30
+ * that pushes everything else off the canvas.
31
+ */
32
+ const PREVIEW_FIELDS = 5;
33
+
34
+ /** Characters an edge label can carry before it would reach a node. */
35
+ const EDGE_LABEL_BUDGET = 22;
36
+
37
+ /** Nodes the reader has opened, by table id. Survives a re-render. */
38
+ const expanded = new Set();
39
+
40
+ /**
41
+ * The columns worth showing first.
42
+ *
43
+ * Ordered by what identifies and connects rather than by declaration order:
44
+ * the key says which row, the foreign keys say what it hangs off, and the
45
+ * label column says what it is. A preview that happened to show `notes` and
46
+ * `external_ref` would be five rows of nothing.
47
+ */
48
+ function orderedColumns(table) {
49
+ const label = labelColumn(table);
50
+ const rank = (c) => {
51
+ if (c.primaryKey) return 0;
52
+ if (c.references) return 1;
53
+ if (c.name === label) return 2;
54
+ return 3;
55
+ };
56
+ return [...table.columns]
57
+ .map((c, i) => ({ c, i }))
58
+ .sort((a, b) => rank(a.c) - rank(b.c) || a.i - b.i)
59
+ .map(({ c }) => c);
60
+ }
61
+
62
+ /**
63
+ * Type names, shortened to fit beside a column name.
64
+ *
65
+ * Postgres spells its timestamps `timestamp with time zone`, which at the
66
+ * width of a node runs straight through the column name to its left. The
67
+ * abbreviations are the ones Postgres itself accepts as aliases, so this
68
+ * shortens the spelling without renaming the type; anything unrecognised is
69
+ * truncated rather than guessed at.
70
+ */
71
+ const TYPE_ALIAS = new Map([
72
+ ['timestamp with time zone', 'timestamptz'],
73
+ ['timestamp without time zone', 'timestamp'],
74
+ ['time with time zone', 'timetz'],
75
+ ['time without time zone', 'time'],
76
+ ['character varying', 'varchar'],
77
+ ['character', 'char'],
78
+ ['double precision', 'float8'],
79
+ ['integer', 'int'],
80
+ ['boolean', 'bool'],
81
+ ]);
82
+
83
+ /** Characters that fit before name and type would touch. */
84
+ const NAME_BUDGET = 18;
85
+ const TYPE_BUDGET = 12;
86
+
87
+ const clip = (text, max) => (text.length > max ? `${text.slice(0, max - 1)}\u2026` : text);
88
+
89
+ function shortType(type) {
90
+ if (!type) return '';
91
+ const lower = type.toLowerCase();
92
+ const alias = TYPE_ALIAS.get(lower)
93
+ ?? TYPE_ALIAS.get(lower.replace(/\(.*\)$/, '').trim());
94
+ return clip(alias ?? type, TYPE_BUDGET);
95
+ }
96
+
97
+ function nodeHeight(tableId, preview = PREVIEW_FIELDS) {
98
+ const table = findTable(tableId);
99
+ if (!table?.columns.length) return HEAD_H;
100
+ const total = table.columns.length;
101
+ const shown = expanded.has(tableId) ? total : Math.min(preview, total);
102
+ const more = shown < total || expanded.has(tableId) ? 1 : 0;
103
+ if (!shown && !more) return HEAD_H;
104
+ return HEAD_H + NODE_PAD + (shown + more) * FIELD_H + NODE_PAD;
105
+ }
106
+
107
+ /**
108
+ * Wrap an SVG in a viewport that can be panned and zoomed.
109
+ *
110
+ * A sixty-six table schema is about four thousand pixels wide. Scaled to fit
111
+ * a panel it is a picture of a schema rather than a readable one — every
112
+ * table name too small to read, which is the one thing a diagram has to do.
113
+ *
114
+ * So the picture keeps its natural size and the window moves over it: drag to
115
+ * pan, wheel or pinch to zoom, and a control to fit the whole thing when you
116
+ * want the shape rather than the detail. The transform lives on a single
117
+ * group so panning is one attribute write per frame rather than a re-layout.
118
+ *
119
+ * @param {SVGElement} svg sized to its natural width and height
120
+ * @param {SVGElement} content the group everything was drawn into
121
+ * @param {{width: number, height: number}} size the drawing's natural size
122
+ * @param {{x: number, y: number}} [focus] a point worth opening centred on
123
+ */
124
+ function panZoom(svg, content, size, focus) {
125
+ const viewport = el('div', { class: 'diagram-viewport' });
126
+ /* No viewBox.
127
+
128
+ A viewBox is itself a scale — it fits the drawing to the element — so
129
+ leaving one in place meant every zoom factor was multiplied by it, and
130
+ "fit" landed at 43% of an already-shrunk picture. Filling the element and
131
+ letting the single transform on the content group do all the scaling
132
+ makes one user unit one CSS pixel, which is what makes 1:1 mean 1:1. */
133
+ svg.removeAttribute('viewBox');
134
+ svg.style.width = '100%';
135
+ svg.style.height = '100%';
136
+
137
+ let scale = 1;
138
+ let x = 0;
139
+ let y = 0;
140
+
141
+ const MIN = 0.15;
142
+ const MAX = 3;
143
+
144
+ const apply = () => {
145
+ content.setAttribute('transform', `translate(${x} ${y}) scale(${scale})`);
146
+ readout.textContent = `${Math.round(scale * 100)}%`;
147
+ };
148
+
149
+ /* Zoom about a point, so the thing under the cursor stays under it. Zooming
150
+ about the origin instead makes the diagram appear to run away from you
151
+ the moment you are not looking at its top-left corner. */
152
+ const zoomAt = (factor, px, py) => {
153
+ const next = Math.min(MAX, Math.max(MIN, scale * factor));
154
+ if (next === scale) return;
155
+ x = px - ((px - x) * next) / scale;
156
+ y = py - ((py - y) * next) / scale;
157
+ scale = next;
158
+ apply();
159
+ };
160
+
161
+ const fit = () => {
162
+ const box = viewport.getBoundingClientRect();
163
+ if (!box.width) return false;
164
+ /* Never enlarges. Fitting is about getting the whole thing on screen, and
165
+ a three-table diagram blown up to 300% to fill the box is not a better
166
+ answer to "show me all of it" — it is the same answer, shouting. Below
167
+ 100% it shrinks as far as it must. */
168
+ scale = Math.min(1, Math.max(MIN, Math.min(
169
+ (box.width - 24) / size.width,
170
+ (box.height - 24) / size.height,
171
+ )));
172
+ // Centred on both axes: a wide, short schema fitted by width leaves the
173
+ // drawing pinned to the top of a mostly empty box, which reads as broken
174
+ // rather than as fitted.
175
+ x = (box.width - size.width * scale) / 2;
176
+ y = (box.height - size.height * scale) / 2;
177
+ apply();
178
+ return true;
179
+ };
180
+
181
+ const reset = () => {
182
+ const box = viewport.getBoundingClientRect();
183
+ scale = 1;
184
+ /* Centred, not parked in the corner. At 1:1 a diagram smaller than its
185
+ panel should sit in the middle of it; one larger than the panel opens
186
+ on its middle, which is where the focus table is. */
187
+ x = Math.min(12, (box.width - size.width) / 2);
188
+ y = Math.min(12, (box.height - size.height) / 2);
189
+ apply();
190
+ };
191
+
192
+ const readout = el('span', { class: 'zoom-readout', text: '100%' });
193
+ const controls = el('div', { class: 'zoom-controls' }, [
194
+ el('button', { type: 'button', class: 'ghost', title: 'Zoom out', 'aria-label': 'Zoom out', text: '\u2212',
195
+ onclick: () => { const b = viewport.getBoundingClientRect(); zoomAt(1 / 1.25, b.width / 2, b.height / 2); } }),
196
+ readout,
197
+ el('button', { type: 'button', class: 'ghost', title: 'Zoom in', 'aria-label': 'Zoom in', text: '+',
198
+ onclick: () => { const b = viewport.getBoundingClientRect(); zoomAt(1.25, b.width / 2, b.height / 2); } }),
199
+ el('button', { type: 'button', class: 'ghost', text: 'Fit', title: 'Fit the whole diagram', onclick: fit }),
200
+ el('button', { type: 'button', class: 'ghost', text: '1:1', title: 'Actual size', onclick: reset }),
201
+ ]);
202
+
203
+ /* Wheel zooms rather than scrolls. The diagram fills its viewport, so a
204
+ wheel here is a gesture aimed at the picture, not at the page — and a
205
+ trackpad pinch arrives as a ctrl-wheel, which this handles identically.
206
+ Shift+wheel pans sideways, matching what every other canvas does. */
207
+ viewport.addEventListener('wheel', (e) => {
208
+ e.preventDefault();
209
+ const box = viewport.getBoundingClientRect();
210
+ const px = e.clientX - box.left;
211
+ const py = e.clientY - box.top;
212
+ if (e.shiftKey && !e.ctrlKey) {
213
+ x -= e.deltaY || e.deltaX;
214
+ apply();
215
+ return;
216
+ }
217
+ zoomAt(Math.exp(-e.deltaY / 400), px, py);
218
+ }, { passive: false });
219
+
220
+ /* Dragging the background pans; dragging starts only where there is no node
221
+ under the pointer, so clicking a table still navigates. */
222
+ let dragging = null;
223
+ viewport.addEventListener('pointerdown', (e) => {
224
+ if (e.button !== 0) return;
225
+ /* The controls sit inside the viewport, so a press on one would otherwise
226
+ start a pan and capture the pointer — which swallowed the click before
227
+ the button ever saw it, and made Fit look broken. */
228
+ if (e.target.closest('.node-head, .node-more, .zoom-controls')) return;
229
+ dragging = { px: e.clientX, py: e.clientY, x, y };
230
+ viewport.setPointerCapture(e.pointerId);
231
+ viewport.classList.add('dragging');
232
+ });
233
+ viewport.addEventListener('pointermove', (e) => {
234
+ if (!dragging) return;
235
+ x = dragging.x + (e.clientX - dragging.px);
236
+ y = dragging.y + (e.clientY - dragging.py);
237
+ apply();
238
+ });
239
+ const endDrag = () => {
240
+ dragging = null;
241
+ viewport.classList.remove('dragging');
242
+ };
243
+ viewport.addEventListener('pointerup', endDrag);
244
+ viewport.addEventListener('pointercancel', endDrag);
245
+
246
+ viewport.append(svg, controls);
247
+
248
+ /* How the diagram opens: fitted when fitting is readable, centred on the
249
+ focus when it is not.
250
+
251
+ Fit was the unconditional opening move, and on a table with seventeen
252
+ relationships it landed at 27% — a postage stamp in a sea of margin,
253
+ with every name too small to read. The whole point of the picture is the
254
+ names. So when fitting would shrink below the point of legibility, the
255
+ diagram opens at full size centred on the table it is about instead:
256
+ readable immediately, with Fit one click away for the shape. A drawing
257
+ with no focus point — the whole-schema view — still opens fitted,
258
+ because there is no one table to centre on. */
259
+ const READABLE = 0.5;
260
+ const open = () => {
261
+ const box = viewport.getBoundingClientRect();
262
+ if (!box.width) return false;
263
+ const fitted = Math.min((box.width - 24) / size.width, (box.height - 24) / size.height);
264
+ if (!focus || fitted >= READABLE) return fit();
265
+ scale = 1;
266
+ x = box.width / 2 - focus.x;
267
+ y = box.height / 2 - focus.y;
268
+ apply();
269
+ return true;
270
+ };
271
+
272
+ /* Opening needs a measured viewport, which does not exist until the node is
273
+ in the document — and a single rAF is not a guarantee of that, which is
274
+ how a repainted diagram ended up parked at the top-left corner of an
275
+ otherwise empty box. The observer waits for a real size and then gets out
276
+ of the way, so a later resize does not throw away the reader's panning. */
277
+ if (!open()) {
278
+ const watch = new ResizeObserver(() => { if (open()) watch.disconnect(); });
279
+ watch.observe(viewport);
280
+ }
281
+ return viewport;
282
+ }
283
+
284
+ export function diagram(tableId) {
285
+ const host = el('div', { class: 'diagram' });
286
+ const paint = () => host.replaceChildren(...neighbourhood(tableId, paint));
287
+ paint();
288
+ return host;
289
+ }
290
+
291
+ /**
292
+ * @param {string} tableId
293
+ * @param {() => void} repaint called when a node is expanded or collapsed
294
+ */
295
+ function neighbourhood(tableId, repaint) {
296
+ const focus = findTable(tableId);
297
+ if (!focus) return [el('p', { class: 'note', text: 'Unknown table.' })];
298
+
299
+ const out = state.schema.foreignKeys.filter((fk) => fk.from.table === tableId && fk.to.table !== tableId);
300
+ const back = state.schema.foreignKeys.filter((fk) => fk.to.table === tableId && fk.from.table !== tableId);
301
+ const self = state.schema.foreignKeys.filter((fk) => fk.from.table === tableId && fk.to.table === tableId);
302
+
303
+ // Several keys can join the same pair of tables; one node per table, with
304
+ // the columns collected onto the edge, keeps the picture readable.
305
+ const group = (keys, side) => {
306
+ const byTable = new Map();
307
+ for (const fk of keys) {
308
+ const other = side === 'out' ? fk.to.table : fk.from.table;
309
+ const entry = byTable.get(other) ?? { table: other, columns: [] };
310
+ entry.columns.push(fk.from.columns.join(', '));
311
+ byTable.set(other, entry);
312
+ }
313
+ return [...byTable.values()];
314
+ };
315
+
316
+ const left = group(out, 'out');
317
+ const right = group(back, 'in');
318
+
319
+ if (!left.length && !right.length && !self.length) {
320
+ return [el('p', { class: 'note', text: `${tableId} has no foreign keys in either direction.` })];
321
+ }
322
+
323
+ /* Laid out from measured heights rather than a fixed row pitch.
324
+
325
+ Once a node can be opened, two nodes in the same column are no longer the
326
+ same height, and a constant pitch either overlaps the tall ones or leaves
327
+ a gap the size of the tallest under every short one. Each column is
328
+ stacked by accumulating real heights and then centred as a block. */
329
+ const stack = (nodes) => {
330
+ const heights = nodes.map((n) => nodeHeight(n.table));
331
+ const total = heights.reduce((a, b) => a + b, 0) + Math.max(0, nodes.length - 1) * ROW_GAP;
332
+ let offset = 0;
333
+ const tops = heights.map((h) => {
334
+ const at = offset;
335
+ offset += h + ROW_GAP;
336
+ return at;
337
+ });
338
+ return { total, tops };
339
+ };
340
+
341
+ const l = stack(left);
342
+ const r = stack(right);
343
+ const focusHeight = nodeHeight(tableId);
344
+ const selfRoom = self.length ? 46 : 0;
345
+
346
+ const extent = Math.max(l.total, r.total, focusHeight);
347
+ const height = extent + selfRoom + 30;
348
+ const columns = 1 + (left.length ? 1 : 0) + (right.length ? 1 : 0);
349
+ const width = NODE_W * columns + COL_GAP * (columns - 1);
350
+
351
+ const top = (blockTotal) => selfRoom + 15 + (extent - blockTotal) / 2;
352
+ const leftX = 0;
353
+ const midX = left.length ? NODE_W + COL_GAP : 0;
354
+ const rightX = midX + NODE_W + COL_GAP;
355
+ const focusTop = top(focusHeight);
356
+ // Edges meet a node at its header, which stays put as the body grows.
357
+ const focusAnchor = focusTop + HEAD_H / 2;
358
+
359
+ const svg = svgEl('svg', {
360
+ viewBox: `0 0 ${width} ${height}`,
361
+ style: `width:${width}px; max-width:100%`,
362
+ role: 'img',
363
+ 'aria-label': `Foreign key neighbourhood of ${tableId}`,
364
+ });
365
+
366
+ const defs = svgEl('defs', {}, svgEl('marker', {
367
+ id: 'arrow', viewBox: '0 0 8 8', refX: 7, refY: 4,
368
+ markerWidth: 7, markerHeight: 7, orient: 'auto-start-reverse',
369
+ }, svgEl('path', { d: 'M0,0 L8,4 L0,8 z', class: 'edge-head' })));
370
+ // Everything is drawn into one group, which is what pan and zoom move.
371
+ const content = svgEl('g');
372
+ svg.append(defs, content);
373
+
374
+ left.forEach((node, i) => {
375
+ const nodeTop = top(l.total) + l.tops[i];
376
+ content.append(edge(leftX + NODE_W, nodeTop + HEAD_H / 2, midX, focusAnchor, node.columns.join(' · '), 'out'));
377
+ content.append(nodeBox(node.table, leftX, nodeTop, 'out', repaint));
378
+ });
379
+ right.forEach((node, i) => {
380
+ const nodeTop = top(r.total) + r.tops[i];
381
+ content.append(edge(rightX, nodeTop + HEAD_H / 2, midX + NODE_W, focusAnchor, node.columns.join(' · '), 'in'));
382
+ content.append(nodeBox(node.table, rightX, nodeTop, 'in', repaint));
383
+ });
384
+
385
+ if (self.length) {
386
+ // A self-reference has nowhere else to go, so it loops above the node.
387
+ const y = focusTop;
388
+ content.append(svgEl('path', {
389
+ class: 'edge',
390
+ 'marker-end': 'url(#arrow)',
391
+ d: `M${midX + NODE_W * 0.3},${y} C${midX + NODE_W * 0.1},${y - 34} ${midX + NODE_W * 0.9},${y - 34} ${midX + NODE_W * 0.7},${y}`,
392
+ }));
393
+ content.append(svgEl('text', {
394
+ x: midX + NODE_W / 2, y: y - 22, class: 'edge-label', 'text-anchor': 'middle',
395
+ }, document.createTextNode(self.map((fk) => fk.from.columns.join(', ')).join(' · '))));
396
+ }
397
+
398
+ content.append(nodeBox(tableId, midX, focusTop, 'focus', repaint));
399
+
400
+ const legend = el('p', { class: 'diagram-legend' }, [
401
+ el('span', { class: 'legend-out', text: 'points at →' }),
402
+ el('span', { text: ` ${focus.name} ` }),
403
+ el('span', { class: 'legend-in', text: '← pointed at by' }),
404
+ ]);
405
+
406
+ /* One table and its neighbours is a selection like any other, and the one
407
+ people most often want to paste into a ticket. */
408
+ const drawn = new Set([tableId, ...out.map((fk) => fk.to.table), ...back.map((fk) => fk.from.table)]);
409
+
410
+ return [
411
+ el('div', { class: 'diagram-head' }, [
412
+ el('h2', { class: 'diagram-title', text: `${focus.name} and what it touches` }),
413
+ schemaExportControl({ only: drawn, picture: () => ({ svg, size: { width, height } }) }),
414
+ ]),
415
+ panZoom(svg, content, { width, height }, { x: midX + NODE_W / 2, y: focusAnchor }),
416
+ legend,
417
+ ];
418
+ }
419
+
420
+ /**
421
+ * One table, as a header and a list of its columns.
422
+ *
423
+ * The header navigates and the body does not, which is the distinction that
424
+ * lets the node be both a link and something you can read. Opening a node to
425
+ * see its columns should not also leave the page you opened it from.
426
+ */
427
+ function nodeBox(tableId, x, y, role, repaint, { preview = PREVIEW_FIELDS, attrs = {} } = {}) {
428
+ const table = findTable(tableId);
429
+ const height = nodeHeight(tableId, preview);
430
+ const g = svgEl('g', { class: `node node-${role}`, ...attrs });
431
+
432
+ g.append(svgEl('rect', { x, y, width: NODE_W, height, rx: 5 }));
433
+
434
+ const head = svgEl('g', {
435
+ class: 'node-head',
436
+ // Clicking a node walks there, so the diagram is navigation and not just
437
+ // a picture of the schema.
438
+ onclick: () => go(tableView(tableId), role === 'focus' ? 'replace' : 'reset'),
439
+ role: 'button',
440
+ tabindex: 0,
441
+ onkeydown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(tableView(tableId), 'reset'); } },
442
+ });
443
+ head.append(svgEl('rect', { x, y, width: NODE_W, height: HEAD_H, rx: 5, class: 'node-head-hit' }));
444
+ head.append(svgEl('title', {}, document.createTextNode(
445
+ table ? `${tableId} — ${table.columns.length} columns` : tableId,
446
+ )));
447
+ const label = tableId.length > 22 ? `${tableId.slice(0, 21)}…` : tableId;
448
+ head.append(svgEl('text', { x: x + 10, y: y + HEAD_H / 2 + 4 }, document.createTextNode(label)));
449
+ g.append(head);
450
+
451
+ if (!table?.columns.length) return g;
452
+
453
+ const ordered = orderedColumns(table);
454
+ const open = expanded.has(tableId);
455
+ const shown = open ? ordered : ordered.slice(0, preview);
456
+ const hidden = ordered.length - shown.length;
457
+
458
+ if (shown.length) {
459
+ g.append(svgEl('line', {
460
+ class: 'node-rule', x1: x, x2: x + NODE_W, y1: y + HEAD_H, y2: y + HEAD_H,
461
+ }));
462
+ }
463
+
464
+ shown.forEach((col, i) => {
465
+ const cy = y + HEAD_H + NODE_PAD + i * FIELD_H + FIELD_H - 5;
466
+ const mark = col.primaryKey ? '\u25c6' : col.references ? '\u2192' : '';
467
+ const field = svgEl('text', {
468
+ x: x + 10, y: cy,
469
+ class: `node-field${col.primaryKey ? ' is-key' : ''}${col.references ? ' is-ref' : ''}`,
470
+ }, document.createTextNode(`${mark ? `${mark} ` : ''}${clip(col.name, NAME_BUDGET)}`));
471
+ // The full name and type stay reachable even when both are shortened.
472
+ field.append(svgEl('title', {}, document.createTextNode(`${col.name} ${col.type ?? ''}`.trim())));
473
+ g.append(field);
474
+ g.append(svgEl('text', {
475
+ x: x + NODE_W - 10, y: cy, class: 'node-type', 'text-anchor': 'end',
476
+ }, document.createTextNode(shortType(col.type))));
477
+ });
478
+
479
+ /* The toggle only exists when there is something behind it. A node whose
480
+ five columns are all of its columns is already fully shown, and "+0 more"
481
+ would be a control that does nothing. */
482
+ if (hidden > 0 || open) {
483
+ const cy = y + HEAD_H + NODE_PAD + shown.length * FIELD_H + FIELD_H - 5;
484
+ const toggle = () => {
485
+ if (open) expanded.delete(tableId); else expanded.add(tableId);
486
+ repaint();
487
+ };
488
+ const more = svgEl('g', {
489
+ class: 'node-more',
490
+ role: 'button',
491
+ tabindex: 0,
492
+ onclick: (e) => { e.stopPropagation(); toggle(); },
493
+ onkeydown: (e) => {
494
+ if (e.key !== 'Enter' && e.key !== ' ') return;
495
+ e.preventDefault();
496
+ toggle();
497
+ },
498
+ });
499
+ more.append(svgEl('rect', {
500
+ x, y: cy - FIELD_H + 4, width: NODE_W, height: FIELD_H, class: 'node-more-hit',
501
+ }));
502
+ more.append(svgEl('text', { x: x + 10, y: cy }, document.createTextNode(
503
+ open ? '\u2212 show less' : `+ ${hidden} ${shown.length ? 'more' : 'columns'}`,
504
+ )));
505
+ g.append(more);
506
+ }
507
+
508
+ return g;
509
+ }
510
+
511
+ function edge(x1, y1, x2, y2, label, side = 'out') {
512
+ const g = svgEl('g');
513
+ const mid = (x1 + x2) / 2;
514
+ g.append(svgEl('path', {
515
+ class: 'edge',
516
+ 'marker-end': 'url(#arrow)',
517
+ d: `M${x1},${y1} C${mid},${y1} ${mid},${y2} ${x2},${y2}`,
518
+ }));
519
+ if (label) {
520
+ /* Horizontally centred in the gap between the two columns, vertically
521
+ parked on its own node's row.
522
+
523
+ The vertical part is what keeps five labels apart: every edge converges
524
+ on the focus table, so labels placed at the curve's midpoint converge
525
+ too and none of them is readable. The horizontal part is what keeps
526
+ them off the boxes — anchored beside its source node instead, a long
527
+ column name ran the width of the gap and printed itself over the focus
528
+ table's first field. */
529
+ const text = svgEl('text', {
530
+ x: mid,
531
+ y: y1 - 6,
532
+ class: 'edge-label',
533
+ 'text-anchor': 'middle',
534
+ }, document.createTextNode(label.length > EDGE_LABEL_BUDGET
535
+ ? `${label.slice(0, EDGE_LABEL_BUDGET - 1)}\u2026`
536
+ : label));
537
+ // Truncated labels keep the full list of columns on hover.
538
+ text.append(svgEl('title', {}, document.createTextNode(label)));
539
+ g.append(text);
540
+ }
541
+ return g;
542
+ }
543
+
544
+ /* ---------- the whole schema ----------
545
+
546
+ A different problem from the neighbourhood view. Every table at once is a
547
+ hairball unless the layout says something, so this one ranks tables by
548
+ dependency depth: tables that reference nothing sit in the first column,
549
+ tables that reference only those in the second, and so on. Reading left to
550
+ right is reading the order you would have to insert rows in.
551
+
552
+ That ordering is also the useful one for the questions people bring to a
553
+ schema they do not know — where does this start, what is a leaf, what is
554
+ the join table in the middle. Alphabetical would tell you nothing. */
555
+
556
+ const S_COL_GAP = 96;
557
+ const S_ROW_GAP = 18;
558
+
559
+ /**
560
+ * Above this many tables, nodes start as headers only.
561
+ *
562
+ * The column list is what makes a node readable; on a schema of two hundred
563
+ * tables it is also what makes the picture a mile tall before anyone has
564
+ * asked a question. Past the threshold every node keeps its "+ n columns"
565
+ * toggle, so the detail is one click away rather than absent.
566
+ */
567
+ const DENSE_SCHEMA = 24;
568
+
569
+ /** Longest chain of references out of each table, capped against cycles. */
570
+ function dependencyDepth(tables, keys) {
571
+ const out = new Map(tables.map((t) => [t.id, []]));
572
+ for (const fk of keys) {
573
+ if (fk.from.table === fk.to.table) continue; // a self-reference adds no depth
574
+ out.get(fk.from.table)?.push(fk.to.table);
575
+ }
576
+ const depth = new Map();
577
+ const visiting = new Set();
578
+ const walk = (id) => {
579
+ if (depth.has(id)) return depth.get(id);
580
+ // A cycle has no well-defined depth; breaking it at 0 keeps the layout
581
+ // stable instead of recursing forever on a legitimate circular schema.
582
+ if (visiting.has(id)) return 0;
583
+ visiting.add(id);
584
+ const targets = out.get(id) ?? [];
585
+ const d = targets.length ? 1 + Math.max(...targets.map(walk)) : 0;
586
+ visiting.delete(id);
587
+ depth.set(id, d);
588
+ return d;
589
+ };
590
+ for (const t of tables) walk(t.id);
591
+ return depth;
592
+ }
593
+
594
+ /**
595
+ * Tables the reader has pinned the diagram to. Empty means the whole schema.
596
+ *
597
+ * Kept outside the render so a repaint — expanding a node, say — does not
598
+ * throw the focus away.
599
+ */
600
+ const focused = new Set();
601
+
602
+ /** How many of a dense schema's tables the overview opens with. */
603
+ const HUB_TABLES = 12;
604
+
605
+ /* Whether the reader has asked for the whole picture despite its density.
606
+ Session state like `focused`: a repaint must not forget the answer. */
607
+ let showEverything = false;
608
+
609
+ /**
610
+ * The most-connected tables — the ones the rest of the schema hangs off.
611
+ *
612
+ * A sixty-five table schema drawn whole is a hundred and forty edges through
613
+ * one box of string: technically complete and readable by nobody, sitting
614
+ * under a prose summary that has already told the story better. So a dense
615
+ * schema opens on its hubs — the tables most foreign keys touch — which is
616
+ * the diagram that has an actual shape. Everything else is one click away:
617
+ * name a table to focus on it, or ask for all of it.
618
+ */
619
+ function hubTables(tables, foreignKeys) {
620
+ const degree = new Map();
621
+ const bump = (id) => degree.set(id, (degree.get(id) ?? 0) + 1);
622
+ for (const fk of foreignKeys) {
623
+ bump(fk.from.table);
624
+ if (fk.to.table !== fk.from.table) bump(fk.to.table);
625
+ }
626
+ return [...tables]
627
+ // Name as the tiebreak, so the same schema always opens on the same picture.
628
+ .sort((a, b) => (degree.get(b.id) ?? 0) - (degree.get(a.id) ?? 0) || a.id.localeCompare(b.id))
629
+ .slice(0, HUB_TABLES);
630
+ }
631
+
632
+ export function schemaDiagram() {
633
+ const host = el('div', { class: 'diagram diagram-schema' });
634
+ const paint = () => host.replaceChildren(...wholeSchema(paint));
635
+ paint();
636
+ return host;
637
+ }
638
+
639
+ /**
640
+ * Narrow the diagram to some tables and whatever touches them.
641
+ *
642
+ * Sixty-six tables laid out at once is a true picture and an unusable one:
643
+ * the question people actually arrive with is "what does `sales_order` touch",
644
+ * and the honest answer is a diagram of eight tables, not of the schema with
645
+ * eight of them highlighted. One hop out rather than the full transitive
646
+ * closure, because two hops from anything in a schema like this is most of
647
+ * the schema again.
648
+ */
649
+ function inScope(tables, foreignKeys) {
650
+ if (!focused.size) return tables;
651
+ const keep = new Set(focused);
652
+ for (const fk of foreignKeys) {
653
+ if (focused.has(fk.from.table)) keep.add(fk.to.table);
654
+ if (focused.has(fk.to.table)) keep.add(fk.from.table);
655
+ }
656
+ return tables.filter((t) => keep.has(t.id));
657
+ }
658
+
659
+ function focusBar(all, repaint) {
660
+ const add = (id) => {
661
+ if (!all.some((t) => t.id === id)) return;
662
+ focused.add(id);
663
+ repaint();
664
+ };
665
+
666
+ const chips = [...focused].map((id) => {
667
+ const table = all.find((t) => t.id === id);
668
+ return el('button', {
669
+ type: 'button', class: 'diagram-chip',
670
+ title: `Stop limiting to ${table?.name ?? id}`,
671
+ onclick: () => { focused.delete(id); repaint(); },
672
+ }, [
673
+ el('span', { text: table?.name ?? id }),
674
+ el('span', { class: 'chip-x', 'aria-hidden': 'true', text: '\u00d7' }),
675
+ ]);
676
+ });
677
+
678
+ /* Chips to the left of the field, not the right.
679
+
680
+ The field is the thing you come back to, and a field that slides left
681
+ every time a chip appears means aiming at a moving target — you add
682
+ `customer`, go to click the box again, and it is no longer where you
683
+ just were. Anchored to the right edge with the chips growing leftwards,
684
+ it never moves. */
685
+ return el('div', { class: 'diagram-focus' }, [
686
+ ...chips,
687
+ focused.size
688
+ ? el('button', {
689
+ type: 'button', class: 'ghost diagram-clear', text: 'Show all',
690
+ onclick: () => { focused.clear(); repaint(); },
691
+ })
692
+ : null,
693
+ picker({
694
+ items: all
695
+ .filter((t) => !focused.has(t.id))
696
+ .map((t) => ({
697
+ value: t.id,
698
+ label: t.name,
699
+ detail: `${t.columns.length} col${t.columns.length === 1 ? '' : 's'}`,
700
+ })),
701
+ onPick: add,
702
+ ariaLabel: 'Limit the diagram to these tables',
703
+ // Short enough to fit the field at any zoom. The instruction the long
704
+ // version carried is the field's own behaviour: focus it and the list
705
+ // of tables is right there.
706
+ placeholder: focused.size ? 'Add another table…' : 'Show only…',
707
+ }),
708
+ ].filter(Boolean));
709
+ }
710
+
711
+ function wholeSchema(repaint) {
712
+ const { tables: allTables, foreignKeys: allKeys } = state.schema;
713
+ if (!allTables.length) return [el('p', { class: 'note', text: 'This database has no tables.' })];
714
+
715
+ /* Dense and unfocused opens on the hubs, not the hairball. A chip in the
716
+ focus bar always wins — the reader named a table, so that is the
717
+ picture — and "Show all" is remembered for the session. */
718
+ const trimmed = !focused.size && !showEverything && allTables.length > DENSE_SCHEMA;
719
+ const tables = trimmed ? hubTables(allTables, allKeys) : inScope(allTables, allKeys);
720
+ const visible = new Set(tables.map((t) => t.id));
721
+ // Keys to tables that are out of scope would draw edges into empty space.
722
+ const foreignKeys = allKeys.filter((fk) => visible.has(fk.from.table) && visible.has(fk.to.table));
723
+
724
+ const preview = tables.length > DENSE_SCHEMA ? 0 : PREVIEW_FIELDS;
725
+
726
+ const depth = dependencyDepth(tables, foreignKeys);
727
+ const columns = new Map();
728
+ for (const t of tables) {
729
+ const d = depth.get(t.id) ?? 0;
730
+ if (!columns.has(d)) columns.set(d, []);
731
+ columns.get(d).push(t);
732
+ }
733
+ const order = [...columns.keys()].sort((a, b) => a - b);
734
+ for (const d of order) columns.get(d).sort((a, b) => a.id.localeCompare(b.id));
735
+
736
+ /* Every node is measured before anything is placed, because a node that has
737
+ been opened is taller than its neighbours and a fixed row pitch would
738
+ either overlap them or leave a gap the size of the tallest under each. */
739
+ const heights = new Map(tables.map((t) => [t.id, nodeHeight(t.id, preview)]));
740
+ const columnTotal = (list) =>
741
+ list.reduce((sum, t) => sum + heights.get(t.id), 0) + Math.max(0, list.length - 1) * S_ROW_GAP;
742
+
743
+ const tallest = Math.max(...order.map((d) => columnTotal(columns.get(d))));
744
+ const width = order.length * NODE_W + (order.length - 1) * S_COL_GAP;
745
+ const height = tallest + 24;
746
+
747
+ /* Positions are computed first so the edges can be drawn underneath every
748
+ node — otherwise a line crosses a box and the box looks struck through.
749
+ `y` is the header's centre, which is where edges meet a node. */
750
+ const pos = new Map();
751
+ order.forEach((d, col) => {
752
+ const list = columns.get(d);
753
+ let top = 12 + (tallest - columnTotal(list)) / 2;
754
+ for (const t of list) {
755
+ pos.set(t.id, { x: col * (NODE_W + S_COL_GAP), top, y: top + HEAD_H / 2 });
756
+ top += heights.get(t.id) + S_ROW_GAP;
757
+ }
758
+ });
759
+
760
+ const svg = svgEl('svg', {
761
+ viewBox: `0 0 ${width} ${height}`,
762
+ style: `width:${width}px; max-width:100%`,
763
+ role: 'img',
764
+ 'aria-label': `Entity diagram of ${state.schema.label}`,
765
+ });
766
+ svg.append(svgEl('defs', {}, svgEl('marker', {
767
+ id: 'arrow-s', viewBox: '0 0 8 8', refX: 7, refY: 4,
768
+ markerWidth: 6, markerHeight: 6, orient: 'auto-start-reverse',
769
+ }, svgEl('path', { d: 'M0,0 L8,4 L0,8 z', class: 'edge-head' }))));
770
+ const content = svgEl('g');
771
+ svg.append(content);
772
+
773
+ const edges = svgEl('g', { class: 'schema-edges' });
774
+ for (const fk of foreignKeys) {
775
+ const from = pos.get(fk.from.table);
776
+ const to = pos.get(fk.to.table);
777
+ if (!from || !to) continue;
778
+ if (fk.from.table === fk.to.table) {
779
+ const x = from.x + NODE_W / 2;
780
+ const y = from.top;
781
+ edges.append(svgEl('path', {
782
+ class: 'edge', 'marker-end': 'url(#arrow-s)',
783
+ d: `M${x - 18},${y} C${x - 24},${y - 22} ${x + 24},${y - 22} ${x + 18},${y}`,
784
+ 'data-from': fk.from.table, 'data-to': fk.to.table,
785
+ }));
786
+ continue;
787
+ }
788
+ // Leaving from whichever side faces the target keeps lines from doubling
789
+ // back across their own node.
790
+ const leftToRight = to.x >= from.x;
791
+ const x1 = leftToRight ? from.x + NODE_W : from.x;
792
+ const x2 = leftToRight ? to.x : to.x + NODE_W;
793
+ const mid = (x1 + x2) / 2;
794
+ edges.append(svgEl('path', {
795
+ class: 'edge', 'marker-end': 'url(#arrow-s)',
796
+ d: `M${x1},${from.y} C${mid},${from.y} ${mid},${to.y} ${x2},${to.y}`,
797
+ 'data-from': fk.from.table, 'data-to': fk.to.table,
798
+ }));
799
+ }
800
+ content.append(edges);
801
+
802
+ for (const table of tables) {
803
+ const p = pos.get(table.id);
804
+ content.append(nodeBox(table.id, p.x, p.top, table.isView ? 'view' : 'out', repaint, {
805
+ preview,
806
+ attrs: {
807
+ 'data-table': table.id,
808
+ /* Hovering a table dims everything it is not connected to. On a
809
+ schema with a hundred tables that is the difference between a
810
+ picture and an answer. */
811
+ onmouseenter: () => highlight(svg, table.id),
812
+ onmouseleave: () => highlight(svg, null),
813
+ },
814
+ }));
815
+ }
816
+
817
+ const counts = el('p', { class: 'diagram-legend' }, [
818
+ el('span', {
819
+ text: trimmed
820
+ ? `the ${tables.length} most-connected of ${allTables.length} tables · ${foreignKeys.length} foreign keys between them · `
821
+ : focused.size
822
+ ? `${tables.length} of ${allTables.length} tables · ${foreignKeys.length} foreign keys · `
823
+ : `${tables.length} tables · ${foreignKeys.length} foreign keys · `,
824
+ }),
825
+ el('span', { text: `${order.length} level${order.length === 1 ? '' : 's'} deep, left to right in reference order` }),
826
+ /* The way out of the trim, next to the sentence that admits to it — and
827
+ the way back, because "Show all" on a dense schema is a decision worth
828
+ being able to reverse without knowing which tables the hubs were. */
829
+ trimmed
830
+ ? el('button', {
831
+ type: 'button', class: 'ghost diagram-scope', text: `Show all ${allTables.length}`,
832
+ onclick: () => { showEverything = true; repaint(); },
833
+ })
834
+ : null,
835
+ !trimmed && !focused.size && showEverything && allTables.length > DENSE_SCHEMA
836
+ ? el('button', {
837
+ type: 'button', class: 'ghost diagram-scope', text: `Show the ${Math.min(HUB_TABLES, allTables.length)} hubs`,
838
+ onclick: () => { showEverything = false; repaint(); },
839
+ })
840
+ : null,
841
+ ].filter(Boolean));
842
+
843
+ const orphans = tables.filter((t) => !foreignKeys.some((fk) => fk.from.table === t.id || fk.to.table === t.id));
844
+ const note = orphans.length
845
+ ? el('p', { class: 'diagram-legend', text: `${orphans.length} table${orphans.length === 1 ? '' : 's'} with no foreign keys at all: ${orphans.slice(0, 6).map((t) => t.name).join(', ')}${orphans.length > 6 ? '…' : ''}` })
846
+ : null;
847
+
848
+ return [
849
+ el('div', { class: 'diagram-head' }, [
850
+ el('h2', { class: 'diagram-title', text: state.schema.label }),
851
+ focusBar(allTables, repaint),
852
+ /* `visible`, not `focused`: the diagram draws one hop past the chips,
853
+ and an export that quietly dropped that ring would not be the picture
854
+ anyone was looking at. */
855
+ schemaExportControl({
856
+ only: focused.size || trimmed ? visible : undefined,
857
+ picture: () => ({ svg, size: { width, height } }),
858
+ }),
859
+ ]),
860
+ panZoom(svg, content, { width, height }),
861
+ counts,
862
+ note,
863
+ ].filter(Boolean);
864
+ }
865
+
866
+ /** Dim everything not connected to `tableId`; pass null to clear. */
867
+ function highlight(svg, tableId) {
868
+ const connected = new Set();
869
+ if (tableId) {
870
+ connected.add(tableId);
871
+ for (const fk of state.schema.foreignKeys) {
872
+ if (fk.from.table === tableId) connected.add(fk.to.table);
873
+ if (fk.to.table === tableId) connected.add(fk.from.table);
874
+ }
875
+ }
876
+ svg.classList.toggle('has-focus', Boolean(tableId));
877
+ for (const node of svg.querySelectorAll('.node')) {
878
+ node.classList.toggle('dim', Boolean(tableId) && !connected.has(node.dataset.table));
879
+ node.classList.toggle('lit', Boolean(tableId) && node.dataset.table === tableId);
880
+ }
881
+ for (const edge of svg.querySelectorAll('.schema-edges path')) {
882
+ const on = !tableId || edge.dataset.from === tableId || edge.dataset.to === tableId;
883
+ edge.classList.toggle('dim', !on);
884
+ }
885
+ }