admindb 2.1.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 (119) hide show
  1. package/README.md +368 -335
  2. package/dist/app.js +227 -1
  3. package/dist/auth/config.d.ts +1 -1
  4. package/dist/auth/config.js +109 -1
  5. package/dist/auth/crypto.js +98 -1
  6. package/dist/auth/index.js +21 -1
  7. package/dist/auth/middleware.js +78 -1
  8. package/dist/auth/routes.js +64 -1
  9. package/dist/auth/types.js +7 -1
  10. package/dist/cli/args.d.ts +1 -1
  11. package/dist/cli/args.js +309 -1
  12. package/dist/cli/config.js +118 -1
  13. package/dist/cli/index.js +20 -1
  14. package/dist/cli/runner.js +208 -1
  15. package/dist/cli.js +4 -1
  16. package/dist/data/chain.d.ts +1 -0
  17. package/dist/data/chain.js +130 -0
  18. package/dist/data/datasets.d.ts +1 -1
  19. package/dist/data/datasets.js +171 -1
  20. package/dist/data/detector.d.ts +1 -1
  21. package/dist/data/detector.js +535 -1
  22. package/dist/data/engine.d.ts +1 -1
  23. package/dist/data/engine.js +586 -1
  24. package/dist/data/index.d.ts +1 -1
  25. package/dist/data/index.js +30 -1
  26. package/dist/data/prng.d.ts +1 -0
  27. package/dist/data/prng.js +73 -0
  28. package/dist/data/registry.d.ts +1 -0
  29. package/dist/data/registry.js +805 -0
  30. package/dist/data/schema-graph.d.ts +1 -0
  31. package/dist/data/schema-graph.js +210 -0
  32. package/dist/data/strategies.d.ts +1 -1
  33. package/dist/data/strategies.js +100 -1
  34. package/dist/data/templates.d.ts +1 -0
  35. package/dist/data/templates.js +134 -0
  36. package/dist/data/types.d.ts +1 -1
  37. package/dist/data/types.js +5 -1
  38. package/dist/data/validator.d.ts +1 -0
  39. package/dist/data/validator.js +160 -0
  40. package/dist/db/database.d.ts +1 -1
  41. package/dist/db/database.js +472 -1
  42. package/dist/db/export.d.ts +1 -1
  43. package/dist/db/export.js +67 -1
  44. package/dist/db/filters.d.ts +1 -1
  45. package/dist/db/filters.js +119 -1
  46. package/dist/db/index.js +24 -1
  47. package/dist/db/introspection.js +164 -1
  48. package/dist/db/manager.js +327 -1
  49. package/dist/db/migrations.js +100 -1
  50. package/dist/db/postgres.d.ts +1 -1
  51. package/dist/db/postgres.js +687 -1
  52. package/dist/db/types.d.ts +1 -1
  53. package/dist/db/types.js +6 -1
  54. package/dist/index.d.ts +1 -1
  55. package/dist/index.js +52 -1
  56. package/dist/public/css/app.css +1 -1
  57. package/dist/public/js/api.js +1 -1
  58. package/dist/public/js/browse.js +1 -1
  59. package/dist/public/js/common.js +1 -0
  60. package/dist/public/js/databases.js +1 -1
  61. package/dist/public/js/designer.js +1 -1
  62. package/dist/public/js/forms.js +1 -1
  63. package/dist/public/js/home.js +1 -0
  64. package/dist/public/js/inspector.js +1 -1
  65. package/dist/public/js/query.js +1 -1
  66. package/dist/public/js/schema.js +1 -1
  67. package/dist/public/js/seed.js +1 -1
  68. package/dist/routes/api/erd.d.ts +1 -0
  69. package/dist/routes/api/erd.js +45 -0
  70. package/dist/routes/api/helpers.d.ts +1 -1
  71. package/dist/routes/api/helpers.js +213 -1
  72. package/dist/routes/api/import-export.js +82 -1
  73. package/dist/routes/api/index.js +48 -1
  74. package/dist/routes/api/query.js +74 -1
  75. package/dist/routes/api/rows.js +358 -1
  76. package/dist/routes/api/seed.js +167 -1
  77. package/dist/routes/api/tables.js +216 -1
  78. package/dist/routes/databases.js +210 -1
  79. package/dist/routes/index.js +19 -1
  80. package/dist/routes/pages.js +446 -1
  81. package/dist/serverless.js +149 -1
  82. package/dist/sql/classifier.js +24 -1
  83. package/dist/sql/error-analyzer.d.ts +1 -0
  84. package/dist/sql/error-analyzer.js +550 -0
  85. package/dist/sql/generator.d.ts +1 -1
  86. package/dist/sql/generator.js +245 -1
  87. package/dist/sql/index.js +18 -1
  88. package/dist/types/api.d.ts +1 -1
  89. package/dist/types/api.js +2 -1
  90. package/dist/utils/colors.js +54 -1
  91. package/dist/utils/common.d.ts +1 -1
  92. package/dist/utils/common.js +185 -1
  93. package/dist/utils/csv.d.ts +1 -1
  94. package/dist/utils/csv.js +61 -1
  95. package/dist/utils/datatype.js +167 -1
  96. package/dist/utils/icons.js +81 -1
  97. package/dist/utils/index.js +20 -1
  98. package/dist/utils/logger.js +36 -1
  99. package/dist/views/layouts/main.hbs +1 -74
  100. package/dist/views/pages/databases.hbs +1 -233
  101. package/dist/views/pages/designer.hbs +1 -89
  102. package/dist/views/pages/erd.hbs +1 -0
  103. package/dist/views/pages/error.hbs +1 -10
  104. package/dist/views/pages/form.hbs +1 -59
  105. package/dist/views/pages/home.hbs +1 -124
  106. package/dist/views/pages/info.hbs +1 -0
  107. package/dist/views/pages/login.hbs +1 -98
  108. package/dist/views/pages/query.hbs +1 -87
  109. package/dist/views/pages/schema.hbs +1 -298
  110. package/dist/views/pages/seed-select.hbs +251 -0
  111. package/dist/views/pages/seed.hbs +273 -167
  112. package/dist/views/pages/table.hbs +1 -327
  113. package/dist/views/partials/navbar.hbs +1 -83
  114. package/dist/views/partials/sidebar.hbs +1 -117
  115. package/docs/API.md +149 -144
  116. package/docs/EXAMPLES.md +579 -576
  117. package/docs/SECURITY.md +265 -265
  118. package/package.json +79 -78
  119. package/dist/public/js/icons.js +0 -1
@@ -0,0 +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">
1
3
  <div class="flex items-center gap-2">
2
4
  <h1 class="text-xl font-bold">ER Diagram</h1>
3
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>
4
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>
5
7
  </div>
6
8
  <div class="ml-auto flex flex-wrap items-center gap-1.5">
7
9
  <div class="relative">
8
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"}}}
9
11
  <input id="erd-search" type="search" placeholder="Find table…" autocomplete="off"
10
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" />
11
13
  </div>
12
14
  <div class="h-5 w-px bg-base-300"></div>
13
15
  <button id="erd-btn-compact" title="Toggle compact mode (C)" class="btn btn-sm btn-ghost gap-1.5 font-normal">
14
16
  {{{icon "compact" class="h-4 w-4"}}}
15
17
  Compact
16
18
  </button>
17
19
  <button id="erd-btn-fit" title="Fit to screen (F)" class="btn btn-sm btn-ghost gap-1.5 font-normal">
18
20
  {{{icon "fit" class="h-4 w-4"}}}
19
21
  Fit
20
22
  </button>
21
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)">
22
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)">
23
25
  {{{icon "layoutForce" class="h-3.5 w-3.5"}}}
24
26
  Force
25
27
  </button>
26
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)">
27
29
  {{{icon "layoutHierarchy" class="h-3.5 w-3.5"}}}
28
30
  Hierarchy
29
31
  </button>
30
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">
31
33
  {{{icon "layoutCircle" class="h-3.5 w-3.5"}}}
32
34
  Circle
33
35
  </button>
34
36
  </div>
35
37
  <button id="erd-btn-export-svg" title="Download as SVG" class="btn btn-sm btn-ghost gap-1.5 font-normal">
36
38
  {{{icon "export" class="h-4 w-4"}}}
37
39
  Export
38
40
  </button>
39
41
  <div class="h-5 w-px bg-base-300"></div>
40
42
  <div class="flex items-center gap-0.5 rounded-lg border border-base-300 bg-base-100 px-1.5 py-1">
41
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 (−)">
42
44
  {{{icon "minus" class="h-3.5 w-3.5"}}}
43
45
  </button>
44
46
  <span id="erd-zoom-label" class="w-12 text-center text-xs font-mono font-semibold tabular-nums">100%</span>
45
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 (+)">
46
48
  {{{icon "plus" class="h-3.5 w-3.5"}}}
47
49
  </button>
48
50
  </div>
49
51
  </div>
50
52
  </div>
51
53
  <div class="flex flex-1 gap-3 min-h-0">
52
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;">
53
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">
54
56
  {{{icon "spinner" class="animate-spin h-8 w-8 text-primary"}}}
55
57
  <span class="text-sm text-base-content/60 font-medium">Loading schema…</span>
56
58
  </div>
57
59
  <div id="erd-error" class="absolute inset-0 hidden flex-col items-center justify-center gap-3 z-20">
58
60
  <div class="rounded-full bg-error/10 p-4">
59
61
  {{{icon "warning" class="h-8 w-8 text-error"}}}
60
62
  </div>
61
63
  <p id="erd-error-msg" class="text-sm text-error font-medium"></p>
62
64
  </div>
63
65
  <div id="erd-empty" class="absolute inset-0 hidden flex-col items-center justify-center gap-3 z-20">
64
66
  <div class="rounded-full bg-base-200 p-6">
65
67
  {{{icon "diagram" class="h-10 w-10 text-base-content/30"}}}
66
68
  </div>
67
69
  <p class="text-base-content/40 text-sm font-medium">No tables found in this database</p>
68
70
  </div>
69
71
  <svg id="erd-svg" width="100%" height="100%" style="display:none; user-select:none;" xmlns="http://www.w3.org/2000/svg">
70
72
  <defs>
71
73
  <marker id="erd-arrow" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto">
72
74
  <path d="M0,0 L0,6 L8,3 z" fill="var(--erd-edge-color)"/>
73
75
  </marker>
74
76
  <marker id="erd-arrow-hl" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto">
75
77
  <path d="M0,0 L0,6 L8,3 z" fill="var(--erd-edge-hl)"/>
76
78
  </marker>
77
79
  <marker id="erd-arrow-dim" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto">
78
80
  <path d="M0,0 L0,6 L8,3 z" fill="var(--erd-edge-dim)"/>
79
81
  </marker>
80
82
  <marker id="erd-cf-one" markerWidth="12" markerHeight="12" refX="2" refY="6" orient="auto">
81
83
  <line x1="2" y1="1" x2="2" y2="11" stroke="var(--erd-edge-color)" stroke-width="1.5"/>
82
84
  <line x1="5" y1="1" x2="5" y2="11" stroke="var(--erd-edge-color)" stroke-width="1.5"/>
83
85
  </marker>
84
86
  <filter id="erd-glow" x="-30%" y="-30%" width="160%" height="160%">
85
87
  <feGaussianBlur stdDeviation="4" result="blur"/>
86
88
  <feFlood flood-color="var(--erd-node-header)" flood-opacity="0.35" result="color"/>
87
89
  <feComposite in="color" in2="blur" operator="in" result="glow"/>
88
90
  <feMerge><feMergeNode in="glow"/><feMergeNode in="SourceGraphic"/></feMerge>
89
91
  </filter>
90
92
  <filter id="erd-shadow" x="-15%" y="-15%" width="130%" height="130%">
91
93
  <feDropShadow dx="0" dy="2" stdDeviation="4" flood-color="#000" flood-opacity="0.10"/>
92
94
  </filter>
93
95
  <filter id="erd-shadow-lg" x="-20%" y="-20%" width="140%" height="140%">
94
96
  <feDropShadow dx="0" dy="6" stdDeviation="12" flood-color="#000" flood-opacity="0.20"/>
95
97
  </filter>
96
98
  </defs>
97
99
  <g id="erd-viewport">
98
100
  <g id="erd-edges"></g>
99
101
  <g id="erd-nodes"></g>
100
102
  </g>
101
103
  </svg>
102
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>
103
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;">
104
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">
105
107
  <span>Overview</span>
106
108
  </div>
107
109
  <svg id="erd-minimap-svg" width="168" height="108" xmlns="http://www.w3.org/2000/svg" style="margin-top:16px;height:92px;"></svg>
108
110
  </div>
109
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">
110
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>
111
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">
112
114
  {{{icon "table" class="h-3.5 w-3.5 shrink-0"}}}
113
115
  Browse rows
114
116
  </a>
115
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">
116
118
  {{{icon "schema" class="h-3.5 w-3.5 shrink-0"}}}
117
119
  Edit schema
118
120
  </a>
119
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">
120
122
  {{{icon "seed" class="h-3.5 w-3.5 shrink-0 text-emerald-500"}}}
121
123
  Seed ER Chain…
122
124
  </a>
123
125
  <div class="my-1 border-t border-base-200"></div>
124
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">
125
127
  {{{icon "focus" class="h-3.5 w-3.5 shrink-0"}}}
126
128
  Focus & zoom
127
129
  </button>
128
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">
129
131
  {{{icon "chevronUp" class="h-3.5 w-3.5 shrink-0"}}}
130
132
  <span id="erd-ctx-collapse-label">Collapse table</span>
131
133
  </button>
132
134
  </div>
133
135
  <div class="absolute bottom-3 left-3 flex flex-col gap-0.5 pointer-events-none select-none">
134
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>
135
137
  </div>
136
138
  </div>
137
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);">
138
140
  <div class="rounded-xl border border-base-300 bg-base-100 overflow-hidden">
139
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;">
140
142
  <div class="flex items-center gap-2 min-w-0">
141
143
  {{{icon "table" class="h-4 w-4 shrink-0 opacity-80"}}}
142
144
  <span class="font-bold text-sm truncate" id="erd-panel-name"></span>
143
145
  </div>
144
146
  <button id="erd-panel-close" class="shrink-0 rounded-full p-1 hover:bg-white/20 transition-colors">
145
147
  {{{icon "x" class="h-4 w-4"}}}
146
148
  </button>
147
149
  </div>
148
150
  <div id="erd-panel-stats" class="flex divide-x divide-base-200 border-b border-base-200 text-center text-xs">
149
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>
150
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>
151
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>
152
154
  </div>
153
155
  <div id="erd-panel-cols" class="divide-y divide-base-100 text-xs"></div>
154
156
  <div class="flex gap-2 p-3 border-t border-base-200">
155
157
  <a id="erd-panel-browse" href="#" class="btn btn-xs btn-primary flex-1 gap-1">
156
158
  {{{icon "table" class="h-3 w-3"}}}
157
159
  Browse
158
160
  </a>
159
161
  <a id="erd-panel-schema" href="#" class="btn btn-xs btn-ghost flex-1 gap-1 border border-base-300">
160
162
  {{{icon "schema" class="h-3 w-3"}}}
161
163
  Schema
162
164
  </a>
163
165
  <a id="erd-panel-seed" href="#" class="btn btn-xs btn-outline btn-success flex-1 gap-1">
164
166
  {{{icon "seed" class="h-3 w-3"}}}
165
167
  Seed
166
168
  </a>
167
169
  </div>
168
170
  </div>
169
171
  <div id="erd-panel-rels" class="hidden rounded-xl border border-base-300 bg-base-100 overflow-hidden">
170
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>
171
173
  <div id="erd-panel-rels-list" class="divide-y divide-base-100 text-xs p-1"></div>
172
174
  </div>
173
175
  </div>
174
176
  </div>
175
177
  :root {
176
178
  --erd-node-bg: #ffffff;
177
179
  --erd-node-border: #e2e8f0;
178
180
  --erd-node-header: #6366f1;
179
181
  --erd-node-header-text: #ffffff;
180
182
  --erd-node-text: #1e293b;
181
183
  --erd-node-subtext: #94a3b8;
182
184
  --erd-node-row-odd: #f8fafc;
183
185
  --erd-node-row-even: #ffffff;
184
186
  --erd-node-row-pk: #eef2ff;
185
187
  --erd-node-row-fk: #fffbeb;
186
188
  --erd-edge-color: #818cf8;
187
189
  --erd-edge-hl: #f59e0b;
188
190
  --erd-edge-dim: #cbd5e1;
189
191
  --erd-canvas-bg: #f1f5f9;
190
192
  --erd-grid-color: rgba(148,163,184,0.18);
191
193
  --erd-grid-dot: rgba(148,163,184,0.45);
192
194
  }
193
195
  [data-theme="admindbDark"] {
194
196
  --erd-node-bg: #1e293b;
195
197
  --erd-node-border: #334155;
196
198
  --erd-node-header: #6366f1;
197
199
  --erd-node-header-text: #ffffff;
198
200
  --erd-node-text: #e2e8f0;
199
201
  --erd-node-subtext: #64748b;
200
202
  --erd-node-row-odd: #1a2744;
201
203
  --erd-node-row-even: #1e293b;
202
204
  --erd-node-row-pk: #1e1b4b;
203
205
  --erd-node-row-fk: #1c1a0e;
204
206
  --erd-edge-color: #818cf8;
205
207
  --erd-edge-hl: #fbbf24;
206
208
  --erd-edge-dim: #334155;
207
209
  --erd-canvas-bg: #0f172a;
208
210
  --erd-grid-color: rgba(51,65,85,0.5);
209
211
  --erd-grid-dot: rgba(71,85,105,0.6);
210
212
  }
211
213
  #erd-canvas-wrap {
212
214
  background-color: var(--erd-canvas-bg);
213
215
  background-image: radial-gradient(var(--erd-grid-dot) 1px, transparent 1px);
214
216
  background-size: 24px 24px;
215
217
  }
216
218
  /* Node transitions */
217
219
  .erd-node { cursor: grab; }
218
220
  .erd-node:active { cursor: grabbing; }
219
221
  .erd-node-rect {
220
222
  transition: filter 0.18s ease, stroke 0.18s ease, stroke-width 0.18s ease;
221
223
  }
222
224
  .erd-node.dimmed .erd-node-rect { opacity: 0.28; }
223
225
  .erd-node.dimmed .erd-node-header-rect { opacity: 0.28; }
224
226
  .erd-node.dimmed text { opacity: 0.28; }
225
227
  .erd-node.selected .erd-node-rect {
226
228
  stroke: var(--erd-edge-hl) !important;
227
229
  stroke-width: 2.5px !important;
228
230
  filter: url(#erd-shadow-lg);
229
231
  }
230
232
  .erd-node.neighbor .erd-node-rect {
231
233
  stroke: var(--erd-edge-color) !important;
232
234
  stroke-width: 2px !important;
233
235
  }
234
236
  /* Edge transitions */
235
237
  .erd-edge {
236
238
  transition: stroke 0.18s ease, stroke-width 0.18s ease, opacity 0.18s ease;
237
239
  cursor: pointer;
238
240
  }
239
241
  .erd-edge.dimmed { opacity: 0.08; }
240
242
  .erd-edge.highlighted {
241
243
  stroke: var(--erd-edge-hl) !important;
242
244
  stroke-width: 2.5px !important;
243
245
  opacity: 1 !important;
244
246
  }
245
247
  .erd-edge.neighbor-edge {
246
248
  stroke: var(--erd-edge-color) !important;
247
249
  stroke-width: 2px !important;
248
250
  opacity: 0.9 !important;
249
251
  }
250
252
  /* Search highlight */
251
253
  .erd-node.search-match .erd-node-rect {
252
254
  stroke: #10b981 !important;
253
255
  stroke-width: 2px !important;
254
256
  }
255
257
  .erd-node.search-no-match { opacity: 0.2; }
256
258
  /* Side panel */
257
259
  #erd-side-panel { display: none; }
258
260
  #erd-side-panel.visible { display: flex; }
259
261
  .erd-col-row {
260
262
  display: flex;
261
263
  align-items: center;
262
264
  gap: 6px;
263
265
  padding: 6px 12px;
264
266
  font-size: 11px;
265
267
  transition: background 0.1s;
266
268
  }
267
269
  .erd-col-row:hover { background: var(--erd-node-row-odd); }
268
270
  .erd-col-row-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: 'JetBrains Mono', monospace; }
269
271
  .erd-col-row-type { color: var(--erd-node-subtext); font-family: 'JetBrains Mono', monospace; font-size: 10px; }
270
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; }
271
273
  .erd-badge-pk { background: rgba(99,102,241,0.15); color: #6366f1; }
272
274
  .erd-badge-fk { background: rgba(245,158,11,0.15); color: #d97706; }
273
275
  .erd-badge-nn { background: rgba(239,68,68,0.10); color: #dc2626; }
274
276
  .erd-rel-row {
275
277
  display: flex;
276
278
  align-items: center;
277
279
  gap: 6px;
278
280
  padding: 7px 12px;
279
281
  font-size: 11px;
280
282
  cursor: pointer;
281
283
  transition: background 0.1s;
282
284
  }
283
285
  .erd-rel-row:hover { background: var(--erd-node-row-odd); }
284
286
  /* Layout mode buttons */
285
287
  .erd-mode-btn { color: oklch(var(--bc) / 0.55); }
286
288
  .erd-mode-btn:hover { color: oklch(var(--bc) / 0.9); background: oklch(var(--b2)); }
287
289
  .erd-mode-btn.bg-primary { color: oklch(var(--pc)); }
288
290
  'use strict';
289
291
  const BASE = window.APP?.basePath ?? '';
290
292
  // ── State ────────────────────────────────────────────────────────────────
291
293
  let schema = null;
292
294
  let positions = {};
293
295
  let collapsed = new Set(); // tables showing only header
294
296
  let scale = 1;
295
297
  let tx = 0, ty = 0;
296
298
  let draggingNode = null;
297
299
  let isPanning = false;
298
300
  let panStart = { x: 0, y: 0, tx: 0, ty: 0 };
299
301
  let selectedTable = null;
300
302
  let compactAll = false;
301
303
  let searchQuery = '';
302
304
  let ctxTable = null;
303
305
  let layoutMode = 'hierarchy'; // 'force' | 'hierarchy' | 'circle'
304
306
  // ── Constants ────────────────────────────────────────────────────────────
305
307
  const NODE_W = 240;
306
308
  const COL_H = 26;
307
309
  const HEADER_H = 40;
308
310
  const NODE_PAD = 6;
309
311
  const COMPACT_H = HEADER_H;
310
312
  // ── Palette: rotate header colors per table ───────────────────────────
311
313
  const PALETTES = [
312
314
  { h: '#6366f1', l: '#eef2ff' }, // indigo
313
315
  { h: '#8b5cf6', l: '#f5f3ff' }, // violet
314
316
  { h: '#ec4899', l: '#fdf2f8' }, // pink
315
317
  { h: '#0ea5e9', l: '#f0f9ff' }, // sky
316
318
  { h: '#10b981', l: '#ecfdf5' }, // emerald
317
319
  { h: '#f59e0b', l: '#fffbeb' }, // amber
318
320
  { h: '#ef4444', l: '#fef2f2' }, // red
319
321
  { h: '#14b8a6', l: '#f0fdfa' }, // teal
320
322
  ];
321
323
  function palOf(name) {
322
324
  let h = 0; for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
323
325
  return PALETTES[h % PALETTES.length];
324
326
  }
325
327
  // ── DOM ──────────────────────────────────────────────────────────────────
326
328
  const wrap = document.getElementById('erd-canvas-wrap');
327
329
  const svgEl_ = document.getElementById('erd-svg');
328
330
  const viewport = document.getElementById('erd-viewport');
329
331
  const edgesG = document.getElementById('erd-edges');
330
332
  const nodesG = document.getElementById('erd-nodes');
331
333
  const loadingEl = document.getElementById('erd-loading');
332
334
  const errorEl = document.getElementById('erd-error');
333
335
  const errorMsg = document.getElementById('erd-error-msg');
334
336
  const emptyEl = document.getElementById('erd-empty');
335
337
  const minimap = document.getElementById('erd-minimap');
336
338
  const minimapSvg = document.getElementById('erd-minimap-svg');
337
339
  const zoomLabel = document.getElementById('erd-zoom-label');
338
340
  const badge = document.getElementById('erd-table-badge');
339
341
  const fkBadge = document.getElementById('erd-fk-badge');
340
342
  const edgeTip = document.getElementById('erd-edge-tooltip');
341
343
  const ctxMenu = document.getElementById('erd-ctx-menu');
342
344
  const sidePanel = document.getElementById('erd-side-panel');
343
345
  const searchInput = document.getElementById('erd-search');
344
346
  // ── Helper ───────────────────────────────────────────────────────────────
345
347
  function svgE(tag, attrs = {}) {
346
348
  const el = document.createElementNS('http://www.w3.org/2000/svg', tag);
347
349
  Object.entries(attrs).forEach(([k, v]) => el.setAttribute(k, String(v)));
348
350
  return el;
349
351
  }
350
352
  function nodeHeight(t) {
351
353
  if (collapsed.has(t.name) || compactAll) return COMPACT_H;
352
354
  return HEADER_H + NODE_PAD * 2 + t.columns.length * COL_H;
353
355
  }
354
356
  // ── Fetch ────────────────────────────────────────────────────────────────
355
357
  async function loadSchema() {
356
358
  try {
357
359
  const res = await fetch(BASE + '/api/erd');
358
360
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
359
361
  const json = await res.json();
360
362
  if (!json.success) throw new Error(json.error ?? 'API error');
361
363
  schema = json.data;
362
364
  if (!schema.tables || schema.tables.length === 0) {
363
365
  loadingEl.style.display = 'none'; emptyEl.style.display = 'flex'; return;
364
366
  }
365
367
  initLayout();
366
368
  renderAll();
367
369
  fitView();
368
370
  loadingEl.style.display = 'none';
369
371
  svgEl_.style.display = '';
370
372
  if (schema.tables.length > 3) minimap.classList.remove('hidden');
371
373
  // badges
372
374
  badge.textContent = schema.tables.length + ' table' + (schema.tables.length !== 1 ? 's' : '');
373
375
  badge.classList.remove('hidden');
374
376
  const totalFks = schema.tables.reduce((s, t) => s + t.foreignKeys.length, 0);
375
377
  if (totalFks > 0) {
376
378
  fkBadge.textContent = totalFks + ' relationship' + (totalFks !== 1 ? 's' : '');
377
379
  fkBadge.classList.remove('hidden');
378
380
  }
379
381
  } catch (e) {
380
382
  loadingEl.style.display = 'none';
381
383
  errorEl.style.display = 'flex';
382
384
  errorMsg.textContent = e.message;
383
385
  }
384
386
  }
385
387
  // ── Layout dispatcher ─────────────────────────────────────────────────────
386
388
  function initLayout() {
387
389
  if (!schema || schema.tables.length === 0) return;
388
390
  if (layoutMode === 'force') layoutForce();
389
391
  else if (layoutMode === 'circle') layoutCircle();
390
392
  else layoutHierarchy();
391
393
  updateModeButtons();
392
394
  }
393
395
  function updateModeButtons() {
394
396
  document.querySelectorAll('.erd-mode-btn').forEach(btn => {
395
397
  const active = btn.dataset.mode === layoutMode;
396
398
  btn.classList.toggle('bg-primary', active);
397
399
  btn.classList.toggle('text-primary-content', active);
398
400
  btn.classList.toggle('shadow-sm', active);
399
401
  btn.classList.toggle('text-base-content', !active);
400
402
  btn.classList.toggle('text-opacity-60', !active);
401
403
  });
402
404
  }
403
405
  // ── 1. Force-directed (physics / organic) ─────────────────────────────────
404
406
  function layoutForce() {
405
407
  const tables = schema.tables;
406
408
  const PAD = 60;
407
409
  // seed in a grid so force has a stable start
408
410
  const cols = Math.max(1, Math.ceil(Math.sqrt(tables.length)));
409
411
  tables.forEach((t, i) => {
410
412
  positions[t.name] = {
411
413
  x: PAD + (i % cols) * (NODE_W + 180),
412
414
  y: PAD + Math.floor(i / cols) * 280,
413
415
  };
414
416
  });
415
417
  const tableNames = new Set(tables.map(t => t.name));
416
418
  const REPEL = 28000;
417
419
  const SPRING = 0.012;
418
420
  const REST = NODE_W + 220;
419
421
  const DAMP = 0.82;
420
422
  const vx = {}, vy = {};
421
423
  tables.forEach(t => { vx[t.name] = 0; vy[t.name] = 0; });
422
424
  for (let iter = 0; iter < 280; iter++) {
423
425
  const fx = {}, fy = {};
424
426
  tables.forEach(t => { fx[t.name] = 0; fy[t.name] = 0; });
425
427
  // Repulsion between all pairs
426
428
  for (let i = 0; i < tables.length; i++) {
427
429
  for (let j = i + 1; j < tables.length; j++) {
428
430
  const a = positions[tables[i].name], b = positions[tables[j].name];
429
431
  let dx = b.x - a.x, dy = b.y - a.y;
430
432
  const dist = Math.max(1, Math.sqrt(dx*dx + dy*dy));
431
433
  const f = REPEL / (dist * dist);
432
434
  dx /= dist; dy /= dist;
433
435
  fx[tables[i].name] -= dx * f; fy[tables[i].name] -= dy * f;
434
436
  fx[tables[j].name] += dx * f; fy[tables[j].name] += dy * f;
435
437
  }
436
438
  }
437
439
  // Spring attraction along FK edges
438
440
  tables.forEach(t => {
439
441
  t.foreignKeys.forEach(fk => {
440
442
  if (!tableNames.has(fk.table) || fk.table === t.name) return;
441
443
  const a = positions[t.name], b = positions[fk.table];
442
444
  let dx = b.x - a.x, dy = b.y - a.y;
443
445
  const dist = Math.max(1, Math.sqrt(dx*dx + dy*dy));
444
446
  const f = SPRING * (dist - REST);
445
447
  dx /= dist; dy /= dist;
446
448
  fx[t.name] += dx * f; fy[t.name] += dy * f;
447
449
  fx[fk.table] -= dx * f; fy[fk.table] -= dy * f;
448
450
  });
449
451
  });
450
452
  // Integrate with damping
451
453
  tables.forEach(t => {
452
454
  vx[t.name] = (vx[t.name] + fx[t.name]) * DAMP;
453
455
  vy[t.name] = (vy[t.name] + fy[t.name]) * DAMP;
454
456
  positions[t.name].x += vx[t.name];
455
457
  positions[t.name].y += vy[t.name];
456
458
  });
457
459
  }
458
460
  // Normalize to (PAD, PAD)
459
461
  const minX = Math.min(...tables.map(t => positions[t.name].x));
460
462
  const minY = Math.min(...tables.map(t => positions[t.name].y));
461
463
  tables.forEach(t => { positions[t.name].x -= minX - PAD; positions[t.name].y -= minY - PAD; });
462
464
  }
463
465
  // ── 2. Hierarchical / layered (reference tables left) ────────────────────
464
466
  function layoutHierarchy() {
465
467
  const tables = schema.tables;
466
468
  const tableNames = new Set(tables.map(t => t.name));
467
469
  const deps = {};
468
470
  tables.forEach(t => {
469
471
  deps[t.name] = [...new Set(
470
472
  t.foreignKeys.map(fk => fk.table).filter(n => n !== t.name && tableNames.has(n))
471
473
  )];
472
474
  });
473
475
  const layerOf = {};
474
476
  function assignLayer(name, stack) {
475
477
  if (name in layerOf) return layerOf[name];
476
478
  if (!stack) stack = new Set();
477
479
  if (stack.has(name)) { layerOf[name] = 0; return 0; }
478
480
  stack.add(name);
479
481
  const d = deps[name] || [];
480
482
  layerOf[name] = d.length === 0 ? 0 : Math.max(...d.map(x => assignLayer(x, new Set(stack)))) + 1;
481
483
  return layerOf[name];
482
484
  }
483
485
  tables.forEach(t => assignLayer(t.name));
484
486
  const byLayer = {};
485
487
  tables.forEach(t => { const l = layerOf[t.name] ?? 0; (byLayer[l] = byLayer[l] || []).push(t); });
486
488
  const isReferenced = new Set();
487
489
  tables.forEach(t => t.foreignKeys.forEach(fk => { if (fk.table !== t.name) isReferenced.add(fk.table); }));
488
490
  const isolated = [];
489
491
  byLayer[0] = (byLayer[0] || []).filter(t => {
490
492
  if (!isReferenced.has(t.name) && deps[t.name].length === 0) { isolated.push(t); return false; }
491
493
  return true;
492
494
  });
493
495
  if (!byLayer[0].length) delete byLayer[0];
494
496
  const layerNums = Object.keys(byLayer).map(Number).sort((a, b) => a - b);
495
497
  layerNums.forEach(ln => {
496
498
  const grp = byLayer[ln]; if (!grp || ln === 0) return;
497
499
  const prev = byLayer[ln - 1] || [], pi = {};
498
500
  prev.forEach((t, i) => pi[t.name] = i);
499
501
  grp.sort((a, b) => {
500
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; };
501
503
  return s(a) - s(b);
502
504
  });
503
505
  });
504
506
  const COL_WIDTH = NODE_W + 200;
505
507
  const ROW_PADDING = 44;
506
508
  const colHeights = layerNums.map(ln => (byLayer[ln]||[]).reduce((s,t) => s + nodeHeight(t) + ROW_PADDING, 0));
507
509
  const maxColH = Math.max(...colHeights, 300);
508
510
  layerNums.forEach((ln, ci) => {
509
511
  const grp = byLayer[ln] || [];
510
512
  let y = Math.max(60, (maxColH - colHeights[ci]) / 2 + 60);
511
513
  grp.forEach(t => { positions[t.name] = { x: 60 + ci * COL_WIDTH, y }; y += nodeHeight(t) + ROW_PADDING; });
512
514
  });
513
515
  if (isolated.length > 0) {
514
516
  const isoX = 60 + layerNums.length * COL_WIDTH;
515
517
  let isoY = 60;
516
518
  isolated.forEach(t => { positions[t.name] = { x: isoX, y: isoY }; isoY += nodeHeight(t) + ROW_PADDING; });
517
519
  }
518
520
  }
519
521
  // ── 3. Circular layout ────────────────────────────────────────────────────
520
522
  function layoutCircle() {
521
523
  const tables = schema.tables;
522
524
  if (tables.length === 0) return;
523
525
  // Separate isolated tables (no FKs and never referenced by anyone)
524
526
  const isReferenced = new Set();
525
527
  tables.forEach(t => t.foreignKeys.forEach(fk => { if (fk.table !== t.name) isReferenced.add(fk.table); }));
526
528
  const isolated = tables.filter(t => !isReferenced.has(t.name) && t.foreignKeys.filter(fk => fk.table !== t.name).length === 0);
527
529
  const connected = tables.filter(t => !isolated.includes(t));
528
530
  // Sort connected: highly-connected first (12 o'clock position)
529
531
  const degree = {};
530
532
  tables.forEach(t => {
531
533
  degree[t.name] = t.foreignKeys.length;
532
534
  tables.forEach(o => { if (o.foreignKeys.some(fk => fk.table === t.name)) degree[t.name]++; });
533
535
  });
534
536
  const sorted = [...connected].sort((a, b) => (degree[b.name] || 0) - (degree[a.name] || 0));
535
537
  const n = sorted.length;
536
538
  if (n === 0) {
537
539
  // All tables are isolated — just grid them
538
540
  const cols = Math.max(1, Math.ceil(Math.sqrt(isolated.length)));
539
541
  isolated.forEach((t, i) => {
540
542
  positions[t.name] = { x: 60 + (i % cols) * (NODE_W + 40), y: 60 + Math.floor(i / cols) * 280 };
541
543
  });
542
544
  return;
543
545
  }
544
546
  if (n === 1) {
545
547
  positions[sorted[0].name] = { x: 60, y: 60 };
546
548
  } else {
547
549
  // Compute radius so circle nodes don't overlap
548
550
  const avgH = sorted.reduce((s, t) => s + nodeHeight(t), 0) / n;
549
551
  const minGap = 36;
550
552
  const arcPerNode = Math.max(NODE_W + minGap, avgH + minGap);
551
553
  const radius = Math.max(240, (n * arcPerNode) / (2 * Math.PI));
552
554
  const cx = radius + NODE_W / 2 + 60;
553
555
  const cy = radius + avgH / 2 + 60;
554
556
  sorted.forEach((t, i) => {
555
557
  const angle = (2 * Math.PI * i / n) - Math.PI / 2; // start at 12 o'clock
556
558
  positions[t.name] = {
557
559
  x: cx + radius * Math.cos(angle) - NODE_W / 2,
558
560
  y: cy + radius * Math.sin(angle) - nodeHeight(t) / 2,
559
561
  };
560
562
  });
561
563
  }
562
564
  // Place isolated tables in a compact column to the right of the circle
563
565
  if (isolated.length > 0) {
564
566
  const circleRight = Math.max(...sorted.map(t => positions[t.name].x + NODE_W)) + 60;
565
567
  const ROW_PAD = 32;
566
568
  let isoY = 60;
567
569
  isolated.forEach(t => {
568
570
  positions[t.name] = { x: circleRight, y: isoY };
569
571
  isoY += nodeHeight(t) + ROW_PAD;
570
572
  });
571
573
  }
572
574
  }
573
575
  // ── Render all ────────────────────────────────────────────────────────────
574
576
  function renderAll() {
575
577
  edgesG.innerHTML = '';
576
578
  nodesG.innerHTML = '';
577
579
  schema.tables.forEach(t => renderNode(t));
578
580
  schema.tables.forEach(t => renderEdges(t));
579
581
  applySearch();
580
582
  renderMinimap();
581
583
  }
582
584
  // ── Render node ──────────────────────────────────────────────────────────
583
585
  function renderNode(t) {
584
586
  const pos = positions[t.name];
585
587
  const h = nodeHeight(t);
586
588
  const pal = palOf(t.name);
587
589
  const isCollapsed = collapsed.has(t.name) || compactAll;
588
590
  const fkCols = new Set(t.foreignKeys.map(f => f.from));
589
591
  const pkCols = new Set((t.primaryKey && t.primaryKey.length ? t.primaryKey : t.columns.filter(c => c.pk > 0).map(c => c.name)));
590
592
  const g = svgE('g', { class: 'erd-node', 'data-table': t.name, transform: `translate(${pos.x},${pos.y})` });
591
593
  // Drop shadow background
592
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)' }));
593
595
  // Main background
594
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' }));
595
597
  // Header gradient strip
596
598
  const hdrClip = svgE('clipPath', { id: `erd-hclip-${t.name.replace(/\W/g,'_')}` });
597
599
  hdrClip.appendChild(svgE('rect', { x: 0, y: 0, width: NODE_W, height: HEADER_H, rx: 12 }));
598
600
  g.appendChild(hdrClip);
599
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,'_')})` });
600
602
  g.appendChild(hdrRect);
601
603
  // bottom fill to square off the lower edge of header
602
604
  g.appendChild(svgE('rect', { x: 0, y: HEADER_H - 12, width: NODE_W, height: 12, fill: pal.h }));
603
605
  // Collapse arrow
604
606
  const arrX = NODE_W - 20, arrY = HEADER_H / 2;
605
607
  const arrPath = isCollapsed ? 'M-4,-2 L0,3 L4,-2' : 'M-4,2 L0,-3 L4,2';
606
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' });
607
609
  arr.addEventListener('click', e => { e.stopPropagation(); toggleCollapse(t.name); });
608
610
  g.appendChild(arr);
609
611
  // Table name
610
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' });
611
613
  const maxLen = 20;
612
614
  nameEl.textContent = t.name.length > maxLen ? t.name.slice(0, maxLen - 1) + '…' : t.name;
613
615
  g.appendChild(nameEl);
614
616
  // Row/col count pill in header
615
617
  const pillW = 36, pillX = NODE_W - pillW - 8;
616
618
  g.appendChild(svgE('rect', { x: pillX, y: 10, width: pillW, height: 18, rx: 9, fill: 'rgba(0,0,0,0.18)' }));
617
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' });
618
620
  pillTxt.textContent = t.columns.length + (t.columns.length === 1 ? ' col' : ' cols');
619
621
  g.appendChild(pillTxt);
620
622
  // Columns (when not collapsed)
621
623
  if (!isCollapsed) {
622
624
  t.columns.forEach((col, idx) => {
623
625
  const cy = HEADER_H + NODE_PAD + idx * COL_H;
624
626
  const isPk = pkCols.has(col.name);
625
627
  const isFk = fkCols.has(col.name);
626
628
  const isLast = idx === t.columns.length - 1;
627
629
  // Row background
628
630
  let rowFill = idx % 2 === 0 ? 'var(--erd-node-row-even)' : 'var(--erd-node-row-odd)';
629
631
  if (isPk) rowFill = 'var(--erd-node-row-pk)';
630
632
  if (isFk && !isPk) rowFill = 'var(--erd-node-row-fk)';
631
633
  const rowBg = svgE('rect', { x: 1, y: cy, width: NODE_W - 2, height: COL_H, fill: rowFill });
632
634
  if (isLast) { rowBg.setAttribute('rx', '0'); } // no extra clip needed
633
635
  g.appendChild(rowBg);
634
636
  // Left accent bar for PK/FK
635
637
  if (isPk || isFk) {
636
638
  g.appendChild(svgE('rect', { x: 1, y: cy, width: 3, height: COL_H, fill: isPk ? pal.h : '#f59e0b' }));
637
639
  }
638
640
  // Column name
639
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' });
640
642
  const trim = 15;
641
643
  nameTxt.textContent = col.name.length > trim ? col.name.slice(0, trim - 1) + '…' : col.name;
642
644
  g.appendChild(nameTxt);
643
645
  // Type
644
646
  const typeStr = (col.type || 'any').toUpperCase().replace('CHARACTER VARYING', 'VARCHAR');
645
647
  const typeTrimmed = typeStr.length > 10 ? typeStr.slice(0, 9) + '…' : typeStr;
646
648
  const typeX = NODE_W - (isPk || isFk ? 46 : 10);
647
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' });
648
650
  typeTxt.textContent = typeTrimmed;
649
651
  g.appendChild(typeTxt);
650
652
  // Badges
651
653
  let badgeX = NODE_W - 8;
652
654
  if (isFk) {
653
655
  badgeX -= 22;
654
656
  g.appendChild(svgE('rect', { x: badgeX, y: cy + 6, width: 20, height: 13, rx: 3, fill: 'rgba(245,158,11,0.18)' }));
655
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' });
656
658
  fkTxt.textContent = 'FK';
657
659
  g.appendChild(fkTxt);
658
660
  }
659
661
  if (isPk) {
660
662
  badgeX -= 22;
661
663
  g.appendChild(svgE('rect', { x: badgeX, y: cy + 6, width: 20, height: 13, rx: 3, fill: 'rgba(99,102,241,0.15)' }));
662
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' });
663
665
  pkTxt.textContent = 'PK';
664
666
  g.appendChild(pkTxt);
665
667
  }
666
668
  // Invisible wide hit target for row hover (used for FK jump)
667
669
  const rowHit = svgE('rect', { x: 0, y: cy, width: NODE_W, height: COL_H, fill: 'transparent' });
668
670
  g.appendChild(rowHit);
669
671
  });
670
672
  // Bottom rounded cap to complete the card
671
673
  const capClip = svgE('clipPath', { id: `erd-bclip-${t.name.replace(/\W/g,'_')}` });
672
674
  capClip.appendChild(svgE('rect', { x: 0, y: h - 12, width: NODE_W, height: 12, rx: 0 }));
673
675
  g.appendChild(capClip);
674
676
  }
675
677
  // Invisible interaction rect (full node)
676
678
  const hitRect = svgE('rect', { x: 0, y: 0, width: NODE_W, height: h, rx: 12, fill: 'transparent', class: 'erd-hit', style: 'cursor:grab;' });
677
679
  g.appendChild(hitRect);
678
680
  // Click → select
679
681
  hitRect.addEventListener('click', e => { e.stopPropagation(); selectTable(t.name); });
680
682
  // Right-click → context menu
681
683
  g.addEventListener('contextmenu', e => {
682
684
  e.preventDefault(); e.stopPropagation();
683
685
  openCtxMenu(t.name, e.clientX, e.clientY);
684
686
  });
685
687
  // Drag
686
688
  hitRect.addEventListener('mousedown', e => {
687
689
  if (e.button !== 0) return;
688
690
  e.stopPropagation();
689
691
  draggingNode = { name: t.name, origX: positions[t.name].x, origY: positions[t.name].y, startX: e.clientX, startY: e.clientY, moved: false };
690
692
  wrap.style.cursor = 'grabbing';
691
693
  });
692
694
  nodesG.appendChild(g);
693
695
  }
694
696
  // ── Edges ─────────────────────────────────────────────────────────────────
695
697
  function getPortY(tableName, colName) {
696
698
  if (collapsed.has(tableName) || compactAll) return COMPACT_H / 2;
697
699
  const t = schema.tables.find(t => t.name === tableName);
698
700
  if (!t) return HEADER_H / 2;
699
701
  const idx = t.columns.findIndex(c => c.name === colName);
700
702
  if (idx === -1) return HEADER_H + NODE_PAD + COL_H / 2;
701
703
  return HEADER_H + NODE_PAD + idx * COL_H + COL_H / 2;
702
704
  }
703
705
  function edgePath(srcPos, srcColY, tgtPos, tgtColY) {
704
706
  let sx = srcPos.x + NODE_W, sy = srcPos.y + srcColY;
705
707
  let ex = tgtPos.x, ey = tgtPos.y + tgtColY;
706
708
  if (tgtPos.x < srcPos.x) { sx = srcPos.x; ex = tgtPos.x + NODE_W; }
707
709
  const dx = Math.abs(ex - sx);
708
710
  const cpDx = Math.max(60, dx * 0.45);
709
711
  return `M${sx},${sy} C${sx + (tgtPos.x < srcPos.x ? -cpDx : cpDx)},${sy} ${ex - (tgtPos.x < srcPos.x ? -cpDx : cpDx)},${ey} ${ex},${ey}`;
710
712
  }
711
713
  function renderEdges(t) {
712
714
  t.foreignKeys.forEach(fk => {
713
715
  const srcPos = positions[t.name], tgtPos = positions[fk.table];
714
716
  if (!srcPos || !tgtPos) return;
715
717
  const d = edgePath(srcPos, getPortY(t.name, fk.from), tgtPos, getPortY(fk.table, fk.to || ''));
716
718
  const path = svgE('path', {
717
719
  class: 'erd-edge',
718
720
  d,
719
721
  fill: 'none',
720
722
  stroke: 'var(--erd-edge-color)',
721
723
  'stroke-width': '1.8',
722
724
  'stroke-dasharray': 'none',
723
725
  'marker-end': 'url(#erd-arrow)',
724
726
  'data-from': t.name,
725
727
  'data-to': fk.table,
726
728
  'data-from-col': fk.from,
727
729
  'data-to-col': fk.to || '',
728
730
  opacity: '0.65',
729
731
  });
730
732
  // Edge hover: show tooltip + highlight
731
733
  path.addEventListener('mouseenter', e => {
732
734
  const rect = wrap.getBoundingClientRect();
733
735
  edgeTip.style.left = (e.clientX - rect.left + 12) + 'px';
734
736
  edgeTip.style.top = (e.clientY - rect.top - 8) + 'px';
735
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>`;
736
738
  edgeTip.classList.remove('hidden');
737
739
  path.setAttribute('stroke-width', '2.8');
738
740
  path.style.opacity = '1';
739
741
  });
740
742
  path.addEventListener('mousemove', e => {
741
743
  const rect = wrap.getBoundingClientRect();
742
744
  edgeTip.style.left = (e.clientX - rect.left + 12) + 'px';
743
745
  edgeTip.style.top = (e.clientY - rect.top - 8) + 'px';
744
746
  });
745
747
  path.addEventListener('mouseleave', () => {
746
748
  edgeTip.classList.add('hidden');
747
749
  if (!path.classList.contains('highlighted') && !path.classList.contains('neighbor-edge')) {
748
750
  path.setAttribute('stroke-width', '1.8');
749
751
  path.style.opacity = '0.65';
750
752
  }
751
753
  });
752
754
  edgesG.appendChild(path);
753
755
  });
754
756
  }
755
757
  function updateEdges() {
756
758
  edgesG.querySelectorAll('.erd-edge').forEach(path => {
757
759
  const fn = path.getAttribute('data-from'), tn = path.getAttribute('data-to');
758
760
  const fc = path.getAttribute('data-from-col'), tc = path.getAttribute('data-to-col');
759
761
  const sp = positions[fn], tp = positions[tn];
760
762
  if (!sp || !tp) return;
761
763
  path.setAttribute('d', edgePath(sp, getPortY(fn, fc), tp, getPortY(tn, tc)));
762
764
  });
763
765
  }
764
766
  // ── Selection & highlighting ──────────────────────────────────────────────
765
767
  function clearHighlights() {
766
768
  nodesG.querySelectorAll('.erd-node').forEach(el => {
767
769
  el.classList.remove('selected', 'neighbor', 'dimmed');
768
770
  });
769
771
  edgesG.querySelectorAll('.erd-edge').forEach(el => {
770
772
  el.classList.remove('highlighted', 'neighbor-edge', 'dimmed');
771
773
  el.setAttribute('stroke-width', '1.8');
772
774
  el.setAttribute('marker-end', 'url(#erd-arrow)');
773
775
  el.style.opacity = '0.65';
774
776
  });
775
777
  }
776
778
  function selectTable(name) {
777
779
  if (selectedTable === name) { deselectTable(); return; }
778
780
  selectedTable = name;
779
781
  clearHighlights();
780
782
  // Collect neighbors
781
783
  const neighbors = new Set();
782
784
  schema.tables.forEach(t => {
783
785
  t.foreignKeys.forEach(fk => {
784
786
  if (t.name === name) neighbors.add(fk.table);
785
787
  if (fk.table === name) neighbors.add(t.name);
786
788
  });
787
789
  });
788
790
  // Apply node classes
789
791
  nodesG.querySelectorAll('.erd-node').forEach(el => {
790
792
  const n = el.getAttribute('data-table');
791
793
  if (n === name) el.classList.add('selected');
792
794
  else if (neighbors.has(n)) el.classList.add('neighbor');
793
795
  else el.classList.add('dimmed');
794
796
  });
795
797
  // Apply edge classes
796
798
  edgesG.querySelectorAll('.erd-edge').forEach(el => {
797
799
  const f = el.getAttribute('data-from'), t = el.getAttribute('data-to');
798
800
  if (f === name || t === name) {
799
801
  el.classList.add('neighbor-edge');
800
802
  el.setAttribute('stroke-width', '2.2');
801
803
  el.style.opacity = '1';
802
804
  el.setAttribute('marker-end', 'url(#erd-arrow)');
803
805
  } else {
804
806
  el.classList.add('dimmed');
805
807
  }
806
808
  });
807
809
  showSidePanel(name, neighbors);
808
810
  }
809
811
  function deselectTable() {
810
812
  selectedTable = null;
811
813
  clearHighlights();
812
814
  hideSidePanel();
813
815
  }
814
816
  // ── Side panel ────────────────────────────────────────────────────────────
815
817
  function showSidePanel(name, neighbors) {
816
818
  const t = schema.tables.find(t => t.name === name);
817
819
  if (!t) return;
818
820
  const pal = palOf(name);
819
821
  sidePanel.classList.add('visible');
820
822
  document.getElementById('erd-panel-header').style.background = pal.h;
821
823
  document.getElementById('erd-panel-name').textContent = name;
822
824
  document.getElementById('erd-panel-col-count').textContent = t.columns.length;
823
825
  document.getElementById('erd-panel-fk-count').textContent = t.foreignKeys.length;
824
826
  // how many other tables reference this one?
825
827
  const refCount = schema.tables.filter(x => x.foreignKeys.some(fk => fk.table === name)).length;
826
828
  document.getElementById('erd-panel-ref-count').textContent = refCount;
827
829
  const panelBrowse = document.getElementById('erd-panel-browse');
828
830
  const panelSchema = document.getElementById('erd-panel-schema');
829
831
  const panelSeed = document.getElementById('erd-panel-seed');
830
832
  panelBrowse.href = `${BASE}/tables/${encodeURIComponent(name)}`;
831
833
  panelSchema.href = `${BASE}/tables/${encodeURIComponent(name)}/schema`;
832
834
  if (panelSeed) panelSeed.href = `${BASE}/seed/${encodeURIComponent(name)}?mode=chain`;
833
835
  // Columns list
834
836
  const fkCols = new Set(t.foreignKeys.map(f => f.from));
835
837
  const pkCols = new Set(t.primaryKey && t.primaryKey.length ? t.primaryKey : t.columns.filter(c => c.pk > 0).map(c => c.name));
836
838
  const colsEl = document.getElementById('erd-panel-cols');
837
839
  colsEl.innerHTML = '';
838
840
  t.columns.forEach(col => {
839
841
  const isPk = pkCols.has(col.name), isFk = fkCols.has(col.name);
840
842
  const row = document.createElement('div');
841
843
  row.className = 'erd-col-row';
842
844
  row.style.background = isPk ? 'rgba(99,102,241,0.06)' : isFk ? 'rgba(245,158,11,0.05)' : '';
843
845
  const lAcc = document.createElement('div');
844
846
  lAcc.style.cssText = `width:3px;height:16px;border-radius:2px;background:${isPk ? pal.h : isFk ? '#f59e0b' : 'transparent'};flex-shrink:0;`;
845
847
  row.appendChild(lAcc);
846
848
  const nameEl = document.createElement('span');
847
849
  nameEl.className = 'erd-col-row-name';
848
850
  nameEl.textContent = col.name;
849
851
  nameEl.style.color = isPk ? pal.h : '';
850
852
  row.appendChild(nameEl);
851
853
  const typeEl = document.createElement('span');
852
854
  typeEl.className = 'erd-col-row-type';
853
855
  typeEl.textContent = (col.type || 'any').toUpperCase().replace('CHARACTER VARYING', 'VARCHAR');
854
856
  row.appendChild(typeEl);
855
857
  if (isPk) { const b = document.createElement('span'); b.className = 'erd-badge erd-badge-pk'; b.textContent = 'PK'; row.appendChild(b); }
856
858
  if (isFk) { const b = document.createElement('span'); b.className = 'erd-badge erd-badge-fk'; b.textContent = 'FK'; row.appendChild(b); }
857
859
  if (col.notnull && !isPk) { const b = document.createElement('span'); b.className = 'erd-badge erd-badge-nn'; b.textContent = 'NN'; row.appendChild(b); }
858
860
  colsEl.appendChild(row);
859
861
  });
860
862
  // Relationships panel
861
863
  const relsPanel = document.getElementById('erd-panel-rels');
862
864
  const relsList = document.getElementById('erd-panel-rels-list');
863
865
  relsList.innerHTML = '';
864
866
  const allRels = [];
865
867
  // outgoing FKs
866
868
  t.foreignKeys.forEach(fk => allRels.push({ dir: 'out', label: `${name}.${fk.from} → ${fk.table}.${fk.to || '?'}`, target: fk.table }));
867
869
  // incoming
868
870
  schema.tables.forEach(src => {
869
871
  if (src.name === name) return;
870
872
  src.foreignKeys.filter(fk => fk.table === name).forEach(fk => {
871
873
  allRels.push({ dir: 'in', label: `${src.name}.${fk.from} → ${name}.${fk.to || '?'}`, target: src.name });
872
874
  });
873
875
  });
874
876
  if (allRels.length > 0) {
875
877
  relsPanel.classList.remove('hidden');
876
878
  allRels.forEach(rel => {
877
879
  const row = document.createElement('div');
878
880
  row.className = 'erd-rel-row';
879
881
  row.innerHTML = `
880
882
  <span style="font-size:10px;opacity:.5">${rel.dir === 'out' ? '→' : '←'}</span>
881
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>
882
884
  <span style="font-size:10px;color:var(--erd-edge-color);font-weight:600;">${rel.target}</span>
883
885
  `;
884
886
  row.addEventListener('click', () => selectTable(rel.target));
885
887
  relsList.appendChild(row);
886
888
  });
887
889
  } else {
888
890
  relsPanel.classList.add('hidden');
889
891
  }
890
892
  }
891
893
  function hideSidePanel() {
892
894
  sidePanel.classList.remove('visible');
893
895
  }
894
896
  document.getElementById('erd-panel-close').addEventListener('click', deselectTable);
895
897
  // ── Context menu ─────────────────────────────────────────────────────────
896
898
  function openCtxMenu(name, cx, cy) {
897
899
  ctxTable = name;
898
900
  document.getElementById('erd-ctx-title').textContent = name;
899
901
  document.getElementById('erd-ctx-browse').href = `${BASE}/tables/${encodeURIComponent(name)}`;
900
902
  document.getElementById('erd-ctx-schema').href = `${BASE}/tables/${encodeURIComponent(name)}/schema`;
901
903
  const ctxSeed = document.getElementById('erd-ctx-seed');
902
904
  if (ctxSeed) ctxSeed.href = `${BASE}/seed/${encodeURIComponent(name)}?mode=chain`;
903
905
  document.getElementById('erd-ctx-collapse-label').textContent = collapsed.has(name) ? 'Expand table' : 'Collapse table';
904
906
  const rect = wrap.getBoundingClientRect();
905
907
  let x = cx - rect.left, y = cy - rect.top;
906
908
  ctxMenu.style.left = Math.min(x, wrap.clientWidth - 200) + 'px';
907
909
  ctxMenu.style.top = Math.min(y, wrap.clientHeight - 160) + 'px';
908
910
  ctxMenu.classList.remove('hidden');
909
911
  selectTable(name);
910
912
  }
911
913
  document.getElementById('erd-ctx-focus').addEventListener('click', () => {
912
914
  ctxMenu.classList.add('hidden');
913
915
  if (!ctxTable) return;
914
916
  const p = positions[ctxTable], t = schema.tables.find(x => x.name === ctxTable);
915
917
  if (!p || !t) return;
916
918
  const nw = NODE_W, nh = nodeHeight(t);
917
919
  const cx = wrap.clientWidth / 2, cy = wrap.clientHeight / 2;
918
920
  scale = Math.min(1.8, Math.min(wrap.clientWidth / (nw + 80), wrap.clientHeight / (nh + 80)));
919
921
  tx = cx - (p.x + nw / 2) * scale;
920
922
  ty = cy - (p.y + nh / 2) * scale;
921
923
  applyTransform();
922
924
  });
923
925
  document.getElementById('erd-ctx-collapse').addEventListener('click', () => {
924
926
  ctxMenu.classList.add('hidden');
925
927
  if (ctxTable) toggleCollapse(ctxTable);
926
928
  });
927
929
  document.addEventListener('click', () => ctxMenu.classList.add('hidden'));
928
930
  document.addEventListener('keydown', e => { if (e.key === 'Escape') ctxMenu.classList.add('hidden'); });
929
931
  // ── Collapse toggle ───────────────────────────────────────────────────────
930
932
  function toggleCollapse(name) {
931
933
  if (collapsed.has(name)) collapsed.delete(name); else collapsed.add(name);
932
934
  edgesG.innerHTML = '';
933
935
  nodesG.innerHTML = '';
934
936
  schema.tables.forEach(t => renderNode(t));
935
937
  schema.tables.forEach(t => renderEdges(t));
936
938
  if (selectedTable) selectTable(selectedTable);
937
939
  applySearch();
938
940
  renderMinimap();
939
941
  }
940
942
  // ── Compact mode (all tables) ─────────────────────────────────────────────
941
943
  document.getElementById('erd-btn-compact').addEventListener('click', () => {
942
944
  compactAll = !compactAll;
943
945
  const btn = document.getElementById('erd-btn-compact');
944
946
  btn.classList.toggle('btn-active', compactAll);
945
947
  positions = {};
946
948
  initLayout();
947
949
  edgesG.innerHTML = '';
948
950
  nodesG.innerHTML = '';
949
951
  schema.tables.forEach(t => renderNode(t));
950
952
  schema.tables.forEach(t => renderEdges(t));
951
953
  fitView();
952
954
  if (selectedTable) selectTable(selectedTable);
953
955
  renderMinimap();
954
956
  });
955
957
  // ── Search/filter ─────────────────────────────────────────────────────────
956
958
  function applySearch() {
957
959
  if (!schema) return;
958
960
  const q = searchQuery.toLowerCase().trim();
959
961
  nodesG.querySelectorAll('.erd-node').forEach(el => {
960
962
  const name = el.getAttribute('data-table');
961
963
  el.classList.remove('search-match', 'search-no-match');
962
964
  if (!q) return;
963
965
  if (name.toLowerCase().includes(q)) el.classList.add('search-match');
964
966
  else el.classList.add('search-no-match');
965
967
  });
966
968
  }
967
969
  searchInput.addEventListener('input', () => {
968
970
  searchQuery = searchInput.value;
969
971
  applySearch();
970
972
  if (searchQuery && schema) {
971
973
  // pan to first match
972
974
  const match = schema.tables.find(t => t.name.toLowerCase().includes(searchQuery.toLowerCase()));
973
975
  if (match && positions[match.name]) {
974
976
  const p = positions[match.name], nh = nodeHeight(match);
975
977
  const cx = wrap.clientWidth / 2, cy = wrap.clientHeight / 2;
976
978
  tx = cx - (p.x + NODE_W / 2) * scale;
977
979
  ty = cy - (p.y + nh / 2) * scale;
978
980
  applyTransform();
979
981
  }
980
982
  }
981
983
  });
982
984
  // ── Viewport transform ────────────────────────────────────────────────────
983
985
  function applyTransform() {
984
986
  viewport.setAttribute('transform', `translate(${tx},${ty}) scale(${scale})`);
985
987
  zoomLabel.textContent = Math.round(scale * 100) + '%';
986
988
  renderMinimap();
987
989
  }
988
990
  function fitView() {
989
991
  if (!schema || schema.tables.length === 0) return;
990
992
  const wW = wrap.clientWidth, wH = wrap.clientHeight;
991
993
  let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
992
994
  schema.tables.forEach(t => {
993
995
  const p = positions[t.name]; if (!p) return;
994
996
  minX = Math.min(minX, p.x); minY = Math.min(minY, p.y);
995
997
  maxX = Math.max(maxX, p.x + NODE_W); maxY = Math.max(maxY, p.y + nodeHeight(t));
996
998
  });
997
999
  const cW = maxX - minX + 120, cH = maxY - minY + 120;
998
1000
  scale = Math.min(1.1, Math.min(wW / cW, wH / cH));
999
1001
  tx = (wW - cW * scale) / 2 - minX * scale + 60 * scale;
1000
1002
  ty = (wH - cH * scale) / 2 - minY * scale + 60 * scale;
1001
1003
  applyTransform();
1002
1004
  }
1003
1005
  // ── Minimap ───────────────────────────────────────────────────────────────
1004
1006
  function renderMinimap() {
1005
1007
  if (!schema || schema.tables.length === 0) return;
1006
1008
  minimapSvg.innerHTML = '';
1007
1009
  let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
1008
1010
  schema.tables.forEach(t => {
1009
1011
  const p = positions[t.name]; if (!p) return;
1010
1012
  minX = Math.min(minX, p.x); minY = Math.min(minY, p.y);
1011
1013
  maxX = Math.max(maxX, p.x + NODE_W); maxY = Math.max(maxY, p.y + nodeHeight(t));
1012
1014
  });
1013
1015
  const cW = maxX - minX || 1, cH = maxY - minY || 1;
1014
1016
  const mW = 168, mH = 92;
1015
1017
  const mS = Math.min((mW - 8) / (cW + 40), (mH - 8) / (cH + 40));
1016
1018
  schema.tables.forEach(t => {
1017
1019
  const p = positions[t.name]; if (!p) return;
1018
1020
  const pal = palOf(t.name);
1019
1021
  const rx = (p.x - minX + 20) * mS + 4, ry = (p.y - minY + 20) * mS + 4;
1020
1022
  const rw = NODE_W * mS, rh = nodeHeight(t) * mS;
1021
1023
  const r = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
1022
1024
  r.setAttribute('x', rx); r.setAttribute('y', ry);
1023
1025
  r.setAttribute('width', Math.max(rw, 2)); r.setAttribute('height', Math.max(rh, 2));
1024
1026
  r.setAttribute('rx', 2);
1025
1027
  r.setAttribute('fill', t.name === selectedTable ? pal.h : pal.h + '55');
1026
1028
  r.setAttribute('stroke', pal.h); r.setAttribute('stroke-width', '0.5');
1027
1029
  minimapSvg.appendChild(r);
1028
1030
  });
1029
1031
  // Viewport rect
1030
1032
  const vpX = (-tx / scale - minX + 20) * mS + 4;
1031
1033
  const vpY = (-ty / scale - minY + 20) * mS + 4;
1032
1034
  const vpW = (wrap.clientWidth / scale) * mS;
1033
1035
  const vpH = (wrap.clientHeight / scale) * mS;
1034
1036
  const vp = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
1035
1037
  vp.setAttribute('x', vpX); vp.setAttribute('y', vpY);
1036
1038
  vp.setAttribute('width', vpW); vp.setAttribute('height', vpH);
1037
1039
  vp.setAttribute('fill', 'rgba(99,102,241,0.06)');
1038
1040
  vp.setAttribute('stroke', '#6366f1'); vp.setAttribute('stroke-width', '1'); vp.setAttribute('rx', '2');
1039
1041
  minimapSvg.appendChild(vp);
1040
1042
  }
1041
1043
  // ── Mouse / touch events ──────────────────────────────────────────────────
1042
1044
  wrap.addEventListener('wheel', e => {
1043
1045
  e.preventDefault();
1044
1046
  const d = e.deltaY > 0 ? 0.88 : 1.12;
1045
1047
  const r = wrap.getBoundingClientRect();
1046
1048
  const mx = e.clientX - r.left, my = e.clientY - r.top;
1047
1049
  const ns = Math.min(3, Math.max(0.1, scale * d));
1048
1050
  tx = mx - (mx - tx) * (ns / scale);
1049
1051
  ty = my - (my - ty) * (ns / scale);
1050
1052
  scale = ns; applyTransform();
1051
1053
  }, { passive: false });
1052
1054
  // Canvas panning
1053
1055
  wrap.addEventListener('mousedown', e => {
1054
1056
  if (draggingNode || e.button !== 0) return;
1055
1057
  const tgt = e.target;
1056
1058
  if (tgt.closest('.erd-node') || tgt.closest('#erd-ctx-menu')) return;
1057
1059
  isPanning = true;
1058
1060
  panStart = { x: e.clientX, y: e.clientY, tx, ty };
1059
1061
  wrap.style.cursor = 'grabbing';
1060
1062
  });
1061
1063
  window.addEventListener('mousemove', e => {
1062
1064
  if (draggingNode) {
1063
1065
  const dx = (e.clientX - draggingNode.startX) / scale;
1064
1066
  const dy = (e.clientY - draggingNode.startY) / scale;
1065
1067
  if (Math.abs(dx) > 2 || Math.abs(dy) > 2) draggingNode.moved = true;
1066
1068
  positions[draggingNode.name].x = draggingNode.origX + dx;
1067
1069
  positions[draggingNode.name].y = draggingNode.origY + dy;
1068
1070
  const el = nodesG.querySelector(`[data-table="${draggingNode.name}"]`);
1069
1071
  if (el) { const p = positions[draggingNode.name]; el.setAttribute('transform', `translate(${p.x},${p.y})`); }
1070
1072
  updateEdges();
1071
1073
  renderMinimap();
1072
1074
  } else if (isPanning) {
1073
1075
  tx = panStart.tx + (e.clientX - panStart.x);
1074
1076
  ty = panStart.ty + (e.clientY - panStart.y);
1075
1077
  applyTransform();
1076
1078
  }
1077
1079
  });
1078
1080
  window.addEventListener('mouseup', () => {
1079
1081
  draggingNode = null; isPanning = false;
1080
1082
  wrap.style.cursor = 'grab';
1081
1083
  });
1082
1084
  // Click on canvas background → deselect
1083
1085
  svgEl_.addEventListener('click', e => {
1084
1086
  if (e.target === svgEl_ || e.target.id === 'erd-viewport') deselectTable();
1085
1087
  });
1086
1088
  // Zoom buttons
1087
1089
  function zoomAt(factor) {
1088
1090
  const cx = wrap.clientWidth / 2, cy = wrap.clientHeight / 2;
1089
1091
  const ns = Math.min(3, Math.max(0.1, scale * factor));
1090
1092
  tx = cx - (cx - tx) * (ns / scale); ty = cy - (cy - ty) * (ns / scale);
1091
1093
  scale = ns; applyTransform();
1092
1094
  }
1093
1095
  document.getElementById('erd-btn-zoom-in').addEventListener('click', () => zoomAt(1.2));
1094
1096
  document.getElementById('erd-btn-zoom-out').addEventListener('click', () => zoomAt(0.83));
1095
1097
  document.getElementById('erd-btn-fit').addEventListener('click', fitView);
1096
1098
  // Layout mode buttons
1097
1099
  function applyLayoutMode(mode) {
1098
1100
  if (!schema) return;
1099
1101
  layoutMode = mode;
1100
1102
  positions = {};
1101
1103
  initLayout();
1102
1104
  edgesG.innerHTML = ''; nodesG.innerHTML = '';
1103
1105
  schema.tables.forEach(t => renderNode(t));
1104
1106
  schema.tables.forEach(t => renderEdges(t));
1105
1107
  fitView();
1106
1108
  if (selectedTable) selectTable(selectedTable);
1107
1109
  applySearch();
1108
1110
  renderMinimap();
1109
1111
  }
1110
1112
  document.querySelectorAll('.erd-mode-btn').forEach(btn => {
1111
1113
  btn.addEventListener('click', () => applyLayoutMode(btn.dataset.mode));
1112
1114
  });
1113
1115
  updateModeButtons();
1114
1116
  // Export SVG
1115
1117
  document.getElementById('erd-btn-export-svg').addEventListener('click', () => {
1116
1118
  if (!schema) return;
1117
1119
  // 1. Compute content bounds in diagram-space
1118
1120
  let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
1119
1121
  schema.tables.forEach(t => {
1120
1122
  const p = positions[t.name]; if (!p) return;
1121
1123
  minX = Math.min(minX, p.x); minY = Math.min(minY, p.y);
1122
1124
  maxX = Math.max(maxX, p.x + NODE_W); maxY = Math.max(maxY, p.y + nodeHeight(t));
1123
1125
  });
1124
1126
  const PAD = 48;
1125
1127
  const vW = maxX - minX + PAD * 2;
1126
1128
  const vH = maxY - minY + PAD * 2;
1127
1129
  // 2. Resolve all current CSS variable values
1128
1130
  const cs = getComputedStyle(document.documentElement);
1129
1131
  const get = v => cs.getPropertyValue(v).trim();
1130
1132
  const VARS = {
1131
1133
  'var(--erd-node-bg)' : get('--erd-node-bg') || '#ffffff',
1132
1134
  'var(--erd-node-border)' : get('--erd-node-border') || '#e2e8f0',
1133
1135
  'var(--erd-node-text)' : get('--erd-node-text') || '#1e293b',
1134
1136
  'var(--erd-node-subtext)' : get('--erd-node-subtext') || '#94a3b8',
1135
1137
  'var(--erd-node-row-odd)' : get('--erd-node-row-odd') || '#f8fafc',
1136
1138
  'var(--erd-node-row-even)': get('--erd-node-row-even') || '#ffffff',
1137
1139
  'var(--erd-node-row-pk)' : get('--erd-node-row-pk') || '#eef2ff',
1138
1140
  'var(--erd-node-row-fk)' : get('--erd-node-row-fk') || '#fffbeb',
1139
1141
  'var(--erd-edge-color)' : get('--erd-edge-color') || '#818cf8',
1140
1142
  'var(--erd-edge-hl)' : get('--erd-edge-hl') || '#f59e0b',
1141
1143
  'var(--erd-edge-dim)' : get('--erd-edge-dim') || '#cbd5e1',
1142
1144
  'var(--erd-canvas-bg)' : get('--erd-canvas-bg') || '#f1f5f9',
1143
1145
  };
1144
1146
  function resolveVars(str) {
1145
1147
  if (!str || !str.includes('var(')) return str;
1146
1148
  return Object.entries(VARS).reduce((s, [k, v]) => s.replaceAll(k, v), str);
1147
1149
  }
1148
1150
  // 3. Deep-clone SVG and walk every element to inline CSS vars
1149
1151
  const clone = svgEl_.cloneNode(true);
1150
1152
  function inlineEl(el) {
1151
1153
  // Inline attribute-level var() references
1152
1154
  for (const attr of [...el.attributes]) {
1153
1155
  const resolved = resolveVars(attr.value);
1154
1156
  if (resolved !== attr.value) el.setAttribute(attr.name, resolved);
1155
1157
  }
1156
1158
  // Inline style attribute
1157
1159
  const styleAttr = el.getAttribute('style');
1158
1160
  if (styleAttr) {
1159
1161
  const resolved = resolveVars(styleAttr);
1160
1162
  if (resolved !== styleAttr) el.setAttribute('style', resolved);
1161
1163
  }
1162
1164
  for (const child of el.children) inlineEl(child);
1163
1165
  }
1164
1166
  inlineEl(clone);
1165
1167
  // 4. Fix dimensions & viewBox
1166
1168
  clone.setAttribute('width', String(Math.ceil(vW)));
1167
1169
  clone.setAttribute('height', String(Math.ceil(vH)));
1168
1170
  clone.setAttribute('viewBox', `0 0 ${Math.ceil(vW)} ${Math.ceil(vH)}`);
1169
1171
  clone.removeAttribute('style'); // removes display:none
1170
1172
  // 5. Reset viewport transform — shift content so it starts at (PAD, PAD)
1171
1173
  const vpClone = clone.querySelector('#erd-viewport');
1172
1174
  if (vpClone) vpClone.setAttribute('transform', `translate(${PAD - minX},${PAD - minY})`);
1173
1175
  // 6. Add background rect behind everything
1174
1176
  const bgRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
1175
1177
  bgRect.setAttribute('x', 0); bgRect.setAttribute('y', 0);
1176
1178
  bgRect.setAttribute('width', Math.ceil(vW)); bgRect.setAttribute('height', Math.ceil(vH));
1177
1179
  bgRect.setAttribute('fill', VARS['var(--erd-canvas-bg)']);
1178
1180
  clone.insertBefore(bgRect, clone.firstChild);
1179
1181
  // 7. Strip interactive-only elements (hit targets, collapse arrows, tooltip, minimap)
1180
1182
  clone.querySelectorAll(
1181
1183
  '.erd-hit, .erd-collapse-btn, #erd-minimap, #erd-edge-tooltip, #erd-ctx-menu'
1182
1184
  ).forEach(el => el.remove());
1183
1185
  // 8. Add Google Fonts import so text renders correctly in SVG viewers that support it
1184
1186
  const defs = clone.querySelector('defs') || clone.insertBefore(
1185
1187
  document.createElementNS('http://www.w3.org/2000/svg', 'defs'), clone.firstChild
1186
1188
  );
1187
1189
  const style = document.createElementNS('http://www.w3.org/2000/svg', 'style');
1188
1190
  style.textContent = `@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&family=JetBrains+Mono:wght@400;600&display=swap');`;
1189
1191
  defs.insertBefore(style, defs.firstChild);
1190
1192
  // 9. Serialize and trigger download
1191
1193
  const xml = new XMLSerializer().serializeToString(clone);
1192
1194
  const blob = new Blob(['<?xml version="1.0" encoding="UTF-8"?>\n' + xml], { type: 'image/svg+xml;charset=utf-8' });
1193
1195
  const a = document.createElement('a');
1194
1196
  a.href = URL.createObjectURL(blob);
1195
1197
  a.download = 'er-diagram.svg';
1196
1198
  document.body.appendChild(a);
1197
1199
  a.click();
1198
1200
  document.body.removeChild(a);
1199
1201
  setTimeout(() => URL.revokeObjectURL(a.href), 1000);
1200
1202
  });
1201
1203
  // Touch pinch-zoom
1202
1204
  let lastPinchDist = null;
1203
1205
  wrap.addEventListener('touchstart', e => {
1204
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);
1205
1207
  }, { passive: true });
1206
1208
  wrap.addEventListener('touchmove', e => {
1207
1209
  if (e.touches.length !== 2 || !lastPinchDist) return;
1208
1210
  const d = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
1209
1211
  const r = wrap.getBoundingClientRect();
1210
1212
  const mx = (e.touches[0].clientX + e.touches[1].clientX) / 2 - r.left;
1211
1213
  const my = (e.touches[0].clientY + e.touches[1].clientY) / 2 - r.top;
1212
1214
  const ns = Math.min(3, Math.max(0.1, scale * d / lastPinchDist));
1213
1215
  tx = mx - (mx - tx) * (ns / scale); ty = my - (my - ty) * (ns / scale);
1214
1216
  scale = ns; applyTransform(); lastPinchDist = d; e.preventDefault();
1215
1217
  }, { passive: false });
1216
1218
  // Keyboard shortcuts
1217
1219
  document.addEventListener('keydown', e => {
1218
1220
  if (e.target.tagName === 'INPUT') return;
1219
1221
  if (e.key === 'Escape') deselectTable();
1220
1222
  if (e.key === 'f' || e.key === 'F') { e.preventDefault(); fitView(); }
1221
1223
  if (e.key === 'l' || e.key === 'L') {
1222
1224
  e.preventDefault();
1223
1225
  const modes = ['force', 'hierarchy', 'circle'];
1224
1226
  applyLayoutMode(modes[(modes.indexOf(layoutMode) + 1) % modes.length]);
1225
1227
  }
1226
1228
  if (e.key === 'c' || e.key === 'C') { document.getElementById('erd-btn-compact').click(); }
1227
1229
  if (e.key === '+' || e.key === '=') zoomAt(1.2);
1228
1230
  if (e.key === '-') zoomAt(0.83);
1229
1231
  if ((e.key === '0') && (e.ctrlKey || e.metaKey)) { e.preventDefault(); fitView(); }
1230
1232
  });
1231
1233
  // ── Init ─────────────────────────────────────────────────────────────────
1232
1234
  loadSchema();