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