rails-schema 0.1.4 → 0.1.6
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +25 -0
- data/CLAUDE.md +18 -7
- data/README.md +67 -2
- data/docs/index.html +936 -76
- data/docs/screenshot.png +0 -0
- data/lib/rails/schema/assets/app.js +496 -41
- data/lib/rails/schema/assets/style.css +119 -4
- data/lib/rails/schema/assets/template.html.erb +8 -2
- data/lib/rails/schema/configuration.rb +17 -1
- data/lib/rails/schema/extractor/model_scanner.rb +21 -20
- data/lib/rails/schema/extractor/mongoid/model_scanner.rb +2 -0
- data/lib/rails/schema/extractor/packwerk_discovery.rb +41 -0
- data/lib/rails/schema/renderer/html_generator.rb +4 -1
- data/lib/rails/schema/transformer/edge.rb +6 -1
- data/lib/rails/schema/transformer/graph_builder.rb +57 -5
- data/lib/rails/schema/transformer/node.rb +6 -7
- data/lib/rails/schema/version.rb +1 -1
- data/lib/rails/schema.rb +1 -0
- metadata +4 -6
|
@@ -14,12 +14,25 @@
|
|
|
14
14
|
el.style.display = "none";
|
|
15
15
|
});
|
|
16
16
|
}
|
|
17
|
+
// Through edges toggle
|
|
18
|
+
var showThroughEdges = config.show_through_edges !== false;
|
|
19
|
+
var throughCheckbox = document.getElementById("toggle-through");
|
|
20
|
+
if (throughCheckbox) {
|
|
21
|
+
throughCheckbox.checked = showThroughEdges;
|
|
22
|
+
throughCheckbox.addEventListener("change", function() {
|
|
23
|
+
showThroughEdges = this.checked;
|
|
24
|
+
render();
|
|
25
|
+
if (selectedNode) selectNode(selectedNode);
|
|
26
|
+
});
|
|
27
|
+
}
|
|
17
28
|
|
|
18
29
|
// State
|
|
19
30
|
var selectedNode = null;
|
|
31
|
+
var lastCheckedIndex = null;
|
|
20
32
|
var visibleModels = new Set(nodes.map(function(n) { return n.id; }));
|
|
21
33
|
var simulation;
|
|
22
34
|
var zoomTransform = d3.zoomIdentity;
|
|
35
|
+
var collapsedGroups = new Set();
|
|
23
36
|
var NODE_WIDTH = 180;
|
|
24
37
|
var NODE_HEADER_HEIGHT = 36;
|
|
25
38
|
var NODE_COLUMN_HEIGHT = 18;
|
|
@@ -39,6 +52,31 @@
|
|
|
39
52
|
var edgeGroup = container.append("g").attr("class", "edges");
|
|
40
53
|
var nodeGroup = container.append("g").attr("class", "nodes");
|
|
41
54
|
|
|
55
|
+
// Group color assignment
|
|
56
|
+
var GROUP_COLORS = [
|
|
57
|
+
"#2563eb", "#dc2626", "#059669", "#d97706",
|
|
58
|
+
"#7c3aed", "#db2777", "#0891b2", "#4f46e5",
|
|
59
|
+
"#b91c1c", "#047857", "#b45309", "#6d28d9"
|
|
60
|
+
];
|
|
61
|
+
var groupColorMap = {};
|
|
62
|
+
var groupIndex = 0;
|
|
63
|
+
|
|
64
|
+
if (config.grouping_enabled) {
|
|
65
|
+
nodes.forEach(function(n) {
|
|
66
|
+
if (n.group && n.group.length > 0) {
|
|
67
|
+
var key = n.group.join("::");
|
|
68
|
+
if (!groupColorMap[key]) {
|
|
69
|
+
groupColorMap[key] = GROUP_COLORS[groupIndex % GROUP_COLORS.length];
|
|
70
|
+
groupIndex++;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
if (config.collapse_groups) {
|
|
75
|
+
Object.keys(groupColorMap).forEach(function(k) { collapsedGroups.add(k); });
|
|
76
|
+
collapsedGroups.add("__ungrouped__");
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
42
80
|
// Marker definitions for crow's foot notation
|
|
43
81
|
var MARKER_TYPES = {
|
|
44
82
|
belongs_to: "--edge-belongs-to",
|
|
@@ -149,6 +187,11 @@
|
|
|
149
187
|
return 0;
|
|
150
188
|
}
|
|
151
189
|
|
|
190
|
+
function bezierPoint(p0, p1, p2, p3, t) {
|
|
191
|
+
var mt = 1 - t;
|
|
192
|
+
return mt*mt*mt*p0 + 3*mt*mt*t*p1 + 3*mt*t*t*p2 + t*t*t*p3;
|
|
193
|
+
}
|
|
194
|
+
|
|
152
195
|
function getConnectionPoints(d) {
|
|
153
196
|
var src = d.source;
|
|
154
197
|
var tgt = d.target;
|
|
@@ -204,6 +247,7 @@
|
|
|
204
247
|
nodes.forEach(function(n) { nodeMap[n.id] = n; });
|
|
205
248
|
|
|
206
249
|
var simEdges = edges.filter(function(e) {
|
|
250
|
+
if (!showThroughEdges && e.through) return false;
|
|
207
251
|
return visibleModels.has(e.from) && visibleModels.has(e.to);
|
|
208
252
|
}).map(function(e) {
|
|
209
253
|
return { source: e.from, target: e.to, data: e };
|
|
@@ -245,6 +289,81 @@
|
|
|
245
289
|
.force("collide", d3.forceCollide().radius(function(d) { return Math.max(NODE_WIDTH, d._height) / 2 + 50; }))
|
|
246
290
|
.on("tick", ticked);
|
|
247
291
|
|
|
292
|
+
if (config.grouping_enabled) {
|
|
293
|
+
simulation
|
|
294
|
+
.force("x", d3.forceX(svgEl.clientWidth / 2).strength(0.03))
|
|
295
|
+
.force("y", d3.forceY(svgEl.clientHeight / 2).strength(0.03));
|
|
296
|
+
simulation.force("group", function(alpha) {
|
|
297
|
+
// Pass 1: Attract same-group nodes toward their group centroid
|
|
298
|
+
var groupNodes = {};
|
|
299
|
+
var centroids = {};
|
|
300
|
+
var counts = {};
|
|
301
|
+
connectedNodes.forEach(function(n) {
|
|
302
|
+
if (!n.group || n.group.length === 0) return;
|
|
303
|
+
var key = n.group.join("::");
|
|
304
|
+
if (!groupNodes[key]) { groupNodes[key] = []; centroids[key] = { x: 0, y: 0 }; counts[key] = 0; }
|
|
305
|
+
groupNodes[key].push(n);
|
|
306
|
+
centroids[key].x += n.x;
|
|
307
|
+
centroids[key].y += n.y;
|
|
308
|
+
counts[key]++;
|
|
309
|
+
});
|
|
310
|
+
var keys = Object.keys(centroids);
|
|
311
|
+
keys.forEach(function(k) {
|
|
312
|
+
centroids[k].x /= counts[k];
|
|
313
|
+
centroids[k].y /= counts[k];
|
|
314
|
+
});
|
|
315
|
+
var attractStrength = 0.15;
|
|
316
|
+
connectedNodes.forEach(function(n) {
|
|
317
|
+
if (!n.group || n.group.length === 0) return;
|
|
318
|
+
var key = n.group.join("::");
|
|
319
|
+
var c = centroids[key];
|
|
320
|
+
n.vx += (c.x - n.x) * alpha * attractStrength;
|
|
321
|
+
n.vy += (c.y - n.y) * alpha * attractStrength;
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
// Pass 2: Push overlapping group bounding boxes apart
|
|
325
|
+
if (keys.length < 2) return;
|
|
326
|
+
var pad = 15;
|
|
327
|
+
var bounds = {};
|
|
328
|
+
keys.forEach(function(k) {
|
|
329
|
+
var b = { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity };
|
|
330
|
+
groupNodes[k].forEach(function(n) {
|
|
331
|
+
var hw = NODE_WIDTH / 2;
|
|
332
|
+
var hh = n._height / 2;
|
|
333
|
+
if (n.x - hw < b.minX) b.minX = n.x - hw;
|
|
334
|
+
if (n.y - hh < b.minY) b.minY = n.y - hh;
|
|
335
|
+
if (n.x + hw > b.maxX) b.maxX = n.x + hw;
|
|
336
|
+
if (n.y + hh > b.maxY) b.maxY = n.y + hh;
|
|
337
|
+
});
|
|
338
|
+
b.minX -= pad; b.minY -= pad; b.maxX += pad; b.maxY += pad;
|
|
339
|
+
b.cx = (b.minX + b.maxX) / 2;
|
|
340
|
+
b.cy = (b.minY + b.maxY) / 2;
|
|
341
|
+
bounds[k] = b;
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
var sepStrength = 0.25;
|
|
345
|
+
for (var i = 0; i < keys.length; i++) {
|
|
346
|
+
for (var j = i + 1; j < keys.length; j++) {
|
|
347
|
+
var a = bounds[keys[i]];
|
|
348
|
+
var b = bounds[keys[j]];
|
|
349
|
+
var overlapX = Math.min(a.maxX, b.maxX) - Math.max(a.minX, b.minX);
|
|
350
|
+
var overlapY = Math.min(a.maxY, b.maxY) - Math.max(a.minY, b.minY);
|
|
351
|
+
if (overlapX <= 0 || overlapY <= 0) continue;
|
|
352
|
+
|
|
353
|
+
var dx = a.cx - b.cx;
|
|
354
|
+
var dy = a.cy - b.cy;
|
|
355
|
+
var dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
|
356
|
+
var push = Math.min(overlapX, overlapY) * alpha * sepStrength / dist;
|
|
357
|
+
var pushX = dx * push;
|
|
358
|
+
var pushY = dy * push;
|
|
359
|
+
|
|
360
|
+
groupNodes[keys[i]].forEach(function(n) { n.vx += pushX; n.vy += pushY; });
|
|
361
|
+
groupNodes[keys[j]].forEach(function(n) { n.vx -= pushX; n.vy -= pushY; });
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
|
|
248
367
|
// Manually resolve edge references for self-ref-only nodes
|
|
249
368
|
simEdges.forEach(function(e) {
|
|
250
369
|
if (typeof e.source === 'string' && selfRefIds.has(e.source)) {
|
|
@@ -349,13 +468,17 @@
|
|
|
349
468
|
});
|
|
350
469
|
}
|
|
351
470
|
|
|
352
|
-
function
|
|
471
|
+
function measureTextWidth(text, font) {
|
|
353
472
|
var canvas = document.createElement("canvas");
|
|
354
473
|
var ctx = canvas.getContext("2d");
|
|
355
|
-
ctx.font = font
|
|
356
|
-
|
|
474
|
+
ctx.font = font;
|
|
475
|
+
return ctx.measureText(text).width;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function truncateText(text, maxWidth, font) {
|
|
479
|
+
if (measureTextWidth(text, font) <= maxWidth) return text;
|
|
357
480
|
var truncated = text;
|
|
358
|
-
while (truncated.length > 0 &&
|
|
481
|
+
while (truncated.length > 0 && measureTextWidth(truncated + "…", font) > maxWidth) {
|
|
359
482
|
truncated = truncated.slice(0, -1);
|
|
360
483
|
}
|
|
361
484
|
return truncated + "…";
|
|
@@ -413,10 +536,21 @@
|
|
|
413
536
|
.attr("class", "edge-label-bg");
|
|
414
537
|
|
|
415
538
|
eGroups.append("text")
|
|
416
|
-
.attr("class", "edge-label")
|
|
539
|
+
.attr("class", function(d) { return "edge-label " + d.data.association_type; })
|
|
417
540
|
.attr("text-anchor", "middle")
|
|
418
541
|
.attr("dy", -6)
|
|
419
542
|
.text(function(d) { return d.data.label; });
|
|
543
|
+
|
|
544
|
+
eGroups.append("rect")
|
|
545
|
+
.attr("class", "edge-label-bg edge-label-reverse-bg")
|
|
546
|
+
.style("display", function(d) { return d.data.reverse_label ? null : "none"; });
|
|
547
|
+
|
|
548
|
+
eGroups.append("text")
|
|
549
|
+
.attr("class", function(d) { var type = d.data.reverse_association_type || d.data.association_type; return "edge-label edge-label-reverse " + type; })
|
|
550
|
+
.attr("text-anchor", "middle")
|
|
551
|
+
.attr("dy", -6)
|
|
552
|
+
.text(function(d) { return d.data.reverse_label || ""; })
|
|
553
|
+
.style("display", function(d) { return d.data.reverse_label ? null : "none"; });
|
|
420
554
|
}
|
|
421
555
|
|
|
422
556
|
function renderNodes(simNodes) {
|
|
@@ -433,6 +567,10 @@
|
|
|
433
567
|
.on("click", function(event, d) {
|
|
434
568
|
event.stopPropagation();
|
|
435
569
|
selectNode(d.id);
|
|
570
|
+
})
|
|
571
|
+
.on("dblclick", function(event, d) {
|
|
572
|
+
event.stopPropagation();
|
|
573
|
+
focusNeighborhood(d.id);
|
|
436
574
|
});
|
|
437
575
|
|
|
438
576
|
// Background rect
|
|
@@ -449,7 +587,11 @@
|
|
|
449
587
|
.attr("width", NODE_WIDTH)
|
|
450
588
|
.attr("height", NODE_HEADER_HEIGHT)
|
|
451
589
|
.attr("x", -NODE_WIDTH / 2)
|
|
452
|
-
.attr("y", function(d) { return -d._height / 2; })
|
|
590
|
+
.attr("y", function(d) { return -d._height / 2; })
|
|
591
|
+
.style("fill", function(d) {
|
|
592
|
+
if (!config.grouping_enabled || !d.group || d.group.length === 0) return null;
|
|
593
|
+
return groupColorMap[d.group.join("::")] || null;
|
|
594
|
+
});
|
|
453
595
|
|
|
454
596
|
// Cover bottom corners of header
|
|
455
597
|
nGroups.append("rect")
|
|
@@ -457,7 +599,11 @@
|
|
|
457
599
|
.attr("width", NODE_WIDTH)
|
|
458
600
|
.attr("height", 10)
|
|
459
601
|
.attr("x", -NODE_WIDTH / 2)
|
|
460
|
-
.attr("y", function(d) { return -d._height / 2 + NODE_HEADER_HEIGHT - 10; })
|
|
602
|
+
.attr("y", function(d) { return -d._height / 2 + NODE_HEADER_HEIGHT - 10; })
|
|
603
|
+
.style("fill", function(d) {
|
|
604
|
+
if (!config.grouping_enabled || !d.group || d.group.length === 0) return null;
|
|
605
|
+
return groupColorMap[d.group.join("::")] || null;
|
|
606
|
+
});
|
|
461
607
|
|
|
462
608
|
// Model name
|
|
463
609
|
nGroups.append("text")
|
|
@@ -466,7 +612,7 @@
|
|
|
466
612
|
.attr("y", function(d) { return -d._height / 2 + 16; })
|
|
467
613
|
.attr("text-anchor", "middle")
|
|
468
614
|
.attr("dominant-baseline", "central")
|
|
469
|
-
.text(function(d) { return truncateText(d.id, NODE_WIDTH - NODE_PADDING * 2, "bold 13px"); });
|
|
615
|
+
.text(function(d) { return truncateText(d.id, NODE_WIDTH - NODE_PADDING * 2, "bold 13px " + getComputedStyle(document.body).fontFamily); });
|
|
470
616
|
|
|
471
617
|
// Table name
|
|
472
618
|
nGroups.append("text")
|
|
@@ -475,20 +621,32 @@
|
|
|
475
621
|
.attr("y", function(d) { return -d._height / 2 + 30; })
|
|
476
622
|
.attr("text-anchor", "middle")
|
|
477
623
|
.attr("dominant-baseline", "central")
|
|
478
|
-
.text(function(d) { return truncateText(d.table_name, NODE_WIDTH - NODE_PADDING * 2, "10px"); });
|
|
624
|
+
.text(function(d) { return truncateText(d.table_name, NODE_WIDTH - NODE_PADDING * 2, "10px " + getComputedStyle(document.body).fontFamily); });
|
|
479
625
|
|
|
480
626
|
// Columns
|
|
481
627
|
nGroups.each(function(d) {
|
|
482
628
|
var g = d3.select(this);
|
|
483
629
|
var startY = -d._height / 2 + NODE_HEADER_HEIGHT + NODE_PADDING;
|
|
484
630
|
|
|
631
|
+
var colFont = '11px "SF Mono", "Fira Code", "Cascadia Code", monospace';
|
|
632
|
+
var typeFont = '10px "SF Mono", "Fira Code", "Cascadia Code", monospace';
|
|
633
|
+
|
|
485
634
|
d._displayCols.forEach(function(col, i) {
|
|
486
635
|
var y = startY + i * NODE_COLUMN_HEIGHT + 10;
|
|
487
|
-
|
|
636
|
+
var fullName = (col.primary ? "PK " : "") + col.name;
|
|
637
|
+
var typeWidth = measureTextWidth(col.type, typeFont);
|
|
638
|
+
var availableWidth = NODE_WIDTH - 20 - typeWidth - 8;
|
|
639
|
+
var displayName = truncateText(fullName, availableWidth, colFont);
|
|
640
|
+
|
|
641
|
+
var nameEl = g.append("text")
|
|
488
642
|
.attr("class", "node-column-text" + (col.primary ? " node-column-pk" : ""))
|
|
489
643
|
.attr("x", -NODE_WIDTH / 2 + 10)
|
|
490
644
|
.attr("y", y)
|
|
491
|
-
.text(
|
|
645
|
+
.text(displayName);
|
|
646
|
+
|
|
647
|
+
if (displayName !== fullName) {
|
|
648
|
+
nameEl.append("title").text(fullName);
|
|
649
|
+
}
|
|
492
650
|
|
|
493
651
|
g.append("text")
|
|
494
652
|
.attr("class", "node-column-type")
|
|
@@ -570,11 +728,17 @@
|
|
|
570
728
|
", " + cp2x + " " + cp2y +
|
|
571
729
|
", " + connPts.x2 + " " + y2);
|
|
572
730
|
|
|
573
|
-
var labelX =
|
|
574
|
-
var labelY = (y1
|
|
575
|
-
g.select("text")
|
|
731
|
+
var labelX = bezierPoint(connPts.x1, cp1x, cp2x, connPts.x2, 0.25);
|
|
732
|
+
var labelY = bezierPoint(y1, cp1y, cp2y, y2, 0.25);
|
|
733
|
+
g.select("text.edge-label:not(.edge-label-reverse)")
|
|
576
734
|
.attr("x", labelX)
|
|
577
735
|
.attr("y", labelY);
|
|
736
|
+
|
|
737
|
+
var revX = bezierPoint(connPts.x1, cp1x, cp2x, connPts.x2, 0.75);
|
|
738
|
+
var revY = bezierPoint(y1, cp1y, cp2y, y2, 0.75);
|
|
739
|
+
g.select("text.edge-label-reverse")
|
|
740
|
+
.attr("x", revX)
|
|
741
|
+
.attr("y", revY);
|
|
578
742
|
});
|
|
579
743
|
|
|
580
744
|
resolveEdgeLabelOverlaps();
|
|
@@ -670,6 +834,7 @@
|
|
|
670
834
|
});
|
|
671
835
|
|
|
672
836
|
svg.call(zoom);
|
|
837
|
+
svg.on("dblclick.zoom", null);
|
|
673
838
|
|
|
674
839
|
// Click background to deselect
|
|
675
840
|
svg.on("click", function() {
|
|
@@ -696,6 +861,17 @@
|
|
|
696
861
|
updateSidebarActive(nodeId);
|
|
697
862
|
}
|
|
698
863
|
|
|
864
|
+
function focusNeighborhood(nodeId) {
|
|
865
|
+
var neighbors = adjacency[nodeId] || new Set();
|
|
866
|
+
visibleModels.clear();
|
|
867
|
+
visibleModels.add(nodeId);
|
|
868
|
+
neighbors.forEach(function(id) { visibleModels.add(id); });
|
|
869
|
+
buildSidebar();
|
|
870
|
+
render();
|
|
871
|
+
selectNode(nodeId);
|
|
872
|
+
setTimeout(fitToScreen, 100);
|
|
873
|
+
}
|
|
874
|
+
|
|
699
875
|
function deselectNode() {
|
|
700
876
|
selectedNode = null;
|
|
701
877
|
nodeGroup.selectAll(".node-group").classed("faded", false).classed("highlighted", false);
|
|
@@ -725,7 +901,7 @@
|
|
|
725
901
|
html += '<ul class="column-list">';
|
|
726
902
|
node.columns.forEach(function(col) {
|
|
727
903
|
var pkCls = col.primary ? ' pk' : '';
|
|
728
|
-
html += '<li><span class="col-name' + pkCls + '">' + (col.primary ? 'PK ' : '') + escapeHtml(col.name) + '</span>';
|
|
904
|
+
html += '<li><span class="col-name' + pkCls + '" title="' + escapeHtml(col.name) + '">' + (col.primary ? 'PK ' : '') + escapeHtml(col.name) + '</span>';
|
|
729
905
|
html += '<span class="col-type">' + escapeHtml(col.type) + (col.nullable ? '' : ' NOT NULL') + '</span></li>';
|
|
730
906
|
});
|
|
731
907
|
html += '</ul>';
|
|
@@ -749,7 +925,14 @@
|
|
|
749
925
|
// Click association targets to navigate
|
|
750
926
|
content.querySelectorAll('.assoc-target').forEach(function(el) {
|
|
751
927
|
el.addEventListener('click', function() {
|
|
752
|
-
|
|
928
|
+
var modelId = el.dataset.model;
|
|
929
|
+
if (!visibleModels.has(modelId)) {
|
|
930
|
+
visibleModels.add(modelId);
|
|
931
|
+
buildSidebar();
|
|
932
|
+
render();
|
|
933
|
+
setTimeout(fitToScreen, 100);
|
|
934
|
+
}
|
|
935
|
+
selectNode(modelId);
|
|
753
936
|
});
|
|
754
937
|
});
|
|
755
938
|
}
|
|
@@ -761,46 +944,189 @@
|
|
|
761
944
|
window.__closeDetail = deselectNode;
|
|
762
945
|
|
|
763
946
|
// Sidebar
|
|
947
|
+
function buildModelItem(n, filtered, groupColor) {
|
|
948
|
+
var edgeCount = edges.filter(function(e) { return e.from === n.id || e.to === n.id; }).length;
|
|
949
|
+
var div = document.createElement("div");
|
|
950
|
+
div.className = "model-item" + (selectedNode === n.id ? " active" : "");
|
|
951
|
+
var nameStyle = groupColor ? ' style="color:' + groupColor + '"' : '';
|
|
952
|
+
div.innerHTML = '<input type="checkbox" ' + (visibleModels.has(n.id) ? "checked" : "") + '>' +
|
|
953
|
+
'<span class="model-name"' + nameStyle + '>' + escapeHtml(n.id) + '</span>' +
|
|
954
|
+
'<span class="assoc-count">' + edgeCount + '</span>';
|
|
955
|
+
|
|
956
|
+
var cb = div.querySelector("input");
|
|
957
|
+
var currentIndex = filtered.indexOf(n);
|
|
958
|
+
cb.addEventListener("click", function(e) {
|
|
959
|
+
e.stopPropagation();
|
|
960
|
+
if (e.shiftKey && lastCheckedIndex !== null) {
|
|
961
|
+
var start = Math.min(lastCheckedIndex, currentIndex);
|
|
962
|
+
var end = Math.max(lastCheckedIndex, currentIndex);
|
|
963
|
+
for (var i = start; i <= end; i++) {
|
|
964
|
+
var modelId = filtered[i].id;
|
|
965
|
+
if (cb.checked) {
|
|
966
|
+
visibleModels.add(modelId);
|
|
967
|
+
} else {
|
|
968
|
+
visibleModels.delete(modelId);
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
} else {
|
|
972
|
+
if (cb.checked) {
|
|
973
|
+
visibleModels.add(n.id);
|
|
974
|
+
} else {
|
|
975
|
+
visibleModels.delete(n.id);
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
lastCheckedIndex = currentIndex;
|
|
979
|
+
buildSidebar();
|
|
980
|
+
render();
|
|
981
|
+
setTimeout(fitToScreen, 100);
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
div.addEventListener("click", function(e) {
|
|
985
|
+
if (e.target.tagName === "INPUT") return;
|
|
986
|
+
selectNode(n.id);
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
div.addEventListener("dblclick", function(e) {
|
|
990
|
+
if (e.target.tagName === "INPUT") return;
|
|
991
|
+
focusNeighborhood(n.id);
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
return div;
|
|
995
|
+
}
|
|
996
|
+
|
|
764
997
|
function buildSidebar() {
|
|
765
998
|
var list = document.getElementById("model-list");
|
|
766
999
|
list.innerHTML = "";
|
|
767
1000
|
|
|
768
1001
|
var filtered = getFilteredModels();
|
|
769
1002
|
|
|
770
|
-
|
|
771
|
-
var
|
|
772
|
-
var
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
var cb = div.querySelector("input");
|
|
779
|
-
cb.addEventListener("change", function(e) {
|
|
780
|
-
e.stopPropagation();
|
|
781
|
-
if (cb.checked) {
|
|
782
|
-
visibleModels.add(n.id);
|
|
1003
|
+
if (config.grouping_enabled) {
|
|
1004
|
+
var grouped = {};
|
|
1005
|
+
var ungrouped = [];
|
|
1006
|
+
filtered.forEach(function(n) {
|
|
1007
|
+
if (n.group && n.group.length > 0) {
|
|
1008
|
+
var key = n.group.join("::");
|
|
1009
|
+
if (!grouped[key]) grouped[key] = { label: n.group, models: [] };
|
|
1010
|
+
grouped[key].models.push(n);
|
|
783
1011
|
} else {
|
|
784
|
-
|
|
1012
|
+
ungrouped.push(n);
|
|
785
1013
|
}
|
|
786
|
-
render();
|
|
787
1014
|
});
|
|
788
1015
|
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
1016
|
+
function buildGroupHeader(models, labelHtml, groupKey) {
|
|
1017
|
+
var header = document.createElement("div");
|
|
1018
|
+
header.className = "group-header";
|
|
1019
|
+
header.dataset.groupKey = groupKey;
|
|
1020
|
+
var isCollapsed = collapsedGroups.has(groupKey);
|
|
1021
|
+
var toggleArrow = isCollapsed ? "▶" : "▼";
|
|
1022
|
+
var allVisible = models.every(function(n) { return visibleModels.has(n.id); });
|
|
1023
|
+
var noneVisible = models.every(function(n) { return !visibleModels.has(n.id); });
|
|
1024
|
+
header.innerHTML = '<input type="checkbox" class="group-checkbox"' + (allVisible ? " checked" : "") + '>' +
|
|
1025
|
+
labelHtml +
|
|
1026
|
+
'<span class="group-count">' + models.length + '</span>' +
|
|
1027
|
+
'<span class="group-toggle">' + toggleArrow + '</span>';
|
|
1028
|
+
|
|
1029
|
+
var cb = header.querySelector(".group-checkbox");
|
|
1030
|
+
if (!allVisible && !noneVisible) cb.indeterminate = true;
|
|
1031
|
+
|
|
1032
|
+
cb.addEventListener("click", function(e) {
|
|
1033
|
+
e.stopPropagation();
|
|
1034
|
+
if (cb.checked) {
|
|
1035
|
+
models.forEach(function(n) { visibleModels.add(n.id); });
|
|
1036
|
+
} else {
|
|
1037
|
+
models.forEach(function(n) { visibleModels.delete(n.id); });
|
|
1038
|
+
}
|
|
1039
|
+
buildSidebar();
|
|
1040
|
+
render();
|
|
1041
|
+
setTimeout(fitToScreen, 100);
|
|
1042
|
+
});
|
|
1043
|
+
|
|
1044
|
+
var clickTimer = null;
|
|
1045
|
+
header.addEventListener("click", function(e) {
|
|
1046
|
+
if (e.target.tagName === "INPUT") return;
|
|
1047
|
+
if (clickTimer) return;
|
|
1048
|
+
clickTimer = setTimeout(function() {
|
|
1049
|
+
clickTimer = null;
|
|
1050
|
+
if (collapsedGroups.has(groupKey)) {
|
|
1051
|
+
collapsedGroups.delete(groupKey);
|
|
1052
|
+
} else {
|
|
1053
|
+
collapsedGroups.add(groupKey);
|
|
1054
|
+
}
|
|
1055
|
+
var content = header.nextElementSibling;
|
|
1056
|
+
var toggle = header.querySelector(".group-toggle");
|
|
1057
|
+
var nowCollapsed = collapsedGroups.has(groupKey);
|
|
1058
|
+
content.style.display = nowCollapsed ? "none" : "";
|
|
1059
|
+
toggle.innerHTML = nowCollapsed ? "▶" : "▼";
|
|
1060
|
+
}, 250);
|
|
1061
|
+
});
|
|
1062
|
+
|
|
1063
|
+
header.addEventListener("dblclick", function(e) {
|
|
1064
|
+
if (e.target.tagName === "INPUT") return;
|
|
1065
|
+
e.stopPropagation();
|
|
1066
|
+
if (clickTimer) { clearTimeout(clickTimer); clickTimer = null; }
|
|
1067
|
+
visibleModels.clear();
|
|
1068
|
+
models.forEach(function(n) {
|
|
1069
|
+
visibleModels.add(n.id);
|
|
1070
|
+
var neighbors = adjacency[n.id] || new Set();
|
|
1071
|
+
neighbors.forEach(function(id) { visibleModels.add(id); });
|
|
1072
|
+
});
|
|
1073
|
+
buildSidebar();
|
|
1074
|
+
render();
|
|
1075
|
+
setTimeout(fitToScreen, 100);
|
|
1076
|
+
});
|
|
1077
|
+
|
|
1078
|
+
return header;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
var sortedGroups = Object.keys(grouped).sort();
|
|
1082
|
+
sortedGroups.forEach(function(key) {
|
|
1083
|
+
var group = grouped[key];
|
|
1084
|
+
var color = groupColorMap[key] || GROUP_COLORS[0];
|
|
1085
|
+
var labelHtml = '<span class="group-color-dot" style="background:' + color + '"></span>' +
|
|
1086
|
+
'<span class="group-header-label">' + escapeHtml(group.label.join(" > ")) + '</span>';
|
|
1087
|
+
var header = buildGroupHeader(group.models, labelHtml, key);
|
|
1088
|
+
list.appendChild(header);
|
|
1089
|
+
|
|
1090
|
+
var groupContainer = document.createElement("div");
|
|
1091
|
+
groupContainer.className = "group-models";
|
|
1092
|
+
if (collapsedGroups.has(key)) groupContainer.style.display = "none";
|
|
1093
|
+
group.models.forEach(function(n) {
|
|
1094
|
+
groupContainer.appendChild(buildModelItem(n, filtered, color));
|
|
1095
|
+
});
|
|
1096
|
+
list.appendChild(groupContainer);
|
|
792
1097
|
});
|
|
793
1098
|
|
|
794
|
-
|
|
795
|
-
|
|
1099
|
+
if (ungrouped.length > 0 && sortedGroups.length > 0) {
|
|
1100
|
+
var ungroupedLabelHtml = '<span class="group-header-label">Ungrouped</span>';
|
|
1101
|
+
var ungroupedHeader = buildGroupHeader(ungrouped, ungroupedLabelHtml, "__ungrouped__");
|
|
1102
|
+
list.appendChild(ungroupedHeader);
|
|
1103
|
+
var ungroupedContainer = document.createElement("div");
|
|
1104
|
+
ungroupedContainer.className = "group-models";
|
|
1105
|
+
if (collapsedGroups.has("__ungrouped__")) ungroupedContainer.style.display = "none";
|
|
1106
|
+
ungrouped.forEach(function(n) {
|
|
1107
|
+
ungroupedContainer.appendChild(buildModelItem(n, filtered));
|
|
1108
|
+
});
|
|
1109
|
+
list.appendChild(ungroupedContainer);
|
|
1110
|
+
} else {
|
|
1111
|
+
ungrouped.forEach(function(n) {
|
|
1112
|
+
list.appendChild(buildModelItem(n, filtered));
|
|
1113
|
+
});
|
|
1114
|
+
}
|
|
1115
|
+
} else {
|
|
1116
|
+
filtered.forEach(function(n) {
|
|
1117
|
+
list.appendChild(buildModelItem(n, filtered));
|
|
1118
|
+
});
|
|
1119
|
+
}
|
|
796
1120
|
}
|
|
797
1121
|
|
|
798
1122
|
function getFilteredModels() {
|
|
799
1123
|
var query = document.getElementById("search-input").value.toLowerCase();
|
|
800
1124
|
if (!query) return nodes;
|
|
801
1125
|
return nodes.filter(function(n) {
|
|
802
|
-
|
|
803
|
-
|
|
1126
|
+
if (n.id.toLowerCase().indexOf(query) !== -1) return true;
|
|
1127
|
+
if (n.table_name.toLowerCase().indexOf(query) !== -1) return true;
|
|
1128
|
+
if (n.group && n.group.join(" ").toLowerCase().indexOf(query) !== -1) return true;
|
|
1129
|
+
return false;
|
|
804
1130
|
});
|
|
805
1131
|
}
|
|
806
1132
|
|
|
@@ -809,26 +1135,95 @@
|
|
|
809
1135
|
var name = el.querySelector(".model-name").textContent;
|
|
810
1136
|
el.classList.toggle("active", name === nodeId);
|
|
811
1137
|
});
|
|
1138
|
+
|
|
1139
|
+
var activeGroupKey = null;
|
|
1140
|
+
if (nodeId) {
|
|
1141
|
+
var node = nodes.find(function(n) { return n.id === nodeId; });
|
|
1142
|
+
if (node && node.group && node.group.length > 0) {
|
|
1143
|
+
activeGroupKey = node.group.join("::");
|
|
1144
|
+
} else if (node) {
|
|
1145
|
+
activeGroupKey = "__ungrouped__";
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
document.querySelectorAll(".group-header").forEach(function(el) {
|
|
1149
|
+
el.classList.toggle("active", el.dataset.groupKey === activeGroupKey);
|
|
1150
|
+
});
|
|
812
1151
|
}
|
|
813
1152
|
|
|
814
1153
|
// Search
|
|
815
|
-
document.getElementById("search-input")
|
|
1154
|
+
var searchInput = document.getElementById("search-input");
|
|
1155
|
+
var searchClear = document.getElementById("search-clear");
|
|
1156
|
+
|
|
1157
|
+
searchInput.addEventListener("input", function() {
|
|
1158
|
+
lastCheckedIndex = null;
|
|
1159
|
+
searchClear.style.display = searchInput.value ? "block" : "none";
|
|
1160
|
+
buildSidebar();
|
|
1161
|
+
});
|
|
1162
|
+
|
|
1163
|
+
searchClear.addEventListener("click", function() {
|
|
1164
|
+
searchInput.value = "";
|
|
1165
|
+
searchClear.style.display = "none";
|
|
1166
|
+
lastCheckedIndex = null;
|
|
1167
|
+
searchInput.focus();
|
|
816
1168
|
buildSidebar();
|
|
817
1169
|
});
|
|
818
1170
|
|
|
819
1171
|
// Select/Deselect all
|
|
820
1172
|
document.getElementById("select-all-btn").addEventListener("click", function() {
|
|
821
|
-
|
|
1173
|
+
var filtered = getFilteredModels();
|
|
1174
|
+
if (visibleModels.size === nodes.length && filtered.length < nodes.length) {
|
|
1175
|
+
visibleModels.clear();
|
|
1176
|
+
filtered.forEach(function(n) { visibleModels.add(n.id); });
|
|
1177
|
+
} else {
|
|
1178
|
+
filtered.forEach(function(n) { visibleModels.add(n.id); });
|
|
1179
|
+
}
|
|
822
1180
|
buildSidebar();
|
|
823
1181
|
render();
|
|
1182
|
+
setTimeout(fitToScreen, 100);
|
|
824
1183
|
});
|
|
825
1184
|
|
|
826
1185
|
document.getElementById("deselect-all-btn").addEventListener("click", function() {
|
|
827
|
-
visibleModels.
|
|
1186
|
+
getFilteredModels().forEach(function(n) { visibleModels.delete(n.id); });
|
|
828
1187
|
buildSidebar();
|
|
829
1188
|
render();
|
|
1189
|
+
setTimeout(fitToScreen, 100);
|
|
830
1190
|
});
|
|
831
1191
|
|
|
1192
|
+
// Collapse/Expand all groups
|
|
1193
|
+
var collapseGroupsBtn = document.getElementById("collapse-groups-btn");
|
|
1194
|
+
if (config.grouping_enabled) {
|
|
1195
|
+
collapseGroupsBtn.style.display = "";
|
|
1196
|
+
if (config.collapse_groups) {
|
|
1197
|
+
collapseGroupsBtn.innerHTML = "\u25B6";
|
|
1198
|
+
collapseGroupsBtn.title = "Expand all groups";
|
|
1199
|
+
}
|
|
1200
|
+
var allGroupKeys = [];
|
|
1201
|
+
nodes.forEach(function(n) {
|
|
1202
|
+
if (n.group && n.group.length > 0) {
|
|
1203
|
+
var key = n.group.join("::");
|
|
1204
|
+
if (allGroupKeys.indexOf(key) === -1) allGroupKeys.push(key);
|
|
1205
|
+
}
|
|
1206
|
+
});
|
|
1207
|
+
allGroupKeys.push("__ungrouped__");
|
|
1208
|
+
|
|
1209
|
+
collapseGroupsBtn.addEventListener("click", function() {
|
|
1210
|
+
var shouldExpand = collapsedGroups.size === allGroupKeys.length;
|
|
1211
|
+
if (shouldExpand) {
|
|
1212
|
+
collapsedGroups.clear();
|
|
1213
|
+
} else {
|
|
1214
|
+
allGroupKeys.forEach(function(k) { collapsedGroups.add(k); });
|
|
1215
|
+
}
|
|
1216
|
+
collapseGroupsBtn.innerHTML = shouldExpand ? "\u25BC" : "\u25B6";
|
|
1217
|
+
collapseGroupsBtn.title = shouldExpand ? "Collapse all groups" : "Expand all groups";
|
|
1218
|
+
document.querySelectorAll(".group-models").forEach(function(el) {
|
|
1219
|
+
el.style.display = shouldExpand ? "" : "none";
|
|
1220
|
+
});
|
|
1221
|
+
document.querySelectorAll(".group-toggle").forEach(function(el) {
|
|
1222
|
+
el.innerHTML = shouldExpand ? "\u25BC" : "\u25B6";
|
|
1223
|
+
});
|
|
1224
|
+
});
|
|
1225
|
+
}
|
|
1226
|
+
|
|
832
1227
|
// Toolbar buttons
|
|
833
1228
|
document.getElementById("zoom-in-btn").addEventListener("click", function() {
|
|
834
1229
|
svg.transition().duration(300).call(zoom.scaleBy, 1.3);
|
|
@@ -920,6 +1315,66 @@
|
|
|
920
1315
|
return div.innerHTML;
|
|
921
1316
|
}
|
|
922
1317
|
|
|
1318
|
+
// Export helpers
|
|
1319
|
+
function triggerDownload(blob, filename) {
|
|
1320
|
+
var url = URL.createObjectURL(blob);
|
|
1321
|
+
var a = document.createElement("a");
|
|
1322
|
+
a.href = url;
|
|
1323
|
+
a.download = filename;
|
|
1324
|
+
document.body.appendChild(a);
|
|
1325
|
+
a.click();
|
|
1326
|
+
document.body.removeChild(a);
|
|
1327
|
+
setTimeout(function() { URL.revokeObjectURL(url); }, 100);
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
function exportMermaid() {
|
|
1331
|
+
var lines = ["erDiagram"];
|
|
1332
|
+
|
|
1333
|
+
// Entity definitions
|
|
1334
|
+
nodes.forEach(function(n) {
|
|
1335
|
+
if (!visibleModels.has(n.id)) return;
|
|
1336
|
+
var name = n.id.replace(/::/g, "_");
|
|
1337
|
+
lines.push(" " + name + " {");
|
|
1338
|
+
n.columns.forEach(function(col) {
|
|
1339
|
+
var constraint = "";
|
|
1340
|
+
if (col.primary) constraint = " PK";
|
|
1341
|
+
else if (col.name.endsWith("_id")) constraint = " FK";
|
|
1342
|
+
var colType = col.type.replace(/\s+/g, "_");
|
|
1343
|
+
lines.push(" " + colType + " " + col.name + constraint);
|
|
1344
|
+
});
|
|
1345
|
+
lines.push(" }");
|
|
1346
|
+
});
|
|
1347
|
+
|
|
1348
|
+
// Relationships
|
|
1349
|
+
var mermaidRelMap = {
|
|
1350
|
+
belongs_to: "}o--||",
|
|
1351
|
+
has_many: "||--o{",
|
|
1352
|
+
has_one: "||--o|",
|
|
1353
|
+
has_and_belongs_to_many: "}o--o{",
|
|
1354
|
+
embeds_many: "||--o{",
|
|
1355
|
+
embeds_one: "||--o|",
|
|
1356
|
+
embedded_in: "}o--||"
|
|
1357
|
+
};
|
|
1358
|
+
|
|
1359
|
+
edges.forEach(function(e) {
|
|
1360
|
+
if (!visibleModels.has(e.from) || !visibleModels.has(e.to)) return;
|
|
1361
|
+
var from = e.from.replace(/::/g, "_");
|
|
1362
|
+
var to = e.to.replace(/::/g, "_");
|
|
1363
|
+
var rel = mermaidRelMap[e.association_type] || "||--||";
|
|
1364
|
+
var label = e.label || e.association_type;
|
|
1365
|
+
if (e.polymorphic) label += " (polymorphic)";
|
|
1366
|
+
if (e.through) label += " (through " + e.through + ")";
|
|
1367
|
+
lines.push(" " + from + " " + rel + " " + to + ' : "' + label + '"');
|
|
1368
|
+
});
|
|
1369
|
+
|
|
1370
|
+
var md = lines.join("\n") + "\n";
|
|
1371
|
+
var blob = new Blob([md], { type: "text/plain;charset=utf-8" });
|
|
1372
|
+
triggerDownload(blob, "schema-diagram.mmd");
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
// Export button listener
|
|
1376
|
+
document.getElementById("export-mermaid-btn").addEventListener("click", function() { exportMermaid(); });
|
|
1377
|
+
|
|
923
1378
|
// Init
|
|
924
1379
|
buildSidebar();
|
|
925
1380
|
render();
|