admindb 2.3.1 → 2.4.1

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.
@@ -1 +1 @@
1
- <div class="flex flex-col" style="height: calc(100vh - 120px); min-height: 500px;">
2
1
  <div class="mb-3 flex flex-wrap items-center gap-2">
3
2
  <div class="flex items-center gap-2">
4
3
  <h1 class="text-xl font-bold">ER Diagram</h1>
5
4
  <span id="erd-table-badge" class="hidden rounded-full bg-primary/15 px-2 py-0.5 text-xs font-semibold text-primary"></span>
6
5
  <span id="erd-fk-badge" class="hidden rounded-full bg-amber-500/15 px-2 py-0.5 text-xs font-semibold text-amber-600"></span>
7
6
  </div>
8
7
  <div class="ml-auto flex flex-wrap items-center gap-1.5">
9
8
  <div class="relative">
10
9
  {{{icon "search" class="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-base-content/40"}}}
11
10
  <input id="erd-search" type="search" placeholder="Find table…" autocomplete="off"
12
11
  class="h-8 w-36 rounded-lg border border-base-300 bg-base-100 pl-7 pr-2 text-xs outline-none transition focus:border-primary/60 focus:ring-2 focus:ring-primary/20" />
13
12
  </div>
14
13
  <div class="h-5 w-px bg-base-300"></div>
15
14
  <button id="erd-btn-compact" title="Toggle compact mode (C)" class="btn btn-sm btn-ghost gap-1.5 font-normal">
16
15
  {{{icon "compact" class="h-4 w-4"}}}
17
16
  Compact
18
17
  </button>
19
18
  <button id="erd-btn-fit" title="Fit to screen (F)" class="btn btn-sm btn-ghost gap-1.5 font-normal">
20
19
  {{{icon "fit" class="h-4 w-4"}}}
21
20
  Fit
22
21
  </button>
23
22
  <div class="flex items-center rounded-lg border border-base-300 bg-base-100 p-0.5 gap-0.5" title="Layout mode (L to cycle)">
24
23
  <button id="erd-mode-force" class="erd-mode-btn rounded-md px-2.5 py-1 text-xs font-semibold transition-all gap-1.5 flex items-center" data-mode="force" title="Force-directed (physics)">
25
24
  {{{icon "layoutForce" class="h-3.5 w-3.5"}}}
26
25
  Force
27
26
  </button>
28
27
  <button id="erd-mode-hierarchy" class="erd-mode-btn rounded-md px-2.5 py-1 text-xs font-semibold transition-all gap-1.5 flex items-center" data-mode="hierarchy" title="Hierarchical (reference tables left)">
29
28
  {{{icon "layoutHierarchy" class="h-3.5 w-3.5"}}}
30
29
  Hierarchy
31
30
  </button>
32
31
  <button id="erd-mode-circle" class="erd-mode-btn rounded-md px-2.5 py-1 text-xs font-semibold transition-all gap-1.5 flex items-center" data-mode="circle" title="Circular layout">
33
32
  {{{icon "layoutCircle" class="h-3.5 w-3.5"}}}
34
33
  Circle
35
34
  </button>
36
35
  </div>
37
36
  <button id="erd-btn-export-svg" title="Download as SVG" class="btn btn-sm btn-ghost gap-1.5 font-normal">
38
37
  {{{icon "export" class="h-4 w-4"}}}
39
38
  Export
40
39
  </button>
41
40
  <div class="h-5 w-px bg-base-300"></div>
42
41
  <div class="flex items-center gap-0.5 rounded-lg border border-base-300 bg-base-100 px-1.5 py-1">
43
42
  <button id="erd-btn-zoom-out" class="rounded px-1 py-0.5 hover:bg-base-200 transition-colors text-base-content/60" title="Zoom out (−)">
44
43
  {{{icon "minus" class="h-3.5 w-3.5"}}}
45
44
  </button>
46
45
  <span id="erd-zoom-label" class="w-12 text-center text-xs font-mono font-semibold tabular-nums">100%</span>
47
46
  <button id="erd-btn-zoom-in" class="rounded px-1 py-0.5 hover:bg-base-200 transition-colors text-base-content/60" title="Zoom in (+)">
48
47
  {{{icon "plus" class="h-3.5 w-3.5"}}}
49
48
  </button>
50
49
  </div>
51
50
  </div>
52
51
  </div>
53
52
  <div class="flex flex-1 gap-3 min-h-0">
54
53
  <div id="erd-canvas-wrap" class="relative flex-1 overflow-hidden rounded-xl border border-base-300 shadow-inner" style="cursor: grab; min-width:0;">
55
54
  <div id="erd-loading" class="absolute inset-0 flex flex-col items-center justify-center gap-3 z-20 bg-base-100/80 backdrop-blur-sm">
56
55
  {{{icon "spinner" class="animate-spin h-8 w-8 text-primary"}}}
57
56
  <span class="text-sm text-base-content/60 font-medium">Loading schema…</span>
58
57
  </div>
59
58
  <div id="erd-error" class="absolute inset-0 hidden flex-col items-center justify-center gap-3 z-20">
60
59
  <div class="rounded-full bg-error/10 p-4">
61
60
  {{{icon "warning" class="h-8 w-8 text-error"}}}
62
61
  </div>
63
62
  <p id="erd-error-msg" class="text-sm text-error font-medium"></p>
64
63
  </div>
65
64
  <div id="erd-empty" class="absolute inset-0 hidden flex-col items-center justify-center gap-3 z-20">
66
65
  <div class="rounded-full bg-base-200 p-6">
67
66
  {{{icon "diagram" class="h-10 w-10 text-base-content/30"}}}
68
67
  </div>
69
68
  <p class="text-base-content/40 text-sm font-medium">No tables found in this database</p>
70
69
  </div>
71
70
  <svg id="erd-svg" width="100%" height="100%" style="display:none; user-select:none;" xmlns="http://www.w3.org/2000/svg">
72
71
  <defs>
73
72
  <marker id="erd-arrow" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto">
74
73
  <path d="M0,0 L0,6 L8,3 z" fill="var(--erd-edge-color)"/>
75
74
  </marker>
76
75
  <marker id="erd-arrow-hl" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto">
77
76
  <path d="M0,0 L0,6 L8,3 z" fill="var(--erd-edge-hl)"/>
78
77
  </marker>
79
78
  <marker id="erd-arrow-dim" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto">
80
79
  <path d="M0,0 L0,6 L8,3 z" fill="var(--erd-edge-dim)"/>
81
80
  </marker>
82
81
  <marker id="erd-cf-one" markerWidth="12" markerHeight="12" refX="2" refY="6" orient="auto">
83
82
  <line x1="2" y1="1" x2="2" y2="11" stroke="var(--erd-edge-color)" stroke-width="1.5"/>
84
83
  <line x1="5" y1="1" x2="5" y2="11" stroke="var(--erd-edge-color)" stroke-width="1.5"/>
85
84
  </marker>
86
85
  <filter id="erd-glow" x="-30%" y="-30%" width="160%" height="160%">
87
86
  <feGaussianBlur stdDeviation="4" result="blur"/>
88
87
  <feFlood flood-color="var(--erd-node-header)" flood-opacity="0.35" result="color"/>
89
88
  <feComposite in="color" in2="blur" operator="in" result="glow"/>
90
89
  <feMerge><feMergeNode in="glow"/><feMergeNode in="SourceGraphic"/></feMerge>
91
90
  </filter>
92
91
  <filter id="erd-shadow" x="-15%" y="-15%" width="130%" height="130%">
93
92
  <feDropShadow dx="0" dy="2" stdDeviation="4" flood-color="#000" flood-opacity="0.10"/>
94
93
  </filter>
95
94
  <filter id="erd-shadow-lg" x="-20%" y="-20%" width="140%" height="140%">
96
95
  <feDropShadow dx="0" dy="6" stdDeviation="12" flood-color="#000" flood-opacity="0.20"/>
97
96
  </filter>
98
97
  </defs>
99
98
  <g id="erd-viewport">
100
99
  <g id="erd-edges"></g>
101
100
  <g id="erd-nodes"></g>
102
101
  </g>
103
102
  </svg>
104
103
  <div id="erd-edge-tooltip" class="pointer-events-none absolute hidden z-30 rounded-lg border border-base-300 bg-base-100 px-2.5 py-1.5 shadow-xl text-xs font-mono"></div>
105
104
  <div id="erd-minimap" class="absolute bottom-3 right-3 hidden rounded-xl border border-base-300/80 bg-base-100/95 backdrop-blur-sm shadow-xl overflow-hidden" style="width:168px;height:108px;">
106
105
  <div class="absolute inset-x-0 top-0 flex items-center justify-between px-2 py-0.5 bg-base-200/80 text-[9px] font-semibold uppercase tracking-wider text-base-content/40">
107
106
  <span>Overview</span>
108
107
  </div>
109
108
  <svg id="erd-minimap-svg" width="168" height="108" xmlns="http://www.w3.org/2000/svg" style="margin-top:16px;height:92px;"></svg>
110
109
  </div>
111
110
  <div id="erd-ctx-menu" class="absolute hidden z-50 rounded-xl border border-base-300 bg-base-100 shadow-2xl py-1.5 w-48 text-sm">
112
111
  <div class="px-3 pb-1.5 pt-0.5 text-[10px] font-semibold uppercase tracking-wider text-base-content/40" id="erd-ctx-title"></div>
113
112
  <a id="erd-ctx-browse" href="#" class="flex items-center gap-2 px-3 py-1.5 hover:bg-base-200 transition-colors text-base-content/80">
114
113
  {{{icon "table" class="h-3.5 w-3.5 shrink-0"}}}
115
114
  Browse rows
116
115
  </a>
117
116
  <a id="erd-ctx-schema" href="#" class="flex items-center gap-2 px-3 py-1.5 hover:bg-base-200 transition-colors text-base-content/80">
118
117
  {{{icon "schema" class="h-3.5 w-3.5 shrink-0"}}}
119
118
  Edit schema
120
119
  </a>
121
120
  <a id="erd-ctx-seed" href="#" class="flex items-center gap-2 px-3 py-1.5 hover:bg-base-200 transition-colors text-emerald-600 font-medium">
122
121
  {{{icon "seed" class="h-3.5 w-3.5 shrink-0 text-emerald-500"}}}
123
122
  Seed ER Chain…
124
123
  </a>
125
124
  <div class="my-1 border-t border-base-200"></div>
126
125
  <button id="erd-ctx-focus" class="flex w-full items-center gap-2 px-3 py-1.5 hover:bg-base-200 transition-colors text-base-content/80">
127
126
  {{{icon "focus" class="h-3.5 w-3.5 shrink-0"}}}
128
127
  Focus & zoom
129
128
  </button>
130
129
  <button id="erd-ctx-collapse" class="flex w-full items-center gap-2 px-3 py-1.5 hover:bg-base-200 transition-colors text-base-content/80">
131
130
  {{{icon "chevronUp" class="h-3.5 w-3.5 shrink-0"}}}
132
131
  <span id="erd-ctx-collapse-label">Collapse table</span>
133
132
  </button>
134
133
  </div>
135
134
  <div class="absolute bottom-3 left-3 flex flex-col gap-0.5 pointer-events-none select-none">
136
135
  <span class="text-[10px] text-base-content/25 leading-relaxed">Scroll · zoom &nbsp;·&nbsp; Drag · pan &nbsp;·&nbsp; Click · select &nbsp;·&nbsp; Right-click · menu</span>
137
136
  </div>
138
137
  </div>
139
138
  <div id="erd-side-panel" class="hidden w-72 shrink-0 flex-col gap-3 overflow-y-auto" style="max-height: calc(100vh - 180px);">
140
139
  <div class="rounded-xl border border-base-300 bg-base-100 overflow-hidden">
141
140
  <div id="erd-panel-header" class="flex items-center justify-between gap-2 px-4 py-3" style="background: var(--erd-node-header); color:#fff;">
142
141
  <div class="flex items-center gap-2 min-w-0">
143
142
  {{{icon "table" class="h-4 w-4 shrink-0 opacity-80"}}}
144
143
  <span class="font-bold text-sm truncate" id="erd-panel-name"></span>
145
144
  </div>
146
145
  <button id="erd-panel-close" class="shrink-0 rounded-full p-1 hover:bg-white/20 transition-colors">
147
146
  {{{icon "x" class="h-4 w-4"}}}
148
147
  </button>
149
148
  </div>
150
149
  <div id="erd-panel-stats" class="flex divide-x divide-base-200 border-b border-base-200 text-center text-xs">
151
150
  <div class="flex-1 py-2"><div class="font-bold text-sm" id="erd-panel-col-count"></div><div class="text-base-content/40">columns</div></div>
152
151
  <div class="flex-1 py-2"><div class="font-bold text-sm text-amber-500" id="erd-panel-fk-count"></div><div class="text-base-content/40">FK refs</div></div>
153
152
  <div class="flex-1 py-2"><div class="font-bold text-sm text-emerald-500" id="erd-panel-ref-count"></div><div class="text-base-content/40">referenced</div></div>
154
153
  </div>
155
154
  <div id="erd-panel-cols" class="divide-y divide-base-100 text-xs"></div>
156
155
  <div class="flex gap-2 p-3 border-t border-base-200">
157
156
  <a id="erd-panel-browse" href="#" class="btn btn-xs btn-primary flex-1 gap-1">
158
157
  {{{icon "table" class="h-3 w-3"}}}
159
158
  Browse
160
159
  </a>
161
160
  <a id="erd-panel-schema" href="#" class="btn btn-xs btn-ghost flex-1 gap-1 border border-base-300">
162
161
  {{{icon "schema" class="h-3 w-3"}}}
163
162
  Schema
164
163
  </a>
165
164
  <a id="erd-panel-seed" href="#" class="btn btn-xs btn-outline btn-success flex-1 gap-1">
166
165
  {{{icon "seed" class="h-3 w-3"}}}
167
166
  Seed
168
167
  </a>
169
168
  </div>
170
169
  </div>
171
170
  <div id="erd-panel-rels" class="hidden rounded-xl border border-base-300 bg-base-100 overflow-hidden">
172
171
  <div class="px-4 py-2.5 border-b border-base-200 text-[11px] font-semibold uppercase tracking-wider text-base-content/40">Relationships</div>
173
172
  <div id="erd-panel-rels-list" class="divide-y divide-base-100 text-xs p-1"></div>
174
173
  </div>
175
174
  </div>
176
175
  </div>
177
176
  :root {
178
177
  --erd-node-bg: #ffffff;
179
178
  --erd-node-border: #e2e8f0;
180
179
  --erd-node-header: #6366f1;
181
180
  --erd-node-header-text: #ffffff;
182
181
  --erd-node-text: #1e293b;
183
182
  --erd-node-subtext: #94a3b8;
184
183
  --erd-node-row-odd: #f8fafc;
185
184
  --erd-node-row-even: #ffffff;
186
185
  --erd-node-row-pk: #eef2ff;
187
186
  --erd-node-row-fk: #fffbeb;
188
187
  --erd-edge-color: #818cf8;
189
188
  --erd-edge-hl: #f59e0b;
190
189
  --erd-edge-dim: #cbd5e1;
191
190
  --erd-canvas-bg: #f1f5f9;
192
191
  --erd-grid-color: rgba(148,163,184,0.18);
193
192
  --erd-grid-dot: rgba(148,163,184,0.45);
194
193
  }
195
194
  [data-theme="admindbDark"] {
196
195
  --erd-node-bg: #1e293b;
197
196
  --erd-node-border: #334155;
198
197
  --erd-node-header: #6366f1;
199
198
  --erd-node-header-text: #ffffff;
200
199
  --erd-node-text: #e2e8f0;
201
200
  --erd-node-subtext: #64748b;
202
201
  --erd-node-row-odd: #1a2744;
203
202
  --erd-node-row-even: #1e293b;
204
203
  --erd-node-row-pk: #1e1b4b;
205
204
  --erd-node-row-fk: #1c1a0e;
206
205
  --erd-edge-color: #818cf8;
207
206
  --erd-edge-hl: #fbbf24;
208
207
  --erd-edge-dim: #334155;
209
208
  --erd-canvas-bg: #0f172a;
210
209
  --erd-grid-color: rgba(51,65,85,0.5);
211
210
  --erd-grid-dot: rgba(71,85,105,0.6);
212
211
  }
213
212
  #erd-canvas-wrap {
214
213
  background-color: var(--erd-canvas-bg);
215
214
  background-image: radial-gradient(var(--erd-grid-dot) 1px, transparent 1px);
216
215
  background-size: 24px 24px;
217
216
  }
218
217
  /* Node transitions */
219
218
  .erd-node { cursor: grab; }
220
219
  .erd-node:active { cursor: grabbing; }
221
220
  .erd-node-rect {
222
221
  transition: filter 0.18s ease, stroke 0.18s ease, stroke-width 0.18s ease;
223
222
  }
224
223
  .erd-node.dimmed .erd-node-rect { opacity: 0.28; }
225
224
  .erd-node.dimmed .erd-node-header-rect { opacity: 0.28; }
226
225
  .erd-node.dimmed text { opacity: 0.28; }
227
226
  .erd-node.selected .erd-node-rect {
228
227
  stroke: var(--erd-edge-hl) !important;
229
228
  stroke-width: 2.5px !important;
230
229
  filter: url(#erd-shadow-lg);
231
230
  }
232
231
  .erd-node.neighbor .erd-node-rect {
233
232
  stroke: var(--erd-edge-color) !important;
234
233
  stroke-width: 2px !important;
235
234
  }
236
235
  /* Edge transitions */
237
236
  .erd-edge {
238
237
  transition: stroke 0.18s ease, stroke-width 0.18s ease, opacity 0.18s ease;
239
238
  cursor: pointer;
240
239
  }
241
240
  .erd-edge.dimmed { opacity: 0.08; }
242
241
  .erd-edge.highlighted {
243
242
  stroke: var(--erd-edge-hl) !important;
244
243
  stroke-width: 2.5px !important;
245
244
  opacity: 1 !important;
246
245
  }
247
246
  .erd-edge.neighbor-edge {
248
247
  stroke: var(--erd-edge-color) !important;
249
248
  stroke-width: 2px !important;
250
249
  opacity: 0.9 !important;
251
250
  }
252
251
  /* Search highlight */
253
252
  .erd-node.search-match .erd-node-rect {
254
253
  stroke: #10b981 !important;
255
254
  stroke-width: 2px !important;
256
255
  }
257
256
  .erd-node.search-no-match { opacity: 0.2; }
258
257
  /* Side panel */
259
258
  #erd-side-panel { display: none; }
260
259
  #erd-side-panel.visible { display: flex; }
261
260
  .erd-col-row {
262
261
  display: flex;
263
262
  align-items: center;
264
263
  gap: 6px;
265
264
  padding: 6px 12px;
266
265
  font-size: 11px;
267
266
  transition: background 0.1s;
268
267
  }
269
268
  .erd-col-row:hover { background: var(--erd-node-row-odd); }
270
269
  .erd-col-row-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: 'JetBrains Mono', monospace; }
271
270
  .erd-col-row-type { color: var(--erd-node-subtext); font-family: 'JetBrains Mono', monospace; font-size: 10px; }
272
271
  .erd-badge { display: inline-flex; align-items: center; padding: 1px 5px; border-radius: 4px; font-size: 9px; font-weight: 700; line-height: 1.4; }
273
272
  .erd-badge-pk { background: rgba(99,102,241,0.15); color: #6366f1; }
274
273
  .erd-badge-fk { background: rgba(245,158,11,0.15); color: #d97706; }
275
274
  .erd-badge-nn { background: rgba(239,68,68,0.10); color: #dc2626; }
276
275
  .erd-rel-row {
277
276
  display: flex;
278
277
  align-items: center;
279
278
  gap: 6px;
280
279
  padding: 7px 12px;
281
280
  font-size: 11px;
282
281
  cursor: pointer;
283
282
  transition: background 0.1s;
284
283
  }
285
284
  .erd-rel-row:hover { background: var(--erd-node-row-odd); }
286
285
  /* Layout mode buttons */
287
286
  .erd-mode-btn { color: oklch(var(--bc) / 0.55); }
288
287
  .erd-mode-btn:hover { color: oklch(var(--bc) / 0.9); background: oklch(var(--b2)); }
289
288
  .erd-mode-btn.bg-primary { color: oklch(var(--pc)); }
290
289
  'use strict';
291
290
  const BASE = window.APP?.basePath ?? '';
292
291
  // ── State ────────────────────────────────────────────────────────────────
293
292
  let schema = null;
294
293
  let positions = {};
295
294
  let collapsed = new Set(); // tables showing only header
296
295
  let scale = 1;
297
296
  let tx = 0, ty = 0;
298
297
  let draggingNode = null;
299
298
  let isPanning = false;
300
299
  let panStart = { x: 0, y: 0, tx: 0, ty: 0 };
301
300
  let selectedTable = null;
302
301
  let compactAll = false;
303
302
  let searchQuery = '';
304
303
  let ctxTable = null;
305
304
  let layoutMode = 'hierarchy'; // 'force' | 'hierarchy' | 'circle'
306
305
  // ── Constants ────────────────────────────────────────────────────────────
307
306
  const NODE_W = 240;
308
307
  const COL_H = 26;
309
308
  const HEADER_H = 40;
310
309
  const NODE_PAD = 6;
311
310
  const COMPACT_H = HEADER_H;
312
311
  // ── Palette: rotate header colors per table ───────────────────────────
313
312
  const PALETTES = [
314
313
  { h: '#6366f1', l: '#eef2ff' }, // indigo
315
314
  { h: '#8b5cf6', l: '#f5f3ff' }, // violet
316
315
  { h: '#ec4899', l: '#fdf2f8' }, // pink
317
316
  { h: '#0ea5e9', l: '#f0f9ff' }, // sky
318
317
  { h: '#10b981', l: '#ecfdf5' }, // emerald
319
318
  { h: '#f59e0b', l: '#fffbeb' }, // amber
320
319
  { h: '#ef4444', l: '#fef2f2' }, // red
321
320
  { h: '#14b8a6', l: '#f0fdfa' }, // teal
322
321
  ];
323
322
  function palOf(name) {
324
323
  let h = 0; for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
325
324
  return PALETTES[h % PALETTES.length];
326
325
  }
327
326
  // ── DOM ──────────────────────────────────────────────────────────────────
328
327
  const wrap = document.getElementById('erd-canvas-wrap');
329
328
  const svgEl_ = document.getElementById('erd-svg');
330
329
  const viewport = document.getElementById('erd-viewport');
331
330
  const edgesG = document.getElementById('erd-edges');
332
331
  const nodesG = document.getElementById('erd-nodes');
333
332
  const loadingEl = document.getElementById('erd-loading');
334
333
  const errorEl = document.getElementById('erd-error');
335
334
  const errorMsg = document.getElementById('erd-error-msg');
336
335
  const emptyEl = document.getElementById('erd-empty');
337
336
  const minimap = document.getElementById('erd-minimap');
338
337
  const minimapSvg = document.getElementById('erd-minimap-svg');
339
338
  const zoomLabel = document.getElementById('erd-zoom-label');
340
339
  const badge = document.getElementById('erd-table-badge');
341
340
  const fkBadge = document.getElementById('erd-fk-badge');
342
341
  const edgeTip = document.getElementById('erd-edge-tooltip');
343
342
  const ctxMenu = document.getElementById('erd-ctx-menu');
344
343
  const sidePanel = document.getElementById('erd-side-panel');
345
344
  const searchInput = document.getElementById('erd-search');
346
345
  // ── Helper ───────────────────────────────────────────────────────────────
347
346
  function svgE(tag, attrs = {}) {
348
347
  const el = document.createElementNS('http://www.w3.org/2000/svg', tag);
349
348
  Object.entries(attrs).forEach(([k, v]) => el.setAttribute(k, String(v)));
350
349
  return el;
351
350
  }
352
351
  function nodeHeight(t) {
353
352
  if (collapsed.has(t.name) || compactAll) return COMPACT_H;
354
353
  return HEADER_H + NODE_PAD * 2 + t.columns.length * COL_H;
355
354
  }
356
355
  // ── Fetch ────────────────────────────────────────────────────────────────
357
356
  async function loadSchema() {
358
357
  try {
359
358
  const res = await fetch(BASE + '/api/erd');
360
359
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
361
360
  const json = await res.json();
362
361
  if (!json.success) throw new Error(json.error ?? 'API error');
363
362
  schema = json.data;
364
363
  if (!schema.tables || schema.tables.length === 0) {
365
364
  loadingEl.style.display = 'none'; emptyEl.style.display = 'flex'; return;
366
365
  }
367
366
  initLayout();
368
367
  renderAll();
369
368
  fitView();
370
369
  loadingEl.style.display = 'none';
371
370
  svgEl_.style.display = '';
372
371
  if (schema.tables.length > 3) minimap.classList.remove('hidden');
373
372
  // badges
374
373
  badge.textContent = schema.tables.length + ' table' + (schema.tables.length !== 1 ? 's' : '');
375
374
  badge.classList.remove('hidden');
376
375
  const totalFks = schema.tables.reduce((s, t) => s + t.foreignKeys.length, 0);
377
376
  if (totalFks > 0) {
378
377
  fkBadge.textContent = totalFks + ' relationship' + (totalFks !== 1 ? 's' : '');
379
378
  fkBadge.classList.remove('hidden');
380
379
  }
381
380
  } catch (e) {
382
381
  loadingEl.style.display = 'none';
383
382
  errorEl.style.display = 'flex';
384
383
  errorMsg.textContent = e.message;
385
384
  }
386
385
  }
387
386
  // ── Layout dispatcher ─────────────────────────────────────────────────────
388
387
  function initLayout() {
389
388
  if (!schema || schema.tables.length === 0) return;
390
389
  if (layoutMode === 'force') layoutForce();
391
390
  else if (layoutMode === 'circle') layoutCircle();
392
391
  else layoutHierarchy();
393
392
  updateModeButtons();
394
393
  }
395
394
  function updateModeButtons() {
396
395
  document.querySelectorAll('.erd-mode-btn').forEach(btn => {
397
396
  const active = btn.dataset.mode === layoutMode;
398
397
  btn.classList.toggle('bg-primary', active);
399
398
  btn.classList.toggle('text-primary-content', active);
400
399
  btn.classList.toggle('shadow-sm', active);
401
400
  btn.classList.toggle('text-base-content', !active);
402
401
  btn.classList.toggle('text-opacity-60', !active);
403
402
  });
404
403
  }
405
404
  // ── 1. Force-directed (physics / organic) ─────────────────────────────────
406
405
  function layoutForce() {
407
406
  const tables = schema.tables;
408
407
  const PAD = 60;
409
408
  // seed in a grid so force has a stable start
410
409
  const cols = Math.max(1, Math.ceil(Math.sqrt(tables.length)));
411
410
  tables.forEach((t, i) => {
412
411
  positions[t.name] = {
413
412
  x: PAD + (i % cols) * (NODE_W + 180),
414
413
  y: PAD + Math.floor(i / cols) * 280,
415
414
  };
416
415
  });
417
416
  const tableNames = new Set(tables.map(t => t.name));
418
417
  const REPEL = 28000;
419
418
  const SPRING = 0.012;
420
419
  const REST = NODE_W + 220;
421
420
  const DAMP = 0.82;
422
421
  const vx = {}, vy = {};
423
422
  tables.forEach(t => { vx[t.name] = 0; vy[t.name] = 0; });
424
423
  for (let iter = 0; iter < 280; iter++) {
425
424
  const fx = {}, fy = {};
426
425
  tables.forEach(t => { fx[t.name] = 0; fy[t.name] = 0; });
427
426
  // Repulsion between all pairs
428
427
  for (let i = 0; i < tables.length; i++) {
429
428
  for (let j = i + 1; j < tables.length; j++) {
430
429
  const a = positions[tables[i].name], b = positions[tables[j].name];
431
430
  let dx = b.x - a.x, dy = b.y - a.y;
432
431
  const dist = Math.max(1, Math.sqrt(dx*dx + dy*dy));
433
432
  const f = REPEL / (dist * dist);
434
433
  dx /= dist; dy /= dist;
435
434
  fx[tables[i].name] -= dx * f; fy[tables[i].name] -= dy * f;
436
435
  fx[tables[j].name] += dx * f; fy[tables[j].name] += dy * f;
437
436
  }
438
437
  }
439
438
  // Spring attraction along FK edges
440
439
  tables.forEach(t => {
441
440
  t.foreignKeys.forEach(fk => {
442
441
  if (!tableNames.has(fk.table) || fk.table === t.name) return;
443
442
  const a = positions[t.name], b = positions[fk.table];
444
443
  let dx = b.x - a.x, dy = b.y - a.y;
445
444
  const dist = Math.max(1, Math.sqrt(dx*dx + dy*dy));
446
445
  const f = SPRING * (dist - REST);
447
446
  dx /= dist; dy /= dist;
448
447
  fx[t.name] += dx * f; fy[t.name] += dy * f;
449
448
  fx[fk.table] -= dx * f; fy[fk.table] -= dy * f;
450
449
  });
451
450
  });
452
451
  // Integrate with damping
453
452
  tables.forEach(t => {
454
453
  vx[t.name] = (vx[t.name] + fx[t.name]) * DAMP;
455
454
  vy[t.name] = (vy[t.name] + fy[t.name]) * DAMP;
456
455
  positions[t.name].x += vx[t.name];
457
456
  positions[t.name].y += vy[t.name];
458
457
  });
459
458
  }
460
459
  // Normalize to (PAD, PAD)
461
460
  const minX = Math.min(...tables.map(t => positions[t.name].x));
462
461
  const minY = Math.min(...tables.map(t => positions[t.name].y));
463
462
  tables.forEach(t => { positions[t.name].x -= minX - PAD; positions[t.name].y -= minY - PAD; });
464
463
  }
465
464
  // ── 2. Hierarchical / layered (reference tables left) ────────────────────
466
465
  function layoutHierarchy() {
467
466
  const tables = schema.tables;
468
467
  const tableNames = new Set(tables.map(t => t.name));
469
468
  const deps = {};
470
469
  tables.forEach(t => {
471
470
  deps[t.name] = [...new Set(
472
471
  t.foreignKeys.map(fk => fk.table).filter(n => n !== t.name && tableNames.has(n))
473
472
  )];
474
473
  });
475
474
  const layerOf = {};
476
475
  function assignLayer(name, stack) {
477
476
  if (name in layerOf) return layerOf[name];
478
477
  if (!stack) stack = new Set();
479
478
  if (stack.has(name)) { layerOf[name] = 0; return 0; }
480
479
  stack.add(name);
481
480
  const d = deps[name] || [];
482
481
  layerOf[name] = d.length === 0 ? 0 : Math.max(...d.map(x => assignLayer(x, new Set(stack)))) + 1;
483
482
  return layerOf[name];
484
483
  }
485
484
  tables.forEach(t => assignLayer(t.name));
486
485
  const byLayer = {};
487
486
  tables.forEach(t => { const l = layerOf[t.name] ?? 0; (byLayer[l] = byLayer[l] || []).push(t); });
488
487
  const isReferenced = new Set();
489
488
  tables.forEach(t => t.foreignKeys.forEach(fk => { if (fk.table !== t.name) isReferenced.add(fk.table); }));
490
489
  const isolated = [];
491
490
  byLayer[0] = (byLayer[0] || []).filter(t => {
492
491
  if (!isReferenced.has(t.name) && deps[t.name].length === 0) { isolated.push(t); return false; }
493
492
  return true;
494
493
  });
495
494
  if (!byLayer[0].length) delete byLayer[0];
496
495
  const layerNums = Object.keys(byLayer).map(Number).sort((a, b) => a - b);
497
496
  layerNums.forEach(ln => {
498
497
  const grp = byLayer[ln]; if (!grp || ln === 0) return;
499
498
  const prev = byLayer[ln - 1] || [], pi = {};
500
499
  prev.forEach((t, i) => pi[t.name] = i);
501
500
  grp.sort((a, b) => {
502
501
  const s = t => { const d = (deps[t.name]||[]).filter(x=>x in pi); return d.length?d.reduce((s,x)=>s+pi[x],0)/d.length:999; };
503
502
  return s(a) - s(b);
504
503
  });
505
504
  });
506
505
  const COL_WIDTH = NODE_W + 200;
507
506
  const ROW_PADDING = 44;
508
507
  const colHeights = layerNums.map(ln => (byLayer[ln]||[]).reduce((s,t) => s + nodeHeight(t) + ROW_PADDING, 0));
509
508
  const maxColH = Math.max(...colHeights, 300);
510
509
  layerNums.forEach((ln, ci) => {
511
510
  const grp = byLayer[ln] || [];
512
511
  let y = Math.max(60, (maxColH - colHeights[ci]) / 2 + 60);
513
512
  grp.forEach(t => { positions[t.name] = { x: 60 + ci * COL_WIDTH, y }; y += nodeHeight(t) + ROW_PADDING; });
514
513
  });
515
514
  if (isolated.length > 0) {
516
515
  const isoX = 60 + layerNums.length * COL_WIDTH;
517
516
  let isoY = 60;
518
517
  isolated.forEach(t => { positions[t.name] = { x: isoX, y: isoY }; isoY += nodeHeight(t) + ROW_PADDING; });
519
518
  }
520
519
  }
521
520
  // ── 3. Circular layout ────────────────────────────────────────────────────
522
521
  function layoutCircle() {
523
522
  const tables = schema.tables;
524
523
  if (tables.length === 0) return;
525
524
  // Separate isolated tables (no FKs and never referenced by anyone)
526
525
  const isReferenced = new Set();
527
526
  tables.forEach(t => t.foreignKeys.forEach(fk => { if (fk.table !== t.name) isReferenced.add(fk.table); }));
528
527
  const isolated = tables.filter(t => !isReferenced.has(t.name) && t.foreignKeys.filter(fk => fk.table !== t.name).length === 0);
529
528
  const connected = tables.filter(t => !isolated.includes(t));
530
529
  // Sort connected: highly-connected first (12 o'clock position)
531
530
  const degree = {};
532
531
  tables.forEach(t => {
533
532
  degree[t.name] = t.foreignKeys.length;
534
533
  tables.forEach(o => { if (o.foreignKeys.some(fk => fk.table === t.name)) degree[t.name]++; });
535
534
  });
536
535
  const sorted = [...connected].sort((a, b) => (degree[b.name] || 0) - (degree[a.name] || 0));
537
536
  const n = sorted.length;
538
537
  if (n === 0) {
539
538
  // All tables are isolated — just grid them
540
539
  const cols = Math.max(1, Math.ceil(Math.sqrt(isolated.length)));
541
540
  isolated.forEach((t, i) => {
542
541
  positions[t.name] = { x: 60 + (i % cols) * (NODE_W + 40), y: 60 + Math.floor(i / cols) * 280 };
543
542
  });
544
543
  return;
545
544
  }
546
545
  if (n === 1) {
547
546
  positions[sorted[0].name] = { x: 60, y: 60 };
548
547
  } else {
549
548
  // Compute radius so circle nodes don't overlap
550
549
  const avgH = sorted.reduce((s, t) => s + nodeHeight(t), 0) / n;
551
550
  const minGap = 36;
552
551
  const arcPerNode = Math.max(NODE_W + minGap, avgH + minGap);
553
552
  const radius = Math.max(240, (n * arcPerNode) / (2 * Math.PI));
554
553
  const cx = radius + NODE_W / 2 + 60;
555
554
  const cy = radius + avgH / 2 + 60;
556
555
  sorted.forEach((t, i) => {
557
556
  const angle = (2 * Math.PI * i / n) - Math.PI / 2; // start at 12 o'clock
558
557
  positions[t.name] = {
559
558
  x: cx + radius * Math.cos(angle) - NODE_W / 2,
560
559
  y: cy + radius * Math.sin(angle) - nodeHeight(t) / 2,
561
560
  };
562
561
  });
563
562
  }
564
563
  // Place isolated tables in a compact column to the right of the circle
565
564
  if (isolated.length > 0) {
566
565
  const circleRight = Math.max(...sorted.map(t => positions[t.name].x + NODE_W)) + 60;
567
566
  const ROW_PAD = 32;
568
567
  let isoY = 60;
569
568
  isolated.forEach(t => {
570
569
  positions[t.name] = { x: circleRight, y: isoY };
571
570
  isoY += nodeHeight(t) + ROW_PAD;
572
571
  });
573
572
  }
574
573
  }
575
574
  // ── Render all ────────────────────────────────────────────────────────────
576
575
  function renderAll() {
577
576
  edgesG.innerHTML = '';
578
577
  nodesG.innerHTML = '';
579
578
  schema.tables.forEach(t => renderNode(t));
580
579
  schema.tables.forEach(t => renderEdges(t));
581
580
  applySearch();
582
581
  renderMinimap();
583
582
  }
584
583
  // ── Render node ──────────────────────────────────────────────────────────
585
584
  function renderNode(t) {
586
585
  const pos = positions[t.name];
587
586
  const h = nodeHeight(t);
588
587
  const pal = palOf(t.name);
589
588
  const isCollapsed = collapsed.has(t.name) || compactAll;
590
589
  const fkCols = new Set(t.foreignKeys.map(f => f.from));
591
590
  const pkCols = new Set((t.primaryKey && t.primaryKey.length ? t.primaryKey : t.columns.filter(c => c.pk > 0).map(c => c.name)));
592
591
  const g = svgE('g', { class: 'erd-node', 'data-table': t.name, transform: `translate(${pos.x},${pos.y})` });
593
592
  // Drop shadow background
594
593
  g.appendChild(svgE('rect', { class: 'erd-node-shadow', x: 0, y: 0, width: NODE_W, height: h, rx: 12, fill: 'transparent', filter: 'url(#erd-shadow)' }));
595
594
  // Main background
596
595
  g.appendChild(svgE('rect', { class: 'erd-node-rect', x: 0, y: 0, width: NODE_W, height: h, rx: 12, fill: 'var(--erd-node-bg)', stroke: 'var(--erd-node-border)', 'stroke-width': '1.5' }));
597
596
  // Header gradient strip
598
597
  const hdrClip = svgE('clipPath', { id: `erd-hclip-${t.name.replace(/\W/g,'_')}` });
599
598
  hdrClip.appendChild(svgE('rect', { x: 0, y: 0, width: NODE_W, height: HEADER_H, rx: 12 }));
600
599
  g.appendChild(hdrClip);
601
600
  const hdrRect = svgE('rect', { class: 'erd-node-header-rect', x: 0, y: 0, width: NODE_W, height: HEADER_H, fill: pal.h, 'clip-path': `url(#erd-hclip-${t.name.replace(/\W/g,'_')})` });
602
601
  g.appendChild(hdrRect);
603
602
  // bottom fill to square off the lower edge of header
604
603
  g.appendChild(svgE('rect', { x: 0, y: HEADER_H - 12, width: NODE_W, height: 12, fill: pal.h }));
605
604
  // Collapse arrow
606
605
  const arrX = NODE_W - 20, arrY = HEADER_H / 2;
607
606
  const arrPath = isCollapsed ? 'M-4,-2 L0,3 L4,-2' : 'M-4,2 L0,-3 L4,2';
608
607
  const arr = svgE('path', { d: arrPath, fill: 'none', stroke: 'rgba(255,255,255,0.7)', 'stroke-width': '2', 'stroke-linecap': 'round', transform: `translate(${arrX},${arrY})`, style: 'cursor:pointer;', class: 'erd-collapse-btn' });
609
608
  arr.addEventListener('click', e => { e.stopPropagation(); toggleCollapse(t.name); });
610
609
  g.appendChild(arr);
611
610
  // Table name
612
611
  const nameEl = svgE('text', { x: 14, y: 26, fill: '#fff', 'font-size': '13', 'font-weight': '700', 'font-family': 'Inter, ui-sans-serif, sans-serif', class: 'erd-node-title' });
613
612
  const maxLen = 20;
614
613
  nameEl.textContent = t.name.length > maxLen ? t.name.slice(0, maxLen - 1) + '…' : t.name;
615
614
  g.appendChild(nameEl);
616
615
  // Row/col count pill in header
617
616
  const pillW = 36, pillX = NODE_W - pillW - 8;
618
617
  g.appendChild(svgE('rect', { x: pillX, y: 10, width: pillW, height: 18, rx: 9, fill: 'rgba(0,0,0,0.18)' }));
619
618
  const pillTxt = svgE('text', { x: pillX + pillW / 2, y: 23, 'text-anchor': 'middle', fill: 'rgba(255,255,255,0.85)', 'font-size': '9.5', 'font-weight': '600', 'font-family': 'Inter, sans-serif' });
620
619
  pillTxt.textContent = t.columns.length + (t.columns.length === 1 ? ' col' : ' cols');
621
620
  g.appendChild(pillTxt);
622
621
  // Columns (when not collapsed)
623
622
  if (!isCollapsed) {
624
623
  t.columns.forEach((col, idx) => {
625
624
  const cy = HEADER_H + NODE_PAD + idx * COL_H;
626
625
  const isPk = pkCols.has(col.name);
627
626
  const isFk = fkCols.has(col.name);
628
627
  const isLast = idx === t.columns.length - 1;
629
628
  // Row background
630
629
  let rowFill = idx % 2 === 0 ? 'var(--erd-node-row-even)' : 'var(--erd-node-row-odd)';
631
630
  if (isPk) rowFill = 'var(--erd-node-row-pk)';
632
631
  if (isFk && !isPk) rowFill = 'var(--erd-node-row-fk)';
633
632
  const rowBg = svgE('rect', { x: 1, y: cy, width: NODE_W - 2, height: COL_H, fill: rowFill });
634
633
  if (isLast) { rowBg.setAttribute('rx', '0'); } // no extra clip needed
635
634
  g.appendChild(rowBg);
636
635
  // Left accent bar for PK/FK
637
636
  if (isPk || isFk) {
638
637
  g.appendChild(svgE('rect', { x: 1, y: cy, width: 3, height: COL_H, fill: isPk ? pal.h : '#f59e0b' }));
639
638
  }
640
639
  // Column name
641
640
  const nameTxt = svgE('text', { x: isPk || isFk ? 14 : 10, y: cy + 17, fill: isPk ? pal.h : 'var(--erd-node-text)', 'font-size': '11.5', 'font-weight': isPk ? '600' : '400', 'font-family': '"JetBrains Mono", "Fira Code", monospace' });
642
641
  const trim = 15;
643
642
  nameTxt.textContent = col.name.length > trim ? col.name.slice(0, trim - 1) + '…' : col.name;
644
643
  g.appendChild(nameTxt);
645
644
  // Type
646
645
  const typeStr = (col.type || 'any').toUpperCase().replace('CHARACTER VARYING', 'VARCHAR');
647
646
  const typeTrimmed = typeStr.length > 10 ? typeStr.slice(0, 9) + '…' : typeStr;
648
647
  const typeX = NODE_W - (isPk || isFk ? 46 : 10);
649
648
  const typeTxt = svgE('text', { x: typeX, y: cy + 17, 'text-anchor': 'end', fill: 'var(--erd-node-subtext)', 'font-size': '9.5', 'font-family': '"JetBrains Mono", monospace' });
650
649
  typeTxt.textContent = typeTrimmed;
651
650
  g.appendChild(typeTxt);
652
651
  // Badges
653
652
  let badgeX = NODE_W - 8;
654
653
  if (isFk) {
655
654
  badgeX -= 22;
656
655
  g.appendChild(svgE('rect', { x: badgeX, y: cy + 6, width: 20, height: 13, rx: 3, fill: 'rgba(245,158,11,0.18)' }));
657
656
  const fkTxt = svgE('text', { x: badgeX + 10, y: cy + 15.5, 'text-anchor': 'middle', fill: '#d97706', 'font-size': '7.5', 'font-weight': '700', 'font-family': 'Inter, sans-serif' });
658
657
  fkTxt.textContent = 'FK';
659
658
  g.appendChild(fkTxt);
660
659
  }
661
660
  if (isPk) {
662
661
  badgeX -= 22;
663
662
  g.appendChild(svgE('rect', { x: badgeX, y: cy + 6, width: 20, height: 13, rx: 3, fill: 'rgba(99,102,241,0.15)' }));
664
663
  const pkTxt = svgE('text', { x: badgeX + 10, y: cy + 15.5, 'text-anchor': 'middle', fill: pal.h, 'font-size': '7.5', 'font-weight': '700', 'font-family': 'Inter, sans-serif' });
665
664
  pkTxt.textContent = 'PK';
666
665
  g.appendChild(pkTxt);
667
666
  }
668
667
  // Invisible wide hit target for row hover (used for FK jump)
669
668
  const rowHit = svgE('rect', { x: 0, y: cy, width: NODE_W, height: COL_H, fill: 'transparent' });
670
669
  g.appendChild(rowHit);
671
670
  });
672
671
  // Bottom rounded cap to complete the card
673
672
  const capClip = svgE('clipPath', { id: `erd-bclip-${t.name.replace(/\W/g,'_')}` });
674
673
  capClip.appendChild(svgE('rect', { x: 0, y: h - 12, width: NODE_W, height: 12, rx: 0 }));
675
674
  g.appendChild(capClip);
676
675
  }
677
676
  // Invisible interaction rect (full node)
678
677
  const hitRect = svgE('rect', { x: 0, y: 0, width: NODE_W, height: h, rx: 12, fill: 'transparent', class: 'erd-hit', style: 'cursor:grab;' });
679
678
  g.appendChild(hitRect);
680
679
  // Click → select
681
680
  hitRect.addEventListener('click', e => { e.stopPropagation(); selectTable(t.name); });
682
681
  // Right-click → context menu
683
682
  g.addEventListener('contextmenu', e => {
684
683
  e.preventDefault(); e.stopPropagation();
685
684
  openCtxMenu(t.name, e.clientX, e.clientY);
686
685
  });
687
686
  // Drag
688
687
  hitRect.addEventListener('mousedown', e => {
689
688
  if (e.button !== 0) return;
690
689
  e.stopPropagation();
691
690
  draggingNode = { name: t.name, origX: positions[t.name].x, origY: positions[t.name].y, startX: e.clientX, startY: e.clientY, moved: false };
692
691
  wrap.style.cursor = 'grabbing';
693
692
  });
694
693
  nodesG.appendChild(g);
695
694
  }
696
695
  // ── Edges ─────────────────────────────────────────────────────────────────
697
696
  function getPortY(tableName, colName) {
698
697
  if (collapsed.has(tableName) || compactAll) return COMPACT_H / 2;
699
698
  const t = schema.tables.find(t => t.name === tableName);
700
699
  if (!t) return HEADER_H / 2;
701
700
  const idx = t.columns.findIndex(c => c.name === colName);
702
701
  if (idx === -1) return HEADER_H + NODE_PAD + COL_H / 2;
703
702
  return HEADER_H + NODE_PAD + idx * COL_H + COL_H / 2;
704
703
  }
705
704
  function edgePath(srcPos, srcColY, tgtPos, tgtColY) {
706
705
  let sx = srcPos.x + NODE_W, sy = srcPos.y + srcColY;
707
706
  let ex = tgtPos.x, ey = tgtPos.y + tgtColY;
708
707
  if (tgtPos.x < srcPos.x) { sx = srcPos.x; ex = tgtPos.x + NODE_W; }
709
708
  const dx = Math.abs(ex - sx);
710
709
  const cpDx = Math.max(60, dx * 0.45);
711
710
  return `M${sx},${sy} C${sx + (tgtPos.x < srcPos.x ? -cpDx : cpDx)},${sy} ${ex - (tgtPos.x < srcPos.x ? -cpDx : cpDx)},${ey} ${ex},${ey}`;
712
711
  }
713
712
  function renderEdges(t) {
714
713
  t.foreignKeys.forEach(fk => {
715
714
  const srcPos = positions[t.name], tgtPos = positions[fk.table];
716
715
  if (!srcPos || !tgtPos) return;
717
716
  const d = edgePath(srcPos, getPortY(t.name, fk.from), tgtPos, getPortY(fk.table, fk.to || ''));
718
717
  const path = svgE('path', {
719
718
  class: 'erd-edge',
720
719
  d,
721
720
  fill: 'none',
722
721
  stroke: 'var(--erd-edge-color)',
723
722
  'stroke-width': '1.8',
724
723
  'stroke-dasharray': 'none',
725
724
  'marker-end': 'url(#erd-arrow)',
726
725
  'data-from': t.name,
727
726
  'data-to': fk.table,
728
727
  'data-from-col': fk.from,
729
728
  'data-to-col': fk.to || '',
730
729
  opacity: '0.65',
731
730
  });
732
731
  // Edge hover: show tooltip + highlight
733
732
  path.addEventListener('mouseenter', e => {
734
733
  const rect = wrap.getBoundingClientRect();
735
734
  edgeTip.style.left = (e.clientX - rect.left + 12) + 'px';
736
735
  edgeTip.style.top = (e.clientY - rect.top - 8) + 'px';
737
736
  edgeTip.innerHTML = `<span style="color:var(--erd-edge-color)">${fk.from}</span> <span style="opacity:.5">→</span> <span style="color:#10b981">${fk.table}</span><span style="opacity:.5">.${fk.to || '?'}</span>`;
738
737
  edgeTip.classList.remove('hidden');
739
738
  path.setAttribute('stroke-width', '2.8');
740
739
  path.style.opacity = '1';
741
740
  });
742
741
  path.addEventListener('mousemove', e => {
743
742
  const rect = wrap.getBoundingClientRect();
744
743
  edgeTip.style.left = (e.clientX - rect.left + 12) + 'px';
745
744
  edgeTip.style.top = (e.clientY - rect.top - 8) + 'px';
746
745
  });
747
746
  path.addEventListener('mouseleave', () => {
748
747
  edgeTip.classList.add('hidden');
749
748
  if (!path.classList.contains('highlighted') && !path.classList.contains('neighbor-edge')) {
750
749
  path.setAttribute('stroke-width', '1.8');
751
750
  path.style.opacity = '0.65';
752
751
  }
753
752
  });
754
753
  edgesG.appendChild(path);
755
754
  });
756
755
  }
757
756
  function updateEdges() {
758
757
  edgesG.querySelectorAll('.erd-edge').forEach(path => {
759
758
  const fn = path.getAttribute('data-from'), tn = path.getAttribute('data-to');
760
759
  const fc = path.getAttribute('data-from-col'), tc = path.getAttribute('data-to-col');
761
760
  const sp = positions[fn], tp = positions[tn];
762
761
  if (!sp || !tp) return;
763
762
  path.setAttribute('d', edgePath(sp, getPortY(fn, fc), tp, getPortY(tn, tc)));
764
763
  });
765
764
  }
766
765
  // ── Selection & highlighting ──────────────────────────────────────────────
767
766
  function clearHighlights() {
768
767
  nodesG.querySelectorAll('.erd-node').forEach(el => {
769
768
  el.classList.remove('selected', 'neighbor', 'dimmed');
770
769
  });
771
770
  edgesG.querySelectorAll('.erd-edge').forEach(el => {
772
771
  el.classList.remove('highlighted', 'neighbor-edge', 'dimmed');
773
772
  el.setAttribute('stroke-width', '1.8');
774
773
  el.setAttribute('marker-end', 'url(#erd-arrow)');
775
774
  el.style.opacity = '0.65';
776
775
  });
777
776
  }
778
777
  function selectTable(name) {
779
778
  if (selectedTable === name) { deselectTable(); return; }
780
779
  selectedTable = name;
781
780
  clearHighlights();
782
781
  // Collect neighbors
783
782
  const neighbors = new Set();
784
783
  schema.tables.forEach(t => {
785
784
  t.foreignKeys.forEach(fk => {
786
785
  if (t.name === name) neighbors.add(fk.table);
787
786
  if (fk.table === name) neighbors.add(t.name);
788
787
  });
789
788
  });
790
789
  // Apply node classes
791
790
  nodesG.querySelectorAll('.erd-node').forEach(el => {
792
791
  const n = el.getAttribute('data-table');
793
792
  if (n === name) el.classList.add('selected');
794
793
  else if (neighbors.has(n)) el.classList.add('neighbor');
795
794
  else el.classList.add('dimmed');
796
795
  });
797
796
  // Apply edge classes
798
797
  edgesG.querySelectorAll('.erd-edge').forEach(el => {
799
798
  const f = el.getAttribute('data-from'), t = el.getAttribute('data-to');
800
799
  if (f === name || t === name) {
801
800
  el.classList.add('neighbor-edge');
802
801
  el.setAttribute('stroke-width', '2.2');
803
802
  el.style.opacity = '1';
804
803
  el.setAttribute('marker-end', 'url(#erd-arrow)');
805
804
  } else {
806
805
  el.classList.add('dimmed');
807
806
  }
808
807
  });
809
808
  showSidePanel(name, neighbors);
810
809
  }
811
810
  function deselectTable() {
812
811
  selectedTable = null;
813
812
  clearHighlights();
814
813
  hideSidePanel();
815
814
  }
816
815
  // ── Side panel ────────────────────────────────────────────────────────────
817
816
  function showSidePanel(name, neighbors) {
818
817
  const t = schema.tables.find(t => t.name === name);
819
818
  if (!t) return;
820
819
  const pal = palOf(name);
821
820
  sidePanel.classList.add('visible');
822
821
  document.getElementById('erd-panel-header').style.background = pal.h;
823
822
  document.getElementById('erd-panel-name').textContent = name;
824
823
  document.getElementById('erd-panel-col-count').textContent = t.columns.length;
825
824
  document.getElementById('erd-panel-fk-count').textContent = t.foreignKeys.length;
826
825
  // how many other tables reference this one?
827
826
  const refCount = schema.tables.filter(x => x.foreignKeys.some(fk => fk.table === name)).length;
828
827
  document.getElementById('erd-panel-ref-count').textContent = refCount;
829
828
  const panelBrowse = document.getElementById('erd-panel-browse');
830
829
  const panelSchema = document.getElementById('erd-panel-schema');
831
830
  const panelSeed = document.getElementById('erd-panel-seed');
832
831
  panelBrowse.href = `${BASE}/tables/${encodeURIComponent(name)}`;
833
832
  panelSchema.href = `${BASE}/tables/${encodeURIComponent(name)}/schema`;
834
833
  if (panelSeed) panelSeed.href = `${BASE}/seed/${encodeURIComponent(name)}?mode=chain`;
835
834
  // Columns list
836
835
  const fkCols = new Set(t.foreignKeys.map(f => f.from));
837
836
  const pkCols = new Set(t.primaryKey && t.primaryKey.length ? t.primaryKey : t.columns.filter(c => c.pk > 0).map(c => c.name));
838
837
  const colsEl = document.getElementById('erd-panel-cols');
839
838
  colsEl.innerHTML = '';
840
839
  t.columns.forEach(col => {
841
840
  const isPk = pkCols.has(col.name), isFk = fkCols.has(col.name);
842
841
  const row = document.createElement('div');
843
842
  row.className = 'erd-col-row';
844
843
  row.style.background = isPk ? 'rgba(99,102,241,0.06)' : isFk ? 'rgba(245,158,11,0.05)' : '';
845
844
  const lAcc = document.createElement('div');
846
845
  lAcc.style.cssText = `width:3px;height:16px;border-radius:2px;background:${isPk ? pal.h : isFk ? '#f59e0b' : 'transparent'};flex-shrink:0;`;
847
846
  row.appendChild(lAcc);
848
847
  const nameEl = document.createElement('span');
849
848
  nameEl.className = 'erd-col-row-name';
850
849
  nameEl.textContent = col.name;
851
850
  nameEl.style.color = isPk ? pal.h : '';
852
851
  row.appendChild(nameEl);
853
852
  const typeEl = document.createElement('span');
854
853
  typeEl.className = 'erd-col-row-type';
855
854
  typeEl.textContent = (col.type || 'any').toUpperCase().replace('CHARACTER VARYING', 'VARCHAR');
856
855
  row.appendChild(typeEl);
857
856
  if (isPk) { const b = document.createElement('span'); b.className = 'erd-badge erd-badge-pk'; b.textContent = 'PK'; row.appendChild(b); }
858
857
  if (isFk) { const b = document.createElement('span'); b.className = 'erd-badge erd-badge-fk'; b.textContent = 'FK'; row.appendChild(b); }
859
858
  if (col.notnull && !isPk) { const b = document.createElement('span'); b.className = 'erd-badge erd-badge-nn'; b.textContent = 'NN'; row.appendChild(b); }
860
859
  colsEl.appendChild(row);
861
860
  });
862
861
  // Relationships panel
863
862
  const relsPanel = document.getElementById('erd-panel-rels');
864
863
  const relsList = document.getElementById('erd-panel-rels-list');
865
864
  relsList.innerHTML = '';
866
865
  const allRels = [];
867
866
  // outgoing FKs
868
867
  t.foreignKeys.forEach(fk => allRels.push({ dir: 'out', label: `${name}.${fk.from} → ${fk.table}.${fk.to || '?'}`, target: fk.table }));
869
868
  // incoming
870
869
  schema.tables.forEach(src => {
871
870
  if (src.name === name) return;
872
871
  src.foreignKeys.filter(fk => fk.table === name).forEach(fk => {
873
872
  allRels.push({ dir: 'in', label: `${src.name}.${fk.from} → ${name}.${fk.to || '?'}`, target: src.name });
874
873
  });
875
874
  });
876
875
  if (allRels.length > 0) {
877
876
  relsPanel.classList.remove('hidden');
878
877
  allRels.forEach(rel => {
879
878
  const row = document.createElement('div');
880
879
  row.className = 'erd-rel-row';
881
880
  row.innerHTML = `
882
881
  <span style="font-size:10px;opacity:.5">${rel.dir === 'out' ? '→' : '←'}</span>
883
882
  <span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:monospace;font-size:10.5px;">${rel.label}</span>
884
883
  <span style="font-size:10px;color:var(--erd-edge-color);font-weight:600;">${rel.target}</span>
885
884
  `;
886
885
  row.addEventListener('click', () => selectTable(rel.target));
887
886
  relsList.appendChild(row);
888
887
  });
889
888
  } else {
890
889
  relsPanel.classList.add('hidden');
891
890
  }
892
891
  }
893
892
  function hideSidePanel() {
894
893
  sidePanel.classList.remove('visible');
895
894
  }
896
895
  document.getElementById('erd-panel-close').addEventListener('click', deselectTable);
897
896
  // ── Context menu ─────────────────────────────────────────────────────────
898
897
  function openCtxMenu(name, cx, cy) {
899
898
  ctxTable = name;
900
899
  document.getElementById('erd-ctx-title').textContent = name;
901
900
  document.getElementById('erd-ctx-browse').href = `${BASE}/tables/${encodeURIComponent(name)}`;
902
901
  document.getElementById('erd-ctx-schema').href = `${BASE}/tables/${encodeURIComponent(name)}/schema`;
903
902
  const ctxSeed = document.getElementById('erd-ctx-seed');
904
903
  if (ctxSeed) ctxSeed.href = `${BASE}/seed/${encodeURIComponent(name)}?mode=chain`;
905
904
  document.getElementById('erd-ctx-collapse-label').textContent = collapsed.has(name) ? 'Expand table' : 'Collapse table';
906
905
  const rect = wrap.getBoundingClientRect();
907
906
  let x = cx - rect.left, y = cy - rect.top;
908
907
  ctxMenu.style.left = Math.min(x, wrap.clientWidth - 200) + 'px';
909
908
  ctxMenu.style.top = Math.min(y, wrap.clientHeight - 160) + 'px';
910
909
  ctxMenu.classList.remove('hidden');
911
910
  selectTable(name);
912
911
  }
913
912
  document.getElementById('erd-ctx-focus').addEventListener('click', () => {
914
913
  ctxMenu.classList.add('hidden');
915
914
  if (!ctxTable) return;
916
915
  const p = positions[ctxTable], t = schema.tables.find(x => x.name === ctxTable);
917
916
  if (!p || !t) return;
918
917
  const nw = NODE_W, nh = nodeHeight(t);
919
918
  const cx = wrap.clientWidth / 2, cy = wrap.clientHeight / 2;
920
919
  scale = Math.min(1.8, Math.min(wrap.clientWidth / (nw + 80), wrap.clientHeight / (nh + 80)));
921
920
  tx = cx - (p.x + nw / 2) * scale;
922
921
  ty = cy - (p.y + nh / 2) * scale;
923
922
  applyTransform();
924
923
  });
925
924
  document.getElementById('erd-ctx-collapse').addEventListener('click', () => {
926
925
  ctxMenu.classList.add('hidden');
927
926
  if (ctxTable) toggleCollapse(ctxTable);
928
927
  });
929
928
  document.addEventListener('click', () => ctxMenu.classList.add('hidden'));
930
929
  document.addEventListener('keydown', e => { if (e.key === 'Escape') ctxMenu.classList.add('hidden'); });
931
930
  // ── Collapse toggle ───────────────────────────────────────────────────────
932
931
  function toggleCollapse(name) {
933
932
  if (collapsed.has(name)) collapsed.delete(name); else collapsed.add(name);
934
933
  edgesG.innerHTML = '';
935
934
  nodesG.innerHTML = '';
936
935
  schema.tables.forEach(t => renderNode(t));
937
936
  schema.tables.forEach(t => renderEdges(t));
938
937
  if (selectedTable) selectTable(selectedTable);
939
938
  applySearch();
940
939
  renderMinimap();
941
940
  }
942
941
  // ── Compact mode (all tables) ─────────────────────────────────────────────
943
942
  document.getElementById('erd-btn-compact').addEventListener('click', () => {
944
943
  compactAll = !compactAll;
945
944
  const btn = document.getElementById('erd-btn-compact');
946
945
  btn.classList.toggle('btn-active', compactAll);
947
946
  positions = {};
948
947
  initLayout();
949
948
  edgesG.innerHTML = '';
950
949
  nodesG.innerHTML = '';
951
950
  schema.tables.forEach(t => renderNode(t));
952
951
  schema.tables.forEach(t => renderEdges(t));
953
952
  fitView();
954
953
  if (selectedTable) selectTable(selectedTable);
955
954
  renderMinimap();
956
955
  });
957
956
  // ── Search/filter ─────────────────────────────────────────────────────────
958
957
  function applySearch() {
959
958
  if (!schema) return;
960
959
  const q = searchQuery.toLowerCase().trim();
961
960
  nodesG.querySelectorAll('.erd-node').forEach(el => {
962
961
  const name = el.getAttribute('data-table');
963
962
  el.classList.remove('search-match', 'search-no-match');
964
963
  if (!q) return;
965
964
  if (name.toLowerCase().includes(q)) el.classList.add('search-match');
966
965
  else el.classList.add('search-no-match');
967
966
  });
968
967
  }
969
968
  searchInput.addEventListener('input', () => {
970
969
  searchQuery = searchInput.value;
971
970
  applySearch();
972
971
  if (searchQuery && schema) {
973
972
  // pan to first match
974
973
  const match = schema.tables.find(t => t.name.toLowerCase().includes(searchQuery.toLowerCase()));
975
974
  if (match && positions[match.name]) {
976
975
  const p = positions[match.name], nh = nodeHeight(match);
977
976
  const cx = wrap.clientWidth / 2, cy = wrap.clientHeight / 2;
978
977
  tx = cx - (p.x + NODE_W / 2) * scale;
979
978
  ty = cy - (p.y + nh / 2) * scale;
980
979
  applyTransform();
981
980
  }
982
981
  }
983
982
  });
984
983
  // ── Viewport transform ────────────────────────────────────────────────────
985
984
  function applyTransform() {
986
985
  viewport.setAttribute('transform', `translate(${tx},${ty}) scale(${scale})`);
987
986
  zoomLabel.textContent = Math.round(scale * 100) + '%';
988
987
  renderMinimap();
989
988
  }
990
989
  function fitView() {
991
990
  if (!schema || schema.tables.length === 0) return;
992
991
  const wW = wrap.clientWidth, wH = wrap.clientHeight;
993
992
  let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
994
993
  schema.tables.forEach(t => {
995
994
  const p = positions[t.name]; if (!p) return;
996
995
  minX = Math.min(minX, p.x); minY = Math.min(minY, p.y);
997
996
  maxX = Math.max(maxX, p.x + NODE_W); maxY = Math.max(maxY, p.y + nodeHeight(t));
998
997
  });
999
998
  const cW = maxX - minX + 120, cH = maxY - minY + 120;
1000
999
  scale = Math.min(1.1, Math.min(wW / cW, wH / cH));
1001
1000
  tx = (wW - cW * scale) / 2 - minX * scale + 60 * scale;
1002
1001
  ty = (wH - cH * scale) / 2 - minY * scale + 60 * scale;
1003
1002
  applyTransform();
1004
1003
  }
1005
1004
  // ── Minimap ───────────────────────────────────────────────────────────────
1006
1005
  function renderMinimap() {
1007
1006
  if (!schema || schema.tables.length === 0) return;
1008
1007
  minimapSvg.innerHTML = '';
1009
1008
  let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
1010
1009
  schema.tables.forEach(t => {
1011
1010
  const p = positions[t.name]; if (!p) return;
1012
1011
  minX = Math.min(minX, p.x); minY = Math.min(minY, p.y);
1013
1012
  maxX = Math.max(maxX, p.x + NODE_W); maxY = Math.max(maxY, p.y + nodeHeight(t));
1014
1013
  });
1015
1014
  const cW = maxX - minX || 1, cH = maxY - minY || 1;
1016
1015
  const mW = 168, mH = 92;
1017
1016
  const mS = Math.min((mW - 8) / (cW + 40), (mH - 8) / (cH + 40));
1018
1017
  schema.tables.forEach(t => {
1019
1018
  const p = positions[t.name]; if (!p) return;
1020
1019
  const pal = palOf(t.name);
1021
1020
  const rx = (p.x - minX + 20) * mS + 4, ry = (p.y - minY + 20) * mS + 4;
1022
1021
  const rw = NODE_W * mS, rh = nodeHeight(t) * mS;
1023
1022
  const r = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
1024
1023
  r.setAttribute('x', rx); r.setAttribute('y', ry);
1025
1024
  r.setAttribute('width', Math.max(rw, 2)); r.setAttribute('height', Math.max(rh, 2));
1026
1025
  r.setAttribute('rx', 2);
1027
1026
  r.setAttribute('fill', t.name === selectedTable ? pal.h : pal.h + '55');
1028
1027
  r.setAttribute('stroke', pal.h); r.setAttribute('stroke-width', '0.5');
1029
1028
  minimapSvg.appendChild(r);
1030
1029
  });
1031
1030
  // Viewport rect
1032
1031
  const vpX = (-tx / scale - minX + 20) * mS + 4;
1033
1032
  const vpY = (-ty / scale - minY + 20) * mS + 4;
1034
1033
  const vpW = (wrap.clientWidth / scale) * mS;
1035
1034
  const vpH = (wrap.clientHeight / scale) * mS;
1036
1035
  const vp = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
1037
1036
  vp.setAttribute('x', vpX); vp.setAttribute('y', vpY);
1038
1037
  vp.setAttribute('width', vpW); vp.setAttribute('height', vpH);
1039
1038
  vp.setAttribute('fill', 'rgba(99,102,241,0.06)');
1040
1039
  vp.setAttribute('stroke', '#6366f1'); vp.setAttribute('stroke-width', '1'); vp.setAttribute('rx', '2');
1041
1040
  minimapSvg.appendChild(vp);
1042
1041
  }
1043
1042
  // ── Mouse / touch events ──────────────────────────────────────────────────
1044
1043
  wrap.addEventListener('wheel', e => {
1045
1044
  e.preventDefault();
1046
1045
  const d = e.deltaY > 0 ? 0.88 : 1.12;
1047
1046
  const r = wrap.getBoundingClientRect();
1048
1047
  const mx = e.clientX - r.left, my = e.clientY - r.top;
1049
1048
  const ns = Math.min(3, Math.max(0.1, scale * d));
1050
1049
  tx = mx - (mx - tx) * (ns / scale);
1051
1050
  ty = my - (my - ty) * (ns / scale);
1052
1051
  scale = ns; applyTransform();
1053
1052
  }, { passive: false });
1054
1053
  // Canvas panning
1055
1054
  wrap.addEventListener('mousedown', e => {
1056
1055
  if (draggingNode || e.button !== 0) return;
1057
1056
  const tgt = e.target;
1058
1057
  if (tgt.closest('.erd-node') || tgt.closest('#erd-ctx-menu')) return;
1059
1058
  isPanning = true;
1060
1059
  panStart = { x: e.clientX, y: e.clientY, tx, ty };
1061
1060
  wrap.style.cursor = 'grabbing';
1062
1061
  });
1063
1062
  window.addEventListener('mousemove', e => {
1064
1063
  if (draggingNode) {
1065
1064
  const dx = (e.clientX - draggingNode.startX) / scale;
1066
1065
  const dy = (e.clientY - draggingNode.startY) / scale;
1067
1066
  if (Math.abs(dx) > 2 || Math.abs(dy) > 2) draggingNode.moved = true;
1068
1067
  positions[draggingNode.name].x = draggingNode.origX + dx;
1069
1068
  positions[draggingNode.name].y = draggingNode.origY + dy;
1070
1069
  const el = nodesG.querySelector(`[data-table="${draggingNode.name}"]`);
1071
1070
  if (el) { const p = positions[draggingNode.name]; el.setAttribute('transform', `translate(${p.x},${p.y})`); }
1072
1071
  updateEdges();
1073
1072
  renderMinimap();
1074
1073
  } else if (isPanning) {
1075
1074
  tx = panStart.tx + (e.clientX - panStart.x);
1076
1075
  ty = panStart.ty + (e.clientY - panStart.y);
1077
1076
  applyTransform();
1078
1077
  }
1079
1078
  });
1080
1079
  window.addEventListener('mouseup', () => {
1081
1080
  draggingNode = null; isPanning = false;
1082
1081
  wrap.style.cursor = 'grab';
1083
1082
  });
1084
1083
  // Click on canvas background → deselect
1085
1084
  svgEl_.addEventListener('click', e => {
1086
1085
  if (e.target === svgEl_ || e.target.id === 'erd-viewport') deselectTable();
1087
1086
  });
1088
1087
  // Zoom buttons
1089
1088
  function zoomAt(factor) {
1090
1089
  const cx = wrap.clientWidth / 2, cy = wrap.clientHeight / 2;
1091
1090
  const ns = Math.min(3, Math.max(0.1, scale * factor));
1092
1091
  tx = cx - (cx - tx) * (ns / scale); ty = cy - (cy - ty) * (ns / scale);
1093
1092
  scale = ns; applyTransform();
1094
1093
  }
1095
1094
  document.getElementById('erd-btn-zoom-in').addEventListener('click', () => zoomAt(1.2));
1096
1095
  document.getElementById('erd-btn-zoom-out').addEventListener('click', () => zoomAt(0.83));
1097
1096
  document.getElementById('erd-btn-fit').addEventListener('click', fitView);
1098
1097
  // Layout mode buttons
1099
1098
  function applyLayoutMode(mode) {
1100
1099
  if (!schema) return;
1101
1100
  layoutMode = mode;
1102
1101
  positions = {};
1103
1102
  initLayout();
1104
1103
  edgesG.innerHTML = ''; nodesG.innerHTML = '';
1105
1104
  schema.tables.forEach(t => renderNode(t));
1106
1105
  schema.tables.forEach(t => renderEdges(t));
1107
1106
  fitView();
1108
1107
  if (selectedTable) selectTable(selectedTable);
1109
1108
  applySearch();
1110
1109
  renderMinimap();
1111
1110
  }
1112
1111
  document.querySelectorAll('.erd-mode-btn').forEach(btn => {
1113
1112
  btn.addEventListener('click', () => applyLayoutMode(btn.dataset.mode));
1114
1113
  });
1115
1114
  updateModeButtons();
1116
1115
  // Export SVG
1117
1116
  document.getElementById('erd-btn-export-svg').addEventListener('click', () => {
1118
1117
  if (!schema) return;
1119
1118
  // 1. Compute content bounds in diagram-space
1120
1119
  let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
1121
1120
  schema.tables.forEach(t => {
1122
1121
  const p = positions[t.name]; if (!p) return;
1123
1122
  minX = Math.min(minX, p.x); minY = Math.min(minY, p.y);
1124
1123
  maxX = Math.max(maxX, p.x + NODE_W); maxY = Math.max(maxY, p.y + nodeHeight(t));
1125
1124
  });
1126
1125
  const PAD = 48;
1127
1126
  const vW = maxX - minX + PAD * 2;
1128
1127
  const vH = maxY - minY + PAD * 2;
1129
1128
  // 2. Resolve all current CSS variable values
1130
1129
  const cs = getComputedStyle(document.documentElement);
1131
1130
  const get = v => cs.getPropertyValue(v).trim();
1132
1131
  const VARS = {
1133
1132
  'var(--erd-node-bg)' : get('--erd-node-bg') || '#ffffff',
1134
1133
  'var(--erd-node-border)' : get('--erd-node-border') || '#e2e8f0',
1135
1134
  'var(--erd-node-text)' : get('--erd-node-text') || '#1e293b',
1136
1135
  'var(--erd-node-subtext)' : get('--erd-node-subtext') || '#94a3b8',
1137
1136
  'var(--erd-node-row-odd)' : get('--erd-node-row-odd') || '#f8fafc',
1138
1137
  'var(--erd-node-row-even)': get('--erd-node-row-even') || '#ffffff',
1139
1138
  'var(--erd-node-row-pk)' : get('--erd-node-row-pk') || '#eef2ff',
1140
1139
  'var(--erd-node-row-fk)' : get('--erd-node-row-fk') || '#fffbeb',
1141
1140
  'var(--erd-edge-color)' : get('--erd-edge-color') || '#818cf8',
1142
1141
  'var(--erd-edge-hl)' : get('--erd-edge-hl') || '#f59e0b',
1143
1142
  'var(--erd-edge-dim)' : get('--erd-edge-dim') || '#cbd5e1',
1144
1143
  'var(--erd-canvas-bg)' : get('--erd-canvas-bg') || '#f1f5f9',
1145
1144
  };
1146
1145
  function resolveVars(str) {
1147
1146
  if (!str || !str.includes('var(')) return str;
1148
1147
  return Object.entries(VARS).reduce((s, [k, v]) => s.replaceAll(k, v), str);
1149
1148
  }
1150
1149
  // 3. Deep-clone SVG and walk every element to inline CSS vars
1151
1150
  const clone = svgEl_.cloneNode(true);
1152
1151
  function inlineEl(el) {
1153
1152
  // Inline attribute-level var() references
1154
1153
  for (const attr of [...el.attributes]) {
1155
1154
  const resolved = resolveVars(attr.value);
1156
1155
  if (resolved !== attr.value) el.setAttribute(attr.name, resolved);
1157
1156
  }
1158
1157
  // Inline style attribute
1159
1158
  const styleAttr = el.getAttribute('style');
1160
1159
  if (styleAttr) {
1161
1160
  const resolved = resolveVars(styleAttr);
1162
1161
  if (resolved !== styleAttr) el.setAttribute('style', resolved);
1163
1162
  }
1164
1163
  for (const child of el.children) inlineEl(child);
1165
1164
  }
1166
1165
  inlineEl(clone);
1167
1166
  // 4. Fix dimensions & viewBox
1168
1167
  clone.setAttribute('width', String(Math.ceil(vW)));
1169
1168
  clone.setAttribute('height', String(Math.ceil(vH)));
1170
1169
  clone.setAttribute('viewBox', `0 0 ${Math.ceil(vW)} ${Math.ceil(vH)}`);
1171
1170
  clone.removeAttribute('style'); // removes display:none
1172
1171
  // 5. Reset viewport transform — shift content so it starts at (PAD, PAD)
1173
1172
  const vpClone = clone.querySelector('#erd-viewport');
1174
1173
  if (vpClone) vpClone.setAttribute('transform', `translate(${PAD - minX},${PAD - minY})`);
1175
1174
  // 6. Add background rect behind everything
1176
1175
  const bgRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
1177
1176
  bgRect.setAttribute('x', 0); bgRect.setAttribute('y', 0);
1178
1177
  bgRect.setAttribute('width', Math.ceil(vW)); bgRect.setAttribute('height', Math.ceil(vH));
1179
1178
  bgRect.setAttribute('fill', VARS['var(--erd-canvas-bg)']);
1180
1179
  clone.insertBefore(bgRect, clone.firstChild);
1181
1180
  // 7. Strip interactive-only elements (hit targets, collapse arrows, tooltip, minimap)
1182
1181
  clone.querySelectorAll(
1183
1182
  '.erd-hit, .erd-collapse-btn, #erd-minimap, #erd-edge-tooltip, #erd-ctx-menu'
1184
1183
  ).forEach(el => el.remove());
1185
1184
  // 8. Add Google Fonts import so text renders correctly in SVG viewers that support it
1186
1185
  const defs = clone.querySelector('defs') || clone.insertBefore(
1187
1186
  document.createElementNS('http://www.w3.org/2000/svg', 'defs'), clone.firstChild
1188
1187
  );
1189
1188
  const style = document.createElementNS('http://www.w3.org/2000/svg', 'style');
1190
1189
  style.textContent = `@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&family=JetBrains+Mono:wght@400;600&display=swap');`;
1191
1190
  defs.insertBefore(style, defs.firstChild);
1192
1191
  // 9. Serialize and trigger download
1193
1192
  const xml = new XMLSerializer().serializeToString(clone);
1194
1193
  const blob = new Blob(['<?xml version="1.0" encoding="UTF-8"?>\n' + xml], { type: 'image/svg+xml;charset=utf-8' });
1195
1194
  const a = document.createElement('a');
1196
1195
  a.href = URL.createObjectURL(blob);
1197
1196
  a.download = 'er-diagram.svg';
1198
1197
  document.body.appendChild(a);
1199
1198
  a.click();
1200
1199
  document.body.removeChild(a);
1201
1200
  setTimeout(() => URL.revokeObjectURL(a.href), 1000);
1202
1201
  });
1203
1202
  // Touch pinch-zoom
1204
1203
  let lastPinchDist = null;
1205
1204
  wrap.addEventListener('touchstart', e => {
1206
1205
  if (e.touches.length === 2) lastPinchDist = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
1207
1206
  }, { passive: true });
1208
1207
  wrap.addEventListener('touchmove', e => {
1209
1208
  if (e.touches.length !== 2 || !lastPinchDist) return;
1210
1209
  const d = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
1211
1210
  const r = wrap.getBoundingClientRect();
1212
1211
  const mx = (e.touches[0].clientX + e.touches[1].clientX) / 2 - r.left;
1213
1212
  const my = (e.touches[0].clientY + e.touches[1].clientY) / 2 - r.top;
1214
1213
  const ns = Math.min(3, Math.max(0.1, scale * d / lastPinchDist));
1215
1214
  tx = mx - (mx - tx) * (ns / scale); ty = my - (my - ty) * (ns / scale);
1216
1215
  scale = ns; applyTransform(); lastPinchDist = d; e.preventDefault();
1217
1216
  }, { passive: false });
1218
1217
  // Keyboard shortcuts
1219
1218
  document.addEventListener('keydown', e => {
1220
1219
  if (e.target.tagName === 'INPUT') return;
1221
1220
  if (e.key === 'Escape') deselectTable();
1222
1221
  if (e.key === 'f' || e.key === 'F') { e.preventDefault(); fitView(); }
1223
1222
  if (e.key === 'l' || e.key === 'L') {
1224
1223
  e.preventDefault();
1225
1224
  const modes = ['force', 'hierarchy', 'circle'];
1226
1225
  applyLayoutMode(modes[(modes.indexOf(layoutMode) + 1) % modes.length]);
1227
1226
  }
1228
1227
  if (e.key === 'c' || e.key === 'C') { document.getElementById('erd-btn-compact').click(); }
1229
1228
  if (e.key === '+' || e.key === '=') zoomAt(1.2);
1230
1229
  if (e.key === '-') zoomAt(0.83);
1231
1230
  if ((e.key === '0') && (e.ctrlKey || e.metaKey)) { e.preventDefault(); fitView(); }
1232
1231
  });
1233
1232
  // ── Init ─────────────────────────────────────────────────────────────────
1234
1233
  loadSchema();
1234
+ <div class="flex flex-col" style="height: calc(100vh - 180px); min-height: 520px;">
1235
1235
  <div class="mb-3 flex flex-wrap items-center gap-2">
1236
1236
  <div class="flex items-center gap-2">
1237
1237
  <h1 class="text-xl font-bold">ER Diagram</h1>
1238
1238
  <span id="erd-table-badge" class="hidden rounded-full bg-primary/15 px-2 py-0.5 text-xs font-semibold text-primary"></span>
1239
1239
  <span id="erd-fk-badge" class="hidden rounded-full bg-amber-500/15 px-2 py-0.5 text-xs font-semibold text-amber-600"></span>
1240
1240
  </div>
1241
1241
  <div class="ml-auto flex flex-wrap items-center gap-1.5">
1242
1242
  <div class="relative">
1243
1243
  {{{icon "search" class="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-base-content/40"}}}
1244
1244
  <input id="erd-search" type="search" placeholder="Find table…" autocomplete="off"
1245
1245
  class="h-8 w-36 rounded-lg border border-base-300 bg-base-100 pl-7 pr-2 text-xs outline-none transition focus:border-primary/60 focus:ring-2 focus:ring-primary/20" />
1246
1246
  </div>
1247
1247
  <div class="h-5 w-px bg-base-300"></div>
1248
1248
  <button id="erd-btn-compact" title="Toggle compact mode (C)" class="btn btn-sm btn-ghost gap-1.5 font-normal">
1249
1249
  {{{icon "compact" class="h-4 w-4"}}}
1250
1250
  Compact
1251
1251
  </button>
1252
1252
  <button id="erd-btn-fit" title="Fit to screen (F)" class="btn btn-sm btn-ghost gap-1.5 font-normal">
1253
1253
  {{{icon "fit" class="h-4 w-4"}}}
1254
1254
  Fit
1255
1255
  </button>
1256
1256
  <div class="flex items-center rounded-lg border border-base-300 bg-base-100 p-0.5 gap-0.5" title="Layout mode (L to cycle)">
1257
1257
  <button id="erd-mode-force" class="erd-mode-btn rounded-md px-2.5 py-1 text-xs font-semibold transition-all gap-1.5 flex items-center" data-mode="force" title="Force-directed (physics)">
1258
1258
  {{{icon "layoutForce" class="h-3.5 w-3.5"}}}
1259
1259
  Force
1260
1260
  </button>
1261
1261
  <button id="erd-mode-hierarchy" class="erd-mode-btn rounded-md px-2.5 py-1 text-xs font-semibold transition-all gap-1.5 flex items-center" data-mode="hierarchy" title="Hierarchical (reference tables left)">
1262
1262
  {{{icon "layoutHierarchy" class="h-3.5 w-3.5"}}}
1263
1263
  Hierarchy
1264
1264
  </button>
1265
1265
  <button id="erd-mode-circle" class="erd-mode-btn rounded-md px-2.5 py-1 text-xs font-semibold transition-all gap-1.5 flex items-center" data-mode="circle" title="Circular layout">
1266
1266
  {{{icon "layoutCircle" class="h-3.5 w-3.5"}}}
1267
1267
  Circle
1268
1268
  </button>
1269
1269
  </div>
1270
1270
  <button id="erd-btn-export-svg" title="Download as SVG" class="btn btn-sm btn-ghost gap-1.5 font-normal">
1271
1271
  {{{icon "export" class="h-4 w-4"}}}
1272
1272
  Export
1273
1273
  </button>
1274
1274
  <div class="h-5 w-px bg-base-300"></div>
1275
1275
  <div class="flex items-center gap-0.5 rounded-lg border border-base-300 bg-base-100 px-1.5 py-1">
1276
1276
  <button id="erd-btn-zoom-out" class="rounded px-1 py-0.5 hover:bg-base-200 transition-colors text-base-content/60" title="Zoom out (−)">
1277
1277
  {{{icon "minus" class="h-3.5 w-3.5"}}}
1278
1278
  </button>
1279
1279
  <span id="erd-zoom-label" class="w-12 text-center text-xs font-mono font-semibold tabular-nums">100%</span>
1280
1280
  <button id="erd-btn-zoom-in" class="rounded px-1 py-0.5 hover:bg-base-200 transition-colors text-base-content/60" title="Zoom in (+)">
1281
1281
  {{{icon "plus" class="h-3.5 w-3.5"}}}
1282
1282
  </button>
1283
1283
  </div>
1284
1284
  </div>
1285
1285
  </div>
1286
1286
  <div class="flex flex-1 gap-3 min-h-0">
1287
1287
  <div id="erd-canvas-wrap" class="relative flex-1 overflow-hidden rounded-xl border border-base-300 shadow-inner" style="cursor: grab; min-width:0;">
1288
1288
  <div id="erd-loading" class="absolute inset-0 flex flex-col items-center justify-center gap-3 z-20 bg-base-100/80 backdrop-blur-sm">
1289
1289
  {{{icon "spinner" class="animate-spin h-8 w-8 text-primary"}}}
1290
1290
  <span class="text-sm text-base-content/60 font-medium">Loading schema…</span>
1291
1291
  </div>
1292
1292
  <div id="erd-error" class="absolute inset-0 hidden flex-col items-center justify-center gap-3 z-20">
1293
1293
  <div class="rounded-full bg-error/10 p-4">
1294
1294
  {{{icon "warning" class="h-8 w-8 text-error"}}}
1295
1295
  </div>
1296
1296
  <p id="erd-error-msg" class="text-sm text-error font-medium"></p>
1297
1297
  </div>
1298
1298
  <div id="erd-empty" class="absolute inset-0 hidden flex-col items-center justify-center gap-3 z-20">
1299
1299
  <div class="rounded-full bg-base-200 p-6">
1300
1300
  {{{icon "diagram" class="h-10 w-10 text-base-content/30"}}}
1301
1301
  </div>
1302
1302
  <p class="text-base-content/40 text-sm font-medium">No tables found in this database</p>
1303
1303
  </div>
1304
1304
  <svg id="erd-svg" width="100%" height="100%" style="display:none; user-select:none;" xmlns="http://www.w3.org/2000/svg">
1305
1305
  <defs>
1306
1306
  <marker id="erd-arrow" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto">
1307
1307
  <path d="M0,0 L0,6 L8,3 z" fill="var(--erd-edge-color)"/>
1308
1308
  </marker>
1309
1309
  <marker id="erd-arrow-hl" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto">
1310
1310
  <path d="M0,0 L0,6 L8,3 z" fill="var(--erd-edge-hl)"/>
1311
1311
  </marker>
1312
1312
  <marker id="erd-arrow-dim" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto">
1313
1313
  <path d="M0,0 L0,6 L8,3 z" fill="var(--erd-edge-dim)"/>
1314
1314
  </marker>
1315
1315
  <marker id="erd-cf-one" markerWidth="12" markerHeight="12" refX="2" refY="6" orient="auto">
1316
1316
  <line x1="2" y1="1" x2="2" y2="11" stroke="var(--erd-edge-color)" stroke-width="1.5"/>
1317
1317
  <line x1="5" y1="1" x2="5" y2="11" stroke="var(--erd-edge-color)" stroke-width="1.5"/>
1318
1318
  </marker>
1319
1319
  <filter id="erd-glow" x="-30%" y="-30%" width="160%" height="160%">
1320
1320
  <feGaussianBlur stdDeviation="4" result="blur"/>
1321
1321
  <feFlood flood-color="var(--erd-node-header)" flood-opacity="0.35" result="color"/>
1322
1322
  <feComposite in="color" in2="blur" operator="in" result="glow"/>
1323
1323
  <feMerge><feMergeNode in="glow"/><feMergeNode in="SourceGraphic"/></feMerge>
1324
1324
  </filter>
1325
1325
  <filter id="erd-shadow" x="-15%" y="-15%" width="130%" height="130%">
1326
1326
  <feDropShadow dx="0" dy="2" stdDeviation="4" flood-color="#000" flood-opacity="0.10"/>
1327
1327
  </filter>
1328
1328
  <filter id="erd-shadow-lg" x="-20%" y="-20%" width="140%" height="140%">
1329
1329
  <feDropShadow dx="0" dy="6" stdDeviation="12" flood-color="#000" flood-opacity="0.20"/>
1330
1330
  </filter>
1331
1331
  </defs>
1332
1332
  <g id="erd-viewport">
1333
1333
  <g id="erd-edges"></g>
1334
1334
  <g id="erd-nodes"></g>
1335
1335
  </g>
1336
1336
  </svg>
1337
1337
  <div id="erd-edge-tooltip" class="pointer-events-none absolute hidden z-30 rounded-lg border border-base-300 bg-base-100 px-2.5 py-1.5 shadow-xl text-xs font-mono"></div>
1338
1338
  <div id="erd-minimap" class="absolute bottom-3 right-3 hidden rounded-xl border border-base-300/80 bg-base-100/95 backdrop-blur-sm shadow-xl overflow-hidden" style="width:168px;height:108px;">
1339
1339
  <div class="absolute inset-x-0 top-0 flex items-center justify-between px-2 py-0.5 bg-base-200/80 text-[9px] font-semibold uppercase tracking-wider text-base-content/40">
1340
1340
  <span>Overview</span>
1341
1341
  </div>
1342
1342
  <svg id="erd-minimap-svg" width="168" height="108" xmlns="http://www.w3.org/2000/svg" style="margin-top:16px;height:92px;"></svg>
1343
1343
  </div>
1344
1344
  <div id="erd-ctx-menu" class="absolute hidden z-50 rounded-xl border border-base-300 bg-base-100 shadow-2xl py-1.5 w-48 text-sm">
1345
1345
  <div class="px-3 pb-1.5 pt-0.5 text-[10px] font-semibold uppercase tracking-wider text-base-content/40" id="erd-ctx-title"></div>
1346
1346
  <a id="erd-ctx-browse" href="#" class="flex items-center gap-2 px-3 py-1.5 hover:bg-base-200 transition-colors text-base-content/80">
1347
1347
  {{{icon "table" class="h-3.5 w-3.5 shrink-0"}}}
1348
1348
  Browse rows
1349
1349
  </a>
1350
1350
  <a id="erd-ctx-schema" href="#" class="flex items-center gap-2 px-3 py-1.5 hover:bg-base-200 transition-colors text-base-content/80">
1351
1351
  {{{icon "schema" class="h-3.5 w-3.5 shrink-0"}}}
1352
1352
  Edit schema
1353
1353
  </a>
1354
1354
  <a id="erd-ctx-seed" href="#" class="flex items-center gap-2 px-3 py-1.5 hover:bg-base-200 transition-colors text-emerald-600 font-medium">
1355
1355
  {{{icon "seed" class="h-3.5 w-3.5 shrink-0 text-emerald-500"}}}
1356
1356
  Seed ER Chain…
1357
1357
  </a>
1358
1358
  <div class="my-1 border-t border-base-200"></div>
1359
1359
  <button id="erd-ctx-focus" class="flex w-full items-center gap-2 px-3 py-1.5 hover:bg-base-200 transition-colors text-base-content/80">
1360
1360
  {{{icon "focus" class="h-3.5 w-3.5 shrink-0"}}}
1361
1361
  Focus & zoom
1362
1362
  </button>
1363
1363
  <button id="erd-ctx-collapse" class="flex w-full items-center gap-2 px-3 py-1.5 hover:bg-base-200 transition-colors text-base-content/80">
1364
1364
  {{{icon "chevronUp" class="h-3.5 w-3.5 shrink-0"}}}
1365
1365
  <span id="erd-ctx-collapse-label">Collapse table</span>
1366
1366
  </button>
1367
1367
  </div>
1368
1368
  <div class="absolute bottom-3 left-3 flex flex-col gap-0.5 pointer-events-none select-none">
1369
1369
  <span class="text-[10px] text-base-content/25 leading-relaxed">Scroll · zoom &nbsp;·&nbsp; Drag · pan &nbsp;·&nbsp; Click · select &nbsp;·&nbsp; Right-click · menu</span>
1370
1370
  </div>
1371
1371
  </div>
1372
1372
  <div id="erd-side-panel" class="hidden w-80 shrink-0 flex-col gap-3 h-full max-h-full overflow-y-auto pr-0.5">
1373
1373
  <div class="rounded-xl border border-base-300 bg-base-100 overflow-hidden flex flex-col shadow-sm">
1374
1374
  <div id="erd-panel-header" class="flex items-center justify-between gap-2 px-4 py-3" style="background: var(--erd-node-header); color:#fff;">
1375
1375
  <div class="flex items-center gap-2 min-w-0">
1376
1376
  {{{icon "table" class="h-4 w-4 shrink-0 opacity-80"}}}
1377
1377
  <span class="font-bold text-sm truncate" id="erd-panel-name"></span>
1378
1378
  </div>
1379
1379
  <button id="erd-panel-close" class="shrink-0 rounded-full p-1 hover:bg-white/20 transition-colors" title="Close panel (Esc)">
1380
1380
  {{{icon "x" class="h-4 w-4"}}}
1381
1381
  </button>
1382
1382
  </div>
1383
1383
  <div id="erd-panel-stats" class="flex divide-x divide-base-200 border-b border-base-200 text-center text-xs">
1384
1384
  <div class="flex-1 py-2"><div class="font-bold text-sm" id="erd-panel-col-count"></div><div class="text-base-content/40">columns</div></div>
1385
1385
  <div class="flex-1 py-2"><div class="font-bold text-sm text-amber-500" id="erd-panel-fk-count"></div><div class="text-base-content/40">FK refs</div></div>
1386
1386
  <div class="flex-1 py-2"><div class="font-bold text-sm text-emerald-500" id="erd-panel-ref-count"></div><div class="text-base-content/40">referenced</div></div>
1387
1387
  </div>
1388
1388
  <div class="flex gap-2 p-3 border-b border-base-200 bg-base-200/40">
1389
1389
  <a id="erd-panel-browse" href="#" class="btn btn-xs btn-primary flex-1 gap-1">
1390
1390
  {{{icon "table" class="h-3 w-3"}}}
1391
1391
  Browse
1392
1392
  </a>
1393
1393
  <a id="erd-panel-schema" href="#" class="btn btn-xs btn-outline flex-1 gap-1">
1394
1394
  {{{icon "schema" class="h-3 w-3"}}}
1395
1395
  Schema
1396
1396
  </a>
1397
1397
  <a id="erd-panel-seed" href="#" class="btn btn-xs btn-outline btn-success flex-1 gap-1">
1398
1398
  {{{icon "seed" class="h-3 w-3"}}}
1399
1399
  Seed
1400
1400
  </a>
1401
1401
  </div>
1402
1402
  <div class="flex flex-col">
1403
1403
  <div class="flex items-center justify-between px-4 py-2 border-b border-base-200 bg-base-200/20 text-[10px] font-semibold uppercase tracking-wider text-base-content/50">
1404
1404
  <span>Columns</span>
1405
1405
  </div>
1406
1406
  <div id="erd-panel-cols" class="divide-y divide-base-200 text-xs max-h-60 overflow-y-auto"></div>
1407
1407
  </div>
1408
1408
  </div>
1409
1409
  <div id="erd-panel-rels" class="hidden rounded-xl border border-base-300 bg-base-100 overflow-hidden flex flex-col shadow-sm">
1410
1410
  <div class="flex items-center justify-between px-4 py-2 border-b border-base-200 bg-base-200/20 text-[10px] font-semibold uppercase tracking-wider text-base-content/50">
1411
1411
  <span>Relationships</span>
1412
1412
  </div>
1413
1413
  <div id="erd-panel-rels-list" class="divide-y divide-base-200 text-xs max-h-52 overflow-y-auto"></div>
1414
1414
  </div>
1415
1415
  </div>
1416
1416
  </div>
1417
1417
  :root {
1418
1418
  --erd-node-bg: #ffffff;
1419
1419
  --erd-node-border: #e2e8f0;
1420
1420
  --erd-node-header: #6366f1;
1421
1421
  --erd-node-header-text: #ffffff;
1422
1422
  --erd-node-text: #1e293b;
1423
1423
  --erd-node-subtext: #94a3b8;
1424
1424
  --erd-node-row-odd: #f8fafc;
1425
1425
  --erd-node-row-even: #ffffff;
1426
1426
  --erd-node-row-pk: #eef2ff;
1427
1427
  --erd-node-row-fk: #fffbeb;
1428
1428
  --erd-edge-color: #818cf8;
1429
1429
  --erd-edge-hl: #f59e0b;
1430
1430
  --erd-edge-dim: #cbd5e1;
1431
1431
  --erd-canvas-bg: #f1f5f9;
1432
1432
  --erd-grid-color: rgba(148,163,184,0.18);
1433
1433
  --erd-grid-dot: rgba(148,163,184,0.45);
1434
1434
  }
1435
1435
  [data-theme="admindbDark"] {
1436
1436
  --erd-node-bg: #1e293b;
1437
1437
  --erd-node-border: #334155;
1438
1438
  --erd-node-header: #6366f1;
1439
1439
  --erd-node-header-text: #ffffff;
1440
1440
  --erd-node-text: #e2e8f0;
1441
1441
  --erd-node-subtext: #64748b;
1442
1442
  --erd-node-row-odd: #1a2744;
1443
1443
  --erd-node-row-even: #1e293b;
1444
1444
  --erd-node-row-pk: #1e1b4b;
1445
1445
  --erd-node-row-fk: #1c1a0e;
1446
1446
  --erd-edge-color: #818cf8;
1447
1447
  --erd-edge-hl: #fbbf24;
1448
1448
  --erd-edge-dim: #334155;
1449
1449
  --erd-canvas-bg: #0f172a;
1450
1450
  --erd-grid-color: rgba(51,65,85,0.5);
1451
1451
  --erd-grid-dot: rgba(71,85,105,0.6);
1452
1452
  }
1453
1453
  #erd-canvas-wrap {
1454
1454
  background-color: var(--erd-canvas-bg);
1455
1455
  background-image: radial-gradient(var(--erd-grid-dot) 1px, transparent 1px);
1456
1456
  background-size: 24px 24px;
1457
1457
  }
1458
1458
  /* Node transitions */
1459
1459
  .erd-node { cursor: grab; }
1460
1460
  .erd-node:active { cursor: grabbing; }
1461
1461
  .erd-node-rect {
1462
1462
  transition: filter 0.18s ease, stroke 0.18s ease, stroke-width 0.18s ease;
1463
1463
  }
1464
1464
  .erd-node.dimmed .erd-node-rect { opacity: 0.28; }
1465
1465
  .erd-node.dimmed .erd-node-header-rect { opacity: 0.28; }
1466
1466
  .erd-node.dimmed text { opacity: 0.28; }
1467
1467
  .erd-node.selected .erd-node-rect {
1468
1468
  stroke: var(--erd-edge-hl) !important;
1469
1469
  stroke-width: 2.5px !important;
1470
1470
  filter: url(#erd-shadow-lg);
1471
1471
  }
1472
1472
  .erd-node.neighbor .erd-node-rect {
1473
1473
  stroke: var(--erd-edge-color) !important;
1474
1474
  stroke-width: 2px !important;
1475
1475
  }
1476
1476
  /* Edge transitions */
1477
1477
  .erd-edge {
1478
1478
  transition: stroke 0.18s ease, stroke-width 0.18s ease, opacity 0.18s ease;
1479
1479
  cursor: pointer;
1480
1480
  }
1481
1481
  .erd-edge.dimmed { opacity: 0.08; }
1482
1482
  .erd-edge.highlighted {
1483
1483
  stroke: var(--erd-edge-hl) !important;
1484
1484
  stroke-width: 2.5px !important;
1485
1485
  opacity: 1 !important;
1486
1486
  }
1487
1487
  .erd-edge.neighbor-edge {
1488
1488
  stroke: var(--erd-edge-color) !important;
1489
1489
  stroke-width: 2px !important;
1490
1490
  opacity: 0.9 !important;
1491
1491
  }
1492
1492
  /* Search highlight */
1493
1493
  .erd-node.search-match .erd-node-rect {
1494
1494
  stroke: #10b981 !important;
1495
1495
  stroke-width: 2px !important;
1496
1496
  }
1497
1497
  .erd-node.search-no-match { opacity: 0.2; }
1498
1498
  /* Side panel */
1499
1499
  #erd-side-panel { display: none; }
1500
1500
  #erd-side-panel.visible { display: flex; }
1501
1501
  .erd-col-row {
1502
1502
  display: flex;
1503
1503
  align-items: center;
1504
1504
  gap: 6px;
1505
1505
  padding: 6px 12px;
1506
1506
  font-size: 11px;
1507
1507
  transition: background 0.1s;
1508
1508
  }
1509
1509
  .erd-col-row:hover { background: var(--erd-node-row-odd); }
1510
1510
  .erd-col-row-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: 'JetBrains Mono', monospace; }
1511
1511
  .erd-col-row-type { color: var(--erd-node-subtext); font-family: 'JetBrains Mono', monospace; font-size: 10px; }
1512
1512
  .erd-badge { display: inline-flex; align-items: center; padding: 1px 5px; border-radius: 4px; font-size: 9px; font-weight: 700; line-height: 1.4; }
1513
1513
  .erd-badge-pk { background: rgba(99,102,241,0.15); color: #6366f1; }
1514
1514
  .erd-badge-fk { background: rgba(245,158,11,0.15); color: #d97706; }
1515
1515
  .erd-badge-nn { background: rgba(239,68,68,0.10); color: #dc2626; }
1516
1516
  .erd-rel-row {
1517
1517
  display: flex;
1518
1518
  align-items: center;
1519
1519
  gap: 6px;
1520
1520
  padding: 7px 12px;
1521
1521
  font-size: 11px;
1522
1522
  cursor: pointer;
1523
1523
  transition: background 0.1s;
1524
1524
  }
1525
1525
  .erd-rel-row:hover { background: var(--erd-node-row-odd); }
1526
1526
  /* Layout mode buttons */
1527
1527
  .erd-mode-btn { color: oklch(var(--bc) / 0.55); }
1528
1528
  .erd-mode-btn:hover { color: oklch(var(--bc) / 0.9); background: oklch(var(--b2)); }
1529
1529
  .erd-mode-btn.bg-primary { color: oklch(var(--pc)); }
1530
1530
  .erd-mode-btn.bg-primary:hover { color: oklch(var(--pc)); background: oklch(var(--p)); }
1531
1531
  'use strict';
1532
1532
  const BASE = window.APP?.basePath ?? '';
1533
1533
  const DB_ID = window.APP?.dbId ?? '{{dbId}}';
1534
1534
  // ── State ────────────────────────────────────────────────────────────────
1535
1535
  let schema = null;
1536
1536
  let positions = {};
1537
1537
  let collapsed = new Set(); // tables showing only header
1538
1538
  let scale = 1;
1539
1539
  let tx = 0, ty = 0;
1540
1540
  let draggingNode = null;
1541
1541
  let wasNodeDragged = false;
1542
1542
  let isPanning = false;
1543
1543
  let panStart = { x: 0, y: 0, tx: 0, ty: 0 };
1544
1544
  let selectedTable = null;
1545
1545
  let compactAll = false;
1546
1546
  let searchQuery = '';
1547
1547
  let ctxTable = null;
1548
1548
  let layoutMode = 'hierarchy'; // 'force' | 'hierarchy' | 'circle'
1549
1549
  // ── Constants ────────────────────────────────────────────────────────────
1550
1550
  const NODE_W = 240;
1551
1551
  const COL_H = 26;
1552
1552
  const HEADER_H = 40;
1553
1553
  const NODE_PAD = 6;
1554
1554
  const COMPACT_H = HEADER_H;
1555
1555
  // ── Palette: rotate header colors per table ───────────────────────────
1556
1556
  const PALETTES = [
1557
1557
  { h: '#6366f1', l: '#eef2ff' }, // indigo
1558
1558
  { h: '#8b5cf6', l: '#f5f3ff' }, // violet
1559
1559
  { h: '#ec4899', l: '#fdf2f8' }, // pink
1560
1560
  { h: '#0ea5e9', l: '#f0f9ff' }, // sky
1561
1561
  { h: '#10b981', l: '#ecfdf5' }, // emerald
1562
1562
  { h: '#f59e0b', l: '#fffbeb' }, // amber
1563
1563
  { h: '#ef4444', l: '#fef2f2' }, // red
1564
1564
  { h: '#14b8a6', l: '#f0fdfa' }, // teal
1565
1565
  ];
1566
1566
  function palOf(name) {
1567
1567
  let h = 0; for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
1568
1568
  return PALETTES[h % PALETTES.length];
1569
1569
  }
1570
1570
  // ── DOM ──────────────────────────────────────────────────────────────────
1571
1571
  const wrap = document.getElementById('erd-canvas-wrap');
1572
1572
  const svgEl_ = document.getElementById('erd-svg');
1573
1573
  const viewport = document.getElementById('erd-viewport');
1574
1574
  const edgesG = document.getElementById('erd-edges');
1575
1575
  const nodesG = document.getElementById('erd-nodes');
1576
1576
  const loadingEl = document.getElementById('erd-loading');
1577
1577
  const errorEl = document.getElementById('erd-error');
1578
1578
  const errorMsg = document.getElementById('erd-error-msg');
1579
1579
  const emptyEl = document.getElementById('erd-empty');
1580
1580
  const minimap = document.getElementById('erd-minimap');
1581
1581
  const minimapSvg = document.getElementById('erd-minimap-svg');
1582
1582
  const zoomLabel = document.getElementById('erd-zoom-label');
1583
1583
  const badge = document.getElementById('erd-table-badge');
1584
1584
  const fkBadge = document.getElementById('erd-fk-badge');
1585
1585
  const edgeTip = document.getElementById('erd-edge-tooltip');
1586
1586
  const ctxMenu = document.getElementById('erd-ctx-menu');
1587
1587
  const sidePanel = document.getElementById('erd-side-panel');
1588
1588
  const searchInput = document.getElementById('erd-search');
1589
1589
  // ── Helper ───────────────────────────────────────────────────────────────
1590
1590
  function svgE(tag, attrs = {}) {
1591
1591
  const el = document.createElementNS('http://www.w3.org/2000/svg', tag);
1592
1592
  Object.entries(attrs).forEach(([k, v]) => el.setAttribute(k, String(v)));
1593
1593
  return el;
1594
1594
  }
1595
1595
  function nodeHeight(t) {
1596
1596
  if (collapsed.has(t.name) || compactAll) return COMPACT_H;
1597
1597
  return HEADER_H + NODE_PAD * 2 + t.columns.length * COL_H;
1598
1598
  }
1599
1599
  // ── Fetch ────────────────────────────────────────────────────────────────
1600
1600
  async function loadSchema() {
1601
1601
  try {
1602
1602
  const res = await fetch(BASE + '/api/erd/' + encodeURIComponent(DB_ID));
1603
1603
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
1604
1604
  const json = await res.json();
1605
1605
  if (!json.success) throw new Error(json.error ?? 'API error');
1606
1606
  schema = json.data;
1607
1607
  if (!schema.tables || schema.tables.length === 0) {
1608
1608
  loadingEl.style.display = 'none'; emptyEl.style.display = 'flex'; return;
1609
1609
  }
1610
1610
  initLayout();
1611
1611
  renderAll();
1612
1612
  fitView();
1613
1613
  loadingEl.style.display = 'none';
1614
1614
  svgEl_.style.display = '';
1615
1615
  if (schema.tables.length > 3) minimap.classList.remove('hidden');
1616
1616
  // badges
1617
1617
  badge.textContent = schema.tables.length + ' table' + (schema.tables.length !== 1 ? 's' : '');
1618
1618
  badge.classList.remove('hidden');
1619
1619
  const totalFks = schema.tables.reduce((s, t) => s + t.foreignKeys.length, 0);
1620
1620
  if (totalFks > 0) {
1621
1621
  fkBadge.textContent = totalFks + ' relationship' + (totalFks !== 1 ? 's' : '');
1622
1622
  fkBadge.classList.remove('hidden');
1623
1623
  }
1624
1624
  } catch (e) {
1625
1625
  loadingEl.style.display = 'none';
1626
1626
  errorEl.style.display = 'flex';
1627
1627
  errorMsg.textContent = e.message;
1628
1628
  }
1629
1629
  }
1630
1630
  // ── Layout dispatcher ─────────────────────────────────────────────────────
1631
1631
  function initLayout() {
1632
1632
  if (!schema || schema.tables.length === 0) return;
1633
1633
  if (layoutMode === 'force') layoutForce();
1634
1634
  else if (layoutMode === 'circle') layoutCircle();
1635
1635
  else layoutHierarchy();
1636
1636
  updateModeButtons();
1637
1637
  }
1638
1638
  function updateModeButtons() {
1639
1639
  document.querySelectorAll('.erd-mode-btn').forEach(btn => {
1640
1640
  const active = btn.dataset.mode === layoutMode;
1641
1641
  btn.classList.toggle('bg-primary', active);
1642
1642
  btn.classList.toggle('text-primary-content', active);
1643
1643
  btn.classList.toggle('shadow-sm', active);
1644
1644
  btn.classList.toggle('text-base-content', !active);
1645
1645
  btn.classList.toggle('text-opacity-60', !active);
1646
1646
  });
1647
1647
  }
1648
1648
  // ── 1. Force-directed (physics / organic) ─────────────────────────────────
1649
1649
  function layoutForce() {
1650
1650
  const tables = schema.tables;
1651
1651
  const PAD = 60;
1652
1652
  // seed in a grid so force has a stable start
1653
1653
  const cols = Math.max(1, Math.ceil(Math.sqrt(tables.length)));
1654
1654
  tables.forEach((t, i) => {
1655
1655
  positions[t.name] = {
1656
1656
  x: PAD + (i % cols) * (NODE_W + 180),
1657
1657
  y: PAD + Math.floor(i / cols) * 280,
1658
1658
  };
1659
1659
  });
1660
1660
  const tableNames = new Set(tables.map(t => t.name));
1661
1661
  const REPEL = 28000;
1662
1662
  const SPRING = 0.012;
1663
1663
  const REST = NODE_W + 220;
1664
1664
  const DAMP = 0.82;
1665
1665
  const vx = {}, vy = {};
1666
1666
  tables.forEach(t => { vx[t.name] = 0; vy[t.name] = 0; });
1667
1667
  for (let iter = 0; iter < 280; iter++) {
1668
1668
  const fx = {}, fy = {};
1669
1669
  tables.forEach(t => { fx[t.name] = 0; fy[t.name] = 0; });
1670
1670
  // Repulsion between all pairs
1671
1671
  for (let i = 0; i < tables.length; i++) {
1672
1672
  for (let j = i + 1; j < tables.length; j++) {
1673
1673
  const a = positions[tables[i].name], b = positions[tables[j].name];
1674
1674
  let dx = b.x - a.x, dy = b.y - a.y;
1675
1675
  const dist = Math.max(1, Math.sqrt(dx*dx + dy*dy));
1676
1676
  const f = REPEL / (dist * dist);
1677
1677
  dx /= dist; dy /= dist;
1678
1678
  fx[tables[i].name] -= dx * f; fy[tables[i].name] -= dy * f;
1679
1679
  fx[tables[j].name] += dx * f; fy[tables[j].name] += dy * f;
1680
1680
  }
1681
1681
  }
1682
1682
  // Spring attraction along FK edges
1683
1683
  tables.forEach(t => {
1684
1684
  t.foreignKeys.forEach(fk => {
1685
1685
  if (!tableNames.has(fk.table) || fk.table === t.name) return;
1686
1686
  const a = positions[t.name], b = positions[fk.table];
1687
1687
  let dx = b.x - a.x, dy = b.y - a.y;
1688
1688
  const dist = Math.max(1, Math.sqrt(dx*dx + dy*dy));
1689
1689
  const f = SPRING * (dist - REST);
1690
1690
  dx /= dist; dy /= dist;
1691
1691
  fx[t.name] += dx * f; fy[t.name] += dy * f;
1692
1692
  fx[fk.table] -= dx * f; fy[fk.table] -= dy * f;
1693
1693
  });
1694
1694
  });
1695
1695
  // Integrate with damping
1696
1696
  tables.forEach(t => {
1697
1697
  vx[t.name] = (vx[t.name] + fx[t.name]) * DAMP;
1698
1698
  vy[t.name] = (vy[t.name] + fy[t.name]) * DAMP;
1699
1699
  positions[t.name].x += vx[t.name];
1700
1700
  positions[t.name].y += vy[t.name];
1701
1701
  });
1702
1702
  }
1703
1703
  // Normalize to (PAD, PAD)
1704
1704
  const minX = Math.min(...tables.map(t => positions[t.name].x));
1705
1705
  const minY = Math.min(...tables.map(t => positions[t.name].y));
1706
1706
  tables.forEach(t => { positions[t.name].x -= minX - PAD; positions[t.name].y -= minY - PAD; });
1707
1707
  }
1708
1708
  // ── 2. Hierarchical / layered (reference tables left) ────────────────────
1709
1709
  function layoutHierarchy() {
1710
1710
  const tables = schema.tables;
1711
1711
  const tableNames = new Set(tables.map(t => t.name));
1712
1712
  const deps = {};
1713
1713
  tables.forEach(t => {
1714
1714
  deps[t.name] = [...new Set(
1715
1715
  t.foreignKeys.map(fk => fk.table).filter(n => n !== t.name && tableNames.has(n))
1716
1716
  )];
1717
1717
  });
1718
1718
  const layerOf = {};
1719
1719
  function assignLayer(name, stack) {
1720
1720
  if (name in layerOf) return layerOf[name];
1721
1721
  if (!stack) stack = new Set();
1722
1722
  if (stack.has(name)) { layerOf[name] = 0; return 0; }
1723
1723
  stack.add(name);
1724
1724
  const d = deps[name] || [];
1725
1725
  layerOf[name] = d.length === 0 ? 0 : Math.max(...d.map(x => assignLayer(x, new Set(stack)))) + 1;
1726
1726
  return layerOf[name];
1727
1727
  }
1728
1728
  tables.forEach(t => assignLayer(t.name));
1729
1729
  const byLayer = {};
1730
1730
  tables.forEach(t => { const l = layerOf[t.name] ?? 0; (byLayer[l] = byLayer[l] || []).push(t); });
1731
1731
  const isReferenced = new Set();
1732
1732
  tables.forEach(t => t.foreignKeys.forEach(fk => { if (fk.table !== t.name) isReferenced.add(fk.table); }));
1733
1733
  const isolated = [];
1734
1734
  byLayer[0] = (byLayer[0] || []).filter(t => {
1735
1735
  if (!isReferenced.has(t.name) && deps[t.name].length === 0) { isolated.push(t); return false; }
1736
1736
  return true;
1737
1737
  });
1738
1738
  if (!byLayer[0].length) delete byLayer[0];
1739
1739
  const layerNums = Object.keys(byLayer).map(Number).sort((a, b) => a - b);
1740
1740
  layerNums.forEach(ln => {
1741
1741
  const grp = byLayer[ln]; if (!grp || ln === 0) return;
1742
1742
  const prev = byLayer[ln - 1] || [], pi = {};
1743
1743
  prev.forEach((t, i) => pi[t.name] = i);
1744
1744
  grp.sort((a, b) => {
1745
1745
  const s = t => { const d = (deps[t.name]||[]).filter(x=>x in pi); return d.length?d.reduce((s,x)=>s+pi[x],0)/d.length:999; };
1746
1746
  return s(a) - s(b);
1747
1747
  });
1748
1748
  });
1749
1749
  const COL_WIDTH = NODE_W + 200;
1750
1750
  const ROW_PADDING = 44;
1751
1751
  const colHeights = layerNums.map(ln => (byLayer[ln]||[]).reduce((s,t) => s + nodeHeight(t) + ROW_PADDING, 0));
1752
1752
  const maxColH = Math.max(...colHeights, 300);
1753
1753
  layerNums.forEach((ln, ci) => {
1754
1754
  const grp = byLayer[ln] || [];
1755
1755
  let y = Math.max(60, (maxColH - colHeights[ci]) / 2 + 60);
1756
1756
  grp.forEach(t => { positions[t.name] = { x: 60 + ci * COL_WIDTH, y }; y += nodeHeight(t) + ROW_PADDING; });
1757
1757
  });
1758
1758
  if (isolated.length > 0) {
1759
1759
  const maxPerCol = 5;
1760
1760
  const baseIsoX = 60 + layerNums.length * COL_WIDTH;
1761
1761
  isolated.forEach((t, i) => {
1762
1762
  const colIdx = Math.floor(i / maxPerCol);
1763
1763
  const rowInCol = i % maxPerCol;
1764
1764
  const curX = baseIsoX + colIdx * (NODE_W + 50);
1765
1765
  let curY = 60;
1766
1766
  for (let r = 0; r < rowInCol; r++) {
1767
1767
  const prevTable = isolated[colIdx * maxPerCol + r];
1768
1768
  curY += nodeHeight(prevTable) + ROW_PADDING;
1769
1769
  }
1770
1770
  positions[t.name] = { x: curX, y: curY };
1771
1771
  });
1772
1772
  }
1773
1773
  }
1774
1774
  // ── 3. Circular layout ────────────────────────────────────────────────────
1775
1775
  function layoutCircle() {
1776
1776
  const tables = schema.tables;
1777
1777
  if (tables.length === 0) return;
1778
1778
  // Separate isolated tables (no FKs and never referenced by anyone)
1779
1779
  const isReferenced = new Set();
1780
1780
  tables.forEach(t => t.foreignKeys.forEach(fk => { if (fk.table !== t.name) isReferenced.add(fk.table); }));
1781
1781
  const isolated = tables.filter(t => !isReferenced.has(t.name) && t.foreignKeys.filter(fk => fk.table !== t.name).length === 0);
1782
1782
  const connected = tables.filter(t => !isolated.includes(t));
1783
1783
  // Sort connected: highly-connected first (12 o'clock position)
1784
1784
  const degree = {};
1785
1785
  tables.forEach(t => {
1786
1786
  degree[t.name] = t.foreignKeys.length;
1787
1787
  tables.forEach(o => { if (o.foreignKeys.some(fk => fk.table === t.name)) degree[t.name]++; });
1788
1788
  });
1789
1789
  const sorted = [...connected].sort((a, b) => (degree[b.name] || 0) - (degree[a.name] || 0));
1790
1790
  const n = sorted.length;
1791
1791
  if (n === 0) {
1792
1792
  // All tables are isolated — just grid them
1793
1793
  const cols = Math.max(1, Math.ceil(Math.sqrt(isolated.length)));
1794
1794
  isolated.forEach((t, i) => {
1795
1795
  positions[t.name] = { x: 60 + (i % cols) * (NODE_W + 40), y: 60 + Math.floor(i / cols) * 280 };
1796
1796
  });
1797
1797
  return;
1798
1798
  }
1799
1799
  if (n === 1) {
1800
1800
  positions[sorted[0].name] = { x: 60, y: 60 };
1801
1801
  } else {
1802
1802
  // Compute radius so circle nodes don't overlap
1803
1803
  const avgH = sorted.reduce((s, t) => s + nodeHeight(t), 0) / n;
1804
1804
  const minGap = 36;
1805
1805
  const arcPerNode = Math.max(NODE_W + minGap, avgH + minGap);
1806
1806
  const radius = Math.max(240, (n * arcPerNode) / (2 * Math.PI));
1807
1807
  const cx = radius + NODE_W / 2 + 60;
1808
1808
  const cy = radius + avgH / 2 + 60;
1809
1809
  sorted.forEach((t, i) => {
1810
1810
  const angle = (2 * Math.PI * i / n) - Math.PI / 2; // start at 12 o'clock
1811
1811
  positions[t.name] = {
1812
1812
  x: cx + radius * Math.cos(angle) - NODE_W / 2,
1813
1813
  y: cy + radius * Math.sin(angle) - nodeHeight(t) / 2,
1814
1814
  };
1815
1815
  });
1816
1816
  }
1817
1817
  // Place isolated tables in a compact grid to the right of the circle
1818
1818
  if (isolated.length > 0) {
1819
1819
  const circleRight = (sorted.length > 0 ? Math.max(...sorted.map(t => positions[t.name].x + NODE_W)) : 0) + 60;
1820
1820
  const ROW_PAD = 32;
1821
1821
  const maxPerCol = 5;
1822
1822
  isolated.forEach((t, i) => {
1823
1823
  const colIdx = Math.floor(i / maxPerCol);
1824
1824
  const rowInCol = i % maxPerCol;
1825
1825
  const curX = circleRight + colIdx * (NODE_W + 40);
1826
1826
  let curY = 60;
1827
1827
  for (let r = 0; r < rowInCol; r++) {
1828
1828
  const prevTable = isolated[colIdx * maxPerCol + r];
1829
1829
  curY += nodeHeight(prevTable) + ROW_PAD;
1830
1830
  }
1831
1831
  positions[t.name] = { x: curX, y: curY };
1832
1832
  });
1833
1833
  }
1834
1834
  }
1835
1835
  // ── Render all ────────────────────────────────────────────────────────────
1836
1836
  function renderAll() {
1837
1837
  edgesG.innerHTML = '';
1838
1838
  nodesG.innerHTML = '';
1839
1839
  schema.tables.forEach(t => renderNode(t));
1840
1840
  schema.tables.forEach(t => renderEdges(t));
1841
1841
  applySearch();
1842
1842
  renderMinimap();
1843
1843
  }
1844
1844
  // ── Render node ──────────────────────────────────────────────────────────
1845
1845
  function renderNode(t) {
1846
1846
  const pos = positions[t.name];
1847
1847
  const h = nodeHeight(t);
1848
1848
  const pal = palOf(t.name);
1849
1849
  const isCollapsed = collapsed.has(t.name) || compactAll;
1850
1850
  const fkCols = new Set(t.foreignKeys.map(f => f.from));
1851
1851
  const pkCols = new Set((t.primaryKey && t.primaryKey.length ? t.primaryKey : t.columns.filter(c => c.pk > 0).map(c => c.name)));
1852
1852
  const g = svgE('g', { class: 'erd-node', 'data-table': t.name, transform: `translate(${pos.x},${pos.y})` });
1853
1853
  // Drop shadow background
1854
1854
  g.appendChild(svgE('rect', { class: 'erd-node-shadow', x: 0, y: 0, width: NODE_W, height: h, rx: 12, fill: 'transparent', filter: 'url(#erd-shadow)' }));
1855
1855
  // Main background
1856
1856
  g.appendChild(svgE('rect', { class: 'erd-node-rect', x: 0, y: 0, width: NODE_W, height: h, rx: 12, fill: 'var(--erd-node-bg)', stroke: 'var(--erd-node-border)', 'stroke-width': '1.5' }));
1857
1857
  // Header gradient strip
1858
1858
  const hdrClip = svgE('clipPath', { id: `erd-hclip-${t.name.replace(/\W/g,'_')}` });
1859
1859
  hdrClip.appendChild(svgE('rect', { x: 0, y: 0, width: NODE_W, height: HEADER_H, rx: 12 }));
1860
1860
  g.appendChild(hdrClip);
1861
1861
  const hdrRect = svgE('rect', { class: 'erd-node-header-rect', x: 0, y: 0, width: NODE_W, height: HEADER_H, fill: pal.h, 'clip-path': `url(#erd-hclip-${t.name.replace(/\W/g,'_')})` });
1862
1862
  g.appendChild(hdrRect);
1863
1863
  // bottom fill to square off the lower edge of header
1864
1864
  g.appendChild(svgE('rect', { x: 0, y: HEADER_H - 12, width: NODE_W, height: 12, fill: pal.h }));
1865
1865
  // Collapse arrow
1866
1866
  const arrX = NODE_W - 20, arrY = HEADER_H / 2;
1867
1867
  const arrPath = isCollapsed ? 'M-4,-2 L0,3 L4,-2' : 'M-4,2 L0,-3 L4,2';
1868
1868
  const arr = svgE('path', { d: arrPath, fill: 'none', stroke: 'rgba(255,255,255,0.7)', 'stroke-width': '2', 'stroke-linecap': 'round', transform: `translate(${arrX},${arrY})`, style: 'cursor:pointer;', class: 'erd-collapse-btn' });
1869
1869
  // Table name
1870
1870
  const nameEl = svgE('text', { x: 14, y: 26, fill: '#fff', 'font-size': '13', 'font-weight': '700', 'font-family': 'Inter, ui-sans-serif, sans-serif', class: 'erd-node-title' });
1871
1871
  const maxLen = 20;
1872
1872
  nameEl.textContent = t.name.length > maxLen ? t.name.slice(0, maxLen - 1) + '…' : t.name;
1873
1873
  g.appendChild(nameEl);
1874
1874
  // Row/col count pill in header
1875
1875
  const pillW = 36, pillX = NODE_W - pillW - 8;
1876
1876
  g.appendChild(svgE('rect', { x: pillX, y: 10, width: pillW, height: 18, rx: 9, fill: 'rgba(0,0,0,0.18)' }));
1877
1877
  const pillTxt = svgE('text', { x: pillX + pillW / 2, y: 23, 'text-anchor': 'middle', fill: 'rgba(255,255,255,0.85)', 'font-size': '9.5', 'font-weight': '600', 'font-family': 'Inter, sans-serif' });
1878
1878
  pillTxt.textContent = t.columns.length + (t.columns.length === 1 ? ' col' : ' cols');
1879
1879
  g.appendChild(pillTxt);
1880
1880
  // Columns (when not collapsed)
1881
1881
  if (!isCollapsed) {
1882
1882
  t.columns.forEach((col, idx) => {
1883
1883
  const cy = HEADER_H + NODE_PAD + idx * COL_H;
1884
1884
  const isPk = pkCols.has(col.name);
1885
1885
  const isFk = fkCols.has(col.name);
1886
1886
  const isLast = idx === t.columns.length - 1;
1887
1887
  // Row background
1888
1888
  let rowFill = idx % 2 === 0 ? 'var(--erd-node-row-even)' : 'var(--erd-node-row-odd)';
1889
1889
  if (isPk) rowFill = 'var(--erd-node-row-pk)';
1890
1890
  if (isFk && !isPk) rowFill = 'var(--erd-node-row-fk)';
1891
1891
  const rowBg = svgE('rect', { x: 1, y: cy, width: NODE_W - 2, height: COL_H, fill: rowFill });
1892
1892
  if (isLast) { rowBg.setAttribute('rx', '0'); } // no extra clip needed
1893
1893
  g.appendChild(rowBg);
1894
1894
  // Left accent bar for PK/FK
1895
1895
  if (isPk || isFk) {
1896
1896
  g.appendChild(svgE('rect', { x: 1, y: cy, width: 3, height: COL_H, fill: isPk ? pal.h : '#f59e0b' }));
1897
1897
  }
1898
1898
  // Column name
1899
1899
  const nameTxt = svgE('text', { x: isPk || isFk ? 14 : 10, y: cy + 17, fill: isPk ? pal.h : 'var(--erd-node-text)', 'font-size': '11.5', 'font-weight': isPk ? '600' : '400', 'font-family': '"JetBrains Mono", "Fira Code", monospace' });
1900
1900
  const trim = 15;
1901
1901
  nameTxt.textContent = col.name.length > trim ? col.name.slice(0, trim - 1) + '…' : col.name;
1902
1902
  g.appendChild(nameTxt);
1903
1903
  // Type
1904
1904
  const typeStr = (col.type || 'any').toUpperCase().replace('CHARACTER VARYING', 'VARCHAR');
1905
1905
  const typeTrimmed = typeStr.length > 10 ? typeStr.slice(0, 9) + '…' : typeStr;
1906
1906
  const typeX = NODE_W - (isPk || isFk ? 46 : 10);
1907
1907
  const typeTxt = svgE('text', { x: typeX, y: cy + 17, 'text-anchor': 'end', fill: 'var(--erd-node-subtext)', 'font-size': '9.5', 'font-family': '"JetBrains Mono", monospace' });
1908
1908
  typeTxt.textContent = typeTrimmed;
1909
1909
  g.appendChild(typeTxt);
1910
1910
  // Badges
1911
1911
  let badgeX = NODE_W - 8;
1912
1912
  if (isFk) {
1913
1913
  badgeX -= 22;
1914
1914
  g.appendChild(svgE('rect', { x: badgeX, y: cy + 6, width: 20, height: 13, rx: 3, fill: 'rgba(245,158,11,0.18)' }));
1915
1915
  const fkTxt = svgE('text', { x: badgeX + 10, y: cy + 15.5, 'text-anchor': 'middle', fill: '#d97706', 'font-size': '7.5', 'font-weight': '700', 'font-family': 'Inter, sans-serif' });
1916
1916
  fkTxt.textContent = 'FK';
1917
1917
  g.appendChild(fkTxt);
1918
1918
  }
1919
1919
  if (isPk) {
1920
1920
  badgeX -= 22;
1921
1921
  g.appendChild(svgE('rect', { x: badgeX, y: cy + 6, width: 20, height: 13, rx: 3, fill: 'rgba(99,102,241,0.15)' }));
1922
1922
  const pkTxt = svgE('text', { x: badgeX + 10, y: cy + 15.5, 'text-anchor': 'middle', fill: pal.h, 'font-size': '7.5', 'font-weight': '700', 'font-family': 'Inter, sans-serif' });
1923
1923
  pkTxt.textContent = 'PK';
1924
1924
  g.appendChild(pkTxt);
1925
1925
  }
1926
1926
  // Invisible wide hit target for row hover (used for FK jump)
1927
1927
  const rowHit = svgE('rect', { x: 0, y: cy, width: NODE_W, height: COL_H, fill: 'transparent' });
1928
1928
  g.appendChild(rowHit);
1929
1929
  });
1930
1930
  // Bottom rounded cap to complete the card
1931
1931
  const capClip = svgE('clipPath', { id: `erd-bclip-${t.name.replace(/\W/g,'_')}` });
1932
1932
  capClip.appendChild(svgE('rect', { x: 0, y: h - 12, width: NODE_W, height: 12, rx: 0 }));
1933
1933
  g.appendChild(capClip);
1934
1934
  }
1935
1935
  // Invisible interaction rect (full node)
1936
1936
  const hitRect = svgE('rect', { x: 0, y: 0, width: NODE_W, height: h, rx: 12, fill: 'transparent', class: 'erd-hit', style: 'cursor:grab;' });
1937
1937
  g.appendChild(hitRect);
1938
1938
  // Collapse button & hit target (appended after hitRect so it receives clicks on top)
1939
1939
  g.appendChild(arr);
1940
1940
  const arrHit = svgE('rect', { x: NODE_W - 36, y: 0, width: 36, height: HEADER_H, fill: 'transparent', style: 'cursor:pointer;', class: 'erd-collapse-hit' });
1941
1941
  arrHit.addEventListener('click', e => { e.stopPropagation(); toggleCollapse(t.name); });
1942
1942
  arrHit.addEventListener('mousedown', e => { e.stopPropagation(); });
1943
1943
  g.appendChild(arrHit);
1944
1944
  // Click → select
1945
1945
  hitRect.addEventListener('click', e => {
1946
1946
  e.stopPropagation();
1947
1947
  if (wasNodeDragged) { wasNodeDragged = false; return; }
1948
1948
  selectTable(t.name);
1949
1949
  });
1950
1950
  // Right-click → context menu
1951
1951
  g.addEventListener('contextmenu', e => {
1952
1952
  e.preventDefault(); e.stopPropagation();
1953
1953
  openCtxMenu(t.name, e.clientX, e.clientY);
1954
1954
  });
1955
1955
  // Drag
1956
1956
  hitRect.addEventListener('mousedown', e => {
1957
1957
  if (e.button !== 0) return;
1958
1958
  e.stopPropagation();
1959
1959
  wasNodeDragged = false;
1960
1960
  draggingNode = { name: t.name, origX: positions[t.name].x, origY: positions[t.name].y, startX: e.clientX, startY: e.clientY, moved: false };
1961
1961
  wrap.style.cursor = 'grabbing';
1962
1962
  });
1963
1963
  nodesG.appendChild(g);
1964
1964
  }
1965
1965
  // ── Edges ─────────────────────────────────────────────────────────────────
1966
1966
  function getPortY(tableName, colName) {
1967
1967
  if (collapsed.has(tableName) || compactAll) return COMPACT_H / 2;
1968
1968
  const t = schema.tables.find(t => t.name === tableName);
1969
1969
  if (!t) return HEADER_H / 2;
1970
1970
  const idx = t.columns.findIndex(c => c.name === colName);
1971
1971
  if (idx === -1) return HEADER_H + NODE_PAD + COL_H / 2;
1972
1972
  return HEADER_H + NODE_PAD + idx * COL_H + COL_H / 2;
1973
1973
  }
1974
1974
  function edgePath(srcPos, srcColY, tgtPos, tgtColY) {
1975
1975
  let sx = srcPos.x + NODE_W, sy = srcPos.y + srcColY;
1976
1976
  let ex = tgtPos.x, ey = tgtPos.y + tgtColY;
1977
1977
  if (tgtPos.x < srcPos.x) { sx = srcPos.x; ex = tgtPos.x + NODE_W; }
1978
1978
  const dx = Math.abs(ex - sx);
1979
1979
  const cpDx = Math.max(60, dx * 0.45);
1980
1980
  return `M${sx},${sy} C${sx + (tgtPos.x < srcPos.x ? -cpDx : cpDx)},${sy} ${ex - (tgtPos.x < srcPos.x ? -cpDx : cpDx)},${ey} ${ex},${ey}`;
1981
1981
  }
1982
1982
  function renderEdges(t) {
1983
1983
  t.foreignKeys.forEach(fk => {
1984
1984
  const srcPos = positions[t.name], tgtPos = positions[fk.table];
1985
1985
  if (!srcPos || !tgtPos) return;
1986
1986
  const d = edgePath(srcPos, getPortY(t.name, fk.from), tgtPos, getPortY(fk.table, fk.to || ''));
1987
1987
  const path = svgE('path', {
1988
1988
  class: 'erd-edge',
1989
1989
  d,
1990
1990
  fill: 'none',
1991
1991
  stroke: 'var(--erd-edge-color)',
1992
1992
  'stroke-width': '1.8',
1993
1993
  'stroke-dasharray': 'none',
1994
1994
  'marker-end': 'url(#erd-arrow)',
1995
1995
  'data-from': t.name,
1996
1996
  'data-to': fk.table,
1997
1997
  'data-from-col': fk.from,
1998
1998
  'data-to-col': fk.to || '',
1999
1999
  opacity: '0.65',
2000
2000
  });
2001
2001
  // Edge hover: show tooltip + highlight
2002
2002
  path.addEventListener('mouseenter', e => {
2003
2003
  const rect = wrap.getBoundingClientRect();
2004
2004
  edgeTip.style.left = (e.clientX - rect.left + 12) + 'px';
2005
2005
  edgeTip.style.top = (e.clientY - rect.top - 8) + 'px';
2006
2006
  edgeTip.innerHTML = `<span style="color:var(--erd-edge-color)">${fk.from}</span> <span style="opacity:.5">→</span> <span style="color:#10b981">${fk.table}</span><span style="opacity:.5">.${fk.to || '?'}</span>`;
2007
2007
  edgeTip.classList.remove('hidden');
2008
2008
  path.setAttribute('stroke-width', '2.8');
2009
2009
  path.style.opacity = '1';
2010
2010
  });
2011
2011
  path.addEventListener('mousemove', e => {
2012
2012
  const rect = wrap.getBoundingClientRect();
2013
2013
  edgeTip.style.left = (e.clientX - rect.left + 12) + 'px';
2014
2014
  edgeTip.style.top = (e.clientY - rect.top - 8) + 'px';
2015
2015
  });
2016
2016
  path.addEventListener('mouseleave', () => {
2017
2017
  edgeTip.classList.add('hidden');
2018
2018
  if (!path.classList.contains('highlighted') && !path.classList.contains('neighbor-edge')) {
2019
2019
  path.setAttribute('stroke-width', '1.8');
2020
2020
  path.style.opacity = '0.65';
2021
2021
  }
2022
2022
  });
2023
2023
  edgesG.appendChild(path);
2024
2024
  });
2025
2025
  }
2026
2026
  function updateEdges() {
2027
2027
  edgesG.querySelectorAll('.erd-edge').forEach(path => {
2028
2028
  const fn = path.getAttribute('data-from'), tn = path.getAttribute('data-to');
2029
2029
  const fc = path.getAttribute('data-from-col'), tc = path.getAttribute('data-to-col');
2030
2030
  const sp = positions[fn], tp = positions[tn];
2031
2031
  if (!sp || !tp) return;
2032
2032
  path.setAttribute('d', edgePath(sp, getPortY(fn, fc), tp, getPortY(tn, tc)));
2033
2033
  });
2034
2034
  }
2035
2035
  // ── Selection & highlighting ──────────────────────────────────────────────
2036
2036
  function clearHighlights() {
2037
2037
  nodesG.querySelectorAll('.erd-node').forEach(el => {
2038
2038
  el.classList.remove('selected', 'neighbor', 'dimmed');
2039
2039
  });
2040
2040
  edgesG.querySelectorAll('.erd-edge').forEach(el => {
2041
2041
  el.classList.remove('highlighted', 'neighbor-edge', 'dimmed');
2042
2042
  el.setAttribute('stroke-width', '1.8');
2043
2043
  el.setAttribute('marker-end', 'url(#erd-arrow)');
2044
2044
  el.style.opacity = '0.65';
2045
2045
  });
2046
2046
  }
2047
2047
  function selectTable(name) {
2048
2048
  if (selectedTable === name) { deselectTable(); return; }
2049
2049
  selectedTable = name;
2050
2050
  clearHighlights();
2051
2051
  // Collect neighbors
2052
2052
  const neighbors = new Set();
2053
2053
  schema.tables.forEach(t => {
2054
2054
  t.foreignKeys.forEach(fk => {
2055
2055
  if (t.name === name) neighbors.add(fk.table);
2056
2056
  if (fk.table === name) neighbors.add(t.name);
2057
2057
  });
2058
2058
  });
2059
2059
  // Apply node classes
2060
2060
  nodesG.querySelectorAll('.erd-node').forEach(el => {
2061
2061
  const n = el.getAttribute('data-table');
2062
2062
  if (n === name) el.classList.add('selected');
2063
2063
  else if (neighbors.has(n)) el.classList.add('neighbor');
2064
2064
  else el.classList.add('dimmed');
2065
2065
  });
2066
2066
  // Apply edge classes
2067
2067
  edgesG.querySelectorAll('.erd-edge').forEach(el => {
2068
2068
  const f = el.getAttribute('data-from'), t = el.getAttribute('data-to');
2069
2069
  if (f === name || t === name) {
2070
2070
  el.classList.add('neighbor-edge');
2071
2071
  el.setAttribute('stroke-width', '2.2');
2072
2072
  el.style.opacity = '1';
2073
2073
  el.setAttribute('marker-end', 'url(#erd-arrow)');
2074
2074
  } else {
2075
2075
  el.classList.add('dimmed');
2076
2076
  }
2077
2077
  });
2078
2078
  showSidePanel(name, neighbors);
2079
2079
  }
2080
2080
  function deselectTable() {
2081
2081
  selectedTable = null;
2082
2082
  clearHighlights();
2083
2083
  hideSidePanel();
2084
2084
  }
2085
2085
  // ── Side panel ────────────────────────────────────────────────────────────
2086
2086
  function showSidePanel(name, neighbors) {
2087
2087
  const t = schema.tables.find(t => t.name === name);
2088
2088
  if (!t) return;
2089
2089
  const pal = palOf(name);
2090
2090
  sidePanel.classList.remove('hidden');
2091
2091
  sidePanel.classList.add('visible');
2092
2092
  document.getElementById('erd-panel-header').style.background = pal.h;
2093
2093
  document.getElementById('erd-panel-name').textContent = name;
2094
2094
  document.getElementById('erd-panel-col-count').textContent = t.columns.length;
2095
2095
  document.getElementById('erd-panel-fk-count').textContent = t.foreignKeys.length;
2096
2096
  // how many other tables reference this one?
2097
2097
  const refCount = schema.tables.filter(x => x.foreignKeys.some(fk => fk.table === name)).length;
2098
2098
  document.getElementById('erd-panel-ref-count').textContent = refCount;
2099
2099
  const panelBrowse = document.getElementById('erd-panel-browse');
2100
2100
  const panelSchema = document.getElementById('erd-panel-schema');
2101
2101
  const panelSeed = document.getElementById('erd-panel-seed');
2102
2102
  panelBrowse.href = `${BASE}/tables/${encodeURIComponent(DB_ID)}/${encodeURIComponent(name)}`;
2103
2103
  panelSchema.href = `${BASE}/schema/${encodeURIComponent(DB_ID)}/${encodeURIComponent(name)}`;
2104
2104
  if (panelSeed) {
2105
2105
  if (window.APP?.readonly) {
2106
2106
  panelSeed.classList.add('hidden');
2107
2107
  } else {
2108
2108
  panelSeed.classList.remove('hidden');
2109
2109
  panelSeed.href = `${BASE}/seed/${encodeURIComponent(DB_ID)}/${encodeURIComponent(name)}?mode=chain`;
2110
2110
  }
2111
2111
  }
2112
2112
  // Columns list
2113
2113
  const fkCols = new Set(t.foreignKeys.map(f => f.from));
2114
2114
  const pkCols = new Set(t.primaryKey && t.primaryKey.length ? t.primaryKey : t.columns.filter(c => c.pk > 0).map(c => c.name));
2115
2115
  const colsEl = document.getElementById('erd-panel-cols');
2116
2116
  colsEl.innerHTML = '';
2117
2117
  t.columns.forEach(col => {
2118
2118
  const isPk = pkCols.has(col.name), isFk = fkCols.has(col.name);
2119
2119
  const row = document.createElement('div');
2120
2120
  row.className = 'erd-col-row';
2121
2121
  row.style.background = isPk ? 'rgba(99,102,241,0.06)' : isFk ? 'rgba(245,158,11,0.05)' : '';
2122
2122
  const lAcc = document.createElement('div');
2123
2123
  lAcc.style.cssText = `width:3px;height:16px;border-radius:2px;background:${isPk ? pal.h : isFk ? '#f59e0b' : 'transparent'};flex-shrink:0;`;
2124
2124
  row.appendChild(lAcc);
2125
2125
  const nameEl = document.createElement('span');
2126
2126
  nameEl.className = 'erd-col-row-name';
2127
2127
  nameEl.textContent = col.name;
2128
2128
  nameEl.style.color = isPk ? pal.h : '';
2129
2129
  row.appendChild(nameEl);
2130
2130
  const typeEl = document.createElement('span');
2131
2131
  typeEl.className = 'erd-col-row-type';
2132
2132
  typeEl.textContent = (col.type || 'any').toUpperCase().replace('CHARACTER VARYING', 'VARCHAR');
2133
2133
  row.appendChild(typeEl);
2134
2134
  if (isPk) { const b = document.createElement('span'); b.className = 'erd-badge erd-badge-pk'; b.textContent = 'PK'; row.appendChild(b); }
2135
2135
  if (isFk) { const b = document.createElement('span'); b.className = 'erd-badge erd-badge-fk'; b.textContent = 'FK'; row.appendChild(b); }
2136
2136
  if (col.notnull && !isPk) { const b = document.createElement('span'); b.className = 'erd-badge erd-badge-nn'; b.textContent = 'NN'; row.appendChild(b); }
2137
2137
  colsEl.appendChild(row);
2138
2138
  });
2139
2139
  // Relationships panel
2140
2140
  const relsPanel = document.getElementById('erd-panel-rels');
2141
2141
  const relsList = document.getElementById('erd-panel-rels-list');
2142
2142
  relsList.innerHTML = '';
2143
2143
  const allRels = [];
2144
2144
  // outgoing FKs
2145
2145
  t.foreignKeys.forEach(fk => allRels.push({ dir: 'out', label: `${name}.${fk.from} → ${fk.table}.${fk.to || '?'}`, target: fk.table }));
2146
2146
  // incoming
2147
2147
  schema.tables.forEach(src => {
2148
2148
  if (src.name === name) return;
2149
2149
  src.foreignKeys.filter(fk => fk.table === name).forEach(fk => {
2150
2150
  allRels.push({ dir: 'in', label: `${src.name}.${fk.from} → ${name}.${fk.to || '?'}`, target: src.name });
2151
2151
  });
2152
2152
  });
2153
2153
  if (allRels.length > 0) {
2154
2154
  relsPanel.classList.remove('hidden');
2155
2155
  allRels.forEach(rel => {
2156
2156
  const row = document.createElement('div');
2157
2157
  row.className = 'erd-rel-row';
2158
2158
  row.innerHTML = `
2159
2159
  <span style="font-size:10px;opacity:.5">${rel.dir === 'out' ? '→' : '←'}</span>
2160
2160
  <span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:monospace;font-size:10.5px;">${rel.label}</span>
2161
2161
  <span style="font-size:10px;color:var(--erd-edge-color);font-weight:600;">${rel.target}</span>
2162
2162
  `;
2163
2163
  row.addEventListener('click', () => selectTable(rel.target));
2164
2164
  relsList.appendChild(row);
2165
2165
  });
2166
2166
  } else {
2167
2167
  relsPanel.classList.add('hidden');
2168
2168
  }
2169
2169
  }
2170
2170
  function hideSidePanel() {
2171
2171
  sidePanel.classList.remove('visible');
2172
2172
  sidePanel.classList.add('hidden');
2173
2173
  }
2174
2174
  document.getElementById('erd-panel-close').addEventListener('click', deselectTable);
2175
2175
  // ── Context menu ─────────────────────────────────────────────────────────
2176
2176
  function openCtxMenu(name, cx, cy) {
2177
2177
  ctxTable = name;
2178
2178
  document.getElementById('erd-ctx-title').textContent = name;
2179
2179
  document.getElementById('erd-ctx-browse').href = `${BASE}/tables/${encodeURIComponent(DB_ID)}/${encodeURIComponent(name)}`;
2180
2180
  document.getElementById('erd-ctx-schema').href = `${BASE}/schema/${encodeURIComponent(DB_ID)}/${encodeURIComponent(name)}`;
2181
2181
  const ctxSeed = document.getElementById('erd-ctx-seed');
2182
2182
  if (ctxSeed) ctxSeed.href = `${BASE}/seed/${encodeURIComponent(DB_ID)}/${encodeURIComponent(name)}?mode=chain`;
2183
2183
  document.getElementById('erd-ctx-collapse-label').textContent = collapsed.has(name) ? 'Expand table' : 'Collapse table';
2184
2184
  const rect = wrap.getBoundingClientRect();
2185
2185
  let x = cx - rect.left, y = cy - rect.top;
2186
2186
  ctxMenu.style.left = Math.max(8, Math.min(x, wrap.clientWidth - 210)) + 'px';
2187
2187
  ctxMenu.style.top = Math.max(8, Math.min(y, wrap.clientHeight - 170)) + 'px';
2188
2188
  ctxMenu.classList.remove('hidden');
2189
2189
  selectTable(name);
2190
2190
  }
2191
2191
  document.getElementById('erd-ctx-focus').addEventListener('click', () => {
2192
2192
  ctxMenu.classList.add('hidden');
2193
2193
  if (!ctxTable) return;
2194
2194
  const p = positions[ctxTable], t = schema.tables.find(x => x.name === ctxTable);
2195
2195
  if (!p || !t) return;
2196
2196
  const nw = NODE_W, nh = nodeHeight(t);
2197
2197
  const cx = wrap.clientWidth / 2, cy = wrap.clientHeight / 2;
2198
2198
  scale = Math.min(1.8, Math.min(wrap.clientWidth / (nw + 80), wrap.clientHeight / (nh + 80)));
2199
2199
  tx = cx - (p.x + nw / 2) * scale;
2200
2200
  ty = cy - (p.y + nh / 2) * scale;
2201
2201
  applyTransform();
2202
2202
  });
2203
2203
  document.getElementById('erd-ctx-collapse').addEventListener('click', () => {
2204
2204
  ctxMenu.classList.add('hidden');
2205
2205
  if (ctxTable) toggleCollapse(ctxTable);
2206
2206
  });
2207
2207
  document.addEventListener('click', () => ctxMenu.classList.add('hidden'));
2208
2208
  document.addEventListener('keydown', e => { if (e.key === 'Escape') ctxMenu.classList.add('hidden'); });
2209
2209
  // ── Collapse toggle ───────────────────────────────────────────────────────
2210
2210
  function toggleCollapse(name) {
2211
2211
  if (collapsed.has(name)) collapsed.delete(name); else collapsed.add(name);
2212
2212
  edgesG.innerHTML = '';
2213
2213
  nodesG.innerHTML = '';
2214
2214
  schema.tables.forEach(t => renderNode(t));
2215
2215
  schema.tables.forEach(t => renderEdges(t));
2216
2216
  if (selectedTable) selectTable(selectedTable);
2217
2217
  applySearch();
2218
2218
  renderMinimap();
2219
2219
  }
2220
2220
  // ── Compact mode (all tables) ─────────────────────────────────────────────
2221
2221
  document.getElementById('erd-btn-compact').addEventListener('click', () => {
2222
2222
  compactAll = !compactAll;
2223
2223
  const btn = document.getElementById('erd-btn-compact');
2224
2224
  btn.classList.toggle('btn-active', compactAll);
2225
2225
  positions = {};
2226
2226
  initLayout();
2227
2227
  edgesG.innerHTML = '';
2228
2228
  nodesG.innerHTML = '';
2229
2229
  schema.tables.forEach(t => renderNode(t));
2230
2230
  schema.tables.forEach(t => renderEdges(t));
2231
2231
  fitView();
2232
2232
  if (selectedTable) selectTable(selectedTable);
2233
2233
  renderMinimap();
2234
2234
  });
2235
2235
  // ── Search/filter ─────────────────────────────────────────────────────────
2236
2236
  function applySearch() {
2237
2237
  if (!schema) return;
2238
2238
  const q = searchQuery.toLowerCase().trim();
2239
2239
  nodesG.querySelectorAll('.erd-node').forEach(el => {
2240
2240
  const name = el.getAttribute('data-table');
2241
2241
  el.classList.remove('search-match', 'search-no-match');
2242
2242
  if (!q) return;
2243
2243
  if (name.toLowerCase().includes(q)) el.classList.add('search-match');
2244
2244
  else el.classList.add('search-no-match');
2245
2245
  });
2246
2246
  }
2247
2247
  searchInput.addEventListener('input', () => {
2248
2248
  searchQuery = searchInput.value;
2249
2249
  applySearch();
2250
2250
  if (searchQuery && schema) {
2251
2251
  // pan to first match
2252
2252
  const match = schema.tables.find(t => t.name.toLowerCase().includes(searchQuery.toLowerCase()));
2253
2253
  if (match && positions[match.name]) {
2254
2254
  const p = positions[match.name], nh = nodeHeight(match);
2255
2255
  const cx = wrap.clientWidth / 2, cy = wrap.clientHeight / 2;
2256
2256
  tx = cx - (p.x + NODE_W / 2) * scale;
2257
2257
  ty = cy - (p.y + nh / 2) * scale;
2258
2258
  applyTransform();
2259
2259
  }
2260
2260
  }
2261
2261
  });
2262
2262
  // ── Viewport transform ────────────────────────────────────────────────────
2263
2263
  function applyTransform() {
2264
2264
  viewport.setAttribute('transform', `translate(${tx},${ty}) scale(${scale})`);
2265
2265
  zoomLabel.textContent = Math.round(scale * 100) + '%';
2266
2266
  renderMinimap();
2267
2267
  }
2268
2268
  function fitView() {
2269
2269
  if (!schema || schema.tables.length === 0) return;
2270
2270
  const wW = wrap.clientWidth, wH = wrap.clientHeight;
2271
2271
  let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
2272
2272
  schema.tables.forEach(t => {
2273
2273
  const p = positions[t.name]; if (!p) return;
2274
2274
  minX = Math.min(minX, p.x); minY = Math.min(minY, p.y);
2275
2275
  maxX = Math.max(maxX, p.x + NODE_W); maxY = Math.max(maxY, p.y + nodeHeight(t));
2276
2276
  });
2277
2277
  const cW = maxX - minX + 120, cH = maxY - minY + 120;
2278
2278
  scale = Math.min(1.1, Math.min(wW / cW, wH / cH));
2279
2279
  tx = (wW - cW * scale) / 2 - minX * scale + 60 * scale;
2280
2280
  ty = (wH - cH * scale) / 2 - minY * scale + 60 * scale;
2281
2281
  applyTransform();
2282
2282
  }
2283
2283
  // ── Minimap ───────────────────────────────────────────────────────────────
2284
2284
  function renderMinimap() {
2285
2285
  if (!schema || schema.tables.length === 0) return;
2286
2286
  minimapSvg.innerHTML = '';
2287
2287
  let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
2288
2288
  schema.tables.forEach(t => {
2289
2289
  const p = positions[t.name]; if (!p) return;
2290
2290
  minX = Math.min(minX, p.x); minY = Math.min(minY, p.y);
2291
2291
  maxX = Math.max(maxX, p.x + NODE_W); maxY = Math.max(maxY, p.y + nodeHeight(t));
2292
2292
  });
2293
2293
  const cW = maxX - minX || 1, cH = maxY - minY || 1;
2294
2294
  const mW = 168, mH = 92;
2295
2295
  const mS = Math.min((mW - 8) / (cW + 40), (mH - 8) / (cH + 40));
2296
2296
  schema.tables.forEach(t => {
2297
2297
  const p = positions[t.name]; if (!p) return;
2298
2298
  const pal = palOf(t.name);
2299
2299
  const rx = (p.x - minX + 20) * mS + 4, ry = (p.y - minY + 20) * mS + 4;
2300
2300
  const rw = NODE_W * mS, rh = nodeHeight(t) * mS;
2301
2301
  const r = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
2302
2302
  r.setAttribute('x', rx); r.setAttribute('y', ry);
2303
2303
  r.setAttribute('width', Math.max(rw, 2)); r.setAttribute('height', Math.max(rh, 2));
2304
2304
  r.setAttribute('rx', 2);
2305
2305
  r.setAttribute('fill', t.name === selectedTable ? pal.h : pal.h + '55');
2306
2306
  r.setAttribute('stroke', pal.h); r.setAttribute('stroke-width', '0.5');
2307
2307
  minimapSvg.appendChild(r);
2308
2308
  });
2309
2309
  // Viewport rect
2310
2310
  const vpX = (-tx / scale - minX + 20) * mS + 4;
2311
2311
  const vpY = (-ty / scale - minY + 20) * mS + 4;
2312
2312
  const vpW = (wrap.clientWidth / scale) * mS;
2313
2313
  const vpH = (wrap.clientHeight / scale) * mS;
2314
2314
  const vp = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
2315
2315
  vp.setAttribute('x', vpX); vp.setAttribute('y', vpY);
2316
2316
  vp.setAttribute('width', vpW); vp.setAttribute('height', vpH);
2317
2317
  vp.setAttribute('fill', 'rgba(99,102,241,0.06)');
2318
2318
  vp.setAttribute('stroke', '#6366f1'); vp.setAttribute('stroke-width', '1'); vp.setAttribute('rx', '2');
2319
2319
  minimapSvg.appendChild(vp);
2320
2320
  }
2321
2321
  // ── Mouse / touch events ──────────────────────────────────────────────────
2322
2322
  wrap.addEventListener('wheel', e => {
2323
2323
  e.preventDefault();
2324
2324
  const d = e.deltaY > 0 ? 0.88 : 1.12;
2325
2325
  const r = wrap.getBoundingClientRect();
2326
2326
  const mx = e.clientX - r.left, my = e.clientY - r.top;
2327
2327
  const ns = Math.min(3, Math.max(0.1, scale * d));
2328
2328
  tx = mx - (mx - tx) * (ns / scale);
2329
2329
  ty = my - (my - ty) * (ns / scale);
2330
2330
  scale = ns; applyTransform();
2331
2331
  }, { passive: false });
2332
2332
  // Canvas panning
2333
2333
  wrap.addEventListener('mousedown', e => {
2334
2334
  if (draggingNode || e.button !== 0) return;
2335
2335
  const tgt = e.target;
2336
2336
  if (tgt.closest('.erd-node') || tgt.closest('#erd-ctx-menu')) return;
2337
2337
  isPanning = true;
2338
2338
  panStart = { x: e.clientX, y: e.clientY, tx, ty };
2339
2339
  wrap.style.cursor = 'grabbing';
2340
2340
  });
2341
2341
  window.addEventListener('mousemove', e => {
2342
2342
  if (draggingNode) {
2343
2343
  const dx = (e.clientX - draggingNode.startX) / scale;
2344
2344
  const dy = (e.clientY - draggingNode.startY) / scale;
2345
2345
  if (Math.abs(dx) > 2 || Math.abs(dy) > 2) {
2346
2346
  draggingNode.moved = true;
2347
2347
  wasNodeDragged = true;
2348
2348
  }
2349
2349
  positions[draggingNode.name].x = draggingNode.origX + dx;
2350
2350
  positions[draggingNode.name].y = draggingNode.origY + dy;
2351
2351
  const el = nodesG.querySelector(`[data-table="${draggingNode.name}"]`);
2352
2352
  if (el) { const p = positions[draggingNode.name]; el.setAttribute('transform', `translate(${p.x},${p.y})`); }
2353
2353
  updateEdges();
2354
2354
  renderMinimap();
2355
2355
  } else if (isPanning) {
2356
2356
  tx = panStart.tx + (e.clientX - panStart.x);
2357
2357
  ty = panStart.ty + (e.clientY - panStart.y);
2358
2358
  applyTransform();
2359
2359
  }
2360
2360
  });
2361
2361
  window.addEventListener('mouseup', () => {
2362
2362
  draggingNode = null; isPanning = false;
2363
2363
  wrap.style.cursor = 'grab';
2364
2364
  });
2365
2365
  // Click on canvas background → deselect
2366
2366
  svgEl_.addEventListener('click', e => {
2367
2367
  if (e.target === svgEl_ || e.target.id === 'erd-viewport') deselectTable();
2368
2368
  });
2369
2369
  // Zoom buttons
2370
2370
  function zoomAt(factor) {
2371
2371
  const cx = wrap.clientWidth / 2, cy = wrap.clientHeight / 2;
2372
2372
  const ns = Math.min(3, Math.max(0.1, scale * factor));
2373
2373
  tx = cx - (cx - tx) * (ns / scale); ty = cy - (cy - ty) * (ns / scale);
2374
2374
  scale = ns; applyTransform();
2375
2375
  }
2376
2376
  document.getElementById('erd-btn-zoom-in').addEventListener('click', () => zoomAt(1.2));
2377
2377
  document.getElementById('erd-btn-zoom-out').addEventListener('click', () => zoomAt(0.83));
2378
2378
  document.getElementById('erd-btn-fit').addEventListener('click', fitView);
2379
2379
  // Layout mode buttons
2380
2380
  function applyLayoutMode(mode) {
2381
2381
  if (!schema) return;
2382
2382
  layoutMode = mode;
2383
2383
  positions = {};
2384
2384
  initLayout();
2385
2385
  edgesG.innerHTML = ''; nodesG.innerHTML = '';
2386
2386
  schema.tables.forEach(t => renderNode(t));
2387
2387
  schema.tables.forEach(t => renderEdges(t));
2388
2388
  fitView();
2389
2389
  if (selectedTable) selectTable(selectedTable);
2390
2390
  applySearch();
2391
2391
  renderMinimap();
2392
2392
  }
2393
2393
  document.querySelectorAll('.erd-mode-btn').forEach(btn => {
2394
2394
  btn.addEventListener('click', () => applyLayoutMode(btn.dataset.mode));
2395
2395
  });
2396
2396
  updateModeButtons();
2397
2397
  // Export SVG
2398
2398
  document.getElementById('erd-btn-export-svg').addEventListener('click', () => {
2399
2399
  if (!schema) return;
2400
2400
  // 1. Compute content bounds in diagram-space
2401
2401
  let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
2402
2402
  schema.tables.forEach(t => {
2403
2403
  const p = positions[t.name]; if (!p) return;
2404
2404
  minX = Math.min(minX, p.x); minY = Math.min(minY, p.y);
2405
2405
  maxX = Math.max(maxX, p.x + NODE_W); maxY = Math.max(maxY, p.y + nodeHeight(t));
2406
2406
  });
2407
2407
  const PAD = 48;
2408
2408
  const vW = maxX - minX + PAD * 2;
2409
2409
  const vH = maxY - minY + PAD * 2;
2410
2410
  // 2. Resolve all current CSS variable values
2411
2411
  const cs = getComputedStyle(document.documentElement);
2412
2412
  const get = v => cs.getPropertyValue(v).trim();
2413
2413
  const VARS = {
2414
2414
  'var(--erd-node-bg)' : get('--erd-node-bg') || '#ffffff',
2415
2415
  'var(--erd-node-border)' : get('--erd-node-border') || '#e2e8f0',
2416
2416
  'var(--erd-node-text)' : get('--erd-node-text') || '#1e293b',
2417
2417
  'var(--erd-node-subtext)' : get('--erd-node-subtext') || '#94a3b8',
2418
2418
  'var(--erd-node-row-odd)' : get('--erd-node-row-odd') || '#f8fafc',
2419
2419
  'var(--erd-node-row-even)': get('--erd-node-row-even') || '#ffffff',
2420
2420
  'var(--erd-node-row-pk)' : get('--erd-node-row-pk') || '#eef2ff',
2421
2421
  'var(--erd-node-row-fk)' : get('--erd-node-row-fk') || '#fffbeb',
2422
2422
  'var(--erd-edge-color)' : get('--erd-edge-color') || '#818cf8',
2423
2423
  'var(--erd-edge-hl)' : get('--erd-edge-hl') || '#f59e0b',
2424
2424
  'var(--erd-edge-dim)' : get('--erd-edge-dim') || '#cbd5e1',
2425
2425
  'var(--erd-canvas-bg)' : get('--erd-canvas-bg') || '#f1f5f9',
2426
2426
  };
2427
2427
  function resolveVars(str) {
2428
2428
  if (!str || !str.includes('var(')) return str;
2429
2429
  return Object.entries(VARS).reduce((s, [k, v]) => s.replaceAll(k, v), str);
2430
2430
  }
2431
2431
  // 3. Deep-clone SVG and walk every element to inline CSS vars
2432
2432
  const clone = svgEl_.cloneNode(true);
2433
2433
  function inlineEl(el) {
2434
2434
  // Inline attribute-level var() references
2435
2435
  for (const attr of [...el.attributes]) {
2436
2436
  const resolved = resolveVars(attr.value);
2437
2437
  if (resolved !== attr.value) el.setAttribute(attr.name, resolved);
2438
2438
  }
2439
2439
  // Inline style attribute
2440
2440
  const styleAttr = el.getAttribute('style');
2441
2441
  if (styleAttr) {
2442
2442
  const resolved = resolveVars(styleAttr);
2443
2443
  if (resolved !== styleAttr) el.setAttribute('style', resolved);
2444
2444
  }
2445
2445
  for (const child of el.children) inlineEl(child);
2446
2446
  }
2447
2447
  inlineEl(clone);
2448
2448
  // 4. Fix dimensions & viewBox
2449
2449
  clone.setAttribute('width', String(Math.ceil(vW)));
2450
2450
  clone.setAttribute('height', String(Math.ceil(vH)));
2451
2451
  clone.setAttribute('viewBox', `0 0 ${Math.ceil(vW)} ${Math.ceil(vH)}`);
2452
2452
  clone.removeAttribute('style'); // removes display:none
2453
2453
  // 5. Reset viewport transform — shift content so it starts at (PAD, PAD)
2454
2454
  const vpClone = clone.querySelector('#erd-viewport');
2455
2455
  if (vpClone) vpClone.setAttribute('transform', `translate(${PAD - minX},${PAD - minY})`);
2456
2456
  // 6. Add background rect behind everything
2457
2457
  const bgRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
2458
2458
  bgRect.setAttribute('x', 0); bgRect.setAttribute('y', 0);
2459
2459
  bgRect.setAttribute('width', Math.ceil(vW)); bgRect.setAttribute('height', Math.ceil(vH));
2460
2460
  bgRect.setAttribute('fill', VARS['var(--erd-canvas-bg)']);
2461
2461
  clone.insertBefore(bgRect, clone.firstChild);
2462
2462
  // 7. Strip interactive-only elements (hit targets, collapse arrows, tooltip, minimap)
2463
2463
  clone.querySelectorAll(
2464
2464
  '.erd-hit, .erd-collapse-btn, #erd-minimap, #erd-edge-tooltip, #erd-ctx-menu'
2465
2465
  ).forEach(el => el.remove());
2466
2466
  // 8. Add Google Fonts import so text renders correctly in SVG viewers that support it
2467
2467
  const defs = clone.querySelector('defs') || clone.insertBefore(
2468
2468
  document.createElementNS('http://www.w3.org/2000/svg', 'defs'), clone.firstChild
2469
2469
  );
2470
2470
  const style = document.createElementNS('http://www.w3.org/2000/svg', 'style');
2471
2471
  style.textContent = `@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&family=JetBrains+Mono:wght@400;600&display=swap');`;
2472
2472
  defs.insertBefore(style, defs.firstChild);
2473
2473
  // 9. Serialize and trigger download
2474
2474
  const xml = new XMLSerializer().serializeToString(clone);
2475
2475
  const blob = new Blob(['<?xml version="1.0" encoding="UTF-8"?>\n' + xml], { type: 'image/svg+xml;charset=utf-8' });
2476
2476
  const a = document.createElement('a');
2477
2477
  a.href = URL.createObjectURL(blob);
2478
2478
  a.download = 'er-diagram.svg';
2479
2479
  document.body.appendChild(a);
2480
2480
  a.click();
2481
2481
  document.body.removeChild(a);
2482
2482
  setTimeout(() => URL.revokeObjectURL(a.href), 1000);
2483
2483
  });
2484
2484
  // Touch pinch-zoom
2485
2485
  let lastPinchDist = null;
2486
2486
  wrap.addEventListener('touchstart', e => {
2487
2487
  if (e.touches.length === 2) lastPinchDist = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
2488
2488
  }, { passive: true });
2489
2489
  wrap.addEventListener('touchmove', e => {
2490
2490
  if (e.touches.length !== 2 || !lastPinchDist) return;
2491
2491
  const d = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
2492
2492
  const r = wrap.getBoundingClientRect();
2493
2493
  const mx = (e.touches[0].clientX + e.touches[1].clientX) / 2 - r.left;
2494
2494
  const my = (e.touches[0].clientY + e.touches[1].clientY) / 2 - r.top;
2495
2495
  const ns = Math.min(3, Math.max(0.1, scale * d / lastPinchDist));
2496
2496
  tx = mx - (mx - tx) * (ns / scale); ty = my - (my - ty) * (ns / scale);
2497
2497
  scale = ns; applyTransform(); lastPinchDist = d; e.preventDefault();
2498
2498
  }, { passive: false });
2499
2499
  // Keyboard shortcuts
2500
2500
  document.addEventListener('keydown', e => {
2501
2501
  if (e.target.tagName === 'INPUT') return;
2502
2502
  if (e.key === 'Escape') deselectTable();
2503
2503
  if (e.key === 'f' || e.key === 'F') { e.preventDefault(); fitView(); }
2504
2504
  if (e.key === 'l' || e.key === 'L') {
2505
2505
  e.preventDefault();
2506
2506
  const modes = ['force', 'hierarchy', 'circle'];
2507
2507
  applyLayoutMode(modes[(modes.indexOf(layoutMode) + 1) % modes.length]);
2508
2508
  }
2509
2509
  if (e.key === 'c' || e.key === 'C') { document.getElementById('erd-btn-compact').click(); }
2510
2510
  if (e.key === '+' || e.key === '=') zoomAt(1.2);
2511
2511
  if (e.key === '-') zoomAt(0.83);
2512
2512
  if ((e.key === '0') && (e.ctrlKey || e.metaKey)) { e.preventDefault(); fitView(); }
2513
2513
  });
2514
2514
  // ── Init ─────────────────────────────────────────────────────────────────
2515
2515
  loadSchema();