rails_db_inspector 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +232 -0
  4. data/Rakefile +3 -0
  5. data/app/assets/stylesheets/rails_db_inspector/application.css +41 -0
  6. data/app/controllers/rails_db_inspector/application_controller.rb +15 -0
  7. data/app/controllers/rails_db_inspector/queries_controller.rb +42 -0
  8. data/app/controllers/rails_db_inspector/schema_controller.rb +13 -0
  9. data/app/helpers/rails_db_inspector/application_helper.rb +274 -0
  10. data/app/helpers/rails_db_inspector/plan_renderer.rb +887 -0
  11. data/app/jobs/rails_db_inspector/application_job.rb +4 -0
  12. data/app/mailers/rails_db_inspector/application_mailer.rb +6 -0
  13. data/app/models/rails_db_inspector/application_record.rb +5 -0
  14. data/app/views/layouts/rails_db_inspector/application.html.erb +55 -0
  15. data/app/views/rails_db_inspector/queries/explain.html.erb +128 -0
  16. data/app/views/rails_db_inspector/queries/index.html.erb +258 -0
  17. data/app/views/rails_db_inspector/queries/show.html.erb +103 -0
  18. data/app/views/rails_db_inspector/schema/index.html.erb +842 -0
  19. data/config/routes.rb +17 -0
  20. data/lib/rails_db_inspector/configuration.rb +17 -0
  21. data/lib/rails_db_inspector/dev_widget_middleware.rb +145 -0
  22. data/lib/rails_db_inspector/engine.rb +22 -0
  23. data/lib/rails_db_inspector/explain/my_sql.rb +28 -0
  24. data/lib/rails_db_inspector/explain/postgres.rb +32 -0
  25. data/lib/rails_db_inspector/explain.rb +27 -0
  26. data/lib/rails_db_inspector/query_store.rb +89 -0
  27. data/lib/rails_db_inspector/schema_inspector.rb +222 -0
  28. data/lib/rails_db_inspector/sql_subscriber.rb +42 -0
  29. data/lib/rails_db_inspector/version.rb +3 -0
  30. data/lib/rails_db_inspector.rb +25 -0
  31. data/lib/tasks/rails_db_inspector_tasks.rake +4 -0
  32. metadata +91 -0
@@ -0,0 +1,842 @@
1
+ <% content_for :head do %>
2
+ <style>
3
+ #schema-app { display: flex; height: calc(100vh - 120px); overflow: hidden; border-radius: 8px; border: 1px solid #e5e7eb; background: #f8fafc; }
4
+ #schema-sidebar { width: 280px; background: #fff; border-right: 1px solid #e5e7eb; display: flex; flex-direction: column; flex-shrink: 0; }
5
+ #schema-sidebar .sidebar-header { padding: 16px; border-bottom: 1px solid #e5e7eb; }
6
+ #schema-sidebar .sidebar-header h2 { font-size: 14px; font-weight: 600; color: #374151; margin: 0 0 8px; }
7
+ #schema-search { width: 100%; padding: 8px 12px; border: 1px solid #d1d5db; border-radius: 6px; font-size: 13px; outline: none; box-sizing: border-box; }
8
+ #schema-search:focus { border-color: #3b82f6; box-shadow: 0 0 0 2px rgba(59,130,246,0.15); }
9
+ #sidebar-list { flex: 1; overflow-y: auto; padding: 8px; }
10
+ .sidebar-item { padding: 8px 12px; border-radius: 6px; cursor: pointer; font-size: 13px; font-family: ui-monospace, monospace; color: #374151; transition: background 0.1s; display: flex; justify-content: space-between; align-items: center; }
11
+ .sidebar-item:hover { background: #f3f4f6; }
12
+ .sidebar-item.active { background: #eff6ff; color: #2563eb; font-weight: 600; }
13
+ .sidebar-item .badge { font-size: 10px; background: #e5e7eb; color: #6b7280; padding: 2px 6px; border-radius: 10px; font-family: sans-serif; }
14
+ .sidebar-item.active .badge { background: #dbeafe; color: #2563eb; }
15
+
16
+ #schema-canvas-wrap { flex: 1; position: relative; overflow: hidden; cursor: grab; }
17
+ #schema-canvas-wrap.dragging-node { cursor: grabbing; }
18
+ #schema-canvas { position: absolute; top: 0; left: 0; transform-origin: 0 0; }
19
+ #schema-svg { position: absolute; top: 0; left: 0; pointer-events: none; transform-origin: 0 0; }
20
+
21
+ .erd-node { position: absolute; background: #fff; border: 2px solid #d1d5db; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); cursor: grab; user-select: none; min-width: 200px; max-width: 260px; transition: opacity 0.25s, border-color 0.2s, box-shadow 0.2s; }
22
+ .erd-node:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.12); }
23
+ .erd-node.focused { border-color: #3b82f6; box-shadow: 0 0 0 3px rgba(59,130,246,0.2), 0 4px 12px rgba(0,0,0,0.1); z-index: 10; }
24
+ .erd-node.neighbor { border-color: #93c5fd; opacity: 1 !important; }
25
+ .erd-node.faded { opacity: 0.15; pointer-events: none; }
26
+ .erd-node .node-header { padding: 8px 12px; background: #1f2937; color: #fff; border-radius: 6px 6px 0 0; font-family: ui-monospace, monospace; font-size: 12px; font-weight: 600; display: flex; justify-content: space-between; align-items: center; }
27
+ .erd-node .node-header .row-count { font-size: 10px; font-weight: 400; color: #9ca3af; }
28
+ .erd-node .node-columns { max-height: 0; overflow: hidden; transition: max-height 0.3s ease; }
29
+ .erd-node .node-columns.expanded { max-height: 600px; }
30
+ .erd-node .col-row { padding: 3px 12px; font-size: 11px; font-family: ui-monospace, monospace; display: flex; justify-content: space-between; border-top: 1px solid #f3f4f6; }
31
+ .erd-node .col-row .col-name { color: #374151; }
32
+ .erd-node .col-row .col-type { color: #9ca3af; }
33
+ .erd-node .col-row .col-icon { margin-right: 4px; font-size: 10px; }
34
+ .erd-node .col-overflow { padding: 4px 12px; font-size: 10px; color: #9ca3af; text-align: center; border-top: 1px solid #f3f4f6; }
35
+
36
+ #detail-panel { position: absolute; top: 12px; right: 12px; width: 340px; background: #fff; border: 1px solid #e5e7eb; border-radius: 8px; box-shadow: 0 10px 25px rgba(0,0,0,0.1); display: none; z-index: 100; max-height: calc(100% - 24px); overflow-y: auto; }
37
+ #detail-panel .detail-header { padding: 14px 16px; border-bottom: 1px solid #e5e7eb; display: flex; justify-content: space-between; align-items: center; }
38
+ #detail-panel .detail-header h3 { font-size: 15px; font-weight: 700; font-family: ui-monospace, monospace; color: #111827; margin: 0; word-break: break-all; }
39
+ #detail-panel .detail-close { background: none; border: none; cursor: pointer; color: #9ca3af; font-size: 18px; padding: 4px; line-height: 1; flex-shrink: 0; }
40
+ #detail-panel .detail-section { padding: 12px 16px; border-bottom: 1px solid #f3f4f6; overflow-x: auto; }
41
+ #detail-panel .detail-section:last-child { border-bottom: none; }
42
+ #detail-panel .detail-section h4 { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: #6b7280; margin: 0 0 8px; }
43
+ #detail-panel .detail-col { display: flex; justify-content: space-between; padding: 3px 0; font-size: 12px; gap: 12px; min-width: 0; }
44
+ #detail-panel .detail-col .name { font-family: ui-monospace, monospace; color: #374151; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex-shrink: 1; min-width: 0; }
45
+ #detail-panel .detail-col .type { font-family: ui-monospace, monospace; color: #9ca3af; white-space: nowrap; flex-shrink: 0; }
46
+ #detail-panel .detail-idx { font-size: 12px; padding: 4px 0; overflow-x: auto; }
47
+ #detail-panel .detail-idx .idx-name { font-family: ui-monospace, monospace; color: #6b7280; word-break: break-all; }
48
+ #detail-panel .detail-idx .idx-cols { font-family: ui-monospace, monospace; color: #3b82f6; font-size: 11px; word-break: break-all; }
49
+ #detail-panel .detail-fk { font-size: 12px; padding: 4px 0; font-family: ui-monospace, monospace; white-space: nowrap; }
50
+ #detail-panel .detail-fk .fk-from { color: #374151; }
51
+ #detail-panel .detail-fk .fk-arrow { color: #9ca3af; margin: 0 4px; }
52
+ #detail-panel .detail-fk .fk-to { color: #059669; }
53
+
54
+ #schema-controls { position: absolute; bottom: 12px; left: 12px; display: flex; gap: 4px; z-index: 50; }
55
+ #schema-controls button { width: 32px; height: 32px; border: 1px solid #d1d5db; background: #fff; border-radius: 6px; cursor: pointer; font-size: 14px; display: flex; align-items: center; justify-content: center; color: #374151; }
56
+ #schema-controls button:hover { background: #f3f4f6; }
57
+ #schema-shortcuts { position: absolute; bottom: 12px; right: 12px; font-size: 11px; color: #9ca3af; z-index: 50; }
58
+ #schema-shortcuts kbd { background: #f3f4f6; border: 1px solid #d1d5db; border-radius: 3px; padding: 1px 4px; font-family: ui-monospace, monospace; font-size: 10px; }
59
+
60
+ .edge-line { fill: none; stroke-width: 1.5; }
61
+ .edge-line.fk { stroke: #3b82f6; }
62
+ .edge-line.conv { stroke: #d1d5db; stroke-dasharray: 5,4; }
63
+ .edge-line.highlighted { stroke-width: 2.5; }
64
+ .edge-line.faded { opacity: 0.08; }
65
+
66
+ /* Heat map node header colors */
67
+ .erd-node .node-header.heat-green { background: #166534; }
68
+ .erd-node .node-header.heat-lime { background: #3f6212; }
69
+ .erd-node .node-header.heat-yellow { background: #854d0e; }
70
+ .erd-node .node-header.heat-orange { background: #9a3412; }
71
+ .erd-node .node-header.heat-red { background: #991b1b; }
72
+
73
+ /* Missing index warning badge on node */
74
+ .node-warning { display: inline-flex; align-items: center; justify-content: center; background: #fbbf24; color: #78350f; font-size: 9px; font-weight: 700; width: 18px; height: 18px; border-radius: 50%; margin-left: 6px; flex-shrink: 0; line-height: 1; font-family: sans-serif; }
75
+
76
+ /* Polymorphic badge on node */
77
+ .node-poly-badge { display: inline-flex; align-items: center; justify-content: center; background: #a78bfa; color: #fff; font-size: 8px; font-weight: 700; width: 16px; height: 16px; border-radius: 50%; margin-left: 4px; flex-shrink: 0; line-height: 1; font-family: sans-serif; }
78
+
79
+ /* Column type color dots */
80
+ .col-type-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; margin-right: 4px; flex-shrink: 0; vertical-align: middle; }
81
+
82
+ /* Health summary bar */
83
+ #health-bar { padding: 12px 16px; border-bottom: 1px solid #e5e7eb; background: #fafbfc; }
84
+ #health-bar .health-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
85
+ #health-bar .health-stat { font-size: 11px; color: #6b7280; display: flex; align-items: center; gap: 4px; }
86
+ #health-bar .health-stat .stat-icon { font-size: 12px; flex-shrink: 0; }
87
+ #health-bar .health-stat .stat-val { font-weight: 700; color: #111827; }
88
+ #health-bar .health-stat.warn .stat-val { color: #d97706; }
89
+ #health-bar .health-stat.danger .stat-val { color: #dc2626; }
90
+ #health-bar .health-stat.ok .stat-val { color: #059669; }
91
+
92
+ /* Export controls */
93
+ #export-controls { position: absolute; bottom: 12px; left: 160px; display: flex; gap: 4px; z-index: 50; }
94
+ #export-controls button { height: 32px; padding: 0 10px; border: 1px solid #d1d5db; background: #fff; border-radius: 6px; cursor: pointer; font-size: 11px; display: flex; align-items: center; gap: 4px; color: #374151; white-space: nowrap; }
95
+ #export-controls button:hover { background: #f3f4f6; }
96
+
97
+ /* Heat map legend */
98
+ #heat-legend { position: absolute; bottom: 48px; left: 12px; display: flex; align-items: center; gap: 4px; font-size: 10px; color: #9ca3af; z-index: 50; background: rgba(255,255,255,0.9); padding: 4px 8px; border-radius: 6px; border: 1px solid #e5e7eb; }
99
+ #heat-legend .heat-swatch { width: 12px; height: 10px; border-radius: 2px; }
100
+
101
+ </style>
102
+ <% end %>
103
+
104
+ <div id="schema-app">
105
+ <!-- Sidebar -->
106
+ <div id="schema-sidebar">
107
+ <div class="sidebar-header">
108
+ <h2>Models (<%= @schema.keys.length %>)</h2>
109
+ <input type="text" id="schema-search" placeholder="Search tables... ( / )" autocomplete="off" />
110
+ </div>
111
+ <div id="health-bar"></div>
112
+ <div id="sidebar-list"></div>
113
+ </div>
114
+
115
+ <!-- Canvas -->
116
+ <div id="schema-canvas-wrap">
117
+ <svg id="schema-svg"></svg>
118
+ <div id="schema-canvas"></div>
119
+
120
+ <!-- Detail Panel -->
121
+ <div id="detail-panel">
122
+ <div class="detail-header">
123
+ <h3 id="detail-title"></h3>
124
+ <button class="detail-close" onclick="window._erd.deselect()">&times;</button>
125
+ </div>
126
+ <div id="detail-body"></div>
127
+ </div>
128
+
129
+ <!-- Zoom Controls -->
130
+ <div id="schema-controls">
131
+ <button onclick="window._erd.zoomBy(1.2)" title="Zoom in (+)">+</button>
132
+ <button onclick="window._erd.zoomBy(0.83)" title="Zoom out (-)">&#x2212;</button>
133
+ <button onclick="window._erd.fitToScreen()" title="Fit to screen (F)">&#x229E;</button>
134
+ </div>
135
+
136
+ <!-- Export Controls -->
137
+ <div id="export-controls">
138
+ <button onclick="window._erd.exportSVG()" title="Export as SVG">&#x1F4BE; Export SVG</button>
139
+ </div>
140
+
141
+ <!-- Heat Map Legend -->
142
+ <div id="heat-legend">
143
+ <span>Rows:</span>
144
+ <span class="heat-swatch" style="background:#166534"></span><span>0</span>
145
+ <span class="heat-swatch" style="background:#3f6212"></span><span>1k</span>
146
+ <span class="heat-swatch" style="background:#854d0e"></span><span>10k</span>
147
+ <span class="heat-swatch" style="background:#9a3412"></span><span>100k</span>
148
+ <span class="heat-swatch" style="background:#991b1b"></span><span>1M+</span>
149
+ </div>
150
+
151
+ <div id="schema-shortcuts">
152
+ <kbd>/</kbd> search &nbsp; <kbd>Esc</kbd> deselect &nbsp; <kbd>+</kbd><kbd>-</kbd> zoom &nbsp; <kbd>F</kbd> fit &nbsp; <kbd>dbl-click</kbd> expand
153
+ </div>
154
+ </div>
155
+ </div>
156
+
157
+ <% content_for :scripts do %>
158
+ <script>
159
+ (function() {
160
+ 'use strict';
161
+
162
+ var schema = <%= raw(schema_to_json(@schema)) %>;
163
+ var relationships = <%= raw(relationships_to_json(@relationships)) %>;
164
+ var tableNames = Object.keys(schema);
165
+
166
+ var canvasWrap = document.getElementById('schema-canvas-wrap');
167
+ var canvas = document.getElementById('schema-canvas');
168
+ var svgEl = document.getElementById('schema-svg');
169
+ var sidebarList = document.getElementById('sidebar-list');
170
+ var searchInput = document.getElementById('schema-search');
171
+ var detailPanel = document.getElementById('detail-panel');
172
+ var detailTitle = document.getElementById('detail-title');
173
+ var detailBody = document.getElementById('detail-body');
174
+
175
+ var nodes = {};
176
+ var edgeData = [];
177
+ var selectedNode = null;
178
+ var zoom = 1, panX = 0, panY = 0;
179
+ var isPanning = false, panStartX, panStartY, panStartPanX, panStartPanY;
180
+ var dragNode = null, dragOffsetX, dragOffsetY, didDrag = false;
181
+ var simAlpha = 1, simRunning = true;
182
+ var SVG_W = 8000, SVG_H = 8000;
183
+
184
+ // Build adjacency map
185
+ var adjacency = {};
186
+ tableNames.forEach(function(t) { adjacency[t] = new Set(); });
187
+ relationships.forEach(function(r) {
188
+ if (adjacency[r.from_table] && adjacency[r.to_table]) {
189
+ adjacency[r.from_table].add(r.to_table);
190
+ adjacency[r.to_table].add(r.from_table);
191
+ }
192
+ });
193
+
194
+ // Initial circular layout
195
+ var cx = SVG_W / 2, cy = SVG_H / 2;
196
+ var radius = Math.max(200, tableNames.length * 22);
197
+
198
+ tableNames.forEach(function(name, i) {
199
+ var angle = (2 * Math.PI * i) / tableNames.length;
200
+ nodes[name] = {
201
+ x: cx + radius * Math.cos(angle),
202
+ y: cy + radius * Math.sin(angle),
203
+ vx: 0, vy: 0, w: 220, h: 40, el: null
204
+ };
205
+ });
206
+
207
+ // Create node DOM elements
208
+ // Column type => color mapping
209
+ function typeColor(sqlType) {
210
+ var t = (sqlType || '').toLowerCase();
211
+ if (t.match(/^(integer|bigint|smallint|int|serial|bigserial)/)) return '#3b82f6'; // blue
212
+ if (t.match(/^(character|varchar|text|string|citext)/)) return '#059669'; // green
213
+ if (t.match(/^(boolean)/)) return '#d97706'; // amber
214
+ if (t.match(/^(timestamp|datetime|date|time)/)) return '#7c3aed'; // purple
215
+ if (t.match(/^(numeric|decimal|float|double|real|money)/)) return '#dc2626'; // red
216
+ if (t.match(/^(json|jsonb|hstore|array)/)) return '#0891b2'; // cyan
217
+ if (t.match(/^(uuid)/)) return '#db2777'; // pink
218
+ if (t.match(/^(inet|cidr|macaddr)/)) return '#65a30d'; // lime
219
+ if (t.match(/^(bytea|binary|blob)/)) return '#78716c'; // stone
220
+ return '#9ca3af'; // gray default
221
+ }
222
+
223
+ // Heat map: row count → header class
224
+ function heatClass(rowCount) {
225
+ if (rowCount == null) return '';
226
+ if (rowCount >= 1000000) return 'heat-red';
227
+ if (rowCount >= 100000) return 'heat-orange';
228
+ if (rowCount >= 10000) return 'heat-yellow';
229
+ if (rowCount >= 1000) return 'heat-lime';
230
+ return 'heat-green';
231
+ }
232
+
233
+ // Compute global health stats
234
+ var healthStats = { tables: tableNames.length, totalCols: 0, totalIndexes: 0, missingIndexes: 0, polymorphics: 0, noTimestamps: 0, noPK: 0, totalRows: 0, noTimestampTables: [], noPKTables: [] };
235
+ tableNames.forEach(function(name) {
236
+ var info = schema[name];
237
+ healthStats.totalCols += (info.columns || []).length;
238
+ healthStats.totalIndexes += (info.indexes || []).length;
239
+ healthStats.missingIndexes += (info.missing_indexes || []).length;
240
+ healthStats.polymorphics += (info.polymorphic_columns || []).length;
241
+ if (info.row_count != null) healthStats.totalRows += info.row_count;
242
+ if (!info.primary_key) { healthStats.noPK++; healthStats.noPKTables.push(name); }
243
+ var colNames = (info.columns || []).map(function(c) { return c.name; });
244
+ if (colNames.indexOf('created_at') === -1 && colNames.indexOf('updated_at') === -1) {
245
+ healthStats.noTimestamps++;
246
+ healthStats.noTimestampTables.push(name);
247
+ }
248
+ });
249
+
250
+ // Render health bar
251
+ (function renderHealthBar() {
252
+ var bar = document.getElementById('health-bar');
253
+ var html = '<div class="health-grid">';
254
+ html += '<div class="health-stat"><span class="stat-icon">&#x1F4CA;</span> Tables: <span class="stat-val">' + healthStats.tables + '</span></div>';
255
+ html += '<div class="health-stat"><span class="stat-icon">&#x1F4DD;</span> Columns: <span class="stat-val">' + healthStats.totalCols + '</span></div>';
256
+ html += '<div class="health-stat"><span class="stat-icon">&#x26A1;</span> Indexes: <span class="stat-val">' + healthStats.totalIndexes + '</span></div>';
257
+ html += '<div class="health-stat"><span class="stat-icon">&#x1F4E6;</span> Total rows: <span class="stat-val">' + healthStats.totalRows.toLocaleString() + '</span></div>';
258
+ if (healthStats.missingIndexes > 0) {
259
+ html += '<div class="health-stat warn"><span class="stat-icon">&#x26A0;</span> Missing indexes: <span class="stat-val">' + healthStats.missingIndexes + '</span></div>';
260
+ } else {
261
+ html += '<div class="health-stat ok"><span class="stat-icon">&#x2705;</span> Missing indexes: <span class="stat-val">0</span></div>';
262
+ }
263
+ if (healthStats.noTimestamps > 0) {
264
+ html += '<div class="health-stat warn" style="cursor:pointer;" title="' + healthStats.noTimestampTables.join(', ') + '" onclick="this.nextElementSibling.style.display=this.nextElementSibling.style.display===\'none\'?\'block\':\'none\'"><span class="stat-icon">&#x23F0;</span> No timestamps: <span class="stat-val">' + healthStats.noTimestamps + '</span> <span style="font-size:9px;color:#b45309;">&#x25BC;</span></div>';
265
+ html += '<div style="display:none;grid-column:1/-1;padding:4px 0;">';
266
+ healthStats.noTimestampTables.forEach(function(t) {
267
+ html += '<div style="font-size:11px;padding:2px 0;"><a href="#" onclick="event.preventDefault();window._erd.select(\'' + t + '\');window._erd.centerOn(\'' + t + '\');" style="color:#d97706;text-decoration:none;font-family:ui-monospace,monospace;">' + t + '</a> <span style="font-size:9px;color:#9ca3af;">— missing created_at / updated_at</span></div>';
268
+ });
269
+ html += '</div>';
270
+ }
271
+ if (healthStats.noPK > 0) {
272
+ html += '<div class="health-stat danger" style="cursor:pointer;" title="' + healthStats.noPKTables.join(', ') + '" onclick="this.nextElementSibling.style.display=this.nextElementSibling.style.display===\'none\'?\'block\':\'none\'"><span class="stat-icon">&#x1F6A8;</span> No primary key: <span class="stat-val">' + healthStats.noPK + '</span> <span style="font-size:9px;color:#dc2626;">&#x25BC;</span></div>';
273
+ html += '<div style="display:none;grid-column:1/-1;padding:4px 0;">';
274
+ healthStats.noPKTables.forEach(function(t) {
275
+ html += '<div style="font-size:11px;padding:2px 0;"><a href="#" onclick="event.preventDefault();window._erd.select(\'' + t + '\');window._erd.centerOn(\'' + t + '\');" style="color:#dc2626;text-decoration:none;font-family:ui-monospace,monospace;">' + t + '</a></div>';
276
+ });
277
+ html += '</div>';
278
+ }
279
+ if (healthStats.polymorphics > 0) {
280
+ html += '<div class="health-stat"><span class="stat-icon">&#x1F48E;</span> Polymorphic: <span class="stat-val">' + healthStats.polymorphics + '</span></div>';
281
+ }
282
+ html += '</div>';
283
+ bar.innerHTML = html;
284
+ })();
285
+
286
+ tableNames.forEach(function(name) {
287
+ var info = schema[name];
288
+ var node = nodes[name];
289
+ var div = document.createElement('div');
290
+ div.className = 'erd-node';
291
+ div.dataset.table = name;
292
+
293
+ var html = '<div class="node-header ' + heatClass(info.row_count) + '"><span>' + name + '</span>';
294
+ var missingCount = (info.missing_indexes || []).length;
295
+ var polyCount = (info.polymorphic_columns || []).length;
296
+ if (missingCount > 0) {
297
+ var missingTip = (info.missing_indexes || []).map(function(c) { return c; }).join(', ');
298
+ html += '<span class="node-warning" title="Missing indexes: ' + missingTip + '">&#x26A0;</span>';
299
+ }
300
+ if (polyCount > 0) {
301
+ html += '<span class="node-poly-badge" title="' + polyCount + ' polymorphic">P</span>';
302
+ }
303
+ if (info.row_count != null) {
304
+ html += '<span class="row-count">' + info.row_count.toLocaleString() + ' rows</span>';
305
+ }
306
+ html += '</div>';
307
+
308
+ html += '<div class="node-columns" id="cols-' + name + '">';
309
+ var cols = info.columns || [];
310
+ var maxShow = 12;
311
+ cols.slice(0, maxShow).forEach(function(col) {
312
+ var isPK = col.name === info.primary_key;
313
+ var isFK = (info.foreign_keys || []).some(function(fk) { return fk.column === col.name; });
314
+ var icon = '';
315
+ if (isPK) icon = '<span class="col-icon">\uD83D\uDD11</span>';
316
+ else if (isFK) icon = '<span class="col-icon">\uD83D\uDD17</span>';
317
+ var dot = '<span class="col-type-dot" style="background:' + typeColor(col.type) + '"></span>';
318
+ html += '<div class="col-row"><span class="col-name">' + icon + col.name + '</span><span class="col-type">' + dot + col.type + '</span></div>';
319
+ });
320
+ if (cols.length > maxShow) {
321
+ html += '<div class="col-overflow">+ ' + (cols.length - maxShow) + ' more</div>';
322
+ }
323
+ html += '</div>';
324
+
325
+ div.innerHTML = html;
326
+
327
+ div.addEventListener('mousedown', function(e) { startDrag(e, name); });
328
+ div.addEventListener('click', function() { if (!didDrag) selectNode(name); });
329
+ div.addEventListener('dblclick', function(e) { e.stopPropagation(); toggleCols(name); });
330
+
331
+ canvas.appendChild(div);
332
+ node.el = div;
333
+ requestAnimationFrame(function() { node.w = div.offsetWidth; node.h = div.offsetHeight; });
334
+ });
335
+
336
+ // Create SVG edges
337
+ var ns = 'http://www.w3.org/2000/svg';
338
+ var defs = document.createElementNS(ns, 'defs');
339
+ ['fk', 'conv'].forEach(function(type) {
340
+ var marker = document.createElementNS(ns, 'marker');
341
+ marker.setAttribute('id', 'arr-' + type);
342
+ marker.setAttribute('markerWidth', '8');
343
+ marker.setAttribute('markerHeight', '6');
344
+ marker.setAttribute('refX', '8');
345
+ marker.setAttribute('refY', '3');
346
+ marker.setAttribute('orient', 'auto');
347
+ var poly = document.createElementNS(ns, 'polygon');
348
+ poly.setAttribute('points', '0 0, 8 3, 0 6');
349
+ poly.setAttribute('fill', type === 'fk' ? '#3b82f6' : '#d1d5db');
350
+ marker.appendChild(poly);
351
+ defs.appendChild(marker);
352
+ });
353
+ svgEl.appendChild(defs);
354
+
355
+ relationships.forEach(function(r) {
356
+ var path = document.createElementNS(ns, 'path');
357
+ var cls = r.type === 'foreign_key' ? 'fk' : 'conv';
358
+ path.classList.add('edge-line', cls);
359
+ path.setAttribute('marker-end', 'url(#arr-' + cls + ')');
360
+ svgEl.appendChild(path);
361
+ edgeData.push({ from: r.from_table, to: r.to_table, type: r.type, el: path });
362
+ });
363
+
364
+ // Sidebar
365
+ function renderSidebar(filter) {
366
+ sidebarList.innerHTML = '';
367
+ var q = (filter || '').toLowerCase();
368
+ tableNames.forEach(function(name) {
369
+ if (q && name.toLowerCase().indexOf(q) === -1) return;
370
+ var div = document.createElement('div');
371
+ div.className = 'sidebar-item' + (selectedNode === name ? ' active' : '');
372
+ var cnt = (schema[name].columns || []).length;
373
+ var badges = '<span class="badge">' + cnt + '</span>';
374
+ var mi = (schema[name].missing_indexes || []).length;
375
+ if (mi > 0) badges = '<span style="color:#d97706;font-size:10px;margin-right:4px;" title="' + mi + ' missing index(es)">&#x26A0;</span>' + badges;
376
+ var pc = (schema[name].polymorphic_columns || []).length;
377
+ if (pc > 0) badges = '<span style="color:#7c3aed;font-size:10px;font-weight:700;margin-right:4px;" title="' + pc + ' polymorphic">P</span>' + badges;
378
+ div.innerHTML = '<span>' + name + '</span><span style="display:flex;align-items:center;">' + badges + '</span>';
379
+ div.addEventListener('click', function() { selectNode(name); centerOn(name); });
380
+ sidebarList.appendChild(div);
381
+ });
382
+ }
383
+ renderSidebar();
384
+ searchInput.addEventListener('input', function() { renderSidebar(searchInput.value); });
385
+
386
+ // Force simulation
387
+ function simulate() {
388
+ if (!simRunning || simAlpha < 0.001) { simRunning = false; return; }
389
+ simAlpha *= 0.994;
390
+
391
+ // Repulsion
392
+ for (var i = 0; i < tableNames.length; i++) {
393
+ for (var j = i + 1; j < tableNames.length; j++) {
394
+ var a = nodes[tableNames[i]], b = nodes[tableNames[j]];
395
+ var dx = b.x - a.x, dy = b.y - a.y;
396
+ var dist = Math.sqrt(dx * dx + dy * dy) || 1;
397
+ var minD = 180;
398
+ if (dist < minD) {
399
+ var f = simAlpha * 8 * (minD - dist) / dist;
400
+ a.vx -= dx * f; a.vy -= dy * f;
401
+ b.vx += dx * f; b.vy += dy * f;
402
+ }
403
+ }
404
+ }
405
+
406
+ // Attraction along edges
407
+ relationships.forEach(function(r) {
408
+ var a = nodes[r.from_table], b = nodes[r.to_table];
409
+ if (!a || !b) return;
410
+ var dx = b.x - a.x, dy = b.y - a.y;
411
+ var dist = Math.sqrt(dx * dx + dy * dy) || 1;
412
+ var ideal = 220;
413
+ var f = simAlpha * 0.3 * (dist - ideal) / dist;
414
+ a.vx += dx * f; a.vy += dy * f;
415
+ b.vx -= dx * f; b.vy -= dy * f;
416
+ });
417
+
418
+ // Center gravity
419
+ tableNames.forEach(function(name) {
420
+ var n = nodes[name];
421
+ if (dragNode === name) return;
422
+ n.vx += (cx - n.x) * simAlpha * 0.008;
423
+ n.vy += (cy - n.y) * simAlpha * 0.008;
424
+ });
425
+
426
+ // Apply
427
+ tableNames.forEach(function(name) {
428
+ var n = nodes[name];
429
+ if (dragNode === name) { n.vx = 0; n.vy = 0; return; }
430
+ n.vx *= 0.55; n.vy *= 0.55;
431
+ n.x += n.vx; n.y += n.vy;
432
+ });
433
+
434
+ updatePositions();
435
+ }
436
+
437
+ function updatePositions() {
438
+ tableNames.forEach(function(name) {
439
+ var n = nodes[name];
440
+ n.el.style.left = n.x + 'px';
441
+ n.el.style.top = n.y + 'px';
442
+ n.w = n.el.offsetWidth;
443
+ n.h = n.el.offsetHeight;
444
+ });
445
+ updateEdges();
446
+ }
447
+
448
+ function updateEdges() {
449
+ svgEl.setAttribute('width', SVG_W);
450
+ svgEl.setAttribute('height', SVG_H);
451
+ svgEl.style.width = SVG_W + 'px';
452
+ svgEl.style.height = SVG_H + 'px';
453
+
454
+ edgeData.forEach(function(e) {
455
+ var from = nodes[e.from], to = nodes[e.to];
456
+ if (!from || !to) return;
457
+ var fcx = from.x + from.w / 2, fcy = from.y + from.h / 2;
458
+ var tcx = to.x + to.w / 2, tcy = to.y + to.h / 2;
459
+
460
+ var p = edgePoints(from, to, fcx, fcy, tcx, tcy);
461
+ var mx = (p.x1 + p.x2) / 2, my = (p.y1 + p.y2) / 2;
462
+ var ddx = p.x2 - p.x1, ddy = p.y2 - p.y1;
463
+ var cpx = mx - ddy * 0.08, cpy = my + ddx * 0.08;
464
+
465
+ e.el.setAttribute('d', 'M ' + p.x1 + ' ' + p.y1 + ' Q ' + cpx + ' ' + cpy + ' ' + p.x2 + ' ' + p.y2);
466
+ });
467
+ }
468
+
469
+ function edgePoints(from, to, fcx, fcy, tcx, tcy) {
470
+ function intersect(rx, ry, rw, rh, px, py) {
471
+ var dx = px - (rx + rw/2), dy = py - (ry + rh/2);
472
+ var ax = Math.abs(dx), ay = Math.abs(dy);
473
+ if (ax * rh > ay * rw) {
474
+ return { x: rx + (dx > 0 ? rw : 0), y: ry + rh/2 + dy * (dx > 0 ? rw/2 : -rw/2) / (dx || 1) };
475
+ } else {
476
+ return { x: rx + rw/2 + dx * (dy > 0 ? rh/2 : -rh/2) / (dy || 1), y: ry + (dy > 0 ? rh : 0) };
477
+ }
478
+ }
479
+ var p1 = intersect(from.x, from.y, from.w, from.h, tcx, tcy);
480
+ var p2 = intersect(to.x, to.y, to.w, to.h, fcx, fcy);
481
+ return { x1: p1.x, y1: p1.y, x2: p2.x, y2: p2.y };
482
+ }
483
+
484
+ // Animation loop
485
+ function tick() {
486
+ simulate();
487
+ requestAnimationFrame(tick);
488
+ }
489
+ tick();
490
+
491
+ // Transform
492
+ function applyTransform() {
493
+ var t = 'translate(' + panX + 'px,' + panY + 'px) scale(' + zoom + ')';
494
+ canvas.style.transform = t;
495
+ svgEl.style.transform = t;
496
+ }
497
+
498
+ // Pan
499
+ canvasWrap.addEventListener('mousedown', function(e) {
500
+ if (e.target === canvasWrap || e.target === svgEl) {
501
+ isPanning = true;
502
+ panStartX = e.clientX; panStartY = e.clientY;
503
+ panStartPanX = panX; panStartPanY = panY;
504
+ canvasWrap.style.cursor = 'grabbing';
505
+ }
506
+ });
507
+
508
+ window.addEventListener('mousemove', function(e) {
509
+ if (isPanning) {
510
+ panX = panStartPanX + (e.clientX - panStartX);
511
+ panY = panStartPanY + (e.clientY - panStartY);
512
+ applyTransform();
513
+ }
514
+ if (dragNode) {
515
+ var n = nodes[dragNode];
516
+ var rect = canvasWrap.getBoundingClientRect();
517
+ n.x = (e.clientX - rect.left - panX) / zoom - dragOffsetX;
518
+ n.y = (e.clientY - rect.top - panY) / zoom - dragOffsetY;
519
+ n.el.style.left = n.x + 'px';
520
+ n.el.style.top = n.y + 'px';
521
+ updateEdges();
522
+ didDrag = true;
523
+ }
524
+ });
525
+
526
+ window.addEventListener('mouseup', function() {
527
+ if (isPanning) { isPanning = false; canvasWrap.style.cursor = 'grab'; }
528
+ if (dragNode) {
529
+ canvasWrap.classList.remove('dragging-node');
530
+ dragNode = null;
531
+ simAlpha = Math.max(simAlpha, 0.08);
532
+ simRunning = true;
533
+ }
534
+ });
535
+
536
+ // Scroll wheel pans (no zoom on scroll)
537
+
538
+ // Node drag
539
+ function startDrag(e, name) {
540
+ e.stopPropagation();
541
+ didDrag = false;
542
+ dragNode = name;
543
+ canvasWrap.classList.add('dragging-node');
544
+ var n = nodes[name];
545
+ var rect = canvasWrap.getBoundingClientRect();
546
+ dragOffsetX = (e.clientX - rect.left - panX) / zoom - n.x;
547
+ dragOffsetY = (e.clientY - rect.top - panY) / zoom - n.y;
548
+ simAlpha = 0;
549
+ }
550
+
551
+ // Click to focus / highlight neighborhood
552
+ function selectNode(name) {
553
+ if (selectedNode === name) { deselect(); return; }
554
+ selectedNode = name;
555
+ var neighbors = adjacency[name] || new Set();
556
+
557
+ tableNames.forEach(function(t) {
558
+ var el = nodes[t].el;
559
+ el.classList.remove('focused', 'neighbor', 'faded');
560
+ if (t === name) el.classList.add('focused');
561
+ else if (neighbors.has(t)) el.classList.add('neighbor');
562
+ else el.classList.add('faded');
563
+ });
564
+
565
+ edgeData.forEach(function(e) {
566
+ e.el.classList.remove('highlighted', 'faded');
567
+ if (e.from === name || e.to === name) e.el.classList.add('highlighted');
568
+ else e.el.classList.add('faded');
569
+ });
570
+
571
+ showDetail(name);
572
+ renderSidebar(searchInput.value);
573
+ }
574
+
575
+ function deselect() {
576
+ selectedNode = null;
577
+ tableNames.forEach(function(t) {
578
+ nodes[t].el.classList.remove('focused', 'neighbor', 'faded');
579
+ });
580
+ edgeData.forEach(function(e) {
581
+ e.el.classList.remove('highlighted', 'faded');
582
+ });
583
+ detailPanel.style.display = 'none';
584
+ renderSidebar(searchInput.value);
585
+ }
586
+
587
+ // Double-click to expand/collapse columns
588
+ function toggleCols(name) {
589
+ var el = document.getElementById('cols-' + name);
590
+ if (el) {
591
+ el.classList.toggle('expanded');
592
+ setTimeout(function() {
593
+ var n = nodes[name];
594
+ n.w = n.el.offsetWidth;
595
+ n.h = n.el.offsetHeight;
596
+ updateEdges();
597
+ }, 350);
598
+ }
599
+ }
600
+
601
+ // Detail panel
602
+ function showDetail(name) {
603
+ var info = schema[name];
604
+ detailTitle.textContent = name;
605
+ var html = '';
606
+
607
+ if (info.row_count != null) {
608
+ html += '<div class="detail-section"><div style="font-size:13px;color:#6b7280;">Rows: <strong style="color:#111827;">' + info.row_count.toLocaleString() + '</strong></div></div>';
609
+ }
610
+
611
+ // Missing index warnings
612
+ var missingIdx = info.missing_indexes || [];
613
+ if (missingIdx.length > 0) {
614
+ html += '<div class="detail-section" style="background:#fffbeb;"><h4 style="color:#92400e;">&#x26A0; Missing Indexes (' + missingIdx.length + ')</h4>';
615
+ missingIdx.forEach(function(col) {
616
+ var idxName = 'index_' + name + '_on_' + col;
617
+ var sql = 'CREATE INDEX ' + idxName + ' ON ' + name + ' (' + col + ');';
618
+ var railsCmd = 'bin/rails generate migration Add' + col.replace(/(^|_)(\w)/g, function(m,p1,p2){ return p2.toUpperCase(); }) + 'IndexTo' + name.replace(/(^|_)(\w)/g, function(m,p1,p2){ return p2.toUpperCase(); });
619
+ var migrationCode = 'add_index :' + name + ', :' + col;
620
+ html += '<div style="font-size:12px;padding:4px 0;color:#92400e;">';
621
+ html += '<div style="font-family:ui-monospace,monospace;">&#x2022; <strong>' + col + '</strong> has no index</div>';
622
+ html += '<div style="margin-top:3px;font-size:10px;color:#78350f;font-weight:600;">SQL</div>';
623
+ html += '<div style="position:relative;"><code style="display:block;font-size:10px;background:#fef3c7;padding:4px 28px 4px 6px;border-radius:4px;color:#78350f;word-break:break-all;cursor:pointer;" title="Click to copy" onclick="navigator.clipboard.writeText(this.textContent.trim());this.nextElementSibling.style.opacity=1;setTimeout(function(){this.style.opacity=0}.bind(this.nextElementSibling),1200)">' + sql + '</code><span style="position:absolute;right:6px;top:3px;font-size:9px;color:#059669;opacity:0;transition:opacity 0.2s;">Copied!</span></div>';
624
+ html += '<div style="margin-top:3px;font-size:10px;color:#78350f;font-weight:600;">Rails migration</div>';
625
+ html += '<div style="position:relative;"><code style="display:block;font-size:10px;background:#fef3c7;padding:4px 28px 4px 6px;border-radius:4px;color:#78350f;word-break:break-all;cursor:pointer;" title="Click to copy" onclick="navigator.clipboard.writeText(this.textContent.trim());this.nextElementSibling.style.opacity=1;setTimeout(function(){this.style.opacity=0}.bind(this.nextElementSibling),1200)">' + migrationCode + '</code><span style="position:absolute;right:6px;top:3px;font-size:9px;color:#059669;opacity:0;transition:opacity 0.2s;">Copied!</span></div>';
626
+ html += '<div style="margin-top:3px;font-size:10px;color:#78350f;font-weight:600;">Generator command</div>';
627
+ html += '<div style="position:relative;"><code style="display:block;font-size:10px;background:#fef3c7;padding:4px 28px 4px 6px;border-radius:4px;color:#78350f;word-break:break-all;cursor:pointer;" title="Click to copy" onclick="navigator.clipboard.writeText(this.textContent.trim());this.nextElementSibling.style.opacity=1;setTimeout(function(){this.style.opacity=0}.bind(this.nextElementSibling),1200)">' + railsCmd + '</code><span style="position:absolute;right:6px;top:3px;font-size:9px;color:#059669;opacity:0;transition:opacity 0.2s;">Copied!</span></div>';
628
+ html += '</div>';
629
+ });
630
+ html += '<div style="font-size:10px;color:#b45309;margin-top:4px;">Adding indexes on foreign key columns improves JOIN and lookup performance.</div>';
631
+ html += '</div>';
632
+ }
633
+
634
+ // Polymorphic columns
635
+ var polyCols = info.polymorphic_columns || [];
636
+ if (polyCols.length > 0) {
637
+ html += '<div class="detail-section" style="background:#f5f3ff;"><h4 style="color:#6d28d9;">&#x1F48E; Polymorphic (' + polyCols.length + ')</h4>';
638
+ polyCols.forEach(function(p) {
639
+ html += '<div style="font-size:12px;padding:2px 0;color:#6d28d9;font-family:ui-monospace,monospace;">&#x2022; <strong>' + p.name + '</strong> (<span style="color:#7c3aed;">' + p.type_column + '</span> + <span style="color:#7c3aed;">' + p.id_column + '</span>)</div>';
640
+ });
641
+ html += '</div>';
642
+ }
643
+
644
+ html += '<div class="detail-section"><h4>Columns (' + (info.columns || []).length + ')</h4>';
645
+ (info.columns || []).forEach(function(col) {
646
+ var isPK = col.name === info.primary_key;
647
+ var isFK = (info.foreign_keys || []).some(function(fk) { return fk.column === col.name; });
648
+ var isMissing = missingIdx.indexOf(col.name) !== -1;
649
+ var icon = isPK ? '\uD83D\uDD11 ' : (isFK ? '\uD83D\uDD17 ' : '');
650
+ var nullable = col.nullable ? '<span style="color:#d97706;margin-left:4px;font-size:10px;">NULL</span>' : '';
651
+ var warningIcon = isMissing ? ' <span style="color:#d97706;font-size:10px;" title="Missing index">&#x26A0;</span>' : '';
652
+ var dot = '<span class="col-type-dot" style="background:' + typeColor(col.type) + '"></span>';
653
+ html += '<div class="detail-col"><span class="name">' + icon + col.name + nullable + warningIcon + '</span><span class="type">' + dot + col.type + '</span></div>';
654
+ });
655
+ html += '</div>';
656
+
657
+ if ((info.indexes || []).length > 0) {
658
+ html += '<div class="detail-section"><h4>Indexes (' + info.indexes.length + ')</h4>';
659
+ info.indexes.forEach(function(idx) {
660
+ var u = idx.unique ? ' <span style="color:#7c3aed;font-size:10px;">UNIQUE</span>' : '';
661
+ html += '<div class="detail-idx"><div class="idx-name">' + idx.name + u + '</div><div class="idx-cols">(' + idx.columns.join(', ') + ')</div></div>';
662
+ });
663
+ html += '</div>';
664
+ }
665
+
666
+ if ((info.foreign_keys || []).length > 0) {
667
+ html += '<div class="detail-section"><h4>Foreign Keys</h4>';
668
+ info.foreign_keys.forEach(function(fk) {
669
+ html += '<div class="detail-fk"><span class="fk-from">' + fk.column + '</span><span class="fk-arrow">\u2192</span><span class="fk-to">' + fk.to_table + '.' + fk.primary_key + '</span></div>';
670
+ });
671
+ html += '</div>';
672
+ }
673
+
674
+ var related = adjacency[name];
675
+ if (related && related.size > 0) {
676
+ html += '<div class="detail-section"><h4>Related Tables</h4>';
677
+ related.forEach(function(t) {
678
+ html += '<div style="font-size:12px;padding:2px 0;"><a href="#" onclick="event.preventDefault();window._erd.select(\'' + t + '\');window._erd.centerOn(\'' + t + '\');" style="color:#2563eb;text-decoration:none;font-family:ui-monospace,monospace;">' + t + '</a></div>';
679
+ });
680
+ html += '</div>';
681
+ }
682
+
683
+ // Associations from ActiveRecord models
684
+ var assocs = info.associations || [];
685
+ if (assocs.length > 0) {
686
+ html += '<div class="detail-section"><h4>Associations (' + assocs.length + ')</h4>';
687
+ var macroColors = { 'has_many': '#059669', 'belongs_to': '#2563eb', 'has_one': '#7c3aed', 'has_and_belongs_to_many': '#d97706' };
688
+ assocs.forEach(function(a) {
689
+ var color = macroColors[a.macro] || '#6b7280';
690
+ var badge = '<span style="display:inline-block;font-size:10px;padding:1px 5px;border-radius:4px;background:' + color + '15;color:' + color + ';font-weight:600;margin-right:6px;">' + a.macro + '</span>';
691
+ var through = a.through ? ' <span style="color:#9ca3af;font-size:10px;">through ' + a.through + '</span>' : '';
692
+ var link = a.target_table ? '<a href="#" onclick="event.preventDefault();window._erd.select(\'' + a.target_table + '\');window._erd.centerOn(\'' + a.target_table + '\');" style="color:#2563eb;text-decoration:none;font-family:ui-monospace,monospace;">' + a.name + '</a>' : '<span style="font-family:ui-monospace,monospace;color:#374151;">' + a.name + '</span>';
693
+ html += '<div style="font-size:12px;padding:3px 0;display:flex;align-items:center;flex-wrap:wrap;">' + badge + link + through + '</div>';
694
+ });
695
+ html += '</div>';
696
+ }
697
+
698
+ detailBody.innerHTML = html;
699
+ detailPanel.style.display = 'block';
700
+ }
701
+
702
+ // Center on a specific node
703
+ function centerOn(name) {
704
+ var n = nodes[name];
705
+ var rect = canvasWrap.getBoundingClientRect();
706
+ panX = rect.width / 2 - (n.x + n.w / 2) * zoom;
707
+ panY = rect.height / 2 - (n.y + n.h / 2) * zoom;
708
+ applyTransform();
709
+ }
710
+
711
+ // Zoom helpers
712
+ function zoomByFn(factor) {
713
+ var rect = canvasWrap.getBoundingClientRect();
714
+ var mx = rect.width / 2, my = rect.height / 2;
715
+ var nz = Math.max(0.1, Math.min(3, zoom * factor));
716
+ panX = mx - (mx - panX) * (nz / zoom);
717
+ panY = my - (my - panY) * (nz / zoom);
718
+ zoom = nz;
719
+ applyTransform();
720
+ }
721
+
722
+ function fitToScreenFn() {
723
+ if (!tableNames.length) return;
724
+ var minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
725
+ tableNames.forEach(function(name) {
726
+ var n = nodes[name];
727
+ minX = Math.min(minX, n.x);
728
+ minY = Math.min(minY, n.y);
729
+ maxX = Math.max(maxX, n.x + n.w);
730
+ maxY = Math.max(maxY, n.y + n.h);
731
+ });
732
+ var rect = canvasWrap.getBoundingClientRect();
733
+ var pad = 80;
734
+ var cw = maxX - minX + pad * 2, ch = maxY - minY + pad * 2;
735
+ zoom = Math.min(rect.width / cw, rect.height / ch, 1.5);
736
+ panX = (rect.width - cw * zoom) / 2 - (minX - pad) * zoom;
737
+ panY = (rect.height - ch * zoom) / 2 - (minY - pad) * zoom;
738
+ applyTransform();
739
+ }
740
+
741
+ // Center on content at 100% zoom after layout settles
742
+ setTimeout(function() {
743
+ if (!tableNames.length) return;
744
+ var minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
745
+ tableNames.forEach(function(name) {
746
+ var n = nodes[name];
747
+ minX = Math.min(minX, n.x);
748
+ minY = Math.min(minY, n.y);
749
+ maxX = Math.max(maxX, n.x + n.w);
750
+ maxY = Math.max(maxY, n.y + n.h);
751
+ });
752
+ var rect = canvasWrap.getBoundingClientRect();
753
+ var contentCX = (minX + maxX) / 2;
754
+ var contentCY = (minY + maxY) / 2;
755
+ zoom = 1;
756
+ panX = rect.width / 2 - contentCX;
757
+ panY = rect.height / 2 - contentCY;
758
+ applyTransform();
759
+ }, 2800);
760
+
761
+ // Keyboard shortcuts
762
+ document.addEventListener('keydown', function(e) {
763
+ if (document.activeElement === searchInput && e.key !== 'Escape') return;
764
+ switch(e.key) {
765
+ case '/': e.preventDefault(); searchInput.focus(); break;
766
+ case 'Escape': deselect(); searchInput.blur(); searchInput.value = ''; renderSidebar(); break;
767
+ case '+': case '=': zoomByFn(1.2); break;
768
+ case '-': zoomByFn(0.83); break;
769
+ case 'f': case 'F': if (document.activeElement !== searchInput) fitToScreenFn(); break;
770
+ }
771
+ });
772
+
773
+ // Click background to deselect
774
+ canvasWrap.addEventListener('click', function(e) {
775
+ if (e.target === canvasWrap) deselect();
776
+ });
777
+
778
+ // Expose API
779
+ window._erd = { select: selectNode, deselect: deselect, centerOn: centerOn, zoomBy: zoomByFn, fitToScreen: fitToScreenFn, exportSVG: exportSVGFn };
780
+
781
+ // Export to SVG
782
+ function exportSVGFn() {
783
+ var minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
784
+ tableNames.forEach(function(name) {
785
+ var n = nodes[name];
786
+ minX = Math.min(minX, n.x);
787
+ minY = Math.min(minY, n.y);
788
+ maxX = Math.max(maxX, n.x + n.w);
789
+ maxY = Math.max(maxY, n.y + n.h);
790
+ });
791
+ var pad = 60;
792
+ var w = maxX - minX + pad * 2;
793
+ var h = maxY - minY + pad * 2;
794
+ var offX = minX - pad;
795
+ var offY = minY - pad;
796
+
797
+ var svgParts = [];
798
+ svgParts.push('<svg xmlns="http://www.w3.org/2000/svg" width="' + w + '" height="' + h + '" viewBox="0 0 ' + w + ' ' + h + '">');
799
+ svgParts.push('<rect width="' + w + '" height="' + h + '" fill="#f8fafc"/>');
800
+
801
+ // Edges
802
+ edgeData.forEach(function(e) {
803
+ var from = nodes[e.from], to = nodes[e.to];
804
+ if (!from || !to) return;
805
+ var fcx = from.x + from.w / 2, fcy = from.y + from.h / 2;
806
+ var tcx = to.x + to.w / 2, tcy = to.y + to.h / 2;
807
+ var p = edgePoints(from, to, fcx, fcy, tcx, tcy);
808
+ var stroke = e.type === 'foreign_key' ? '#3b82f6' : '#d1d5db';
809
+ var dash = e.type === 'foreign_key' ? '' : ' stroke-dasharray="5,4"';
810
+ svgParts.push('<line x1="' + (p.x1-offX) + '" y1="' + (p.y1-offY) + '" x2="' + (p.x2-offX) + '" y2="' + (p.y2-offY) + '" stroke="' + stroke + '" stroke-width="1.5"' + dash + '/>');
811
+ });
812
+
813
+ // Nodes
814
+ var heatColors = { 'heat-green': '#166534', 'heat-lime': '#3f6212', 'heat-yellow': '#854d0e', 'heat-orange': '#9a3412', 'heat-red': '#991b1b' };
815
+ tableNames.forEach(function(name) {
816
+ var n = nodes[name];
817
+ var info = schema[name];
818
+ var x = n.x - offX, y = n.y - offY;
819
+ var hc = heatClass(info.row_count);
820
+ var headerColor = heatColors[hc] || '#1f2937';
821
+
822
+ svgParts.push('<rect x="' + x + '" y="' + y + '" width="' + n.w + '" height="' + n.h + '" rx="8" fill="#fff" stroke="#d1d5db" stroke-width="2"/>');
823
+ svgParts.push('<rect x="' + x + '" y="' + y + '" width="' + n.w + '" height="32" rx="8" fill="' + headerColor + '"/>');
824
+ svgParts.push('<rect x="' + x + '" y="' + (y+24) + '" width="' + n.w + '" height="8" fill="' + headerColor + '"/>');
825
+ svgParts.push('<text x="' + (x+10) + '" y="' + (y+21) + '" fill="#fff" font-size="12" font-weight="600" font-family="ui-monospace,monospace">' + name + '</text>');
826
+ if (info.row_count != null) {
827
+ svgParts.push('<text x="' + (x+n.w-10) + '" y="' + (y+20) + '" fill="#9ca3af" font-size="10" text-anchor="end" font-family="sans-serif">' + info.row_count.toLocaleString() + ' rows</text>');
828
+ }
829
+ });
830
+
831
+ svgParts.push('</svg>');
832
+ var blob = new Blob([svgParts.join('')], { type: 'image/svg+xml' });
833
+ var url = URL.createObjectURL(blob);
834
+ var a = document.createElement('a');
835
+ a.download = 'schema-erd.svg';
836
+ a.href = url;
837
+ a.click();
838
+ setTimeout(function() { URL.revokeObjectURL(url); }, 1000);
839
+ }
840
+ })();
841
+ </script>
842
+ <% end %>