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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +17 -0
- data/CLAUDE.md +20 -7
- data/README.md +64 -2
- data/docs/index.html +686 -55
- data/docs/screenshot.png +0 -0
- data/lib/rails/schema/assets/app.js +548 -51
- data/lib/rails/schema/assets/style.css +123 -0
- data/lib/rails/schema/assets/template.html.erb +13 -2
- data/lib/rails/schema/configuration.rb +16 -1
- data/lib/rails/schema/extractor/model_scanner.rb +19 -20
- 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/graph_builder.rb +6 -2
- 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,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
|
-
|
|
552
|
-
|
|
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
|
-
|
|
703
|
-
|
|
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
|
-
|
|
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
|
-
|
|
826
|
-
var
|
|
827
|
-
var
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
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 ? "▶" : "▼";
|
|
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
|
-
|
|
856
|
-
|
|
857
|
-
|
|
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 ? "▶" : "▼";
|
|
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
|
-
|
|
861
|
-
|
|
862
|
-
|
|
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
|
-
|
|
866
|
-
|
|
867
|
-
|
|
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
|
-
|
|
879
|
-
|
|
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
|
-
//
|
|
1083
|
-
|
|
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
|
})();
|