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.
- package/LICENSE +21 -0
- package/README.md +553 -0
- package/dist/adapters/adapter.js +372 -0
- package/dist/adapters/connect.js +33 -0
- package/dist/adapters/mysql.js +951 -0
- package/dist/adapters/postgres.js +1000 -0
- package/dist/adapters/sqlite.js +781 -0
- package/dist/client/agent.js +262 -0
- package/dist/client/app.js +973 -0
- package/dist/client/arrange.js +254 -0
- package/dist/client/ask.js +133 -0
- package/dist/client/breakdown.js +317 -0
- package/dist/client/clauses.js +390 -0
- package/dist/client/columns.js +98 -0
- package/dist/client/complete.js +437 -0
- package/dist/client/compose.js +166 -0
- package/dist/client/composer.css +495 -0
- package/dist/client/composer.js +1972 -0
- package/dist/client/connections.js +234 -0
- package/dist/client/connmanager.js +962 -0
- package/dist/client/connurl.js +188 -0
- package/dist/client/core.js +893 -0
- package/dist/client/deeplink.js +270 -0
- package/dist/client/delete.js +144 -0
- package/dist/client/diagram.js +885 -0
- package/dist/client/dropdown.js +279 -0
- package/dist/client/export.js +456 -0
- package/dist/client/features.css +524 -0
- package/dist/client/findvalue.js +169 -0
- package/dist/client/grid.js +205 -0
- package/dist/client/handoff.js +153 -0
- package/dist/client/help.css +145 -0
- package/dist/client/help.js +881 -0
- package/dist/client/history.js +222 -0
- package/dist/client/index.html +116 -0
- package/dist/client/insert.js +151 -0
- package/dist/client/menu.js +160 -0
- package/dist/client/nested.js +255 -0
- package/dist/client/page.css +713 -0
- package/dist/client/page.js +1345 -0
- package/dist/client/pagebuilder.js +1222 -0
- package/dist/client/pagemarks.js +95 -0
- package/dist/client/palette.js +374 -0
- package/dist/client/peek.js +254 -0
- package/dist/client/picker.js +139 -0
- package/dist/client/pins.js +140 -0
- package/dist/client/prompt.js +129 -0
- package/dist/client/record.js +707 -0
- package/dist/client/schemaexport.js +242 -0
- package/dist/client/schematext.js +125 -0
- package/dist/client/shape.js +178 -0
- package/dist/client/shapecheck.js +129 -0
- package/dist/client/skeleton.js +139 -0
- package/dist/client/sql.css +126 -0
- package/dist/client/sql.js +398 -0
- package/dist/client/sqlcomplete.js +163 -0
- package/dist/client/sqlsaved.js +107 -0
- package/dist/client/style.css +2711 -0
- package/dist/client/summary.js +259 -0
- package/dist/client/table.js +1035 -0
- package/dist/client/template.js +539 -0
- package/dist/client/theme.js +74 -0
- package/dist/client/tour.js +324 -0
- package/dist/client/undo.js +105 -0
- package/dist/client/url.js +166 -0
- package/dist/client/value.js +223 -0
- package/dist/client/views.js +215 -0
- package/dist/client/virtual.js +176 -0
- package/dist/client/welcome.js +170 -0
- package/dist/client/write.js +414 -0
- package/dist/server/changeimpact.js +195 -0
- package/dist/server/connections.js +615 -0
- package/dist/server/constraints.js +62 -0
- package/dist/server/credentials.js +230 -0
- package/dist/server/fixture.js +199 -0
- package/dist/server/graph.js +194 -0
- package/dist/server/impact.js +48 -0
- package/dist/server/index.js +2204 -0
- package/dist/server/journal.js +173 -0
- package/dist/server/layouts.js +128 -0
- package/dist/server/mcp.js +2840 -0
- package/dist/server/shapeonly.js +91 -0
- package/dist/shared/breakdown.js +231 -0
- package/dist/shared/breakdowntext.js +257 -0
- package/dist/shared/diff.js +130 -0
- package/dist/shared/like.js +29 -0
- package/dist/shared/lint.js +149 -0
- package/dist/shared/order.js +133 -0
- package/dist/shared/page.js +932 -0
- package/dist/shared/query.js +831 -0
- package/dist/shared/recordview.js +343 -0
- package/dist/shared/schema.js +377 -0
- package/dist/shared/sqlsaved.js +67 -0
- package/dist/shared/view.js +981 -0
- package/dist/shared/viewtext.js +273 -0
- package/dist/shared/vocabulary.js +164 -0
- 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
|
+
}
|