admindb 2.1.1 → 2.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -12
- package/dist/app.js +230 -1
- package/dist/auth/config.js +121 -1
- package/dist/auth/crypto.js +108 -1
- package/dist/auth/index.js +21 -1
- package/dist/auth/middleware.js +85 -1
- package/dist/auth/routes.js +70 -1
- package/dist/auth/types.js +7 -1
- package/dist/cli/args.js +320 -1
- package/dist/cli/config.js +128 -1
- package/dist/cli/index.js +20 -1
- package/dist/cli/runner.js +211 -1
- package/dist/cli.js +4 -1
- package/dist/data/chain.d.ts +1 -0
- package/dist/data/chain.js +707 -0
- package/dist/data/datasets.js +129 -1
- package/dist/data/detector.d.ts +1 -1
- package/dist/data/detector.js +511 -1
- package/dist/data/engine.d.ts +1 -1
- package/dist/data/engine.js +245 -1
- package/dist/data/index.d.ts +1 -1
- package/dist/data/index.js +22 -1
- package/dist/data/strategies.d.ts +1 -1
- package/dist/data/strategies.js +339 -1
- package/dist/data/types.d.ts +1 -1
- package/dist/data/types.js +5 -1
- package/dist/db/database.d.ts +1 -1
- package/dist/db/database.js +477 -1
- package/dist/db/export.d.ts +1 -1
- package/dist/db/export.js +90 -1
- package/dist/db/filters.js +142 -1
- package/dist/db/index.js +24 -1
- package/dist/db/introspection.js +167 -1
- package/dist/db/manager.js +351 -1
- package/dist/db/migrations.js +107 -1
- package/dist/db/postgres.d.ts +1 -1
- package/dist/db/postgres.js +735 -1
- package/dist/db/types.d.ts +1 -1
- package/dist/db/types.js +6 -1
- package/dist/index.js +52 -1
- package/dist/public/css/app.css +1 -1
- package/dist/public/js/api.js +1 -1
- package/dist/public/js/browse.js +1 -1
- package/dist/public/js/common.js +1 -0
- package/dist/public/js/databases.js +1 -1
- package/dist/public/js/designer.js +1 -1
- package/dist/public/js/forms.js +1 -1
- package/dist/public/js/home.js +1 -0
- package/dist/public/js/inspector.js +1 -1
- package/dist/public/js/query.js +1 -1
- package/dist/public/js/schema.js +1 -1
- package/dist/public/js/seed.js +1 -1
- package/dist/routes/api/erd.d.ts +1 -0
- package/dist/routes/api/erd.js +50 -0
- package/dist/routes/api/helpers.d.ts +1 -1
- package/dist/routes/api/helpers.js +163 -1
- package/dist/routes/api/import-export.js +83 -1
- package/dist/routes/api/index.js +48 -1
- package/dist/routes/api/query.js +78 -1
- package/dist/routes/api/rows.js +362 -1
- package/dist/routes/api/seed.js +153 -1
- package/dist/routes/api/tables.js +231 -1
- package/dist/routes/databases.js +228 -1
- package/dist/routes/index.js +19 -1
- package/dist/routes/pages.js +397 -1
- package/dist/serverless.js +174 -1
- package/dist/sql/classifier.js +24 -1
- package/dist/sql/error-analyzer.d.ts +1 -0
- package/dist/sql/error-analyzer.js +550 -0
- package/dist/sql/generator.js +319 -1
- package/dist/sql/index.js +18 -1
- package/dist/types/api.js +2 -1
- package/dist/utils/colors.js +54 -1
- package/dist/utils/common.d.ts +1 -1
- package/dist/utils/common.js +225 -1
- package/dist/utils/csv.js +67 -1
- package/dist/utils/datatype.js +181 -1
- package/dist/utils/icons.js +81 -1
- package/dist/utils/index.js +20 -1
- package/dist/utils/logger.js +36 -1
- package/dist/views/layouts/main.hbs +14 -1
- package/dist/views/pages/erd.hbs +1241 -0
- package/dist/views/pages/home.hbs +98 -2
- package/dist/views/pages/info.hbs +107 -0
- package/dist/views/pages/query.hbs +54 -12
- package/dist/views/pages/seed.hbs +435 -171
- package/dist/views/partials/sidebar.hbs +12 -0
- package/docs/API.md +6 -1
- package/docs/EXAMPLES.md +3 -0
- package/package.json +5 -4
- package/dist/public/js/icons.js +0 -1
|
@@ -0,0 +1,1241 @@
|
|
|
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 · Drag · pan · Click · select · 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>
|