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.
@@ -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 truncateText(text, maxWidth, font) {
471
+ function measureTextWidth(text, font) {
353
472
  var canvas = document.createElement("canvas");
354
473
  var ctx = canvas.getContext("2d");
355
- ctx.font = font + " " + getComputedStyle(document.body).fontFamily;
356
- if (ctx.measureText(text).width <= maxWidth) return text;
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 && ctx.measureText(truncated + "…").width > maxWidth) {
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
- g.append("text")
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((col.primary ? "PK " : "") + col.name);
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 = 0.125 * connPts.x1 + 0.375 * cp1x + 0.375 * cp2x + 0.125 * connPts.x2;
574
- var labelY = (y1 + y2) / 2;
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
- selectNode(el.dataset.model);
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
- filtered.forEach(function(n) {
771
- var edgeCount = edges.filter(function(e) { return e.from === n.id || e.to === n.id; }).length;
772
- var div = document.createElement("div");
773
- div.className = "model-item" + (selectedNode === n.id ? " active" : "");
774
- div.innerHTML = '<input type="checkbox" ' + (visibleModels.has(n.id) ? "checked" : "") + '>' +
775
- '<span class="model-name">' + escapeHtml(n.id) + '</span>' +
776
- '<span class="assoc-count">' + edgeCount + '</span>';
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
- visibleModels.delete(n.id);
1012
+ ungrouped.push(n);
785
1013
  }
786
- render();
787
1014
  });
788
1015
 
789
- div.addEventListener("click", function(e) {
790
- if (e.target.tagName === "INPUT") return;
791
- selectNode(n.id);
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 ? "&#9654;" : "&#9660;";
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 ? "&#9654;" : "&#9660;";
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
- list.appendChild(div);
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
- return n.id.toLowerCase().indexOf(query) !== -1 ||
803
- n.table_name.toLowerCase().indexOf(query) !== -1;
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").addEventListener("input", function() {
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
- nodes.forEach(function(n) { visibleModels.add(n.id); });
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.clear();
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();