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