admindb 2.2.1 → 2.3.0

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