rails-schema 0.1.5 → 0.1.7

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,6 +14,38 @@
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
+ autoSave();
27
+ });
28
+ }
29
+
30
+ // Freeze layout toggle — when enabled, all simulation forces are removed so
31
+ // nodes stay wherever they are dropped without being pushed by the simulation.
32
+ var manualPositioningCheckbox = document.getElementById("manual-positioning");
33
+ function removeAllForces() {
34
+ ["link", "charge", "center", "collide", "x", "y", "group"].forEach(function(name) {
35
+ simulation.force(name, null);
36
+ });
37
+ }
38
+ if (manualPositioningCheckbox) {
39
+ manualPositioningCheckbox.addEventListener("change", function () {
40
+ if (this.checked) {
41
+ removeAllForces();
42
+ } else {
43
+ render();
44
+ if (selectedNode) selectNode(selectedNode);
45
+ }
46
+ autoSave();
47
+ });
48
+ }
17
49
 
18
50
  // State
19
51
  var selectedNode = null;
@@ -21,11 +53,119 @@
21
53
  var visibleModels = new Set(nodes.map(function(n) { return n.id; }));
22
54
  var simulation;
23
55
  var zoomTransform = d3.zoomIdentity;
56
+ var collapsedGroups = new Set();
24
57
  var NODE_WIDTH = 180;
25
58
  var NODE_HEADER_HEIGHT = 36;
26
59
  var NODE_COLUMN_HEIGHT = 18;
27
60
  var NODE_PADDING = 8;
28
61
 
62
+ // Layout persistence
63
+ function computeFingerprint() {
64
+ var ids = nodes.map(function(n) { return n.id; }).sort().join(",");
65
+ var hash = 5381;
66
+ for (var i = 0; i < ids.length; i++) {
67
+ hash = ((hash << 5) + hash + ids.charCodeAt(i)) & 0xffffffff;
68
+ }
69
+ return hash.toString(16);
70
+ }
71
+ var layoutStorageKey = "rails-schema-layout:" + computeFingerprint();
72
+
73
+ function serializeLayout() {
74
+ var positions = {};
75
+ nodes.forEach(function(n) {
76
+ if (n.x !== undefined && n.y !== undefined) {
77
+ positions[n.id] = { x: n.x, y: n.y };
78
+ }
79
+ });
80
+ return {
81
+ version: 1,
82
+ schemaFingerprint: computeFingerprint(),
83
+ nodePositions: positions,
84
+ visibleModels: Array.from(visibleModels),
85
+ zoomTransform: { k: zoomTransform.k, x: zoomTransform.x, y: zoomTransform.y },
86
+ collapsedGroups: Array.from(collapsedGroups),
87
+ manualPositioning: manualPositioningCheckbox ? manualPositioningCheckbox.checked : false,
88
+ showThroughEdges: showThroughEdges
89
+ };
90
+ }
91
+
92
+ function applyLayout(layout) {
93
+ if (!layout || layout.version !== 1) return;
94
+
95
+ var nodeIdSet = new Set(nodes.map(function(n) { return n.id; }));
96
+
97
+ // Restore visibility
98
+ if (layout.visibleModels) {
99
+ visibleModels.clear();
100
+ layout.visibleModels.forEach(function(id) {
101
+ if (nodeIdSet.has(id)) visibleModels.add(id);
102
+ });
103
+ }
104
+
105
+ // Restore collapsed groups
106
+ collapsedGroups.clear();
107
+ if (layout.collapsedGroups) {
108
+ layout.collapsedGroups.forEach(function(g) { collapsedGroups.add(g); });
109
+ }
110
+
111
+ // Restore through edges
112
+ if (layout.showThroughEdges !== undefined) {
113
+ showThroughEdges = layout.showThroughEdges;
114
+ if (throughCheckbox) throughCheckbox.checked = showThroughEdges;
115
+ }
116
+
117
+ // Rebuild with restored visibility
118
+ buildSidebar();
119
+ render();
120
+
121
+ var manual = !!layout.manualPositioning;
122
+
123
+ // Apply positions AFTER render created the simulation nodes. Pin with fx/fy
124
+ // only when restoring into manual mode; otherwise seed x/y as initial
125
+ // conditions and let the simulation evolve from there.
126
+ if (layout.nodePositions) {
127
+ nodes.forEach(function(n) {
128
+ var saved = layout.nodePositions[n.id];
129
+ if (saved) {
130
+ n.x = saved.x;
131
+ n.y = saved.y;
132
+ if (manual) {
133
+ n.fx = saved.x;
134
+ n.fy = saved.y;
135
+ } else {
136
+ n.fx = null;
137
+ n.fy = null;
138
+ }
139
+ }
140
+ });
141
+ }
142
+
143
+ if (manualPositioningCheckbox) {
144
+ manualPositioningCheckbox.checked = manual;
145
+ }
146
+ if (manual) {
147
+ removeAllForces();
148
+ }
149
+
150
+ // Update display
151
+ ticked();
152
+
153
+ // Restore zoom
154
+ if (layout.zoomTransform) {
155
+ var t = layout.zoomTransform;
156
+ var transform = d3.zoomIdentity.translate(t.x, t.y).scale(t.k);
157
+ svg.call(zoom.transform, transform);
158
+ }
159
+ }
160
+
161
+ function autoSave() {
162
+ try {
163
+ localStorage.setItem(layoutStorageKey, JSON.stringify(serializeLayout()));
164
+ } catch (e) {
165
+ // localStorage full or unavailable — silently ignore
166
+ }
167
+ }
168
+
29
169
  // Bezier curve tuning for edge routing
30
170
  var MIN_LOOP_OFFSET = 60; // Minimum control-point distance for same-side (loop) edges
31
171
  var LOOP_OFFSET_RATIO = 0.5; // Proportion of vertical gap used for loop curve spread
@@ -40,6 +180,31 @@
40
180
  var edgeGroup = container.append("g").attr("class", "edges");
41
181
  var nodeGroup = container.append("g").attr("class", "nodes");
42
182
 
183
+ // Group color assignment
184
+ var GROUP_COLORS = [
185
+ "#2563eb", "#dc2626", "#059669", "#d97706",
186
+ "#7c3aed", "#db2777", "#0891b2", "#4f46e5",
187
+ "#b91c1c", "#047857", "#b45309", "#6d28d9"
188
+ ];
189
+ var groupColorMap = {};
190
+ var groupIndex = 0;
191
+
192
+ if (config.grouping_enabled) {
193
+ nodes.forEach(function(n) {
194
+ if (n.group && n.group.length > 0) {
195
+ var key = n.group.join("::");
196
+ if (!groupColorMap[key]) {
197
+ groupColorMap[key] = GROUP_COLORS[groupIndex % GROUP_COLORS.length];
198
+ groupIndex++;
199
+ }
200
+ }
201
+ });
202
+ if (config.collapse_groups) {
203
+ Object.keys(groupColorMap).forEach(function(k) { collapsedGroups.add(k); });
204
+ collapsedGroups.add("__ungrouped__");
205
+ }
206
+ }
207
+
43
208
  // Marker definitions for crow's foot notation
44
209
  var MARKER_TYPES = {
45
210
  belongs_to: "--edge-belongs-to",
@@ -210,6 +375,7 @@
210
375
  nodes.forEach(function(n) { nodeMap[n.id] = n; });
211
376
 
212
377
  var simEdges = edges.filter(function(e) {
378
+ if (!showThroughEdges && e.through) return false;
213
379
  return visibleModels.has(e.from) && visibleModels.has(e.to);
214
380
  }).map(function(e) {
215
381
  return { source: e.from, target: e.to, data: e };
@@ -249,7 +415,83 @@
249
415
  .force("charge", d3.forceManyBody().strength(-600))
250
416
  .force("center", d3.forceCenter(svgEl.clientWidth / 2, svgEl.clientHeight / 2))
251
417
  .force("collide", d3.forceCollide().radius(function(d) { return Math.max(NODE_WIDTH, d._height) / 2 + 50; }))
252
- .on("tick", ticked);
418
+ .on("tick", ticked)
419
+ .on("end", autoSave);
420
+
421
+ if (config.grouping_enabled) {
422
+ simulation
423
+ .force("x", d3.forceX(svgEl.clientWidth / 2).strength(0.03))
424
+ .force("y", d3.forceY(svgEl.clientHeight / 2).strength(0.03));
425
+ simulation.force("group", function(alpha) {
426
+ // Pass 1: Attract same-group nodes toward their group centroid
427
+ var groupNodes = {};
428
+ var centroids = {};
429
+ var counts = {};
430
+ connectedNodes.forEach(function(n) {
431
+ if (!n.group || n.group.length === 0) return;
432
+ var key = n.group.join("::");
433
+ if (!groupNodes[key]) { groupNodes[key] = []; centroids[key] = { x: 0, y: 0 }; counts[key] = 0; }
434
+ groupNodes[key].push(n);
435
+ centroids[key].x += n.x;
436
+ centroids[key].y += n.y;
437
+ counts[key]++;
438
+ });
439
+ var keys = Object.keys(centroids);
440
+ keys.forEach(function(k) {
441
+ centroids[k].x /= counts[k];
442
+ centroids[k].y /= counts[k];
443
+ });
444
+ var attractStrength = 0.15;
445
+ connectedNodes.forEach(function(n) {
446
+ if (!n.group || n.group.length === 0) return;
447
+ var key = n.group.join("::");
448
+ var c = centroids[key];
449
+ n.vx += (c.x - n.x) * alpha * attractStrength;
450
+ n.vy += (c.y - n.y) * alpha * attractStrength;
451
+ });
452
+
453
+ // Pass 2: Push overlapping group bounding boxes apart
454
+ if (keys.length < 2) return;
455
+ var pad = 15;
456
+ var bounds = {};
457
+ keys.forEach(function(k) {
458
+ var b = { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity };
459
+ groupNodes[k].forEach(function(n) {
460
+ var hw = NODE_WIDTH / 2;
461
+ var hh = n._height / 2;
462
+ if (n.x - hw < b.minX) b.minX = n.x - hw;
463
+ if (n.y - hh < b.minY) b.minY = n.y - hh;
464
+ if (n.x + hw > b.maxX) b.maxX = n.x + hw;
465
+ if (n.y + hh > b.maxY) b.maxY = n.y + hh;
466
+ });
467
+ b.minX -= pad; b.minY -= pad; b.maxX += pad; b.maxY += pad;
468
+ b.cx = (b.minX + b.maxX) / 2;
469
+ b.cy = (b.minY + b.maxY) / 2;
470
+ bounds[k] = b;
471
+ });
472
+
473
+ var sepStrength = 0.25;
474
+ for (var i = 0; i < keys.length; i++) {
475
+ for (var j = i + 1; j < keys.length; j++) {
476
+ var a = bounds[keys[i]];
477
+ var b = bounds[keys[j]];
478
+ var overlapX = Math.min(a.maxX, b.maxX) - Math.max(a.minX, b.minX);
479
+ var overlapY = Math.min(a.maxY, b.maxY) - Math.max(a.minY, b.minY);
480
+ if (overlapX <= 0 || overlapY <= 0) continue;
481
+
482
+ var dx = a.cx - b.cx;
483
+ var dy = a.cy - b.cy;
484
+ var dist = Math.sqrt(dx * dx + dy * dy) || 1;
485
+ var push = Math.min(overlapX, overlapY) * alpha * sepStrength / dist;
486
+ var pushX = dx * push;
487
+ var pushY = dy * push;
488
+
489
+ groupNodes[keys[i]].forEach(function(n) { n.vx += pushX; n.vy += pushY; });
490
+ groupNodes[keys[j]].forEach(function(n) { n.vx -= pushX; n.vy -= pushY; });
491
+ }
492
+ }
493
+ });
494
+ }
253
495
 
254
496
  // Manually resolve edge references for self-ref-only nodes
255
497
  simEdges.forEach(function(e) {
@@ -378,6 +620,9 @@
378
620
  currentSim = setupSimulation();
379
621
  renderEdges(currentSim.simEdges);
380
622
  renderNodes(currentSim.simNodes);
623
+ if (manualPositioningCheckbox && manualPositioningCheckbox.checked) {
624
+ removeAllForces();
625
+ }
381
626
  }
382
627
 
383
628
  function renderEdges(simEdges) {
@@ -474,7 +719,11 @@
474
719
  .attr("width", NODE_WIDTH)
475
720
  .attr("height", NODE_HEADER_HEIGHT)
476
721
  .attr("x", -NODE_WIDTH / 2)
477
- .attr("y", function(d) { return -d._height / 2; });
722
+ .attr("y", function(d) { return -d._height / 2; })
723
+ .style("fill", function(d) {
724
+ if (!config.grouping_enabled || !d.group || d.group.length === 0) return null;
725
+ return groupColorMap[d.group.join("::")] || null;
726
+ });
478
727
 
479
728
  // Cover bottom corners of header
480
729
  nGroups.append("rect")
@@ -482,7 +731,11 @@
482
731
  .attr("width", NODE_WIDTH)
483
732
  .attr("height", 10)
484
733
  .attr("x", -NODE_WIDTH / 2)
485
- .attr("y", function(d) { return -d._height / 2 + NODE_HEADER_HEIGHT - 10; });
734
+ .attr("y", function(d) { return -d._height / 2 + NODE_HEADER_HEIGHT - 10; })
735
+ .style("fill", function(d) {
736
+ if (!config.grouping_enabled || !d.group || d.group.length === 0) return null;
737
+ return groupColorMap[d.group.join("::")] || null;
738
+ });
486
739
 
487
740
  // Model name
488
741
  nGroups.append("text")
@@ -548,8 +801,10 @@
548
801
  }
549
802
 
550
803
  function ticked() {
551
- layoutOrphans();
552
- layoutSelfRefNodes();
804
+ if (!manualPositioningCheckbox?.checked) {
805
+ layoutOrphans();
806
+ layoutSelfRefNodes();
807
+ }
553
808
  edgeGroup.selectAll(".edge-group").each(function(d) {
554
809
  var g = d3.select(this);
555
810
  var src = d.source;
@@ -699,8 +954,11 @@
699
954
  function dragEnded(event, d) {
700
955
  if (isOrphan(d)) return;
701
956
  if (!event.active) simulation.alphaTarget(0);
702
- d.fx = null;
703
- d.fy = null;
957
+ if (!manualPositioningCheckbox?.checked) {
958
+ d.fx = null;
959
+ d.fy = null;
960
+ }
961
+ autoSave();
704
962
  }
705
963
 
706
964
  // Zoom
@@ -710,6 +968,7 @@
710
968
  zoomTransform = event.transform;
711
969
  container.attr("transform", event.transform);
712
970
  document.getElementById("zoom-info").textContent = Math.round(event.transform.k * 100) + "%";
971
+ autoSave();
713
972
  });
714
973
 
715
974
  svg.call(zoom);
@@ -749,6 +1008,7 @@
749
1008
  render();
750
1009
  selectNode(nodeId);
751
1010
  setTimeout(fitToScreen, 100);
1011
+ autoSave();
752
1012
  }
753
1013
 
754
1014
  function deselectNode() {
@@ -804,7 +1064,14 @@
804
1064
  // Click association targets to navigate
805
1065
  content.querySelectorAll('.assoc-target').forEach(function(el) {
806
1066
  el.addEventListener('click', function() {
807
- selectNode(el.dataset.model);
1067
+ var modelId = el.dataset.model;
1068
+ if (!visibleModels.has(modelId)) {
1069
+ visibleModels.add(modelId);
1070
+ buildSidebar();
1071
+ render();
1072
+ setTimeout(fitToScreen, 100);
1073
+ }
1074
+ selectNode(modelId);
808
1075
  });
809
1076
  });
810
1077
  }
@@ -816,67 +1083,193 @@
816
1083
  window.__closeDetail = deselectNode;
817
1084
 
818
1085
  // Sidebar
1086
+ function buildModelItem(n, filtered, groupColor) {
1087
+ var edgeCount = edges.filter(function(e) { return e.from === n.id || e.to === n.id; }).length;
1088
+ var div = document.createElement("div");
1089
+ div.className = "model-item" + (selectedNode === n.id ? " active" : "");
1090
+ var nameStyle = groupColor ? ' style="color:' + groupColor + '"' : '';
1091
+ div.innerHTML = '<input type="checkbox" ' + (visibleModels.has(n.id) ? "checked" : "") + '>' +
1092
+ '<span class="model-name"' + nameStyle + '>' + escapeHtml(n.id) + '</span>' +
1093
+ '<span class="assoc-count">' + edgeCount + '</span>';
1094
+
1095
+ var cb = div.querySelector("input");
1096
+ var currentIndex = filtered.indexOf(n);
1097
+ cb.addEventListener("click", function(e) {
1098
+ e.stopPropagation();
1099
+ if (e.shiftKey && lastCheckedIndex !== null) {
1100
+ var start = Math.min(lastCheckedIndex, currentIndex);
1101
+ var end = Math.max(lastCheckedIndex, currentIndex);
1102
+ for (var i = start; i <= end; i++) {
1103
+ var modelId = filtered[i].id;
1104
+ if (cb.checked) {
1105
+ visibleModels.add(modelId);
1106
+ } else {
1107
+ visibleModels.delete(modelId);
1108
+ }
1109
+ }
1110
+ } else {
1111
+ if (cb.checked) {
1112
+ visibleModels.add(n.id);
1113
+ } else {
1114
+ visibleModels.delete(n.id);
1115
+ }
1116
+ }
1117
+ lastCheckedIndex = currentIndex;
1118
+ buildSidebar();
1119
+ render();
1120
+ setTimeout(fitToScreen, 100);
1121
+ autoSave();
1122
+ });
1123
+
1124
+ div.addEventListener("click", function(e) {
1125
+ if (e.target.tagName === "INPUT") return;
1126
+ selectNode(n.id);
1127
+ });
1128
+
1129
+ div.addEventListener("dblclick", function(e) {
1130
+ if (e.target.tagName === "INPUT") return;
1131
+ focusNeighborhood(n.id);
1132
+ });
1133
+
1134
+ return div;
1135
+ }
1136
+
819
1137
  function buildSidebar() {
820
1138
  var list = document.getElementById("model-list");
821
1139
  list.innerHTML = "";
822
1140
 
823
1141
  var filtered = getFilteredModels();
824
1142
 
825
- filtered.forEach(function(n) {
826
- var edgeCount = edges.filter(function(e) { return e.from === n.id || e.to === n.id; }).length;
827
- var div = document.createElement("div");
828
- div.className = "model-item" + (selectedNode === n.id ? " active" : "");
829
- div.innerHTML = '<input type="checkbox" ' + (visibleModels.has(n.id) ? "checked" : "") + '>' +
830
- '<span class="model-name">' + escapeHtml(n.id) + '</span>' +
831
- '<span class="assoc-count">' + edgeCount + '</span>';
832
-
833
- var cb = div.querySelector("input");
834
- var currentIndex = filtered.indexOf(n);
835
- cb.addEventListener("click", function(e) {
836
- e.stopPropagation();
837
- if (e.shiftKey && lastCheckedIndex !== null) {
838
- var start = Math.min(lastCheckedIndex, currentIndex);
839
- var end = Math.max(lastCheckedIndex, currentIndex);
840
- for (var i = start; i <= end; i++) {
841
- var modelId = filtered[i].id;
842
- if (cb.checked) {
843
- visibleModels.add(modelId);
844
- } else {
845
- visibleModels.delete(modelId);
846
- }
847
- }
1143
+ if (config.grouping_enabled) {
1144
+ var grouped = {};
1145
+ var ungrouped = [];
1146
+ filtered.forEach(function(n) {
1147
+ if (n.group && n.group.length > 0) {
1148
+ var key = n.group.join("::");
1149
+ if (!grouped[key]) grouped[key] = { label: n.group, models: [] };
1150
+ grouped[key].models.push(n);
848
1151
  } else {
1152
+ ungrouped.push(n);
1153
+ }
1154
+ });
1155
+
1156
+ function buildGroupHeader(models, labelHtml, groupKey) {
1157
+ var header = document.createElement("div");
1158
+ header.className = "group-header";
1159
+ header.dataset.groupKey = groupKey;
1160
+ var isCollapsed = collapsedGroups.has(groupKey);
1161
+ var toggleArrow = isCollapsed ? "&#9654;" : "&#9660;";
1162
+ var allVisible = models.every(function(n) { return visibleModels.has(n.id); });
1163
+ var noneVisible = models.every(function(n) { return !visibleModels.has(n.id); });
1164
+ header.innerHTML = '<input type="checkbox" class="group-checkbox"' + (allVisible ? " checked" : "") + '>' +
1165
+ labelHtml +
1166
+ '<span class="group-count">' + models.length + '</span>' +
1167
+ '<span class="group-toggle">' + toggleArrow + '</span>';
1168
+
1169
+ var cb = header.querySelector(".group-checkbox");
1170
+ if (!allVisible && !noneVisible) cb.indeterminate = true;
1171
+
1172
+ cb.addEventListener("click", function(e) {
1173
+ e.stopPropagation();
849
1174
  if (cb.checked) {
850
- visibleModels.add(n.id);
1175
+ models.forEach(function(n) { visibleModels.add(n.id); });
851
1176
  } else {
852
- visibleModels.delete(n.id);
1177
+ models.forEach(function(n) { visibleModels.delete(n.id); });
853
1178
  }
854
- }
855
- lastCheckedIndex = currentIndex;
856
- buildSidebar();
857
- render();
858
- });
1179
+ buildSidebar();
1180
+ render();
1181
+ setTimeout(fitToScreen, 100);
1182
+ autoSave();
1183
+ });
1184
+
1185
+ var clickTimer = null;
1186
+ header.addEventListener("click", function(e) {
1187
+ if (e.target.tagName === "INPUT") return;
1188
+ if (clickTimer) return;
1189
+ clickTimer = setTimeout(function() {
1190
+ clickTimer = null;
1191
+ if (collapsedGroups.has(groupKey)) {
1192
+ collapsedGroups.delete(groupKey);
1193
+ } else {
1194
+ collapsedGroups.add(groupKey);
1195
+ }
1196
+ var content = header.nextElementSibling;
1197
+ var toggle = header.querySelector(".group-toggle");
1198
+ var nowCollapsed = collapsedGroups.has(groupKey);
1199
+ content.style.display = nowCollapsed ? "none" : "";
1200
+ toggle.innerHTML = nowCollapsed ? "&#9654;" : "&#9660;";
1201
+ autoSave();
1202
+ }, 250);
1203
+ });
1204
+
1205
+ header.addEventListener("dblclick", function(e) {
1206
+ if (e.target.tagName === "INPUT") return;
1207
+ e.stopPropagation();
1208
+ if (clickTimer) { clearTimeout(clickTimer); clickTimer = null; }
1209
+ visibleModels.clear();
1210
+ models.forEach(function(n) {
1211
+ visibleModels.add(n.id);
1212
+ var neighbors = adjacency[n.id] || new Set();
1213
+ neighbors.forEach(function(id) { visibleModels.add(id); });
1214
+ });
1215
+ buildSidebar();
1216
+ render();
1217
+ setTimeout(fitToScreen, 100);
1218
+ autoSave();
1219
+ });
1220
+
1221
+ return header;
1222
+ }
859
1223
 
860
- div.addEventListener("click", function(e) {
861
- if (e.target.tagName === "INPUT") return;
862
- selectNode(n.id);
1224
+ var sortedGroups = Object.keys(grouped).sort();
1225
+ sortedGroups.forEach(function(key) {
1226
+ var group = grouped[key];
1227
+ var color = groupColorMap[key] || GROUP_COLORS[0];
1228
+ var labelHtml = '<span class="group-color-dot" style="background:' + color + '"></span>' +
1229
+ '<span class="group-header-label">' + escapeHtml(group.label.join(" > ")) + '</span>';
1230
+ var header = buildGroupHeader(group.models, labelHtml, key);
1231
+ list.appendChild(header);
1232
+
1233
+ var groupContainer = document.createElement("div");
1234
+ groupContainer.className = "group-models";
1235
+ if (collapsedGroups.has(key)) groupContainer.style.display = "none";
1236
+ group.models.forEach(function(n) {
1237
+ groupContainer.appendChild(buildModelItem(n, filtered, color));
1238
+ });
1239
+ list.appendChild(groupContainer);
863
1240
  });
864
1241
 
865
- div.addEventListener("dblclick", function(e) {
866
- if (e.target.tagName === "INPUT") return;
867
- focusNeighborhood(n.id);
1242
+ if (ungrouped.length > 0 && sortedGroups.length > 0) {
1243
+ var ungroupedLabelHtml = '<span class="group-header-label">Ungrouped</span>';
1244
+ var ungroupedHeader = buildGroupHeader(ungrouped, ungroupedLabelHtml, "__ungrouped__");
1245
+ list.appendChild(ungroupedHeader);
1246
+ var ungroupedContainer = document.createElement("div");
1247
+ ungroupedContainer.className = "group-models";
1248
+ if (collapsedGroups.has("__ungrouped__")) ungroupedContainer.style.display = "none";
1249
+ ungrouped.forEach(function(n) {
1250
+ ungroupedContainer.appendChild(buildModelItem(n, filtered));
1251
+ });
1252
+ list.appendChild(ungroupedContainer);
1253
+ } else {
1254
+ ungrouped.forEach(function(n) {
1255
+ list.appendChild(buildModelItem(n, filtered));
1256
+ });
1257
+ }
1258
+ } else {
1259
+ filtered.forEach(function(n) {
1260
+ list.appendChild(buildModelItem(n, filtered));
868
1261
  });
869
-
870
- list.appendChild(div);
871
- });
1262
+ }
872
1263
  }
873
1264
 
874
1265
  function getFilteredModels() {
875
1266
  var query = document.getElementById("search-input").value.toLowerCase();
876
1267
  if (!query) return nodes;
877
1268
  return nodes.filter(function(n) {
878
- return n.id.toLowerCase().indexOf(query) !== -1 ||
879
- n.table_name.toLowerCase().indexOf(query) !== -1;
1269
+ if (n.id.toLowerCase().indexOf(query) !== -1) return true;
1270
+ if (n.table_name.toLowerCase().indexOf(query) !== -1) return true;
1271
+ if (n.group && n.group.join(" ").toLowerCase().indexOf(query) !== -1) return true;
1272
+ return false;
880
1273
  });
881
1274
  }
882
1275
 
@@ -885,6 +1278,19 @@
885
1278
  var name = el.querySelector(".model-name").textContent;
886
1279
  el.classList.toggle("active", name === nodeId);
887
1280
  });
1281
+
1282
+ var activeGroupKey = null;
1283
+ if (nodeId) {
1284
+ var node = nodes.find(function(n) { return n.id === nodeId; });
1285
+ if (node && node.group && node.group.length > 0) {
1286
+ activeGroupKey = node.group.join("::");
1287
+ } else if (node) {
1288
+ activeGroupKey = "__ungrouped__";
1289
+ }
1290
+ }
1291
+ document.querySelectorAll(".group-header").forEach(function(el) {
1292
+ el.classList.toggle("active", el.dataset.groupKey === activeGroupKey);
1293
+ });
888
1294
  }
889
1295
 
890
1296
  // Search
@@ -916,14 +1322,54 @@
916
1322
  }
917
1323
  buildSidebar();
918
1324
  render();
1325
+ setTimeout(fitToScreen, 100);
1326
+ autoSave();
919
1327
  });
920
1328
 
921
1329
  document.getElementById("deselect-all-btn").addEventListener("click", function() {
922
1330
  getFilteredModels().forEach(function(n) { visibleModels.delete(n.id); });
923
1331
  buildSidebar();
924
1332
  render();
1333
+ setTimeout(fitToScreen, 100);
1334
+ autoSave();
925
1335
  });
926
1336
 
1337
+ // Collapse/Expand all groups
1338
+ var collapseGroupsBtn = document.getElementById("collapse-groups-btn");
1339
+ if (config.grouping_enabled) {
1340
+ collapseGroupsBtn.style.display = "";
1341
+ if (config.collapse_groups) {
1342
+ collapseGroupsBtn.innerHTML = "\u25B6";
1343
+ collapseGroupsBtn.title = "Expand all groups";
1344
+ }
1345
+ var allGroupKeys = [];
1346
+ nodes.forEach(function(n) {
1347
+ if (n.group && n.group.length > 0) {
1348
+ var key = n.group.join("::");
1349
+ if (allGroupKeys.indexOf(key) === -1) allGroupKeys.push(key);
1350
+ }
1351
+ });
1352
+ allGroupKeys.push("__ungrouped__");
1353
+
1354
+ collapseGroupsBtn.addEventListener("click", function() {
1355
+ var shouldExpand = collapsedGroups.size === allGroupKeys.length;
1356
+ if (shouldExpand) {
1357
+ collapsedGroups.clear();
1358
+ } else {
1359
+ allGroupKeys.forEach(function(k) { collapsedGroups.add(k); });
1360
+ }
1361
+ collapseGroupsBtn.innerHTML = shouldExpand ? "\u25BC" : "\u25B6";
1362
+ collapseGroupsBtn.title = shouldExpand ? "Collapse all groups" : "Expand all groups";
1363
+ document.querySelectorAll(".group-models").forEach(function(el) {
1364
+ el.style.display = shouldExpand ? "" : "none";
1365
+ });
1366
+ document.querySelectorAll(".group-toggle").forEach(function(el) {
1367
+ el.innerHTML = shouldExpand ? "\u25BC" : "\u25B6";
1368
+ });
1369
+ autoSave();
1370
+ });
1371
+ }
1372
+
927
1373
  // Toolbar buttons
928
1374
  document.getElementById("zoom-in-btn").addEventListener("click", function() {
929
1375
  svg.transition().duration(300).call(zoom.scaleBy, 1.3);
@@ -1072,13 +1518,64 @@
1072
1518
  triggerDownload(blob, "schema-diagram.mmd");
1073
1519
  }
1074
1520
 
1521
+ // File menu dropdown
1522
+ var fileMenuBtn = document.getElementById("file-menu-btn");
1523
+ var fileMenu = document.getElementById("file-menu");
1524
+ fileMenuBtn.addEventListener("click", function(e) {
1525
+ e.stopPropagation();
1526
+ fileMenu.classList.toggle("open");
1527
+ });
1528
+ document.addEventListener("click", function() {
1529
+ fileMenu.classList.remove("open");
1530
+ });
1531
+
1075
1532
  // Export button listener
1076
1533
  document.getElementById("export-mermaid-btn").addEventListener("click", function() { exportMermaid(); });
1077
1534
 
1535
+ // Save Layout
1536
+ document.getElementById("save-layout-btn").addEventListener("click", function() {
1537
+ var layout = serializeLayout();
1538
+ var blob = new Blob([JSON.stringify(layout, null, 2)], { type: "application/json" });
1539
+ var title = document.title.replace(/[^a-zA-Z0-9]/g, "-").replace(/-+/g, "-");
1540
+ triggerDownload(blob, title + "-layout.json");
1541
+ });
1542
+
1543
+ // Load Layout
1544
+ var loadLayoutInput = document.getElementById("load-layout-input");
1545
+ document.getElementById("load-layout-btn").addEventListener("click", function() {
1546
+ loadLayoutInput.click();
1547
+ });
1548
+ loadLayoutInput.addEventListener("change", function() {
1549
+ var file = this.files[0];
1550
+ if (!file) return;
1551
+ var reader = new FileReader();
1552
+ reader.onload = function(e) {
1553
+ try {
1554
+ var layout = JSON.parse(e.target.result);
1555
+ applyLayout(layout);
1556
+ autoSave();
1557
+ } catch (err) {
1558
+ console.error("Failed to load layout:", err);
1559
+ }
1560
+ };
1561
+ reader.readAsText(file);
1562
+ this.value = "";
1563
+ });
1564
+
1078
1565
  // Init
1079
1566
  buildSidebar();
1080
1567
  render();
1081
1568
 
1082
- // Fit after simulation settles
1083
- setTimeout(function() { fitToScreen(); }, 1500);
1569
+ // Restore saved layout or fit to screen
1570
+ var savedLayout = null;
1571
+ try {
1572
+ var stored = localStorage.getItem(layoutStorageKey);
1573
+ if (stored) savedLayout = JSON.parse(stored);
1574
+ } catch (e) { /* ignore */ }
1575
+
1576
+ if (savedLayout) {
1577
+ applyLayout(savedLayout);
1578
+ } else {
1579
+ setTimeout(function() { fitToScreen(); }, 1500);
1580
+ }
1084
1581
  })();