rdbr 0.1.0

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 (83) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +36 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +155 -0
  5. data/app/public/application.js +609 -0
  6. data/app/public/database-diagram.js +602 -0
  7. data/app/public/styles.css +2141 -0
  8. data/app/views/data.haml +166 -0
  9. data/app/views/group.haml +50 -0
  10. data/app/views/index.haml +108 -0
  11. data/app/views/layout.haml +59 -0
  12. data/app/views/record.haml +81 -0
  13. data/demo/README.md +47 -0
  14. data/demo/compose.yml +34 -0
  15. data/demo/seed.rb +270 -0
  16. data/demo/smoke.rb +13 -0
  17. data/docs/RELEASING.md +30 -0
  18. data/docs/VALUE_PRESENTATION.md +54 -0
  19. data/exe/rdbr +5 -0
  20. data/lib/rdbr/access/read_only.rb +61 -0
  21. data/lib/rdbr/access.rb +6 -0
  22. data/lib/rdbr/browser_launcher.rb +71 -0
  23. data/lib/rdbr/catalog/connection.rb +68 -0
  24. data/lib/rdbr/catalog/errors.rb +12 -0
  25. data/lib/rdbr/catalog/mysql/queries.rb +131 -0
  26. data/lib/rdbr/catalog/mysql.rb +146 -0
  27. data/lib/rdbr/catalog/postgresql/queries.rb +171 -0
  28. data/lib/rdbr/catalog/postgresql.rb +138 -0
  29. data/lib/rdbr/catalog/reader.rb +30 -0
  30. data/lib/rdbr/catalog/serializer.rb +24 -0
  31. data/lib/rdbr/catalog/sqlite.rb +182 -0
  32. data/lib/rdbr/catalog.rb +20 -0
  33. data/lib/rdbr/cli.rb +74 -0
  34. data/lib/rdbr/cli_options.rb +63 -0
  35. data/lib/rdbr/metadata/column.rb +44 -0
  36. data/lib/rdbr/metadata/constraints.rb +25 -0
  37. data/lib/rdbr/metadata/database.rb +37 -0
  38. data/lib/rdbr/metadata/index.rb +30 -0
  39. data/lib/rdbr/metadata/keys.rb +56 -0
  40. data/lib/rdbr/metadata/namespace.rb +26 -0
  41. data/lib/rdbr/metadata/relation.rb +104 -0
  42. data/lib/rdbr/metadata/relationship_inference.rb +75 -0
  43. data/lib/rdbr/metadata/relationships.rb +126 -0
  44. data/lib/rdbr/metadata/values.rb +73 -0
  45. data/lib/rdbr/metadata.rb +10 -0
  46. data/lib/rdbr/presentation/record_identity.rb +125 -0
  47. data/lib/rdbr/query/connection.rb +138 -0
  48. data/lib/rdbr/query/errors.rb +33 -0
  49. data/lib/rdbr/query/lookup.rb +40 -0
  50. data/lib/rdbr/query/mysql.rb +15 -0
  51. data/lib/rdbr/query/page.rb +66 -0
  52. data/lib/rdbr/query/postgresql.rb +7 -0
  53. data/lib/rdbr/query/postgresql_dialect.rb +11 -0
  54. data/lib/rdbr/query/relational.rb +239 -0
  55. data/lib/rdbr/query/sqlite.rb +15 -0
  56. data/lib/rdbr/query/type_support.rb +68 -0
  57. data/lib/rdbr/query/value_limiter.rb +38 -0
  58. data/lib/rdbr/query.rb +32 -0
  59. data/lib/rdbr/version.rb +3 -0
  60. data/lib/rdbr/web/application.rb +234 -0
  61. data/lib/rdbr/web/array_value_helpers.rb +92 -0
  62. data/lib/rdbr/web/binary_value_helpers.rb +51 -0
  63. data/lib/rdbr/web/column_preferences.rb +56 -0
  64. data/lib/rdbr/web/database_graph.rb +91 -0
  65. data/lib/rdbr/web/deferred_column_value_helpers.rb +20 -0
  66. data/lib/rdbr/web/deferred_value_helpers.rb +76 -0
  67. data/lib/rdbr/web/document_value_helpers.rb +68 -0
  68. data/lib/rdbr/web/filter_presentation_helpers.rb +44 -0
  69. data/lib/rdbr/web/helpers.rb +42 -0
  70. data/lib/rdbr/web/identifier_value_helpers.rb +20 -0
  71. data/lib/rdbr/web/json_page_stream.rb +30 -0
  72. data/lib/rdbr/web/navigation_helpers.rb +115 -0
  73. data/lib/rdbr/web/presentation_helpers.rb +114 -0
  74. data/lib/rdbr/web/range_value_helpers.rb +86 -0
  75. data/lib/rdbr/web/record_value_helpers.rb +90 -0
  76. data/lib/rdbr/web/related_value_helpers.rb +99 -0
  77. data/lib/rdbr/web/relationship_loader.rb +65 -0
  78. data/lib/rdbr/web/search_value_helpers.rb +58 -0
  79. data/lib/rdbr/web/spatial_value_helpers.rb +83 -0
  80. data/lib/rdbr/web/through_relationship_loader.rb +90 -0
  81. data/lib/rdbr/web/vector_value_helpers.rb +59 -0
  82. data/lib/rdbr.rb +25 -0
  83. metadata +298 -0
@@ -0,0 +1,602 @@
1
+ (() => {
2
+ const dialog = document.querySelector('#database-diagram');
3
+ const trigger = document.querySelector('[data-open-database-diagram]');
4
+ if (!dialog || !trigger) return;
5
+
6
+ const svg = dialog.querySelector('[data-diagram-svg]');
7
+ const canvas = dialog.querySelector('[data-diagram-canvas]');
8
+ const details = dialog.querySelector('[data-diagram-details]');
9
+ const status = dialog.querySelector('[data-diagram-status]');
10
+ const search = dialog.querySelector('[data-diagram-search]');
11
+ const svgNamespace = 'http://www.w3.org/2000/svg';
12
+ const nodeWidth = 220;
13
+ const nodeHeight = 58;
14
+ const componentGap = 140;
15
+ const nodeClearance = 110;
16
+ const ringSpacing = 320;
17
+ const maximumNeighbourhoodSize = 22;
18
+ let graph;
19
+ let viewport;
20
+ let positions = new Map();
21
+ let transform = { x: 0, y: 0, scale: 1 };
22
+ let drag;
23
+
24
+ const svgElement = (name, attributes = {}) => {
25
+ const element = document.createElementNS(svgNamespace, name);
26
+ Object.entries(attributes).forEach(([key, value]) => element.setAttribute(key, value));
27
+ return element;
28
+ };
29
+
30
+ const setTransform = () => {
31
+ viewport?.setAttribute('transform', `translate(${transform.x} ${transform.y}) scale(${transform.scale})`);
32
+ };
33
+
34
+ const fit = () => {
35
+ if (!graph?.nodes.length) return;
36
+
37
+ const bounds = Array.from(positions.values());
38
+ const minimumX = Math.min(...bounds.map((position) => position.x));
39
+ const minimumY = Math.min(...bounds.map((position) => position.y));
40
+ const width = Math.max(...bounds.map((position) => position.x)) + nodeWidth - minimumX;
41
+ const height = Math.max(...bounds.map((position) => position.y)) + nodeHeight - minimumY;
42
+ const scale = Math.min((canvas.clientWidth - 80) / width, (canvas.clientHeight - 80) / height, 1);
43
+ transform = {
44
+ scale: Math.max(scale, 0.12),
45
+ x: (canvas.clientWidth - width * scale) / 2 - minimumX * scale,
46
+ y: (canvas.clientHeight - height * scale) / 2 - minimumY * scale
47
+ };
48
+ setTransform();
49
+ };
50
+
51
+ const zoom = (factor, originX = canvas.clientWidth / 2, originY = canvas.clientHeight / 2) => {
52
+ const oldScale = transform.scale;
53
+ const newScale = Math.max(0.1, Math.min(2.5, oldScale * factor));
54
+ const graphX = (originX - transform.x) / oldScale;
55
+ const graphY = (originY - transform.y) / oldScale;
56
+ transform.x = originX - graphX * newScale;
57
+ transform.y = originY - graphY * newScale;
58
+ transform.scale = newScale;
59
+ setTransform();
60
+ };
61
+
62
+ const showDetails = (node) => {
63
+ details.replaceChildren();
64
+ const heading = document.createElement('h3');
65
+ const kind = document.createElement('p');
66
+ const list = document.createElement('dl');
67
+ heading.textContent = `${node.namespace}.${node.name}`;
68
+ kind.className = 'database-diagram-kind';
69
+ kind.textContent = node.kind.replaceAll('_', ' ');
70
+ details.append(heading, kind);
71
+ if (node.comment) {
72
+ const comment = document.createElement('p');
73
+ comment.textContent = node.comment;
74
+ details.append(comment);
75
+ }
76
+ node.columns.forEach((column) => {
77
+ const row = document.createElement('div');
78
+ const name = document.createElement('dt');
79
+ const type = document.createElement('dd');
80
+ const badges = [];
81
+ if (column.primary_key) badges.push('PK');
82
+ column.foreign_keys.forEach((foreignKey) => {
83
+ const namespace = foreignKey.namespace || node.namespace;
84
+ const inference = foreignKey.inferred ? ' (inferred)' : '';
85
+ badges.push(`FK → ${namespace}.${foreignKey.relation}.${foreignKey.column}${inference}`);
86
+ });
87
+ name.textContent = column.name;
88
+ type.textContent = [column.type || 'Unknown', ...badges].join(' · ');
89
+ row.append(name, type);
90
+ list.append(row);
91
+ });
92
+ details.append(list);
93
+ viewport.querySelectorAll('.database-diagram-node').forEach((element) => {
94
+ element.classList.toggle('selected', element.dataset.nodeId === node.id);
95
+ });
96
+ };
97
+
98
+ const connectedComponents = (adjacency) => {
99
+ const remaining = new Set(graph.nodes.map((node) => node.id));
100
+ const components = [];
101
+ while (remaining.size) {
102
+ const start = Array.from(remaining).sort((left, right) =>
103
+ adjacency.get(right).size - adjacency.get(left).size)[0];
104
+ const component = [];
105
+ const queue = [start];
106
+ remaining.delete(start);
107
+ while (queue.length) {
108
+ const id = queue.shift();
109
+ component.push(id);
110
+ adjacency.get(id).forEach((neighbour) => {
111
+ if (!remaining.has(neighbour)) return;
112
+ remaining.delete(neighbour);
113
+ queue.push(neighbour);
114
+ });
115
+ }
116
+ components.push(component);
117
+ }
118
+ return components.sort((left, right) => right.length - left.length);
119
+ };
120
+
121
+ const distancesFrom = (start, componentIds, adjacency) => {
122
+ const distances = new Map([[start, 0]]);
123
+ const queue = [start];
124
+ while (queue.length) {
125
+ const id = queue.shift();
126
+ adjacency.get(id).forEach((neighbour) => {
127
+ if (!componentIds.has(neighbour) || distances.has(neighbour)) return;
128
+ distances.set(neighbour, distances.get(id) + 1);
129
+ queue.push(neighbour);
130
+ });
131
+ }
132
+ return distances;
133
+ };
134
+
135
+ const neighbourhoodHubs = (component, adjacency) => {
136
+ const componentIds = new Set(component);
137
+ const hubCount = Math.max(1, Math.ceil(component.length / maximumNeighbourhoodSize));
138
+ const byDegree = component.slice().sort((left, right) =>
139
+ adjacency.get(right).size - adjacency.get(left).size || left.localeCompare(right));
140
+ const hubs = [byDegree[0]];
141
+ const distanceMaps = [distancesFrom(hubs[0], componentIds, adjacency)];
142
+ while (hubs.length < hubCount) {
143
+ const candidates = component.filter((id) => !hubs.includes(id)).map((id) => {
144
+ const separation = Math.min(...distanceMaps.map((distances) => distances.get(id)));
145
+ return { id, score: (separation + 1) * (adjacency.get(id).size + 1) };
146
+ });
147
+ candidates.sort((left, right) => right.score - left.score || left.id.localeCompare(right.id));
148
+ hubs.push(candidates[0].id);
149
+ distanceMaps.push(distancesFrom(candidates[0].id, componentIds, adjacency));
150
+ }
151
+ return hubs;
152
+ };
153
+
154
+ const partitionComponent = (component, adjacency) => {
155
+ if (component.length <= maximumNeighbourhoodSize) return [component];
156
+
157
+ const hubs = neighbourhoodHubs(component, adjacency);
158
+ const unassigned = new Set(component);
159
+ const groups = new Map(hubs.map((hub) => [hub, [hub]]));
160
+ let frontiers = new Map(hubs.map((hub) => [hub, [hub]]));
161
+ hubs.forEach((hub) => unassigned.delete(hub));
162
+ while (unassigned.size) {
163
+ const proposals = new Map();
164
+ frontiers.forEach((frontier, hub) => {
165
+ frontier.forEach((id) => {
166
+ adjacency.get(id).forEach((neighbour) => {
167
+ if (!unassigned.has(neighbour)) return;
168
+ if (!proposals.has(neighbour)) proposals.set(neighbour, []);
169
+ proposals.get(neighbour).push(hub);
170
+ });
171
+ });
172
+ });
173
+ const nextFrontiers = new Map(hubs.map((hub) => [hub, []]));
174
+ Array.from(proposals.keys()).sort().forEach((id) => {
175
+ const candidates = proposals.get(id).sort((left, right) =>
176
+ groups.get(left).length - groups.get(right).length || left.localeCompare(right));
177
+ const owner = candidates[0];
178
+ groups.get(owner).push(id);
179
+ nextFrontiers.get(owner).push(id);
180
+ unassigned.delete(id);
181
+ });
182
+ frontiers = nextFrontiers;
183
+ }
184
+ return hubs.map((hub) => groups.get(hub));
185
+ };
186
+
187
+ const ringOrder = (ids, levels, angles, adjacency) => {
188
+ const desired = ids.map((id) => {
189
+ const innerNeighbours = Array.from(adjacency.get(id)).filter((neighbour) =>
190
+ levels.get(neighbour) < levels.get(id) && angles.has(neighbour));
191
+ if (!innerNeighbours.length) return { id, angle: 0 };
192
+ const x = innerNeighbours.reduce((sum, neighbour) => sum + Math.cos(angles.get(neighbour)), 0);
193
+ const y = innerNeighbours.reduce((sum, neighbour) => sum + Math.sin(angles.get(neighbour)), 0);
194
+ return { id, angle: Math.atan2(y, x) };
195
+ });
196
+ desired.sort((left, right) => left.angle - right.angle || left.id.localeCompare(right.id));
197
+ return desired;
198
+ };
199
+
200
+ const groupRingNeighbours = (ordered, adjacency) => {
201
+ const entries = new Map(ordered.map((entry) => [entry.id, entry]));
202
+ const remaining = new Set(entries.keys());
203
+ const groups = [];
204
+ while (remaining.size) {
205
+ const start = Array.from(remaining).sort()[0];
206
+ const group = [];
207
+ const queue = [start];
208
+ remaining.delete(start);
209
+ while (queue.length) {
210
+ const id = queue.shift();
211
+ group.push(entries.get(id));
212
+ Array.from(adjacency.get(id)).sort().forEach((neighbour) => {
213
+ if (!remaining.has(neighbour) || !entries.has(neighbour)) return;
214
+ remaining.delete(neighbour);
215
+ queue.push(neighbour);
216
+ });
217
+ }
218
+ groups.push(group);
219
+ }
220
+ return groups.sort((left, right) => left[0].id.localeCompare(right[0].id)).flat();
221
+ };
222
+
223
+ const normalizeAngle = (angle) => {
224
+ let normalized = angle;
225
+ while (normalized <= -Math.PI) normalized += Math.PI * 2;
226
+ while (normalized > Math.PI) normalized -= Math.PI * 2;
227
+ return normalized;
228
+ };
229
+
230
+ const truncateSvgText = (element, value, maximumWidth) => {
231
+ element.textContent = value;
232
+ if (!element.getComputedTextLength || element.getComputedTextLength() <= maximumWidth) return;
233
+
234
+ let lower = 0;
235
+ let upper = value.length;
236
+ while (lower < upper) {
237
+ const length = Math.ceil((lower + upper) / 2);
238
+ element.textContent = `${value.slice(0, length)}…`;
239
+ if (element.getComputedTextLength() <= maximumWidth) lower = length;
240
+ else upper = length - 1;
241
+ }
242
+ element.textContent = `${value.slice(0, lower)}…`;
243
+ };
244
+
245
+ const spreadRing = (ordered, level, radius) => {
246
+ if (level === 1) {
247
+ const step = (Math.PI * 2) / ordered.length;
248
+ return ordered.map((_entry, index) => -Math.PI / 2 + index * step);
249
+ }
250
+
251
+ const minimumGap = (nodeWidth + nodeClearance) / radius;
252
+ const angles = ordered.map((entry, index) => entry.angle + index * 0.0001);
253
+ for (let iteration = 0; iteration < 180; iteration += 1) {
254
+ const forces = ordered.map((entry, index) => normalizeAngle(entry.angle - angles[index]) * 0.035);
255
+ for (let left = 0; left < angles.length; left += 1) {
256
+ for (let right = left + 1; right < angles.length; right += 1) {
257
+ const difference = normalizeAngle(angles[right] - angles[left]);
258
+ const distance = Math.abs(difference);
259
+ if (distance >= minimumGap) continue;
260
+ const direction = difference >= 0 ? 1 : -1;
261
+ const separation = (minimumGap - distance) * 0.22;
262
+ forces[left] -= direction * separation;
263
+ forces[right] += direction * separation;
264
+ }
265
+ }
266
+ angles.forEach((_angle, index) => {
267
+ angles[index] = normalizeAngle(angles[index] + forces[index]);
268
+ });
269
+ }
270
+ return angles;
271
+ };
272
+
273
+ const layoutComponent = (component, adjacency) => {
274
+ const componentIds = new Set(component);
275
+ const hub = component.slice().sort((left, right) => {
276
+ const degreeDifference = adjacency.get(right).size - adjacency.get(left).size;
277
+ return degreeDifference || left.localeCompare(right);
278
+ })[0];
279
+ const levels = new Map([[hub, 0]]);
280
+ const queue = [hub];
281
+ while (queue.length) {
282
+ const id = queue.shift();
283
+ adjacency.get(id).forEach((neighbour) => {
284
+ if (!componentIds.has(neighbour) || levels.has(neighbour)) return;
285
+ levels.set(neighbour, levels.get(id) + 1);
286
+ queue.push(neighbour);
287
+ });
288
+ }
289
+
290
+ const local = new Map([[hub, { x: -nodeWidth / 2, y: -nodeHeight / 2 }]]);
291
+ const angles = new Map([[hub, 0]]);
292
+ const maximumLevel = Math.max(...levels.values());
293
+ let previousRadius = 0;
294
+ for (let level = 1; level <= maximumLevel; level += 1) {
295
+ const ring = component.filter((id) => levels.get(id) === level);
296
+ let ordered = ringOrder(ring, levels, angles, adjacency);
297
+ if (level === 1) ordered = groupRingNeighbours(ordered, adjacency);
298
+ const capacityRadius = ordered.length * (nodeWidth + nodeClearance) / (Math.PI * 2);
299
+ const radius = Math.max(previousRadius + ringSpacing, capacityRadius);
300
+ const ringAngles = spreadRing(ordered, level, radius);
301
+ ordered.forEach((entry, index) => {
302
+ const angle = ringAngles[index];
303
+ angles.set(entry.id, angle);
304
+ local.set(entry.id, {
305
+ x: Math.cos(angle) * radius - nodeWidth / 2,
306
+ y: Math.sin(angle) * radius - nodeHeight / 2
307
+ });
308
+ });
309
+ previousRadius = radius;
310
+ }
311
+
312
+ const values = Array.from(local.values());
313
+ const minimumX = Math.min(...values.map((position) => position.x));
314
+ const minimumY = Math.min(...values.map((position) => position.y));
315
+ const maximumX = Math.max(...values.map((position) => position.x)) + nodeWidth;
316
+ const maximumY = Math.max(...values.map((position) => position.y)) + nodeHeight;
317
+ local.forEach((position) => {
318
+ position.x -= minimumX;
319
+ position.y -= minimumY;
320
+ });
321
+ return { positions: local, width: maximumX - minimumX, height: maximumY - minimumY };
322
+ };
323
+
324
+ const packNeighbourhoods = (neighbourhoods) => {
325
+ const arranged = new Map();
326
+ const totalArea = neighbourhoods.reduce((sum, neighbourhood) =>
327
+ sum + (neighbourhood.width + componentGap) * (neighbourhood.height + componentGap), 0);
328
+ const targetWidth = Math.max(Math.sqrt(totalArea * 1.35), nodeWidth + componentGap);
329
+ let cursorX = 0;
330
+ let cursorY = 0;
331
+ let rowHeight = 0;
332
+ neighbourhoods.forEach((neighbourhood) => {
333
+ if (cursorX && cursorX + neighbourhood.width > targetWidth) {
334
+ cursorX = 0;
335
+ cursorY += rowHeight + componentGap;
336
+ rowHeight = 0;
337
+ }
338
+ neighbourhood.positions.forEach((position, id) => {
339
+ arranged.set(id, { x: position.x + cursorX, y: position.y + cursorY });
340
+ });
341
+ cursorX += neighbourhood.width + componentGap;
342
+ rowHeight = Math.max(rowHeight, neighbourhood.height);
343
+ });
344
+ return arranged;
345
+ };
346
+
347
+ const normalizeNeighbourhoods = (neighbourhoods, arranged) => {
348
+ neighbourhoods.forEach((neighbourhood) => {
349
+ const ids = Array.from(neighbourhood.positions.keys());
350
+ const values = ids.map((id) => arranged.get(id));
351
+ const minimumX = Math.min(...values.map((position) => position.x));
352
+ const minimumY = Math.min(...values.map((position) => position.y));
353
+ const maximumX = Math.max(...values.map((position) => position.x)) + nodeWidth;
354
+ const maximumY = Math.max(...values.map((position) => position.y)) + nodeHeight;
355
+ ids.forEach((id) => {
356
+ const position = arranged.get(id);
357
+ neighbourhood.positions.set(id, { x: position.x - minimumX, y: position.y - minimumY });
358
+ });
359
+ neighbourhood.width = maximumX - minimumX;
360
+ neighbourhood.height = maximumY - minimumY;
361
+ });
362
+ };
363
+
364
+ const neighbourhoodBounds = (neighbourhood, arranged) => {
365
+ const values = Array.from(neighbourhood.positions.keys()).map((id) => arranged.get(id));
366
+ const minimumX = Math.min(...values.map((position) => position.x));
367
+ const minimumY = Math.min(...values.map((position) => position.y));
368
+ const maximumX = Math.max(...values.map((position) => position.x)) + nodeWidth;
369
+ const maximumY = Math.max(...values.map((position) => position.y)) + nodeHeight;
370
+ return { x: (minimumX + maximumX) / 2, y: (minimumY + maximumY) / 2 };
371
+ };
372
+
373
+ const orientNeighbourhoods = (neighbourhoods, arranged) => {
374
+ const owners = new Map();
375
+ neighbourhoods.forEach((neighbourhood, index) => {
376
+ neighbourhood.positions.forEach((_position, id) => owners.set(id, index));
377
+ });
378
+ const centers = neighbourhoods.map((neighbourhood) => neighbourhoodBounds(neighbourhood, arranged));
379
+ const rotations = neighbourhoods.map(() => []);
380
+ graph.edges.forEach((edge) => {
381
+ const sourceOwner = owners.get(edge.source);
382
+ const targetOwner = owners.get(edge.target);
383
+ if (sourceOwner === undefined || targetOwner === undefined || sourceOwner === targetOwner) return;
384
+ [[edge.source, sourceOwner, targetOwner], [edge.target, targetOwner, sourceOwner]].forEach(
385
+ ([id, owner, otherOwner]) => {
386
+ const position = arranged.get(id);
387
+ const center = centers[owner];
388
+ const current = Math.atan2(position.y + nodeHeight / 2 - center.y,
389
+ position.x + nodeWidth / 2 - center.x);
390
+ const desired = Math.atan2(centers[otherOwner].y - center.y, centers[otherOwner].x - center.x);
391
+ rotations[owner].push(normalizeAngle(desired - current));
392
+ }
393
+ );
394
+ });
395
+ rotations.forEach((values, owner) => {
396
+ if (!values.length) return;
397
+ const rotation = Math.atan2(values.reduce((sum, angle) => sum + Math.sin(angle), 0),
398
+ values.reduce((sum, angle) => sum + Math.cos(angle), 0));
399
+ const center = centers[owner];
400
+ neighbourhoods[owner].positions.forEach((_position, id) => {
401
+ const position = arranged.get(id);
402
+ const x = position.x + nodeWidth / 2 - center.x;
403
+ const y = position.y + nodeHeight / 2 - center.y;
404
+ arranged.set(id, {
405
+ x: center.x + x * Math.cos(rotation) - y * Math.sin(rotation) - nodeWidth / 2,
406
+ y: center.y + x * Math.sin(rotation) + y * Math.cos(rotation) - nodeHeight / 2
407
+ });
408
+ });
409
+ });
410
+ };
411
+
412
+ const layoutGraph = () => {
413
+ const nodeIds = new Set(graph.nodes.map((node) => node.id));
414
+ const adjacency = new Map(graph.nodes.map((node) => [node.id, new Set()]));
415
+ graph.edges.forEach((edge) => {
416
+ if (!nodeIds.has(edge.source) || !nodeIds.has(edge.target)) return;
417
+ adjacency.get(edge.source).add(edge.target);
418
+ adjacency.get(edge.target).add(edge.source);
419
+ });
420
+ const neighbourhoods = connectedComponents(adjacency)
421
+ .flatMap((component) => partitionComponent(component, adjacency))
422
+ .map((component) => layoutComponent(component, adjacency))
423
+ .sort((left, right) => right.width * right.height - left.width * left.height);
424
+ const initial = packNeighbourhoods(neighbourhoods);
425
+ orientNeighbourhoods(neighbourhoods, initial);
426
+ normalizeNeighbourhoods(neighbourhoods, initial);
427
+ return packNeighbourhoods(neighbourhoods);
428
+ };
429
+
430
+ const edgeAnchors = (source, target) => {
431
+ const sourceCenter = { x: source.x + nodeWidth / 2, y: source.y + nodeHeight / 2 };
432
+ const targetCenter = { x: target.x + nodeWidth / 2, y: target.y + nodeHeight / 2 };
433
+ const dx = targetCenter.x - sourceCenter.x;
434
+ const dy = targetCenter.y - sourceCenter.y;
435
+ const sourceScale = 1 / Math.max(Math.abs(dx) / (nodeWidth / 2), Math.abs(dy) / (nodeHeight / 2));
436
+ const targetScale = 1 / Math.max(Math.abs(dx) / (nodeWidth / 2), Math.abs(dy) / (nodeHeight / 2));
437
+ return {
438
+ source: { x: sourceCenter.x + dx * sourceScale, y: sourceCenter.y + dy * sourceScale },
439
+ target: { x: targetCenter.x - dx * targetScale, y: targetCenter.y - dy * targetScale }
440
+ };
441
+ };
442
+
443
+ const renderEdge = (edge) => {
444
+ const source = positions.get(edge.source);
445
+ const target = positions.get(edge.target);
446
+ if (!source || !target) return;
447
+
448
+ const selfRelationship = edge.source === edge.target;
449
+ const anchors = selfRelationship ? undefined : edgeAnchors(source, target);
450
+ const path = selfRelationship ?
451
+ `M ${source.x + nodeWidth} ${source.y + nodeHeight / 3} ` +
452
+ `C ${source.x + nodeWidth + 75} ${source.y - 55}, ` +
453
+ `${source.x + nodeWidth + 75} ${source.y + nodeHeight + 55}, ` +
454
+ `${source.x + nodeWidth} ${source.y + nodeHeight * 2 / 3}` :
455
+ `M ${anchors.source.x} ${anchors.source.y} L ${anchors.target.x} ${anchors.target.y}`;
456
+ const line = svgElement('path', {
457
+ class: `database-diagram-edge${edge.inferred ? ' inferred' : ''}`,
458
+ d: path,
459
+ 'marker-end': 'url(#database-diagram-arrow)'
460
+ });
461
+ const title = svgElement('title');
462
+ const inference = edge.inferred ? ' (inferred)' : '';
463
+ title.textContent = edge.columns.map((column) =>
464
+ `${column.source} → ${column.target}${inference}`).join(', ');
465
+ line.append(title);
466
+ viewport.append(line);
467
+ };
468
+
469
+ const renderNode = (node) => {
470
+ const position = positions.get(node.id);
471
+ const group = svgElement('g', {
472
+ class: 'database-diagram-node',
473
+ transform: `translate(${position.x} ${position.y})`,
474
+ tabindex: '0',
475
+ role: 'button',
476
+ 'aria-label': `Inspect ${node.namespace}.${node.name}`,
477
+ 'data-node-id': node.id
478
+ });
479
+ const box = svgElement('rect', { width: nodeWidth, height: nodeHeight, rx: 7 });
480
+ const name = svgElement('text', { x: 12, y: 25, class: 'database-diagram-node-name' });
481
+ const namespace = svgElement('text', { x: 12, y: 44, class: 'database-diagram-node-namespace' });
482
+ const title = svgElement('title');
483
+ title.textContent = `${node.namespace}.${node.name}`;
484
+ group.append(title, box, name, namespace);
485
+ group.addEventListener('click', (event) => {
486
+ if (event.detail === 0) showDetails(node);
487
+ });
488
+ group.addEventListener('keydown', (event) => {
489
+ if (event.key === 'Enter' || event.key === ' ') {
490
+ event.preventDefault();
491
+ showDetails(node);
492
+ }
493
+ });
494
+ viewport.append(group);
495
+ truncateSvgText(name, node.name, nodeWidth - 24);
496
+ truncateSvgText(namespace, `${node.namespace} · ${node.kind.replaceAll('_', ' ')}`, nodeWidth - 24);
497
+ };
498
+
499
+ const render = () => {
500
+ svg.replaceChildren();
501
+ if (!graph.nodes.length) {
502
+ status.textContent = 'No relations were discovered.';
503
+ status.hidden = false;
504
+ return;
505
+ }
506
+ positions = layoutGraph();
507
+
508
+ const definitions = svgElement('defs');
509
+ const marker = svgElement('marker', {
510
+ id: 'database-diagram-arrow', viewBox: '0 0 10 10', refX: '9', refY: '5',
511
+ markerWidth: '7', markerHeight: '7', orient: 'auto-start-reverse'
512
+ });
513
+ marker.append(svgElement('path', { d: 'M 0 0 L 10 5 L 0 10 z' }));
514
+ definitions.append(marker);
515
+ viewport = svgElement('g', { class: 'database-diagram-viewport' });
516
+ svg.append(definitions, viewport);
517
+ graph.edges.forEach(renderEdge);
518
+ graph.nodes.forEach(renderNode);
519
+ status.hidden = true;
520
+ fit();
521
+ };
522
+
523
+ const load = async () => {
524
+ if (graph) return;
525
+
526
+ try {
527
+ const response = await fetch(dialog.dataset.graphUrl, { headers: { Accept: 'application/json' } });
528
+ if (!response.ok) throw new Error(`Graph request failed (${response.status})`);
529
+ graph = await response.json();
530
+ render();
531
+ } catch (error) {
532
+ status.textContent = `Unable to load the database diagram: ${error.message}`;
533
+ }
534
+ };
535
+
536
+ const close = () => {
537
+ dialog.hidden = true;
538
+ trigger.focus();
539
+ };
540
+
541
+ trigger.addEventListener('click', () => {
542
+ dialog.hidden = false;
543
+ search.focus();
544
+ load();
545
+ });
546
+ dialog.addEventListener('click', (event) => {
547
+ if (event.target === dialog || event.target.closest('[data-close-database-diagram]')) close();
548
+ });
549
+ dialog.querySelector('[data-diagram-fit]').addEventListener('click', fit);
550
+ dialog.querySelector('[data-diagram-zoom-in]').addEventListener('click', () => zoom(1.25));
551
+ dialog.querySelector('[data-diagram-zoom-out]').addEventListener('click', () => zoom(0.8));
552
+ search.addEventListener('input', () => {
553
+ const query = search.value.trim().toLocaleLowerCase();
554
+ viewport?.querySelectorAll('.database-diagram-node').forEach((element) => {
555
+ const node = graph.nodes.find((candidate) => candidate.id === element.dataset.nodeId);
556
+ const matches = query && (`${node.namespace}.${node.name}`.toLocaleLowerCase().includes(query) ||
557
+ node.columns.some((column) => column.name.toLocaleLowerCase().includes(query)));
558
+ element.classList.toggle('search-match', Boolean(matches));
559
+ });
560
+ });
561
+ svg.addEventListener('wheel', (event) => {
562
+ event.preventDefault();
563
+ const bounds = canvas.getBoundingClientRect();
564
+ zoom(event.deltaY < 0 ? 1.12 : 0.89, event.clientX - bounds.left, event.clientY - bounds.top);
565
+ }, { passive: false });
566
+ svg.addEventListener('pointerdown', (event) => {
567
+ if (event.button !== 0) return;
568
+
569
+ drag = { pointerId: event.pointerId, x: event.clientX, y: event.clientY,
570
+ transformX: transform.x, transformY: transform.y, moved: false,
571
+ node: event.target.closest('.database-diagram-node') };
572
+ svg.setPointerCapture(event.pointerId);
573
+ });
574
+ svg.addEventListener('pointermove', (event) => {
575
+ if (!drag || event.pointerId !== drag.pointerId) return;
576
+ const distance = Math.hypot(event.clientX - drag.x, event.clientY - drag.y);
577
+ if (!drag.moved && distance < 6) return;
578
+
579
+ drag.moved = true;
580
+ svg.classList.add('panning');
581
+ transform.x = drag.transformX + event.clientX - drag.x;
582
+ transform.y = drag.transformY + event.clientY - drag.y;
583
+ setTransform();
584
+ });
585
+ const finishPointer = (event, cancelled = false) => {
586
+ if (!drag || event.pointerId !== drag.pointerId) return;
587
+
588
+ if (!cancelled && !drag.moved && drag.node) {
589
+ const node = graph.nodes.find((candidate) => candidate.id === drag.node.dataset.nodeId);
590
+ if (node) showDetails(node);
591
+ }
592
+ if (svg.hasPointerCapture(event.pointerId)) svg.releasePointerCapture(event.pointerId);
593
+ drag = undefined;
594
+ svg.classList.remove('panning');
595
+ };
596
+ svg.addEventListener('pointerup', (event) => finishPointer(event));
597
+ svg.addEventListener('pointercancel', (event) => finishPointer(event, true));
598
+ window.addEventListener('resize', () => { if (!dialog.hidden && graph) fit(); });
599
+ document.addEventListener('keydown', (event) => {
600
+ if (event.key === 'Escape' && !dialog.hidden) close();
601
+ });
602
+ })();