@memberjunction/ng-entity-relationship-diagram 5.30.1 → 5.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/__tests__/compute-erd-layout.test.d.ts +2 -0
  2. package/dist/__tests__/compute-erd-layout.test.d.ts.map +1 -0
  3. package/dist/__tests__/compute-erd-layout.test.js +207 -0
  4. package/dist/__tests__/compute-erd-layout.test.js.map +1 -0
  5. package/dist/lib/components/entity-details/entity-details.component.d.ts +2 -1
  6. package/dist/lib/components/entity-details/entity-details.component.d.ts.map +1 -1
  7. package/dist/lib/components/entity-details/entity-details.component.js +7 -7
  8. package/dist/lib/components/entity-details/entity-details.component.js.map +1 -1
  9. package/dist/lib/components/entity-filter-panel/entity-filter-panel.component.d.ts +10 -4
  10. package/dist/lib/components/entity-filter-panel/entity-filter-panel.component.d.ts.map +1 -1
  11. package/dist/lib/components/entity-filter-panel/entity-filter-panel.component.js +60 -72
  12. package/dist/lib/components/entity-filter-panel/entity-filter-panel.component.js.map +1 -1
  13. package/dist/lib/components/erd-composite/erd-composite.component.d.ts +4 -1
  14. package/dist/lib/components/erd-composite/erd-composite.component.d.ts.map +1 -1
  15. package/dist/lib/components/erd-composite/erd-composite.component.js +22 -8
  16. package/dist/lib/components/erd-composite/erd-composite.component.js.map +1 -1
  17. package/dist/lib/components/erd-diagram.component.d.ts +169 -443
  18. package/dist/lib/components/erd-diagram.component.d.ts.map +1 -1
  19. package/dist/lib/components/erd-diagram.component.js +916 -1777
  20. package/dist/lib/components/erd-diagram.component.js.map +1 -1
  21. package/dist/lib/components/mj-entity-erd.component.d.ts +3 -2
  22. package/dist/lib/components/mj-entity-erd.component.d.ts.map +1 -1
  23. package/dist/lib/components/mj-entity-erd.component.js +6 -6
  24. package/dist/lib/components/mj-entity-erd.component.js.map +1 -1
  25. package/dist/lib/entity-relationship-diagram.module.d.ts +2 -1
  26. package/dist/lib/entity-relationship-diagram.module.d.ts.map +1 -1
  27. package/dist/lib/entity-relationship-diagram.module.js +9 -3
  28. package/dist/lib/entity-relationship-diagram.module.js.map +1 -1
  29. package/dist/lib/interfaces/erd-types.d.ts +38 -7
  30. package/dist/lib/interfaces/erd-types.d.ts.map +1 -1
  31. package/dist/lib/interfaces/erd-types.js.map +1 -1
  32. package/dist/lib/layout/compute-dagre-layout.d.ts +32 -0
  33. package/dist/lib/layout/compute-dagre-layout.d.ts.map +1 -0
  34. package/dist/lib/layout/compute-dagre-layout.js +168 -0
  35. package/dist/lib/layout/compute-dagre-layout.js.map +1 -0
  36. package/dist/lib/layout/compute-erd-layout.d.ts +103 -0
  37. package/dist/lib/layout/compute-erd-layout.d.ts.map +1 -0
  38. package/dist/lib/layout/compute-erd-layout.js +254 -0
  39. package/dist/lib/layout/compute-erd-layout.js.map +1 -0
  40. package/package.json +6 -5
@@ -1,1908 +1,1041 @@
1
- import { Component, Input, Output, EventEmitter, ViewChild } from '@angular/core';
2
- import * as d3 from 'd3';
3
- import * as dagre from '@dagrejs/dagre';
1
+ /**
2
+ * @module erd-diagram.component
3
+ * @description
4
+ * Schema-grouped Entity Relationship Diagram. Each schema becomes a rounded
5
+ * rectangular band; entities tile in a grid within the band. Click a node to
6
+ * enter focus mode (neighbours highlighted, everything else dimmed) and see a
7
+ * floating details card with its inbound and outbound FKs.
8
+ *
9
+ * The component is pure Angular + SVG (no D3, no Dagre). Layout is computed
10
+ * by a pure function in `../layout/compute-erd-layout.ts` that is easily
11
+ * unit-tested.
12
+ *
13
+ * Public API is preserved from the previous D3-based implementation so
14
+ * existing consumers (Database Designer wizard preview, admin ERD dashboard,
15
+ * Entity Record view) continue to work without changes.
16
+ */
17
+ import { Component, Input, Output, EventEmitter, ElementRef, ViewChild, HostBinding, ChangeDetectionStrategy, ChangeDetectorRef, inject, } from '@angular/core';
18
+ import { computeErdLayout, pointsToPath, getNeighbors, } from '../layout/compute-erd-layout';
19
+ import { computeDagreLayout } from '../layout/compute-dagre-layout';
4
20
  import * as i0 from "@angular/core";
5
- const _c0 = ["erdContainer"];
6
- function ERDDiagramComponent_Conditional_1_Template(rf, ctx) { if (rf & 1) {
7
- const _r1 = i0.ɵɵgetCurrentView();
8
- i0.ɵɵelementStart(0, "div", 2)(1, "h3");
21
+ const _c0 = ["canvasRef"];
22
+ function ERDDiagramComponent_Conditional_0_Conditional_16_Template(rf, ctx) { if (rf & 1) {
23
+ const _r4 = i0.ɵɵgetCurrentView();
24
+ i0.ɵɵelementStart(0, "button", 44);
25
+ i0.ɵɵlistener("click", function ERDDiagramComponent_Conditional_0_Conditional_16_Template_button_click_0_listener() { i0.ɵɵrestoreView(_r4); const ctx_r2 = i0.ɵɵnextContext(2); return i0.ɵɵresetView(ctx_r2.onClearSearch()); });
26
+ i0.ɵɵtext(1, "\u00D7");
27
+ i0.ɵɵelementEnd();
28
+ } }
29
+ function ERDDiagramComponent_Conditional_0_Conditional_18_For_2_Template(rf, ctx) { if (rf & 1) {
30
+ const _r5 = i0.ɵɵgetCurrentView();
31
+ i0.ɵɵelementStart(0, "button", 46);
32
+ i0.ɵɵlistener("click", function ERDDiagramComponent_Conditional_0_Conditional_18_For_2_Template_button_click_0_listener() { const chip_r6 = i0.ɵɵrestoreView(_r5).$implicit; const ctx_r2 = i0.ɵɵnextContext(3); return i0.ɵɵresetView(ctx_r2.onSchemaChipToggle(chip_r6.name)); });
33
+ i0.ɵɵelementStart(1, "span", 47);
9
34
  i0.ɵɵtext(2);
10
35
  i0.ɵɵelementEnd();
11
- i0.ɵɵelementStart(3, "div", 5)(4, "button", 6);
12
- i0.ɵɵlistener("click", function ERDDiagramComponent_Conditional_1_Template_button_click_4_listener() { i0.ɵɵrestoreView(_r1); const ctx_r1 = i0.ɵɵnextContext(); return i0.ɵɵresetView(ctx_r1.zoomIn()); });
13
- i0.ɵɵelement(5, "span", 7);
36
+ i0.ɵɵelementStart(3, "span", 48);
37
+ i0.ɵɵtext(4);
38
+ i0.ɵɵelementEnd()();
39
+ } if (rf & 2) {
40
+ const chip_r6 = ctx.$implicit;
41
+ i0.ɵɵclassProp("active", chip_r6.active);
42
+ i0.ɵɵattribute("aria-pressed", chip_r6.active);
43
+ i0.ɵɵadvance(2);
44
+ i0.ɵɵtextInterpolate(chip_r6.name);
45
+ i0.ɵɵadvance(2);
46
+ i0.ɵɵtextInterpolate(chip_r6.count);
47
+ } }
48
+ function ERDDiagramComponent_Conditional_0_Conditional_18_Template(rf, ctx) { if (rf & 1) {
49
+ i0.ɵɵelementStart(0, "div", 30);
50
+ i0.ɵɵrepeaterCreate(1, ERDDiagramComponent_Conditional_0_Conditional_18_For_2_Template, 5, 5, "button", 45, i0.ɵɵcomponentInstance().trackByChip, true);
51
+ i0.ɵɵelementEnd();
52
+ } if (rf & 2) {
53
+ const ctx_r2 = i0.ɵɵnextContext(2);
54
+ i0.ɵɵadvance();
55
+ i0.ɵɵrepeater(ctx_r2.schemaChips);
56
+ } }
57
+ function ERDDiagramComponent_Conditional_0_Template(rf, ctx) { if (rf & 1) {
58
+ const _r2 = i0.ɵɵgetCurrentView();
59
+ i0.ɵɵelementStart(0, "header", 1)(1, "div", 19)(2, "div", 20);
60
+ i0.ɵɵtext(3);
14
61
  i0.ɵɵelementEnd();
15
- i0.ɵɵelementStart(6, "button", 8);
16
- i0.ɵɵlistener("click", function ERDDiagramComponent_Conditional_1_Template_button_click_6_listener() { i0.ɵɵrestoreView(_r1); const ctx_r1 = i0.ɵɵnextContext(); return i0.ɵɵresetView(ctx_r1.zoomOut()); });
17
- i0.ɵɵelement(7, "span", 9);
62
+ i0.ɵɵelementStart(4, "div", 21)(5, "span", 22);
63
+ i0.ɵɵtext(6);
18
64
  i0.ɵɵelementEnd();
19
- i0.ɵɵelementStart(8, "button", 10);
20
- i0.ɵɵlistener("click", function ERDDiagramComponent_Conditional_1_Template_button_click_8_listener() { i0.ɵɵrestoreView(_r1); const ctx_r1 = i0.ɵɵnextContext(); return i0.ɵɵresetView(ctx_r1.resetZoom()); });
21
- i0.ɵɵelement(9, "span", 11);
65
+ i0.ɵɵelementStart(7, "span", 23);
66
+ i0.ɵɵtext(8);
22
67
  i0.ɵɵelementEnd();
23
- i0.ɵɵelementStart(10, "button", 12);
24
- i0.ɵɵlistener("click", function ERDDiagramComponent_Conditional_1_Template_button_click_10_listener() { i0.ɵɵrestoreView(_r1); const ctx_r1 = i0.ɵɵnextContext(); ctx_r1.refresh(); return i0.ɵɵresetView(ctx_r1.refreshRequested.emit()); });
25
- i0.ɵɵelement(11, "span", 13);
68
+ i0.ɵɵelementStart(9, "span", 24);
69
+ i0.ɵɵtext(10, "\u00B7");
70
+ i0.ɵɵelementEnd();
71
+ i0.ɵɵelementStart(11, "span");
72
+ i0.ɵɵtext(12);
26
73
  i0.ɵɵelementEnd()()();
74
+ i0.ɵɵelementStart(13, "label", 25);
75
+ i0.ɵɵelement(14, "i", 26);
76
+ i0.ɵɵelementStart(15, "input", 27);
77
+ i0.ɵɵlistener("input", function ERDDiagramComponent_Conditional_0_Template_input_input_15_listener($event) { i0.ɵɵrestoreView(_r2); const ctx_r2 = i0.ɵɵnextContext(); return i0.ɵɵresetView(ctx_r2.onSearchChange($event.target.value)); });
78
+ i0.ɵɵelementEnd();
79
+ i0.ɵɵconditionalCreate(16, ERDDiagramComponent_Conditional_0_Conditional_16_Template, 2, 0, "button", 28);
80
+ i0.ɵɵelementEnd();
81
+ i0.ɵɵelementStart(17, "div", 29);
82
+ i0.ɵɵconditionalCreate(18, ERDDiagramComponent_Conditional_0_Conditional_18_Template, 3, 0, "div", 30);
83
+ i0.ɵɵelementStart(19, "div", 31)(20, "button", 32);
84
+ i0.ɵɵlistener("click", function ERDDiagramComponent_Conditional_0_Template_button_click_20_listener() { i0.ɵɵrestoreView(_r2); const ctx_r2 = i0.ɵɵnextContext(); return i0.ɵɵresetView(ctx_r2.setLayoutAlgorithm("schema-grid")); });
85
+ i0.ɵɵelement(21, "i", 33);
86
+ i0.ɵɵelementEnd();
87
+ i0.ɵɵelementStart(22, "button", 34);
88
+ i0.ɵɵlistener("click", function ERDDiagramComponent_Conditional_0_Template_button_click_22_listener() { i0.ɵɵrestoreView(_r2); const ctx_r2 = i0.ɵɵnextContext(); return i0.ɵɵresetView(ctx_r2.setLayoutAlgorithm("dagre")); });
89
+ i0.ɵɵelement(23, "i", 35);
90
+ i0.ɵɵelementEnd()();
91
+ i0.ɵɵelementStart(24, "div", 36)(25, "button", 37);
92
+ i0.ɵɵlistener("click", function ERDDiagramComponent_Conditional_0_Template_button_click_25_listener() { i0.ɵɵrestoreView(_r2); const ctx_r2 = i0.ɵɵnextContext(); return i0.ɵɵresetView(ctx_r2.zoomBy(1.2)); });
93
+ i0.ɵɵelement(26, "i", 38);
94
+ i0.ɵɵelementEnd();
95
+ i0.ɵɵelementStart(27, "button", 39);
96
+ i0.ɵɵlistener("click", function ERDDiagramComponent_Conditional_0_Template_button_click_27_listener() { i0.ɵɵrestoreView(_r2); const ctx_r2 = i0.ɵɵnextContext(); return i0.ɵɵresetView(ctx_r2.zoomBy(0.83)); });
97
+ i0.ɵɵelement(28, "i", 40);
98
+ i0.ɵɵelementEnd();
99
+ i0.ɵɵelementStart(29, "button", 41);
100
+ i0.ɵɵlistener("click", function ERDDiagramComponent_Conditional_0_Template_button_click_29_listener() { i0.ɵɵrestoreView(_r2); const ctx_r2 = i0.ɵɵnextContext(); return i0.ɵɵresetView(ctx_r2.fitToView()); });
101
+ i0.ɵɵelement(30, "i", 42);
102
+ i0.ɵɵelementEnd();
103
+ i0.ɵɵelementStart(31, "span", 43);
104
+ i0.ɵɵtext(32);
105
+ i0.ɵɵelementEnd()()()();
27
106
  } if (rf & 2) {
28
- const ctx_r1 = i0.ɵɵnextContext();
107
+ const ctx_r2 = i0.ɵɵnextContext();
108
+ i0.ɵɵadvance(3);
109
+ i0.ɵɵtextInterpolate(ctx_r2.headerTitle);
110
+ i0.ɵɵadvance(3);
111
+ i0.ɵɵtextInterpolate(ctx_r2.visibleCount);
112
+ i0.ɵɵadvance(2);
113
+ i0.ɵɵtextInterpolate1("of ", ctx_r2.totalCount, " entities");
114
+ i0.ɵɵadvance(4);
115
+ i0.ɵɵtextInterpolate1("", ctx_r2.layout.edges.length, " relationships");
116
+ i0.ɵɵadvance(3);
117
+ i0.ɵɵproperty("value", ctx_r2.searchQuery);
118
+ i0.ɵɵadvance();
119
+ i0.ɵɵconditional(ctx_r2.searchQuery ? 16 : -1);
120
+ i0.ɵɵadvance(2);
121
+ i0.ɵɵconditional(ctx_r2.schemaChips.length > 1 ? 18 : -1);
29
122
  i0.ɵɵadvance(2);
30
- i0.ɵɵtextInterpolate(ctx_r1.headerTitle);
123
+ i0.ɵɵclassProp("active", ctx_r2.activeLayout === "schema-grid");
124
+ i0.ɵɵadvance(2);
125
+ i0.ɵɵclassProp("active", ctx_r2.activeLayout === "dagre");
126
+ i0.ɵɵadvance(10);
127
+ i0.ɵɵtextInterpolate(ctx_r2.zoomPercent);
128
+ } }
129
+ function ERDDiagramComponent_Conditional_14_For_1_Template(rf, ctx) { if (rf & 1) {
130
+ i0.ɵɵnamespaceSVG();
131
+ i0.ɵɵelementStart(0, "g", 49);
132
+ i0.ɵɵelement(1, "rect", 50);
133
+ i0.ɵɵelementStart(2, "text", 51);
134
+ i0.ɵɵtext(3);
135
+ i0.ɵɵelementEnd()();
136
+ } if (rf & 2) {
137
+ const band_r7 = ctx.$implicit;
138
+ i0.ɵɵadvance();
139
+ i0.ɵɵattribute("x", band_r7.x)("y", band_r7.y)("width", band_r7.width)("height", band_r7.height);
140
+ i0.ɵɵadvance();
141
+ i0.ɵɵattribute("x", band_r7.x + band_r7.width / 2)("y", band_r7.y + 32);
142
+ i0.ɵɵadvance();
143
+ i0.ɵɵtextInterpolate1(" ", band_r7.schemaName, " ");
31
144
  } }
32
- function ERDDiagramComponent_Conditional_4_Template(rf, ctx) { if (rf & 1) {
33
- i0.ɵɵelementStart(0, "div", 4)(1, "div", 14)(2, "div", 15);
34
- i0.ɵɵelement(3, "div", 16)(4, "div", 16)(5, "div", 16);
145
+ function ERDDiagramComponent_Conditional_14_Template(rf, ctx) { if (rf & 1) {
146
+ i0.ɵɵrepeaterCreate(0, ERDDiagramComponent_Conditional_14_For_1_Template, 4, 7, ":svg:g", 49, i0.ɵɵcomponentInstance().trackByBand, true);
147
+ } if (rf & 2) {
148
+ const ctx_r2 = i0.ɵɵnextContext();
149
+ i0.ɵɵrepeater(ctx_r2.layout.bands);
150
+ } }
151
+ function ERDDiagramComponent_For_17_Template(rf, ctx) { if (rf & 1) {
152
+ i0.ɵɵnamespaceSVG();
153
+ i0.ɵɵelement(0, "path", 52);
154
+ } if (rf & 2) {
155
+ const edge_r8 = ctx.$implicit;
156
+ const ctx_r2 = i0.ɵɵnextContext();
157
+ i0.ɵɵclassProp("erd-edge-active", ctx_r2.isEdgeActive(edge_r8))("erd-edge-dimmed", ctx_r2.isEdgeDimmed(edge_r8));
158
+ i0.ɵɵattribute("d", ctx_r2.edgePath(edge_r8))("marker-start", ctx_r2.config.crowsFoot ?? true ? ctx_r2.isEdgeActive(edge_r8) ? "url(#erd-many-active)" : "url(#erd-many)" : null)("marker-end", ctx_r2.isEdgeActive(edge_r8) ? "url(#erd-arrow-active)" : "url(#erd-arrow)");
159
+ } }
160
+ function ERDDiagramComponent_Conditional_18_For_2_Conditional_1_Template(rf, ctx) { if (rf & 1) {
161
+ i0.ɵɵnamespaceSVG();
162
+ i0.ɵɵelementStart(0, "g", 54);
163
+ i0.ɵɵelement(1, "rect", 55);
164
+ i0.ɵɵelementStart(2, "text", 56);
165
+ i0.ɵɵtext(3);
166
+ i0.ɵɵelementEnd()();
167
+ } if (rf & 2) {
168
+ const edge_r9 = i0.ɵɵnextContext().$implicit;
169
+ const pos_r10 = i0.ɵɵreadContextLet(0);
170
+ const ctx_r2 = i0.ɵɵnextContext(2);
171
+ i0.ɵɵclassProp("erd-edge-label-active", ctx_r2.isEdgeActive(edge_r9))("erd-edge-label-dimmed", ctx_r2.isEdgeDimmed(edge_r9));
172
+ i0.ɵɵadvance();
173
+ i0.ɵɵattribute("x", pos_r10.x - pos_r10.w / 2 - 4)("y", pos_r10.y - 8)("width", pos_r10.w + 8);
174
+ i0.ɵɵadvance();
175
+ i0.ɵɵattribute("x", pos_r10.x)("y", pos_r10.y + 4);
176
+ i0.ɵɵadvance();
177
+ i0.ɵɵtextInterpolate(edge_r9.sourceField.name);
178
+ } }
179
+ function ERDDiagramComponent_Conditional_18_For_2_Template(rf, ctx) { if (rf & 1) {
180
+ i0.ɵɵdeclareLet(0);
181
+ i0.ɵɵconditionalCreate(1, ERDDiagramComponent_Conditional_18_For_2_Conditional_1_Template, 4, 10, ":svg:g", 53);
182
+ } if (rf & 2) {
183
+ const edge_r9 = ctx.$implicit;
184
+ const pos_r11 = i0.ɵɵstoreLet(i0.ɵɵnextContext(2).edgeLabelPosition(edge_r9));
185
+ i0.ɵɵadvance();
186
+ i0.ɵɵconditional(pos_r11 ? 1 : -1);
187
+ } }
188
+ function ERDDiagramComponent_Conditional_18_Template(rf, ctx) { if (rf & 1) {
189
+ i0.ɵɵnamespaceSVG();
190
+ i0.ɵɵelementStart(0, "g", 14);
191
+ i0.ɵɵrepeaterCreate(1, ERDDiagramComponent_Conditional_18_For_2_Template, 2, 2, null, null, i0.ɵɵcomponentInstance().trackByEdge, true);
192
+ i0.ɵɵelementEnd();
193
+ } if (rf & 2) {
194
+ const ctx_r2 = i0.ɵɵnextContext();
195
+ i0.ɵɵadvance();
196
+ i0.ɵɵrepeater(ctx_r2.layout.edges);
197
+ } }
198
+ function ERDDiagramComponent_For_21_Conditional_8_Template(rf, ctx) { if (rf & 1) {
199
+ i0.ɵɵelementStart(0, "span", 64);
200
+ i0.ɵɵtext(1);
35
201
  i0.ɵɵelementEnd();
36
- i0.ɵɵelementStart(6, "div", 17);
37
- i0.ɵɵtext(7, "Loading diagram...");
202
+ } if (rf & 2) {
203
+ const node_r13 = i0.ɵɵnextContext().$implicit;
204
+ const ctx_r2 = i0.ɵɵnextContext();
205
+ i0.ɵɵadvance();
206
+ i0.ɵɵtextInterpolate(ctx_r2.formatCount(node_r13.customData.rows));
207
+ } }
208
+ function ERDDiagramComponent_For_21_For_11_Conditional_2_Template(rf, ctx) { if (rf & 1) {
209
+ i0.ɵɵelementStart(0, "span", 70);
210
+ i0.ɵɵtext(1, "PK");
211
+ i0.ɵɵelementEnd();
212
+ } }
213
+ function ERDDiagramComponent_For_21_For_11_Conditional_3_Template(rf, ctx) { if (rf & 1) {
214
+ i0.ɵɵelementStart(0, "span", 71);
215
+ i0.ɵɵtext(1, "FK");
216
+ i0.ɵɵelementEnd();
217
+ } }
218
+ function ERDDiagramComponent_For_21_For_11_Conditional_6_Template(rf, ctx) { if (rf & 1) {
219
+ i0.ɵɵelementStart(0, "span", 73);
220
+ i0.ɵɵtext(1);
221
+ i0.ɵɵelementEnd();
222
+ } if (rf & 2) {
223
+ const field_r14 = i0.ɵɵnextContext().$implicit;
224
+ i0.ɵɵproperty("title", field_r14.type);
225
+ i0.ɵɵadvance();
226
+ i0.ɵɵtextInterpolate(field_r14.type);
227
+ } }
228
+ function ERDDiagramComponent_For_21_For_11_Template(rf, ctx) { if (rf & 1) {
229
+ i0.ɵɵnamespaceSVG();
230
+ i0.ɵɵelementStart(0, "foreignObject", 66);
231
+ i0.ɵɵnamespaceHTML();
232
+ i0.ɵɵelementStart(1, "div", 69);
233
+ i0.ɵɵconditionalCreate(2, ERDDiagramComponent_For_21_For_11_Conditional_2_Template, 2, 0, "span", 70)(3, ERDDiagramComponent_For_21_For_11_Conditional_3_Template, 2, 0, "span", 71);
234
+ i0.ɵɵelementStart(4, "span", 72);
235
+ i0.ɵɵtext(5);
236
+ i0.ɵɵelementEnd();
237
+ i0.ɵɵconditionalCreate(6, ERDDiagramComponent_For_21_For_11_Conditional_6_Template, 2, 2, "span", 73);
238
+ i0.ɵɵelementEnd()();
239
+ } if (rf & 2) {
240
+ const field_r14 = ctx.$implicit;
241
+ const ɵ$index_149_r15 = ctx.$index;
242
+ const node_r13 = i0.ɵɵnextContext().$implicit;
243
+ i0.ɵɵattribute("y", 36 + ɵ$index_149_r15 * 22)("width", node_r13.width);
244
+ i0.ɵɵadvance();
245
+ i0.ɵɵclassProp("erd-field-pk", field_r14.isPrimaryKey)("erd-field-fk", !field_r14.isPrimaryKey && field_r14.relatedNodeId);
246
+ i0.ɵɵadvance();
247
+ i0.ɵɵconditional(field_r14.isPrimaryKey ? 2 : field_r14.relatedNodeId ? 3 : -1);
248
+ i0.ɵɵadvance(2);
249
+ i0.ɵɵproperty("title", field_r14.name);
250
+ i0.ɵɵadvance();
251
+ i0.ɵɵtextInterpolate(field_r14.name);
252
+ i0.ɵɵadvance();
253
+ i0.ɵɵconditional(field_r14.type ? 6 : -1);
254
+ } }
255
+ function ERDDiagramComponent_For_21_Conditional_12_Template(rf, ctx) { if (rf & 1) {
256
+ const _r16 = i0.ɵɵgetCurrentView();
257
+ i0.ɵɵnamespaceSVG();
258
+ i0.ɵɵelementStart(0, "foreignObject", 74);
259
+ i0.ɵɵlistener("click", function ERDDiagramComponent_For_21_Conditional_12_Template_foreignObject_click_0_listener($event) { i0.ɵɵrestoreView(_r16); const node_r13 = i0.ɵɵnextContext().$implicit; const ctx_r2 = i0.ɵɵnextContext(); return i0.ɵɵresetView(ctx_r2.onToggleExpand(node_r13.id, $event)); });
260
+ i0.ɵɵnamespaceHTML();
261
+ i0.ɵɵelementStart(1, "div", 75);
262
+ i0.ɵɵtext(2);
263
+ i0.ɵɵelementEnd()();
264
+ } if (rf & 2) {
265
+ const node_r13 = i0.ɵɵnextContext().$implicit;
266
+ i0.ɵɵattribute("y", 36 + node_r13.visibleFields.length * 22 + 6)("width", node_r13.width - 8);
267
+ i0.ɵɵadvance(2);
268
+ i0.ɵɵtextInterpolate1(" + ", node_r13.fields.length - node_r13.visibleFields.length, " more fields ");
269
+ } }
270
+ function ERDDiagramComponent_For_21_Template(rf, ctx) { if (rf & 1) {
271
+ const _r12 = i0.ɵɵgetCurrentView();
272
+ i0.ɵɵnamespaceSVG();
273
+ i0.ɵɵelementStart(0, "g", 57);
274
+ i0.ɵɵlistener("click", function ERDDiagramComponent_For_21_Template_g_click_0_listener($event) { const node_r13 = i0.ɵɵrestoreView(_r12).$implicit; const ctx_r2 = i0.ɵɵnextContext(); return i0.ɵɵresetView(ctx_r2.onNodeClick(node_r13, $event)); })("mouseenter", function ERDDiagramComponent_For_21_Template_g_mouseenter_0_listener($event) { const node_r13 = i0.ɵɵrestoreView(_r12).$implicit; const ctx_r2 = i0.ɵɵnextContext(); return i0.ɵɵresetView(ctx_r2.onNodeMouseEnter(node_r13, $event)); })("mouseleave", function ERDDiagramComponent_For_21_Template_g_mouseleave_0_listener() { const node_r13 = i0.ɵɵrestoreView(_r12).$implicit; const ctx_r2 = i0.ɵɵnextContext(); return i0.ɵɵresetView(ctx_r2.onNodeMouseLeave(node_r13)); });
275
+ i0.ɵɵelement(1, "rect", 58)(2, "rect", 59)(3, "rect", 60);
276
+ i0.ɵɵelementStart(4, "foreignObject", 61);
277
+ i0.ɵɵnamespaceHTML();
278
+ i0.ɵɵelementStart(5, "div", 62)(6, "span", 63);
279
+ i0.ɵɵtext(7);
280
+ i0.ɵɵelementEnd();
281
+ i0.ɵɵconditionalCreate(8, ERDDiagramComponent_For_21_Conditional_8_Template, 2, 1, "span", 64);
282
+ i0.ɵɵelementEnd()();
283
+ i0.ɵɵnamespaceSVG();
284
+ i0.ɵɵelement(9, "line", 65);
285
+ i0.ɵɵrepeaterCreate(10, ERDDiagramComponent_For_21_For_11_Template, 7, 10, ":svg:foreignObject", 66, i0.ɵɵcomponentInstance().trackByField, true);
286
+ i0.ɵɵconditionalCreate(12, ERDDiagramComponent_For_21_Conditional_12_Template, 3, 3, ":svg:foreignObject", 67);
287
+ i0.ɵɵelement(13, "rect", 68);
288
+ i0.ɵɵelementEnd();
289
+ } if (rf & 2) {
290
+ const node_r13 = ctx.$implicit;
291
+ const ctx_r2 = i0.ɵɵnextContext();
292
+ i0.ɵɵclassProp("erd-node-focus", ctx_r2.isFocus(node_r13.id))("erd-node-neighbour", ctx_r2.isNeighbour(node_r13.id))("erd-node-dimmed", ctx_r2.isDimmed(node_r13.id));
293
+ i0.ɵɵattribute("transform", "translate(" + node_r13.x + " " + node_r13.y + ")");
294
+ i0.ɵɵadvance();
295
+ i0.ɵɵattribute("width", node_r13.width)("height", node_r13.height);
296
+ i0.ɵɵadvance();
297
+ i0.ɵɵattribute("width", node_r13.width)("height", node_r13.height);
298
+ i0.ɵɵadvance();
299
+ i0.ɵɵattribute("width", node_r13.width);
300
+ i0.ɵɵadvance();
301
+ i0.ɵɵattribute("width", node_r13.width - 28);
302
+ i0.ɵɵadvance(2);
303
+ i0.ɵɵproperty("title", node_r13.name);
304
+ i0.ɵɵadvance();
305
+ i0.ɵɵtextInterpolate(node_r13.name);
306
+ i0.ɵɵadvance();
307
+ i0.ɵɵconditional(node_r13.customData && node_r13.customData.rows != null ? 8 : -1);
308
+ i0.ɵɵadvance();
309
+ i0.ɵɵattribute("x2", node_r13.width);
310
+ i0.ɵɵadvance();
311
+ i0.ɵɵrepeater(node_r13.visibleFields);
312
+ i0.ɵɵadvance(2);
313
+ i0.ɵɵconditional(node_r13.hasMore ? 12 : -1);
314
+ i0.ɵɵadvance();
315
+ i0.ɵɵattribute("width", node_r13.width)("height", node_r13.height);
316
+ } }
317
+ function ERDDiagramComponent_Conditional_22_Conditional_5_Template(rf, ctx) { if (rf & 1) {
318
+ i0.ɵɵelementStart(0, "div", 79);
319
+ i0.ɵɵtext(1, "Try adjusting schema filters or clearing search");
320
+ i0.ɵɵelementEnd();
321
+ } }
322
+ function ERDDiagramComponent_Conditional_22_Template(rf, ctx) { if (rf & 1) {
323
+ i0.ɵɵelementStart(0, "div", 17)(1, "div", 76);
324
+ i0.ɵɵelement(2, "i", 77);
325
+ i0.ɵɵelementEnd();
326
+ i0.ɵɵelementStart(3, "div", 78);
327
+ i0.ɵɵtext(4);
328
+ i0.ɵɵelementEnd();
329
+ i0.ɵɵconditionalCreate(5, ERDDiagramComponent_Conditional_22_Conditional_5_Template, 2, 0, "div", 79);
330
+ i0.ɵɵelementEnd();
331
+ } if (rf & 2) {
332
+ const ctx_r2 = i0.ɵɵnextContext();
333
+ i0.ɵɵadvance(2);
334
+ i0.ɵɵclassMap(ctx_r2.config.emptyStateIcon || "fa-solid fa-diagram-project");
335
+ i0.ɵɵadvance(2);
336
+ i0.ɵɵtextInterpolate1(" ", ctx_r2.totalCount === 0 ? ctx_r2.config.emptyStateMessage || "No entities to display" : "No entities match", " ");
337
+ i0.ɵɵadvance();
338
+ i0.ɵɵconditional(ctx_r2.totalCount > 0 ? 5 : -1);
339
+ } }
340
+ function ERDDiagramComponent_Conditional_23_Template(rf, ctx) { if (rf & 1) {
341
+ i0.ɵɵelementStart(0, "div", 18)(1, "div", 80);
342
+ i0.ɵɵelement(2, "i", 81);
343
+ i0.ɵɵelementStart(3, "span");
344
+ i0.ɵɵtext(4, "Loading\u2026");
38
345
  i0.ɵɵelementEnd()()();
39
346
  } }
40
- /**
41
- * Default configuration values for the ERD diagram.
42
- * These values provide a good starting point for most use cases.
43
- */
44
- /** Default dagre configuration */
45
- const DEFAULT_DAGRE_CONFIG = {
46
- rankDir: 'LR',
47
- nodeSep: 80, // Increased from 50 - more vertical space between nodes in same rank
48
- rankSep: 150, // Increased from 100 - more horizontal space between ranks for labels
49
- edgeSep: 20, // Increased from 10 - more space between edge paths
50
- ranker: 'network-simplex',
51
- align: undefined
52
- };
53
- const DEFAULT_CONFIG = {
54
- nodeWidth: 180,
55
- nodeBaseHeight: 60,
56
- fieldHeight: 20,
57
- maxNodeHeight: 300,
58
- chargeStrength: -800,
59
- linkDistance: 80,
60
- collisionPadding: 20,
61
- showFieldDetails: true,
62
- showRelationshipLabels: true,
63
- showHeader: true,
64
- showNodeCount: true,
65
- showMinimap: false,
66
- showLegend: false,
67
- enableDragging: true,
68
- enableZoom: true,
69
- enablePan: true,
70
- minZoom: 0.1,
71
- maxZoom: 4,
72
- initialZoom: 1,
73
- enableMultiSelect: false,
74
- animationDuration: 750,
75
- fitOnLoad: true,
76
- skipAnimation: false,
77
- emptyStateMessage: 'No entities to display',
78
- emptyStateIcon: 'fa-solid fa-diagram-project',
79
- layoutAlgorithm: 'dagre',
80
- dagreConfig: DEFAULT_DAGRE_CONFIG,
81
- colors: {
82
- nodeBackground: '#f8f9fa',
83
- nodeBorder: '#333',
84
- nodeHeader: '#007bff',
85
- nodeHeaderText: 'white',
86
- primaryKeyBackground: '#fff3cd',
87
- primaryKeyText: '#856404',
88
- foreignKeyBackground: '#cce5ff',
89
- foreignKeyText: '#004085',
90
- linkColor: '#666',
91
- selectedBorder: '#4CAF50',
92
- highlightBorder: '#ff9800',
93
- relatedBorder: '#ff6b35'
94
- }
95
- };
96
- /**
97
- * Entity Relationship Diagram component using D3.js force-directed graph.
98
- *
99
- * This is a **generic, reusable Angular component** that can visualize any data
100
- * that conforms to the `ERDNode` interface. It is completely decoupled from
101
- * MemberJunction-specific types and can be used in any Angular project.
102
- *
103
- * ## Features
104
- *
105
- * - **Interactive Force-Directed Layout**: Nodes automatically arrange themselves
106
- * using physics-based simulation with customizable forces
107
- * - **Zoom and Pan**: Mouse wheel zoom and drag-to-pan with configurable limits
108
- * - **Node Selection**: Click to select nodes with visual highlighting
109
- * - **Focus Mode**: Show a single node with its N-hop related entities
110
- * - **Relationship Visualization**: Arrows show foreign key relationships
111
- * - **Customizable Styling**: Full control over colors, sizes, and appearance
112
- * - **Rich Event System**: Comprehensive events for integration with parent components
113
- * - **State Persistence**: Save and restore diagram state (zoom, pan, selection, positions)
114
- *
115
- * ## Basic Usage
116
- *
117
- * ```html
118
- * <mj-erd-diagram
119
- * [nodes]="entityNodes"
120
- * [selectedNodeId]="selectedId"
121
- * [config]="erdConfig"
122
- * (nodeClick)="onNodeClick($event)"
123
- * (nodeSelected)="onNodeSelected($event)"
124
- * (zoomChange)="onZoomChange($event)">
125
- * </mj-erd-diagram>
126
- * ```
127
- *
128
- * ## Focus Mode Example
129
- *
130
- * Show a single entity with its immediate relationships:
131
- *
132
- * ```html
133
- * <mj-erd-diagram
134
- * [nodes]="allNodes"
135
- * [focusNodeId]="'user-entity-id'"
136
- * [focusDepth]="1"
137
- * (nodeSelected)="onNodeSelected($event)">
138
- * </mj-erd-diagram>
139
- * ```
140
- *
141
- * ## State Persistence Example
142
- *
143
- * ```typescript
144
- * // Save state
145
- * const state = this.erdDiagram.getState();
146
- * localStorage.setItem('erd-state', JSON.stringify(state));
147
- *
148
- * // Restore state
149
- * const savedState = JSON.parse(localStorage.getItem('erd-state'));
150
- * this.erdDiagram.setState(savedState);
151
- * ```
152
- *
153
- * @see ERDNode for the node data structure
154
- * @see ERDConfig for configuration options
155
- * @see ERDState for state persistence
156
- */
157
347
  export class ERDDiagramComponent {
158
- erdContainer;
159
- // ============================================================================
160
- // INPUTS - Data
161
- // ============================================================================
162
- /**
163
- * The nodes (entities) to display in the diagram.
164
- * Each node should conform to the `ERDNode` interface.
165
- *
166
- * @example
167
- * ```typescript
168
- * nodes: ERDNode[] = [
169
- * {
170
- * id: 'user-1',
171
- * name: 'Users',
172
- * fields: [
173
- * { id: 'id', name: 'ID', isPrimaryKey: true },
174
- * { id: 'email', name: 'Email', isPrimaryKey: false }
175
- * ]
176
- * }
177
- * ];
178
- * ```
179
- */
348
+ cdr = inject(ChangeDetectorRef);
349
+ host = inject(ElementRef);
350
+ // ─── INPUTS ───────────────────────────────────────────────────────────
180
351
  nodes = [];
181
- /**
182
- * ID of the currently selected node.
183
- * Use this for external control of selection state.
184
- * The component will highlight this node and emit selection events accordingly.
185
- *
186
- * @example `[selectedNodeId]="'entity-123'"`
187
- */
188
352
  selectedNodeId = null;
189
- /**
190
- * IDs of nodes to highlight (in addition to selected node).
191
- * Highlighted nodes receive a distinct visual style (orange border by default).
192
- * Useful for showing search results or related items.
193
- *
194
- * @example `[highlightedNodeIds]="['entity-1', 'entity-2']"`
195
- */
196
353
  highlightedNodeIds = [];
197
- /**
198
- * ID of the node to focus on.
199
- * When set, the diagram will only show this node and its related nodes
200
- * up to `focusDepth` hops away. This is useful for entity-centric views.
201
- *
202
- * Set to `null` to show all nodes.
203
- *
204
- * @example `[focusNodeId]="'user-entity-id'"`
205
- */
206
354
  focusNodeId = null;
207
- /**
208
- * Number of relationship hops to include when in focus mode.
209
- * - `0`: Show only the focus node
210
- * - `1`: Show focus node + directly related nodes
211
- * - `2`: Show focus node + nodes within 2 relationship hops
212
- *
213
- * Only applies when `focusNodeId` is set.
214
- *
215
- * @default 1
216
- * @example `[focusDepth]="2"`
217
- */
218
355
  focusDepth = 1;
219
- // ============================================================================
220
- // INPUTS - State
221
- // ============================================================================
222
- /**
223
- * Whether the diagram is currently refreshing/loading.
224
- * When `true`, displays a loading overlay on the diagram.
225
- *
226
- * @example `[isRefreshing]="isLoadingData"`
227
- */
228
356
  isRefreshing = false;
229
- /**
230
- * Whether the diagram is in read-only mode.
231
- * When `true`, disables dragging and selection (zoom/pan still work).
232
- *
233
- * @default false
234
- */
235
357
  readOnly = false;
236
- // ============================================================================
237
- // INPUTS - Configuration
238
- // ============================================================================
239
- /**
240
- * Configuration options for the diagram.
241
- * All options are optional with sensible defaults.
242
- *
243
- * @see ERDConfig for all available options
244
- * @example
245
- * ```typescript
246
- * config: ERDConfig = {
247
- * nodeWidth: 200,
248
- * chargeStrength: -1000,
249
- * colors: {
250
- * nodeHeader: '#0066cc'
251
- * }
252
- * };
253
- * ```
254
- */
255
358
  config = {};
256
- /**
257
- * Whether to show the header bar with zoom controls.
258
- * Set to `false` to hide the header and use programmatic control instead.
259
- *
260
- * @default true
261
- */
262
359
  showHeader = true;
263
- /**
264
- * Title to display in the header bar.
265
- * Only visible when `showHeader` is `true`.
266
- *
267
- * @default 'Entity Relationship Diagram'
268
- */
269
360
  headerTitle = 'Entity Relationship Diagram';
270
- // ============================================================================
271
- // OUTPUTS - Selection Events
272
- // ============================================================================
273
- /**
274
- * Emitted when a node is clicked.
275
- * Set `event.cancel = true` to prevent the default selection behavior.
276
- *
277
- * @example
278
- * ```typescript
279
- * onNodeClick(event: ERDNodeClickEvent) {
280
- * console.log('Clicked:', event.node.name);
281
- * // Prevent selection for certain nodes
282
- * if (event.node.customData?.['locked']) {
283
- * event.cancel = true;
284
- * }
285
- * }
286
- * ```
287
- */
361
+ // ─── OUTPUTS (preserved from legacy API) ──────────────────────────────
288
362
  nodeClick = new EventEmitter();
289
- /**
290
- * Emitted when a node is double-clicked.
291
- * Useful for opening detail views or triggering actions.
292
- *
293
- * @example
294
- * ```typescript
295
- * onNodeDoubleClick(event: ERDNodeDoubleClickEvent) {
296
- * this.openEntityDetail(event.node.id);
297
- * }
298
- * ```
299
- */
300
363
  nodeDoubleClick = new EventEmitter();
301
- /**
302
- * Emitted when a node is selected (after click, unless cancelled).
303
- * The node parameter contains the full `ERDNode` data.
304
- */
305
364
  nodeSelected = new EventEmitter();
306
- /**
307
- * Emitted when the current node is deselected.
308
- * This happens when clicking the background or selecting a different node.
309
- */
310
365
  nodeDeselected = new EventEmitter();
311
- /**
312
- * Emitted when a relationship link is clicked.
313
- * The event includes both source and target nodes for context.
314
- */
315
366
  linkClick = new EventEmitter();
316
- // ============================================================================
317
- // OUTPUTS - Hover Events
318
- // ============================================================================
319
- /**
320
- * Emitted when the mouse enters a node.
321
- * Includes related nodes and position for tooltip display.
322
- *
323
- * @example
324
- * ```typescript
325
- * onNodeHover(event: ERDNodeHoverEvent) {
326
- * this.showTooltip(event.node, event.position);
327
- * }
328
- * ```
329
- */
330
367
  nodeHover = new EventEmitter();
331
- /**
332
- * Emitted when the mouse leaves a node.
333
- */
334
368
  nodeHoverEnd = new EventEmitter();
335
- /**
336
- * Emitted when the mouse enters a relationship link.
337
- */
338
369
  linkHover = new EventEmitter();
339
- /**
340
- * Emitted when the mouse leaves a relationship link.
341
- */
342
370
  linkHoverEnd = new EventEmitter();
343
- // ============================================================================
344
- // OUTPUTS - Context Menu Events
345
- // ============================================================================
346
- /**
347
- * Emitted when a node is right-clicked.
348
- * Set `event.cancel = true` to prevent the default browser context menu.
349
- *
350
- * @example
351
- * ```typescript
352
- * onNodeContextMenu(event: ERDNodeContextMenuEvent) {
353
- * event.cancel = true;
354
- * this.showContextMenu(event.node, event.position);
355
- * }
356
- * ```
357
- */
358
371
  nodeContextMenu = new EventEmitter();
359
- /**
360
- * Emitted when a relationship link is right-clicked.
361
- */
362
372
  linkContextMenu = new EventEmitter();
363
- /**
364
- * Emitted when the diagram background is right-clicked.
365
- * Includes diagram coordinates for adding new nodes at click position.
366
- */
367
373
  diagramContextMenu = new EventEmitter();
368
- // ============================================================================
369
- // OUTPUTS - Drag Events
370
- // ============================================================================
371
- /**
372
- * Emitted when a node drag operation starts.
373
- * Set `event.cancel = true` to prevent the drag.
374
- */
375
374
  nodeDragStart = new EventEmitter();
376
- /**
377
- * Emitted when a node drag operation ends.
378
- */
379
375
  nodeDragEnd = new EventEmitter();
380
- // ============================================================================
381
- // OUTPUTS - Diagram Events
382
- // ============================================================================
383
- /**
384
- * Emitted when zoom level or pan position changes.
385
- * Useful for synchronizing zoom controls or saving view state.
386
- */
387
376
  zoomChange = new EventEmitter();
388
- /**
389
- * Emitted when the diagram requests a refresh.
390
- * Triggered by the refresh button in the header.
391
- */
392
377
  refreshRequested = new EventEmitter();
393
- /**
394
- * Emitted when the force simulation completes initial layout.
395
- * Useful for performing actions after nodes have settled.
396
- */
397
378
  layoutComplete = new EventEmitter();
379
+ stateChange = new EventEmitter();
380
+ // ─── VIEW CHILDREN ────────────────────────────────────────────────────
381
+ canvasRef;
382
+ // ─── STATE ────────────────────────────────────────────────────────────
383
+ /** Current pan + zoom. */
384
+ transform = { x: 0, y: 0, k: 1 };
385
+ /** Set of schema names the user has filtered IN. Null means "all". */
386
+ activeSchemas = null;
387
+ /** Search query string. */
388
+ searchQuery = '';
389
+ /** Node IDs the user has clicked "+N more fields" on. */
390
+ expandedNodeIds = new Set();
391
+ /** Node currently being hovered (for highlight preview). */
392
+ hoverNodeId = null;
393
+ /** Cached layout — recomputed when nodes, filter, search or expansion changes. */
394
+ layout = { nodes: [], edges: [], bands: [], totalWidth: 0, totalHeight: 0 };
398
395
  /**
399
- * Emitted when diagram state changes (selection, zoom, highlights).
400
- * Useful for auto-saving state or syncing with other components.
396
+ * Active layout algorithm. Starts from `config.layoutAlgorithm` (default
397
+ * `schema-grid`) but can be toggled at runtime via `setLayoutAlgorithm`.
398
+ * Exposed so the chrome toggle + saved user state can drive it.
401
399
  */
402
- stateChange = new EventEmitter();
403
- // ============================================================================
404
- // PRIVATE STATE
405
- // ============================================================================
406
- svg = null;
407
- simulation = null;
408
- internalNodes = [];
409
- internalLinks = [];
410
- visibleNodes = [];
411
- visibleLinks = [];
412
- zoom = null;
400
+ activeLayout = 'schema-grid';
401
+ /** Derived schema summary for the chip row. */
402
+ schemaChips = [];
403
+ /** Derived — number of entities currently visible. */
404
+ visibleCount = 0;
405
+ /** Derived — total entities in the input, before any filtering. */
406
+ totalCount = 0;
407
+ /** Derived — the Set of IDs in the focus/hover neighbourhood (null when no highlight active). */
408
+ highlightSet = null;
409
+ /** Derived — the single selected entity, if any. */
410
+ selectedEntity = null;
411
+ // Panning bookkeeping (not reactive). Public so the template can bind a
412
+ // cursor attribute to it; treat as internal state.
413
+ panning = false;
414
+ panStart = null;
415
+ /** Raw mousedown coords — used to compute drag distance for the
416
+ * pan-vs-click threshold. */
417
+ dragOrigin = null;
418
+ /** True once movement crossed the drag threshold — used to suppress
419
+ * the synthetic click that follows the mouseup. */
420
+ dragMoved = false;
421
+ /** Pixels of movement before mousedown is treated as a drag. */
422
+ static DRAG_THRESHOLD = 4;
423
+ /** Bound window listeners — kept on the instance so we can detach. */
424
+ boundWindowMove;
425
+ boundWindowUp;
413
426
  resizeObserver;
414
- lastKnownSize = { width: 0, height: 0 };
415
- resizeTimeout;
416
- mergedConfig = DEFAULT_CONFIG;
417
- layoutCompleted = false;
418
- isLayoutFrozen = false;
419
- // ============================================================================
420
- // LIFECYCLE HOOKS
421
- // ============================================================================
422
- ngAfterViewInit() {
423
- setTimeout(() => {
424
- this.setupERD();
425
- }, 100);
426
- this.setupResizeObserver();
427
- }
427
+ rootClass = true;
428
+ // ─── LIFECYCLE ────────────────────────────────────────────────────────
428
429
  ngOnChanges(changes) {
429
- if (changes['nodes'] && !changes['nodes'].firstChange) {
430
- this.setupERD();
431
- }
432
- if (changes['selectedNodeId'] && !changes['selectedNodeId'].firstChange) {
433
- this.updateSelectionHighlighting();
434
- this.emitStateChange();
435
- }
436
- if (changes['highlightedNodeIds'] && !changes['highlightedNodeIds'].firstChange) {
437
- this.updateHighlighting();
438
- this.emitStateChange();
439
- }
440
- if (changes['focusNodeId'] && !changes['focusNodeId'].firstChange) {
441
- this.setupERD();
442
- this.emitStateChange();
443
- }
444
- if (changes['focusDepth'] && !changes['focusDepth'].firstChange) {
445
- if (this.focusNodeId) {
446
- this.setupERD();
447
- this.emitStateChange();
430
+ const nodesChanged = !!changes['nodes'];
431
+ // Compare config by the fields we actually use — consumers that bind
432
+ // an inline `[config]="{...}"` object literal get a new reference
433
+ // every CD cycle, so a naive `changes['config']` check would
434
+ // recompute + refit endlessly.
435
+ const configChanged = this.configAffectsLayout(changes['config']?.previousValue, changes['config']?.currentValue);
436
+ if (nodesChanged || configChanged) {
437
+ if (nodesChanged) {
438
+ this.activeSchemas = null;
439
+ this.expandedNodeIds = new Set();
440
+ this.hoverNodeId = null;
448
441
  }
442
+ // Honour `config.layoutAlgorithm` on first arrival; runtime
443
+ // changes happen via `setLayoutAlgorithm`.
444
+ if (this.config.layoutAlgorithm === 'dagre')
445
+ this.activeLayout = 'dagre';
446
+ else if (this.config.layoutAlgorithm === 'schema-grid')
447
+ this.activeLayout = 'schema-grid';
448
+ this.recompute();
449
+ // Fit-to-view once the DOM has the new canvas dimensions.
450
+ queueMicrotask(() => this.fitToView());
449
451
  }
450
- if (changes['config'] && !changes['config'].firstChange) {
451
- this.mergeConfig();
452
- this.setupERD();
453
- }
454
- }
455
- ngOnDestroy() {
456
- if (this.simulation) {
457
- this.simulation.stop();
458
- }
459
- if (this.resizeObserver) {
460
- this.resizeObserver.disconnect();
461
- }
462
- if (this.resizeTimeout) {
463
- clearTimeout(this.resizeTimeout);
452
+ if (changes['focusNodeId']) {
453
+ this.updateHighlightSet();
454
+ this.cdr.markForCheck();
464
455
  }
465
456
  }
466
- // ============================================================================
467
- // PUBLIC API - Zoom Control
468
- // ============================================================================
469
- /**
470
- * Zoom in on the diagram by a fixed factor (1.2x).
471
- * Uses smooth animation transition.
472
- */
473
- zoomIn() {
474
- if (this.zoom && this.svg) {
475
- this.svg.transition().duration(300).call(this.zoom.scaleBy, 1.2);
476
- }
457
+ /** True iff any config field that influences the cached layout changed. */
458
+ configAffectsLayout(prev, curr) {
459
+ if (prev === curr)
460
+ return false;
461
+ return ((prev?.nodeWidth ?? null) !== (curr?.nodeWidth ?? null) ||
462
+ (prev?.fieldHeight ?? null) !== (curr?.fieldHeight ?? null) ||
463
+ (prev?.showAllFields ?? null) !== (curr?.showAllFields ?? null) ||
464
+ (prev?.showSchemaBands ?? null) !== (curr?.showSchemaBands ?? null) ||
465
+ (prev?.crowsFoot ?? null) !== (curr?.crowsFoot ?? null) ||
466
+ (prev?.maxFitZoom ?? null) !== (curr?.maxFitZoom ?? null) ||
467
+ (prev?.minZoom ?? null) !== (curr?.minZoom ?? null) ||
468
+ (prev?.maxZoom ?? null) !== (curr?.maxZoom ?? null));
477
469
  }
478
- /**
479
- * Zoom out on the diagram by a fixed factor (0.83x).
480
- * Uses smooth animation transition.
481
- */
482
- zoomOut() {
483
- if (this.zoom && this.svg) {
484
- this.svg.transition().duration(300).call(this.zoom.scaleBy, 0.83);
485
- }
470
+ ngAfterViewInit() {
471
+ this.resizeObserver = this.watchResize();
472
+ this.recompute();
473
+ queueMicrotask(() => this.fitToView());
486
474
  }
487
- /**
488
- * Reset zoom to default level (1x) and center the diagram.
489
- * Uses smooth animation transition.
490
- */
491
- resetZoom() {
492
- if (this.zoom && this.svg) {
493
- this.svg.transition().duration(500).call(this.zoom.transform, d3.zoomIdentity);
494
- }
475
+ ngOnDestroy() {
476
+ this.resizeObserver?.disconnect();
477
+ // Detach any pan listeners still bound from an in-progress drag.
478
+ if (this.boundWindowMove)
479
+ window.removeEventListener('mousemove', this.boundWindowMove);
480
+ if (this.boundWindowUp)
481
+ window.removeEventListener('mouseup', this.boundWindowUp);
495
482
  }
496
- /**
497
- * Zoom and pan to center on a specific node.
498
- *
499
- * @param nodeId - ID of the node to center on
500
- * @param scale - Optional zoom scale (default: 1.5)
501
- *
502
- * @example
503
- * ```typescript
504
- * // Center on node with default zoom
505
- * this.erdDiagram.zoomToNode('user-entity-id');
506
- *
507
- * // Center with custom zoom level
508
- * this.erdDiagram.zoomToNode('user-entity-id', 2.0);
509
- * ```
510
- */
511
- zoomToNode(nodeId, scale = 1.5) {
512
- const node = this.internalNodes.find(n => n.id === nodeId);
513
- if (!node || !this.zoom || !this.svg)
514
- return;
515
- const container = this.erdContainer.nativeElement;
516
- const width = container.clientWidth;
517
- const height = container.clientHeight;
518
- const x = width / 2 - (node.x || 0) * scale;
519
- const y = height / 2 - (node.y || 0) * scale;
520
- this.svg.transition().duration(this.mergedConfig.animationDuration).call(this.zoom.transform, d3.zoomIdentity.translate(x, y).scale(scale));
483
+ // ─── RECOMPUTATION ────────────────────────────────────────────────────
484
+ /** Rebuild the layout + derived view-model from current inputs/state. */
485
+ recompute() {
486
+ this.totalCount = this.nodes.length;
487
+ const filteredBySchema = this.activeSchemas
488
+ ? this.nodes.filter(n => this.activeSchemas.has(n.schemaName || '_'))
489
+ : this.nodes;
490
+ const q = this.searchQuery.trim().toLowerCase();
491
+ const filtered = q
492
+ ? filteredBySchema.filter(n => n.name.toLowerCase().includes(q) ||
493
+ (n.schemaName?.toLowerCase().includes(q) ?? false) ||
494
+ n.fields.some(f => f.name.toLowerCase().includes(q)))
495
+ : filteredBySchema;
496
+ this.visibleCount = filtered.length;
497
+ const commonOpts = {
498
+ nodeWidth: this.config.nodeWidth ?? 220,
499
+ fieldHeight: this.config.fieldHeight ?? 22,
500
+ expandedNodeIds: this.expandedNodeIds,
501
+ showAllFields: this.config.showAllFields ?? false,
502
+ };
503
+ this.layout = this.activeLayout === 'dagre'
504
+ ? computeDagreLayout(filtered, {
505
+ ...commonOpts,
506
+ rankDir: this.config.dagreConfig?.rankDir ?? 'LR',
507
+ nodeSep: this.config.dagreConfig?.nodeSep,
508
+ rankSep: this.config.dagreConfig?.rankSep,
509
+ })
510
+ : computeErdLayout(filtered, commonOpts);
511
+ this.schemaChips = this.buildSchemaChips();
512
+ this.updateHighlightSet();
513
+ this.updateSelectedEntity();
514
+ this.cdr.markForCheck();
521
515
  }
522
516
  /**
523
- * Fit all visible nodes within the viewport.
524
- * Calculates optimal zoom level and position to show all nodes.
517
+ * Switch between the schema-grid and dagre hierarchical layouts at
518
+ * runtime. Emits a state change so the consumer can persist the
519
+ * user's preference (via `userStateChange` on `mj-erd-composite`).
525
520
  *
526
- * @param padding - Padding around nodes (default: 50px)
521
+ * Also clears the current focus/selection — switching layouts moves
522
+ * cards to entirely new positions, so the previously-selected entity
523
+ * would otherwise stay highlighted in a different spot with the side
524
+ * panel still open, which is jarring. A clean slate is better.
527
525
  */
528
- zoomToFit(padding = 50) {
529
- if (!this.svg || !this.zoom || this.visibleNodes.length === 0)
530
- return;
531
- const container = this.erdContainer.nativeElement;
532
- const width = container.clientWidth;
533
- const height = container.clientHeight;
534
- // Guard against zero container dimensions (container not laid out yet)
535
- if (width === 0 || height === 0)
536
- return;
537
- // Calculate bounds
538
- let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
539
- this.visibleNodes.forEach(node => {
540
- const halfWidth = node.width / 2;
541
- const halfHeight = node.height / 2;
542
- minX = Math.min(minX, (node.x || 0) - halfWidth);
543
- minY = Math.min(minY, (node.y || 0) - halfHeight);
544
- maxX = Math.max(maxX, (node.x || 0) + halfWidth);
545
- maxY = Math.max(maxY, (node.y || 0) + halfHeight);
546
- });
547
- // Guard against invalid bounds (nodes without valid positions)
548
- if (!isFinite(minX) || !isFinite(minY) || !isFinite(maxX) || !isFinite(maxY))
549
- return;
550
- const boundsWidth = maxX - minX + padding * 2;
551
- const boundsHeight = maxY - minY + padding * 2;
552
- // Guard against zero-dimension bounds
553
- if (boundsWidth <= 0 || boundsHeight <= 0)
554
- return;
555
- const centerX = (minX + maxX) / 2;
556
- const centerY = (minY + maxY) / 2;
557
- const scale = Math.min(width / boundsWidth, height / boundsHeight, this.mergedConfig.maxZoom);
558
- // Final sanity check before applying transform
559
- if (!isFinite(scale) || scale <= 0)
526
+ setLayoutAlgorithm(algo) {
527
+ if (this.activeLayout === algo)
560
528
  return;
561
- const translateX = width / 2 - centerX * scale;
562
- const translateY = height / 2 - centerY * scale;
563
- this.svg.transition().duration(this.mergedConfig.animationDuration).call(this.zoom.transform, d3.zoomIdentity.translate(translateX, translateY).scale(scale));
529
+ this.activeLayout = algo;
530
+ const hadSelection = !!(this.focusNodeId || this.selectedNodeId);
531
+ this.focusNodeId = null;
532
+ this.selectedNodeId = null;
533
+ this.hoverNodeId = null;
534
+ this.updateHighlightSet();
535
+ this.updateSelectedEntity();
536
+ if (hadSelection)
537
+ this.nodeDeselected.emit();
538
+ this.recompute();
539
+ queueMicrotask(() => this.fitToView());
540
+ this.stateChange.emit(this.getState());
564
541
  }
565
- // ============================================================================
566
- // PUBLIC API - Selection
567
- // ============================================================================
568
- /**
569
- * Programmatically select a node by ID.
570
- *
571
- * @param nodeId - ID of the node to select
572
- * @returns `true` if node was found and selected, `false` otherwise
573
- */
574
- selectNode(nodeId) {
575
- const node = this.nodes.find(n => n.id === nodeId);
576
- if (node) {
577
- this.nodeSelected.emit(node);
578
- return true;
542
+ buildSchemaChips() {
543
+ const counts = new Map();
544
+ for (const n of this.nodes) {
545
+ const s = n.schemaName || '_';
546
+ counts.set(s, (counts.get(s) ?? 0) + 1);
579
547
  }
580
- return false;
548
+ return [...counts.entries()]
549
+ .sort(([a], [b]) => a.localeCompare(b))
550
+ .map(([name, count]) => ({
551
+ name,
552
+ count,
553
+ active: this.activeSchemas ? this.activeSchemas.has(name) : true,
554
+ }));
581
555
  }
582
- /**
583
- * Clear all selections.
584
- */
585
- deselectAll() {
586
- this.nodeDeselected.emit();
587
- this.clearAllHighlighting();
556
+ updateHighlightSet() {
557
+ const id = this.focusNodeId ?? this.hoverNodeId;
558
+ this.highlightSet = id ? getNeighbors(this.nodes, id) : null;
588
559
  }
589
- // ============================================================================
590
- // PUBLIC API - Highlighting
591
- // ============================================================================
592
- /**
593
- * Highlight a specific node.
594
- *
595
- * @param nodeId - ID of the node to highlight
596
- */
597
- highlightNode(nodeId) {
598
- if (!this.highlightedNodeIds.includes(nodeId)) {
599
- this.highlightedNodeIds = [...this.highlightedNodeIds, nodeId];
600
- this.updateHighlighting();
601
- this.emitStateChange();
602
- }
560
+ updateSelectedEntity() {
561
+ const id = this.focusNodeId ?? this.selectedNodeId;
562
+ this.selectedEntity = id ? this.nodes.find(n => n.id === id) ?? null : null;
603
563
  }
604
- /**
605
- * Clear all highlights.
606
- */
607
- clearHighlights() {
608
- this.highlightedNodeIds = [];
609
- this.updateHighlighting();
610
- this.emitStateChange();
564
+ watchResize() {
565
+ const observer = new ResizeObserver(() => this.fitToView());
566
+ observer.observe(this.host.nativeElement);
567
+ return observer;
611
568
  }
569
+ // ─── PAN / ZOOM / FIT ────────────────────────────────────────────────
612
570
  /**
613
- * Highlight a node and all its directly related nodes.
571
+ * Mousedown anywhere on the SVG (background OR a node card). We
572
+ * arm a potential pan and attach window-level move/up listeners so
573
+ * the drag continues even if the cursor leaves the canvas (passes
574
+ * over the focus card, scrollbars, the chrome, etc.).
614
575
  *
615
- * @param nodeId - ID of the center node
616
- * @param depth - Number of relationship hops to highlight (default: 1)
576
+ * The actual `panning = true` flip happens in the move handler once
577
+ * the cursor crosses `DRAG_THRESHOLD` — that way short clicks still
578
+ * register as clicks on cards (no accidental selection-toggle from
579
+ * a tiny mouse jitter).
617
580
  */
618
- highlightRelated(nodeId, depth = 1) {
619
- const relatedIds = this.getRelatedNodeIds(nodeId, depth);
620
- this.highlightedNodeIds = [nodeId, ...relatedIds];
621
- this.updateHighlighting();
622
- this.emitStateChange();
581
+ onBackgroundMouseDown(e) {
582
+ if (e.button !== 0)
583
+ return;
584
+ this.dragOrigin = { clientX: e.clientX, clientY: e.clientY };
585
+ this.dragMoved = false;
586
+ this.panStart = { x: e.clientX - this.transform.x, y: e.clientY - this.transform.y };
587
+ this.boundWindowMove = (ev) => this.onWindowMouseMove(ev);
588
+ this.boundWindowUp = (ev) => this.onWindowMouseUp(ev);
589
+ window.addEventListener('mousemove', this.boundWindowMove);
590
+ window.addEventListener('mouseup', this.boundWindowUp);
623
591
  }
624
- /**
625
- * Get nodes that are related to a specific node.
626
- *
627
- * @param nodeId - ID of the node to find relationships for
628
- * @param depth - Number of relationship hops (default: 1)
629
- * @returns Array of relationship information
630
- */
631
- getRelatedNodes(nodeId, depth = 1) {
632
- const result = [];
633
- const visited = new Set([nodeId]);
634
- const toProcess = [{ id: nodeId, currentDepth: 0 }];
635
- while (toProcess.length > 0) {
636
- const current = toProcess.shift();
637
- if (current.currentDepth >= depth)
638
- continue;
639
- // Find outgoing relationships
640
- this.nodes.forEach(node => {
641
- node.fields.forEach(field => {
642
- if (field.relatedNodeId) {
643
- if (node.id === current.id && !visited.has(field.relatedNodeId)) {
644
- const targetNode = this.nodes.find(n => n.id === field.relatedNodeId);
645
- if (targetNode) {
646
- visited.add(field.relatedNodeId);
647
- toProcess.push({ id: field.relatedNodeId, currentDepth: current.currentDepth + 1 });
648
- result.push({
649
- node: targetNode,
650
- link: {
651
- sourceNodeId: node.id,
652
- targetNodeId: field.relatedNodeId,
653
- sourceField: field,
654
- isSelfReference: node.id === field.relatedNodeId
655
- },
656
- direction: 'outgoing',
657
- field
658
- });
659
- }
660
- }
661
- // Incoming relationships
662
- if (field.relatedNodeId === current.id && !visited.has(node.id)) {
663
- visited.add(node.id);
664
- toProcess.push({ id: node.id, currentDepth: current.currentDepth + 1 });
665
- result.push({
666
- node,
667
- link: {
668
- sourceNodeId: node.id,
669
- targetNodeId: field.relatedNodeId,
670
- sourceField: field,
671
- isSelfReference: node.id === field.relatedNodeId
672
- },
673
- direction: 'incoming',
674
- field
675
- });
676
- }
677
- }
678
- });
679
- });
592
+ onWindowMouseMove(e) {
593
+ if (!this.dragOrigin || !this.panStart)
594
+ return;
595
+ if (!this.panning) {
596
+ const dx = e.clientX - this.dragOrigin.clientX;
597
+ const dy = e.clientY - this.dragOrigin.clientY;
598
+ if (Math.hypot(dx, dy) < ERDDiagramComponent.DRAG_THRESHOLD)
599
+ return;
600
+ this.panning = true;
601
+ this.dragMoved = true;
680
602
  }
681
- return result;
682
- }
683
- // ============================================================================
684
- // PUBLIC API - State Management
685
- // ============================================================================
686
- /**
687
- * Get the current state of the diagram for persistence.
688
- * Includes selection, highlights, zoom, pan, and node positions.
689
- *
690
- * @returns Current diagram state
691
- *
692
- * @example
693
- * ```typescript
694
- * // Save to localStorage
695
- * const state = erdDiagram.getState();
696
- * localStorage.setItem('erd-state', JSON.stringify(state));
697
- * ```
698
- */
699
- getState() {
700
- const zoomState = this.getZoomState();
701
- const nodePositions = {};
702
- this.internalNodes.forEach(node => {
703
- nodePositions[node.id] = {
704
- x: node.x || 0,
705
- y: node.y || 0,
706
- fx: node.fx,
707
- fy: node.fy
708
- };
709
- });
710
- return {
711
- selectedNodeId: this.selectedNodeId,
712
- highlightedNodeIds: [...this.highlightedNodeIds],
713
- zoomLevel: zoomState.zoomLevel,
714
- translateX: zoomState.translateX,
715
- translateY: zoomState.translateY,
716
- focusNodeId: this.focusNodeId,
717
- focusDepth: this.focusDepth,
718
- nodePositions
603
+ this.transform = {
604
+ ...this.transform,
605
+ x: e.clientX - this.panStart.x,
606
+ y: e.clientY - this.panStart.y,
719
607
  };
608
+ this.emitZoom();
609
+ this.cdr.markForCheck();
720
610
  }
721
- /**
722
- * Restore a previously saved diagram state.
723
- *
724
- * @param state - State object from `getState()`
725
- * @param restorePositions - Whether to restore node positions (default: true)
726
- *
727
- * @example
728
- * ```typescript
729
- * // Restore from localStorage
730
- * const savedState = JSON.parse(localStorage.getItem('erd-state'));
731
- * if (savedState) {
732
- * erdDiagram.setState(savedState);
733
- * }
734
- * ```
735
- */
736
- setState(state, restorePositions = true) {
737
- if (state.highlightedNodeIds) {
738
- this.highlightedNodeIds = [...state.highlightedNodeIds];
611
+ onWindowMouseUp(_e) {
612
+ // If a drag actually happened, swallow the synthetic click that
613
+ // browsers fire on mouseup so it doesn't reach the node click
614
+ // handler and toggle the focus. We use capture phase + once so
615
+ // we only catch the immediate next click from this mouse-up.
616
+ if (this.dragMoved) {
617
+ const swallow = (ev) => {
618
+ ev.stopPropagation();
619
+ ev.preventDefault();
620
+ };
621
+ window.addEventListener('click', swallow, { capture: true, once: true });
739
622
  }
740
- // Restore zoom/pan
741
- if (this.svg && this.zoom && state.zoomLevel != null) {
742
- this.svg.call(this.zoom.transform, d3.zoomIdentity
743
- .translate(state.translateX || 0, state.translateY || 0)
744
- .scale(state.zoomLevel));
623
+ this.panning = false;
624
+ this.panStart = null;
625
+ this.dragOrigin = null;
626
+ this.dragMoved = false;
627
+ if (this.boundWindowMove) {
628
+ window.removeEventListener('mousemove', this.boundWindowMove);
629
+ this.boundWindowMove = undefined;
745
630
  }
746
- // Restore node positions
747
- if (restorePositions && state.nodePositions) {
748
- this.internalNodes.forEach(node => {
749
- const savedPos = state.nodePositions?.[node.id];
750
- if (savedPos) {
751
- node.x = savedPos.x;
752
- node.y = savedPos.y;
753
- node.fx = savedPos.fx;
754
- node.fy = savedPos.fy;
755
- }
756
- });
757
- // Restart simulation briefly to update display
758
- if (this.simulation) {
759
- this.simulation.alpha(0.1).restart();
760
- }
631
+ if (this.boundWindowUp) {
632
+ window.removeEventListener('mouseup', this.boundWindowUp);
633
+ this.boundWindowUp = undefined;
761
634
  }
762
- this.updateSelectionHighlighting();
763
- this.updateHighlighting();
764
635
  }
765
- // ============================================================================
766
- // PUBLIC API - Layout Control
767
- // ============================================================================
768
- /**
769
- * Freeze the layout simulation.
770
- * Nodes will stay in their current positions and won't move.
771
- */
772
- freezeLayout() {
773
- this.isLayoutFrozen = true;
774
- if (this.simulation) {
775
- this.simulation.stop();
776
- // Fix all node positions
777
- this.internalNodes.forEach(node => {
778
- node.fx = node.x;
779
- node.fy = node.y;
780
- });
781
- }
782
- }
783
- /**
784
- * Unfreeze the layout simulation.
785
- * Nodes will resume natural movement based on forces.
786
- */
787
- unfreezeLayout() {
788
- this.isLayoutFrozen = false;
789
- // Unfix all node positions
790
- this.internalNodes.forEach(node => {
791
- node.fx = null;
792
- node.fy = null;
793
- });
794
- if (this.simulation) {
795
- this.simulation.alpha(0.3).restart();
796
- }
797
- }
798
- /**
799
- * Center the diagram without changing zoom level.
800
- */
801
- centerDiagram() {
802
- if (!this.svg || !this.zoom || this.visibleNodes.length === 0)
636
+ onWheel(e) {
637
+ if (this.config.enableZoom === false)
803
638
  return;
804
- const container = this.erdContainer.nativeElement;
805
- const width = container.clientWidth;
806
- const height = container.clientHeight;
807
- // Calculate center of all nodes
808
- let sumX = 0, sumY = 0;
809
- this.visibleNodes.forEach(node => {
810
- sumX += node.x || 0;
811
- sumY += node.y || 0;
812
- });
813
- const centerX = sumX / this.visibleNodes.length;
814
- const centerY = sumY / this.visibleNodes.length;
815
- const currentTransform = d3.zoomTransform(this.svg.node());
816
- const translateX = width / 2 - centerX * currentTransform.k;
817
- const translateY = height / 2 - centerY * currentTransform.k;
818
- this.svg.transition().duration(this.mergedConfig.animationDuration).call(this.zoom.transform, d3.zoomIdentity.translate(translateX, translateY).scale(currentTransform.k));
819
- }
820
- // ============================================================================
821
- // PUBLIC API - Utilities
822
- // ============================================================================
823
- /**
824
- * Rebuild the entire ERD visualization.
825
- */
826
- refresh() {
827
- this.setupERD();
828
- }
829
- /**
830
- * Trigger a resize recalculation.
831
- * Call this when the container size changes.
832
- */
833
- triggerResize() {
834
- if (this.resizeTimeout) {
835
- clearTimeout(this.resizeTimeout);
836
- }
837
- this.resizeTimeout = window.setTimeout(() => {
838
- this.resizeSVG();
839
- }, 100);
840
- }
841
- /**
842
- * Get current zoom state.
843
- *
844
- * @returns Current zoom level and translation
845
- */
846
- getZoomState() {
847
- if (!this.svg) {
848
- return { zoomLevel: 1, translateX: 0, translateY: 0 };
849
- }
850
- const transform = d3.zoomTransform(this.svg.node());
851
- return {
852
- zoomLevel: transform.k,
853
- translateX: transform.x,
854
- translateY: transform.y
639
+ e.preventDefault();
640
+ const svg = this.canvasRef?.nativeElement;
641
+ if (!svg)
642
+ return;
643
+ const rect = svg.getBoundingClientRect();
644
+ const mx = e.clientX - rect.left;
645
+ const my = e.clientY - rect.top;
646
+ const delta = -e.deltaY * 0.001;
647
+ const minZoom = this.config.minZoom ?? 0.15;
648
+ const maxZoom = this.config.maxZoom ?? 2.5;
649
+ const newK = Math.max(minZoom, Math.min(maxZoom, this.transform.k * (1 + delta)));
650
+ const ratio = newK / this.transform.k;
651
+ this.transform = {
652
+ k: newK,
653
+ x: mx - (mx - this.transform.x) * ratio,
654
+ y: my - (my - this.transform.y) * ratio,
855
655
  };
656
+ this.emitZoom();
657
+ this.cdr.markForCheck();
856
658
  }
857
- /**
858
- * Export the diagram as an SVG string.
859
- *
860
- * @returns SVG markup string
861
- */
862
- exportAsSVG() {
863
- if (!this.svg)
864
- return '';
865
- const svgNode = this.svg.node();
866
- if (!svgNode)
867
- return '';
868
- const serializer = new XMLSerializer();
869
- return serializer.serializeToString(svgNode);
870
- }
871
- // ============================================================================
872
- // SETUP METHODS
873
- // ============================================================================
874
- setupERD() {
875
- if (!this.erdContainer?.nativeElement) {
659
+ zoomBy(factor) {
660
+ const svg = this.canvasRef?.nativeElement;
661
+ if (!svg)
876
662
  return;
877
- }
878
- this.layoutCompleted = false;
879
- this.mergeConfig();
880
- this.clearVisualization();
881
- this.createInternalNodes();
882
- this.createInternalLinks();
883
- this.applyFocusMode();
884
- this.createVisualization();
885
- }
886
- mergeConfig() {
887
- this.mergedConfig = {
888
- ...DEFAULT_CONFIG,
889
- ...this.config,
890
- colors: {
891
- ...DEFAULT_CONFIG.colors,
892
- ...this.config.colors
893
- },
894
- dagreConfig: {
895
- ...DEFAULT_DAGRE_CONFIG,
896
- ...this.config.dagreConfig
897
- }
663
+ const rect = svg.getBoundingClientRect();
664
+ const mx = rect.width / 2;
665
+ const my = rect.height / 2;
666
+ const minZoom = this.config.minZoom ?? 0.15;
667
+ const maxZoom = this.config.maxZoom ?? 2.5;
668
+ const newK = Math.max(minZoom, Math.min(maxZoom, this.transform.k * factor));
669
+ const ratio = newK / this.transform.k;
670
+ this.transform = {
671
+ k: newK,
672
+ x: mx - (mx - this.transform.x) * ratio,
673
+ y: my - (my - this.transform.y) * ratio,
898
674
  };
899
- // Map 'horizontal' and 'vertical' layout algorithms to dagre with appropriate direction
900
- if (this.mergedConfig.layoutAlgorithm === 'horizontal') {
901
- this.mergedConfig.dagreConfig.rankDir = 'LR';
902
- }
903
- else if (this.mergedConfig.layoutAlgorithm === 'vertical') {
904
- this.mergedConfig.dagreConfig.rankDir = 'TB';
905
- }
675
+ this.emitZoom();
676
+ this.cdr.markForCheck();
906
677
  }
907
- resizeSVG() {
908
- if (!this.svg || !this.erdContainer?.nativeElement) {
678
+ fitToView() {
679
+ const svg = this.canvasRef?.nativeElement;
680
+ if (!svg)
909
681
  return;
910
- }
911
- const container = this.erdContainer.nativeElement;
912
- const width = container.clientWidth || 800;
913
- const height = container.clientHeight || 600;
914
- if (Math.abs(this.lastKnownSize.width - width) < 5 &&
915
- Math.abs(this.lastKnownSize.height - height) < 5) {
682
+ const rect = svg.getBoundingClientRect();
683
+ if (rect.width === 0 || rect.height === 0 || this.layout.totalWidth === 0)
684
+ return;
685
+ const padding = 60;
686
+ // Allow fit to scale UP for small diagrams (e.g. the Database Designer
687
+ // wizard preview, where a single entity + a couple of satellites
688
+ // would otherwise look lost in the pane). Consumers can override via
689
+ // `config.maxFitZoom`. Default 2.5x: a 220px card becomes ~550px,
690
+ // which comfortably fills a typical preview pane without looking
691
+ // cartoonish (13px text → ~33px, still a readable ceiling).
692
+ const maxFit = this.config.maxFitZoom ?? 2.5;
693
+ const idealK = Math.min((rect.width - padding * 2) / this.layout.totalWidth, (rect.height - padding * 2) / this.layout.totalHeight, maxFit);
694
+ // Clamp the fit zoom so cards stay legible. On very large schemas
695
+ // (hundreds of entities) fit-to-view would otherwise shrink each card
696
+ // to a few pixels wide. We'd rather show part of the canvas at a
697
+ // readable zoom and let the user pan to see the rest.
698
+ const minZoom = this.config.minZoom ?? 0.35;
699
+ const k = Math.max(idealK, minZoom);
700
+ // Center horizontally only if the canvas fits; otherwise anchor at left
701
+ // so the user starts at the first schema band instead of the middle.
702
+ const canvasFitsX = this.layout.totalWidth * k <= rect.width - padding * 2;
703
+ const canvasFitsY = this.layout.totalHeight * k <= rect.height - padding * 2;
704
+ const newX = canvasFitsX ? (rect.width - this.layout.totalWidth * k) / 2 : padding;
705
+ const newY = canvasFitsY ? (rect.height - this.layout.totalHeight * k) / 2 : padding;
706
+ // Idempotent guard. If the computed transform is effectively identical
707
+ // to the current one, bail out without reassigning or calling
708
+ // markForCheck. Without this, a consumer that passes an inline
709
+ // `[config]="{...}"` object literal re-triggers ngOnChanges on every
710
+ // change-detection tick, producing an infinite fitToView → markForCheck
711
+ // → CD → ngOnChanges → fitToView loop (NG0103).
712
+ const TOL = 0.001;
713
+ if (Math.abs(this.transform.k - k) < TOL &&
714
+ Math.abs(this.transform.x - newX) < 0.5 &&
715
+ Math.abs(this.transform.y - newY) < 0.5) {
916
716
  return;
917
717
  }
918
- this.lastKnownSize = { width, height };
919
- const svgElement = this.svg.node();
920
- if (svgElement) {
921
- svgElement.style.width = '100%';
922
- svgElement.style.height = '100%';
923
- }
924
- this.svg.attr('viewBox', `0 0 ${width} ${height}`);
925
- if (this.simulation && !this.isLayoutFrozen) {
926
- this.simulation.force('center', d3.forceCenter(width / 2, height / 2));
927
- this.simulation.alpha(0.3).restart();
928
- }
718
+ this.transform = { k, x: newX, y: newY };
719
+ this.emitZoom();
720
+ this.layoutComplete.emit();
721
+ this.cdr.markForCheck();
929
722
  }
930
- setupResizeObserver() {
931
- if (!this.erdContainer?.nativeElement) {
723
+ centerOn(nodeId) {
724
+ const n = this.layout.nodes.find(x => x.id === nodeId);
725
+ const svg = this.canvasRef?.nativeElement;
726
+ if (!n || !svg)
932
727
  return;
933
- }
934
- this.resizeObserver = new ResizeObserver(() => {
935
- if (this.resizeTimeout) {
936
- clearTimeout(this.resizeTimeout);
937
- }
938
- this.resizeTimeout = window.setTimeout(() => {
939
- const container = this.erdContainer?.nativeElement;
940
- if (!container)
941
- return;
942
- const newWidth = container.clientWidth;
943
- const newHeight = container.clientHeight;
944
- if (Math.abs(this.lastKnownSize.width - newWidth) >= 5 ||
945
- Math.abs(this.lastKnownSize.height - newHeight) >= 5) {
946
- requestAnimationFrame(() => {
947
- this.resizeSVG();
948
- });
949
- }
950
- }, 50);
951
- });
952
- this.resizeObserver.observe(this.erdContainer.nativeElement);
728
+ const rect = svg.getBoundingClientRect();
729
+ const k = Math.max(this.transform.k, 0.9);
730
+ this.transform = {
731
+ k,
732
+ x: rect.width / 2 - (n.x + n.width / 2) * k,
733
+ y: rect.height / 2 - (n.y + n.height / 2) * k,
734
+ };
735
+ this.emitZoom();
736
+ // External callers (e.g. zoomToNode invoked from the side panel)
737
+ // need CD to pick up the new transform — onNodeClick used to do
738
+ // this itself but external pathways were silently no-op'ing.
739
+ this.cdr.markForCheck();
953
740
  }
954
- clearVisualization() {
955
- if (this.simulation) {
956
- this.simulation.stop();
957
- }
958
- d3.select(this.erdContainer.nativeElement).selectAll('*').remove();
741
+ emitZoom() {
742
+ this.zoomChange.emit({
743
+ zoomLevel: this.transform.k,
744
+ translateX: this.transform.x,
745
+ translateY: this.transform.y,
746
+ });
959
747
  }
960
- // ============================================================================
961
- // FOCUS MODE
962
- // ============================================================================
963
- applyFocusMode() {
964
- if (!this.focusNodeId) {
965
- // No focus mode - show all nodes
966
- this.visibleNodes = [...this.internalNodes];
967
- this.visibleLinks = [...this.internalLinks];
748
+ // ─── NODE INTERACTIONS ───────────────────────────────────────────────
749
+ onNodeClick(node, e) {
750
+ e.stopPropagation();
751
+ const event = { node, mouseEvent: e, cancel: false };
752
+ this.nodeClick.emit(event);
753
+ if (event.cancel)
968
754
  return;
755
+ if (this.focusNodeId === node.id) {
756
+ // Toggle off — deselect.
757
+ this.focusNodeId = null;
758
+ this.selectedNodeId = null;
759
+ this.nodeDeselected.emit();
969
760
  }
970
- // Get IDs of nodes within focus depth
971
- const visibleIds = new Set([this.focusNodeId]);
972
- const relatedIds = this.getRelatedNodeIds(this.focusNodeId, this.focusDepth);
973
- relatedIds.forEach(id => visibleIds.add(id));
974
- // Filter nodes
975
- this.visibleNodes = this.internalNodes.filter(n => visibleIds.has(n.id));
976
- // Filter links - only show links between visible nodes
977
- this.visibleLinks = this.internalLinks.filter(link => {
978
- const sourceId = typeof link.source === 'string' ? link.source : link.source.id;
979
- const targetId = typeof link.target === 'string' ? link.target : link.target.id;
980
- return visibleIds.has(sourceId) && visibleIds.has(targetId);
981
- });
982
- }
983
- getRelatedNodeIds(nodeId, depth) {
984
- const visited = new Set([nodeId]);
985
- const toProcess = [{ id: nodeId, currentDepth: 0 }];
986
- const result = [];
987
- while (toProcess.length > 0) {
988
- const current = toProcess.shift();
989
- if (current.currentDepth >= depth)
990
- continue;
991
- // Find relationships
992
- this.nodes.forEach(node => {
993
- node.fields.forEach(field => {
994
- if (field.relatedNodeId) {
995
- // Outgoing from current
996
- if (node.id === current.id && !visited.has(field.relatedNodeId)) {
997
- visited.add(field.relatedNodeId);
998
- result.push(field.relatedNodeId);
999
- toProcess.push({ id: field.relatedNodeId, currentDepth: current.currentDepth + 1 });
1000
- }
1001
- // Incoming to current
1002
- if (field.relatedNodeId === current.id && !visited.has(node.id)) {
1003
- visited.add(node.id);
1004
- result.push(node.id);
1005
- toProcess.push({ id: node.id, currentDepth: current.currentDepth + 1 });
1006
- }
1007
- }
1008
- });
1009
- });
761
+ else {
762
+ this.focusNodeId = node.id;
763
+ this.selectedNodeId = node.id;
764
+ this.nodeSelected.emit(node);
765
+ this.centerOn(node.id);
1010
766
  }
1011
- return result;
767
+ this.updateHighlightSet();
768
+ this.updateSelectedEntity();
769
+ this.cdr.markForCheck();
1012
770
  }
1013
- // ============================================================================
1014
- // NODE/LINK CREATION
1015
- // ============================================================================
1016
- createInternalNodes() {
1017
- const cfg = this.mergedConfig;
1018
- this.internalNodes = this.nodes.map(node => {
1019
- const primaryKeys = node.fields.filter(f => f.isPrimaryKey);
1020
- const foreignKeys = node.fields.filter(f => f.relatedNodeId && !f.isPrimaryKey);
1021
- const fieldCount = Math.max(1, primaryKeys.length + foreignKeys.length);
1022
- const calculatedHeight = Math.min(cfg.nodeBaseHeight + (fieldCount * cfg.fieldHeight), cfg.maxNodeHeight);
1023
- return {
1024
- id: node.id,
1025
- name: node.name || node.schemaName || 'Unknown',
1026
- node: node,
1027
- width: cfg.nodeWidth,
1028
- height: calculatedHeight,
1029
- primaryKeys: primaryKeys,
1030
- foreignKeys: foreignKeys
1031
- };
771
+ onNodeMouseEnter(node, e) {
772
+ this.hoverNodeId = node.id;
773
+ this.updateHighlightSet();
774
+ const related = this.nodes.filter(n => this.highlightSet?.has(n.id) && n.id !== node.id);
775
+ this.nodeHover.emit({
776
+ node, mouseEvent: e, relatedNodes: related,
777
+ position: { x: e.clientX, y: e.clientY },
1032
778
  });
779
+ this.cdr.markForCheck();
1033
780
  }
1034
- createInternalLinks() {
1035
- this.internalLinks = [];
1036
- const nodeMap = new Map(this.internalNodes.map(n => [n.id, n]));
1037
- this.nodes.forEach(node => {
1038
- node.fields.forEach(field => {
1039
- if (field.relatedNodeId) {
1040
- const sourceNode = nodeMap.get(node.id);
1041
- const targetNode = nodeMap.get(field.relatedNodeId);
1042
- if (sourceNode && targetNode) {
1043
- const isSelfReference = node.id === field.relatedNodeId;
1044
- const targetField = targetNode.primaryKeys.find(pk => pk.name === field.relatedFieldName);
1045
- this.internalLinks.push({
1046
- source: sourceNode,
1047
- target: targetNode,
1048
- sourceField: field,
1049
- targetField: targetField,
1050
- isSelfReference: isSelfReference
1051
- });
1052
- }
1053
- }
1054
- });
1055
- });
781
+ onNodeMouseLeave(node) {
782
+ this.hoverNodeId = null;
783
+ this.updateHighlightSet();
784
+ this.nodeHoverEnd.emit(node);
785
+ this.cdr.markForCheck();
786
+ }
787
+ onToggleExpand(nodeId, e) {
788
+ e.stopPropagation();
789
+ if (this.expandedNodeIds.has(nodeId))
790
+ this.expandedNodeIds.delete(nodeId);
791
+ else
792
+ this.expandedNodeIds.add(nodeId);
793
+ // New Set reference so @for trackBy treats it as changed.
794
+ this.expandedNodeIds = new Set(this.expandedNodeIds);
795
+ this.recompute();
796
+ }
797
+ // ─── CHROME INTERACTIONS ─────────────────────────────────────────────
798
+ onSearchChange(value) {
799
+ this.searchQuery = value;
800
+ this.recompute();
801
+ }
802
+ onClearSearch() {
803
+ this.searchQuery = '';
804
+ this.recompute();
805
+ }
806
+ onSchemaChipToggle(schemaName) {
807
+ const current = this.activeSchemas ?? new Set(this.schemaChips.map(c => c.name));
808
+ const next = new Set(current);
809
+ if (next.has(schemaName))
810
+ next.delete(schemaName);
811
+ else
812
+ next.add(schemaName);
813
+ this.activeSchemas = next.size === this.schemaChips.length ? null : next;
814
+ this.recompute();
815
+ }
816
+ onRequestRefresh() {
817
+ this.refreshRequested.emit();
818
+ }
819
+ // ─── DERIVED DATA FOR TEMPLATE ───────────────────────────────────────
820
+ /** Path string for an edge — uses the orthogonal polyline from layout. */
821
+ edgePath(edge) {
822
+ return pointsToPath(edge.points);
1056
823
  }
1057
- // ============================================================================
1058
- // DAGRE LAYOUT
1059
- // ============================================================================
1060
824
  /**
1061
- * Applies dagre hierarchical layout to position nodes and compute edge paths.
1062
- * This provides clean, orthogonal edge routing.
825
+ * Whether to render FK field labels along edges. Defaults to ON in
826
+ * both layouts — the zoom-gate (>=0.55x) keeps the labels off when
827
+ * the user is zoomed out far enough to see hundreds of entities, so
828
+ * "noise on dense views" isn't a real concern. Consumers can still
829
+ * force off via `config.showRelationshipLabels: false`.
1063
830
  */
1064
- applyDagreLayout() {
1065
- const cfg = this.mergedConfig;
1066
- const dagreCfg = cfg.dagreConfig;
1067
- // Create a new directed graph
1068
- const g = new dagre.graphlib.Graph({ directed: true, multigraph: true });
1069
- // Set graph options
1070
- g.setGraph({
1071
- rankdir: dagreCfg.rankDir,
1072
- nodesep: dagreCfg.nodeSep,
1073
- ranksep: dagreCfg.rankSep,
1074
- edgesep: dagreCfg.edgeSep,
1075
- ranker: dagreCfg.ranker,
1076
- align: dagreCfg.align
1077
- });
1078
- // Default edge label (required by dagre)
1079
- g.setDefaultEdgeLabel(() => ({}));
1080
- // Add nodes to the graph
1081
- this.visibleNodes.forEach(node => {
1082
- g.setNode(node.id, {
1083
- width: node.width,
1084
- height: node.height,
1085
- label: node.name
1086
- });
1087
- });
1088
- // Add edges to the graph
1089
- this.visibleLinks.forEach((link, index) => {
1090
- const sourceId = typeof link.source === 'string' ? link.source : link.source.id;
1091
- const targetId = typeof link.target === 'string' ? link.target : link.target.id;
1092
- // Use index as edge name for multigraph support (multiple edges between same nodes)
1093
- g.setEdge(sourceId, targetId, {
1094
- label: link.sourceField.name || '',
1095
- labelpos: 'c',
1096
- labeloffset: 10
1097
- }, `edge-${index}`);
1098
- });
1099
- // Run the layout algorithm
1100
- dagre.layout(g);
1101
- // Apply computed positions to nodes
1102
- this.visibleNodes.forEach(node => {
1103
- const dagreNode = g.node(node.id);
1104
- if (dagreNode) {
1105
- node.x = dagreNode.x;
1106
- node.y = dagreNode.y;
1107
- // Fix position to prevent force simulation from moving nodes
1108
- node.fx = dagreNode.x;
1109
- node.fy = dagreNode.y;
1110
- }
1111
- });
1112
- // Store edge points for orthogonal path rendering
1113
- this.visibleLinks.forEach((link, index) => {
1114
- const sourceId = typeof link.source === 'string' ? link.source : link.source.id;
1115
- const targetId = typeof link.target === 'string' ? link.target : link.target.id;
1116
- const edge = g.edge(sourceId, targetId, `edge-${index}`);
1117
- if (edge && edge.points) {
1118
- link.points = edge.points;
1119
- }
1120
- });
831
+ get showEdgeLabels() {
832
+ const enabled = this.config.showRelationshipLabels ?? true;
833
+ if (!enabled)
834
+ return false;
835
+ return this.transform.k >= 0.55;
1121
836
  }
1122
837
  /**
1123
- * Checks if the current layout algorithm uses dagre.
838
+ * Pick a point along the edge polyline for a label and approximate
839
+ * its width. Returns null for self-loops (no good place to put text).
840
+ * Uses the longest horizontal segment when one exists; falls back to
841
+ * the segment midpoint otherwise.
1124
842
  */
1125
- usesDagreLayout() {
1126
- const algo = this.mergedConfig.layoutAlgorithm;
1127
- return algo === 'dagre' || algo === 'horizontal' || algo === 'vertical';
1128
- }
1129
- // ============================================================================
1130
- // VISUALIZATION
1131
- // ============================================================================
1132
- createVisualization() {
1133
- const container = this.erdContainer.nativeElement;
1134
- const width = container.clientWidth || 800;
1135
- const height = container.clientHeight || 600;
1136
- const cfg = this.mergedConfig;
1137
- const colors = cfg.colors;
1138
- this.lastKnownSize = { width, height };
1139
- // Create zoom behavior
1140
- if (cfg.enableZoom) {
1141
- this.zoom = d3.zoom()
1142
- .scaleExtent([cfg.minZoom, cfg.maxZoom])
1143
- .on('zoom', (event) => {
1144
- if (this.svg) {
1145
- this.svg.select('.chart-area').attr('transform', event.transform);
1146
- this.zoomChange.emit({
1147
- zoomLevel: event.transform.k,
1148
- translateX: event.transform.x,
1149
- translateY: event.transform.y
1150
- });
1151
- }
1152
- });
1153
- }
1154
- // Create SVG
1155
- this.svg = d3
1156
- .select(container)
1157
- .append('svg')
1158
- .attr('viewBox', `0 0 ${width} ${height}`)
1159
- .style('width', '100%')
1160
- .style('height', '100%')
1161
- .style('position', 'absolute')
1162
- .style('top', '0')
1163
- .style('left', '0');
1164
- if (this.zoom) {
1165
- this.svg.call(this.zoom);
1166
- }
1167
- // Background click handler
1168
- this.svg.on('click', (event) => {
1169
- if (event.target === event.currentTarget) {
1170
- this.nodeDeselected.emit();
1171
- this.clearAllHighlighting();
843
+ edgeLabelPosition(edge) {
844
+ if (edge.selfReference)
845
+ return null;
846
+ const pts = edge.points;
847
+ if (pts.length < 2)
848
+ return null;
849
+ // Find the longest horizontal segment so the label sits on a
850
+ // straight portion, not over a corner.
851
+ let bestIdx = 0;
852
+ let bestLen = 0;
853
+ for (let i = 0; i < pts.length - 1; i++) {
854
+ const [x1, y1] = pts[i];
855
+ const [x2, y2] = pts[i + 1];
856
+ if (Math.abs(y2 - y1) > 0.5)
857
+ continue; // skip vertical segs
858
+ const len = Math.abs(x2 - x1);
859
+ if (len > bestLen) {
860
+ bestLen = len;
861
+ bestIdx = i;
1172
862
  }
1173
- });
1174
- // Background context menu handler
1175
- this.svg.on('contextmenu', (event) => {
1176
- if (event.target === event.currentTarget) {
1177
- const contextEvent = {
1178
- mouseEvent: event,
1179
- cancel: false,
1180
- position: { x: event.clientX, y: event.clientY },
1181
- diagramPosition: this.screenToDiagramCoords(event.clientX, event.clientY)
1182
- };
1183
- this.diagramContextMenu.emit(contextEvent);
1184
- if (contextEvent.cancel) {
1185
- event.preventDefault();
1186
- }
1187
- }
1188
- });
1189
- const chartArea = this.svg.append('g').attr('class', 'chart-area');
1190
- // Define arrow marker
1191
- this.svg
1192
- .append('defs')
1193
- .selectAll('marker')
1194
- .data(['end-arrow'])
1195
- .enter()
1196
- .append('marker')
1197
- .attr('id', 'end-arrow')
1198
- .attr('viewBox', '0 -5 10 10')
1199
- .attr('refX', 8)
1200
- .attr('refY', 0)
1201
- .attr('markerWidth', 6)
1202
- .attr('markerHeight', 6)
1203
- .attr('orient', 'auto')
1204
- .append('path')
1205
- .attr('d', 'M0,-5L10,0L0,5')
1206
- .attr('fill', colors.linkColor);
1207
- // Apply layout based on algorithm
1208
- if (this.usesDagreLayout()) {
1209
- // Use dagre for hierarchical layout with orthogonal edges
1210
- this.applyDagreLayout();
1211
- this.simulation = null; // No force simulation needed
1212
- }
1213
- else {
1214
- // Create force simulation with visible nodes only (original behavior)
1215
- this.simulation = d3
1216
- .forceSimulation(this.visibleNodes)
1217
- .force('link', d3
1218
- .forceLink(this.visibleLinks)
1219
- .id((d) => d.id)
1220
- .distance((d) => {
1221
- const source = d.source;
1222
- const target = d.target;
1223
- const sourceSize = Math.max(source.width, source.height);
1224
- const targetSize = Math.max(target.width, target.height);
1225
- return (sourceSize + targetSize) / 2 + cfg.linkDistance;
1226
- }))
1227
- .force('charge', d3.forceManyBody().strength(cfg.chargeStrength))
1228
- .force('center', d3.forceCenter(width / 2, height / 2))
1229
- .force('collision', d3.forceCollide().radius((d) => {
1230
- return Math.max(d.width, d.height) / 2 + cfg.collisionPadding;
1231
- }));
1232
- }
1233
- // Create links
1234
- const link = chartArea
1235
- .selectAll('.link')
1236
- .data(this.visibleLinks)
1237
- .enter()
1238
- .append('g')
1239
- .attr('class', (d) => d.isSelfReference ? 'link-group self-reference' : 'link-group');
1240
- link
1241
- .append('path')
1242
- .attr('class', 'link')
1243
- .attr('stroke', colors.linkColor)
1244
- .attr('stroke-opacity', 0.8)
1245
- .attr('stroke-width', 2)
1246
- .attr('fill', 'none')
1247
- .attr('marker-end', (d) => (d.isSelfReference ? 'none' : 'url(#end-arrow)'));
1248
- if (cfg.showRelationshipLabels) {
1249
- // Add background rect for label (will be sized after text is rendered)
1250
- // Self-referencing links get distinct styling via .self-reference CSS class
1251
- link
1252
- .append('rect')
1253
- .attr('class', 'link-label-bg')
1254
- .attr('stroke-width', 0.5)
1255
- .attr('stroke-opacity', (d) => d.isSelfReference ? 0.6 : 0.4)
1256
- .attr('rx', 2)
1257
- .attr('ry', 2);
1258
- // Add the label text
1259
- link
1260
- .append('text')
1261
- .attr('class', 'link-label')
1262
- .attr('font-size', '10px')
1263
- .attr('text-anchor', 'middle')
1264
- .attr('dominant-baseline', 'middle')
1265
- .text((d) => d.sourceField.name || '');
1266
- }
1267
- // Link event handlers
1268
- this.attachLinkEventHandlers(link);
1269
- // Create nodes
1270
- const nodeGroup = chartArea
1271
- .selectAll('.node')
1272
- .data(this.visibleNodes)
1273
- .enter()
1274
- .append('g')
1275
- .attr('class', 'node')
1276
- .style('cursor', this.readOnly ? 'default' : 'pointer');
1277
- if (cfg.enableDragging && !this.readOnly) {
1278
- nodeGroup.call(d3.drag()
1279
- .on('start', (event, d) => this.dragstarted(event, d))
1280
- .on('drag', (event, d) => this.dragged(event, d))
1281
- .on('end', (event, d) => this.dragended(event, d)));
1282
- }
1283
- // Node rectangle
1284
- nodeGroup
1285
- .append('rect')
1286
- .attr('class', 'entity-rect')
1287
- .attr('width', (d) => d.width)
1288
- .attr('height', (d) => d.height)
1289
- .attr('x', (d) => -d.width / 2)
1290
- .attr('y', (d) => -d.height / 2)
1291
- .attr('fill', colors.nodeBackground)
1292
- .attr('stroke', colors.nodeBorder)
1293
- .attr('stroke-width', 2)
1294
- .attr('rx', 4);
1295
- // Node header
1296
- nodeGroup
1297
- .append('rect')
1298
- .attr('class', 'entity-header')
1299
- .attr('width', (d) => d.width)
1300
- .attr('height', 30)
1301
- .attr('x', (d) => -d.width / 2)
1302
- .attr('y', (d) => -d.height / 2)
1303
- .attr('fill', colors.nodeHeader)
1304
- .attr('rx', 4);
1305
- nodeGroup
1306
- .append('rect')
1307
- .attr('class', 'entity-header-bottom')
1308
- .attr('width', (d) => d.width)
1309
- .attr('height', 15)
1310
- .attr('x', (d) => -d.width / 2)
1311
- .attr('y', (d) => -d.height / 2 + 15)
1312
- .attr('fill', colors.nodeHeader);
1313
- // Node name
1314
- const entityNameText = nodeGroup
1315
- .append('text')
1316
- .attr('class', 'entity-name')
1317
- .attr('text-anchor', 'middle')
1318
- .attr('y', (d) => -d.height / 2 + 20)
1319
- .attr('font-size', '12px')
1320
- .attr('font-weight', 'bold')
1321
- .attr('fill', colors.nodeHeaderText)
1322
- .text((d) => d.name);
1323
- // Truncate entity names that overflow the header
1324
- entityNameText.each((_d, i, nodes) => {
1325
- this.truncateTextElement(nodes[i], cfg.nodeWidth - 16);
1326
- });
1327
- // Add fields if configured
1328
- if (cfg.showFieldDetails) {
1329
- this.renderNodeFields(nodeGroup, colors);
1330
863
  }
1331
- // Event handlers
1332
- this.attachNodeEventHandlers(nodeGroup);
1333
- // Tooltips
1334
- nodeGroup
1335
- .append('title')
1336
- .text((d) => `${d.name}\nPrimary Keys: ${d.primaryKeys.length}\nForeign Keys: ${d.foreignKeys.length}`);
1337
- // Handle layout completion based on algorithm type
1338
- if (this.usesDagreLayout()) {
1339
- // Dagre layout is synchronous - positions are already set
1340
- this.updatePositions(link, nodeGroup);
1341
- this.layoutCompleted = true;
1342
- this.layoutComplete.emit();
1343
- // Fit to view after positions are set
1344
- if (cfg.fitOnLoad && this.visibleNodes.length > 1) {
1345
- setTimeout(() => this.zoomToFit(), 50);
1346
- }
1347
- }
1348
- else if (this.simulation) {
1349
- // Force simulation layout
1350
- if (cfg.skipAnimation) {
1351
- // Run simulation to completion without animation
1352
- this.simulation.stop();
1353
- // Run enough ticks to settle the layout (typically 300 is enough)
1354
- const tickCount = 300;
1355
- for (let i = 0; i < tickCount; i++) {
1356
- this.simulation.tick();
1357
- }
1358
- // Update positions once at the end
1359
- this.updatePositions(link, nodeGroup);
1360
- this.layoutCompleted = true;
1361
- this.layoutComplete.emit();
1362
- // Fit to view after positions are set
1363
- if (cfg.fitOnLoad && this.visibleNodes.length > 1) {
1364
- setTimeout(() => this.zoomToFit(), 50);
1365
- }
1366
- }
1367
- else {
1368
- // Normal animated mode
1369
- // Simulation tick
1370
- this.simulation.on('tick', () => {
1371
- this.updatePositions(link, nodeGroup);
1372
- });
1373
- // Layout complete detection
1374
- this.simulation.on('end', () => {
1375
- if (!this.layoutCompleted) {
1376
- this.layoutCompleted = true;
1377
- this.layoutComplete.emit();
1378
- if (cfg.fitOnLoad && this.visibleNodes.length > 1) {
1379
- setTimeout(() => this.zoomToFit(), 100);
1380
- }
1381
- }
1382
- });
1383
- }
1384
- }
1385
- // Apply initial selection highlighting
1386
- this.updateSelectionHighlighting();
1387
- this.updateHighlighting();
864
+ const [x1, y1] = pts[bestIdx];
865
+ const [x2, y2] = pts[bestIdx + 1];
866
+ const x = (x1 + x2) / 2;
867
+ const y = (y1 + y2) / 2;
868
+ // 6.5 px per char is a good approximation for the 11px mono we
869
+ // use for labels — only used to size the background rect.
870
+ const w = Math.max(20, edge.sourceField.name.length * 6.5);
871
+ return { x, y, w };
1388
872
  }
1389
- renderNodeFields(nodeGroup, colors) {
1390
- nodeGroup.each((d, i, nodes) => {
1391
- const group = d3.select(nodes[i]);
1392
- let currentY = -d.height / 2 + 40;
1393
- // Primary keys
1394
- d.primaryKeys.forEach((pk) => {
1395
- const fieldGroup = group.append('g').attr('class', 'field-group primary-key');
1396
- fieldGroup
1397
- .append('rect')
1398
- .attr('class', 'field-bg')
1399
- .attr('x', -d.width / 2 + 2)
1400
- .attr('y', currentY - 15)
1401
- .attr('width', d.width - 4)
1402
- .attr('height', 18)
1403
- .attr('fill', colors.primaryKeyBackground);
1404
- fieldGroup
1405
- .append('text')
1406
- .attr('class', 'field-icon')
1407
- .attr('x', -d.width / 2 + 8)
1408
- .attr('y', currentY - 2)
1409
- .attr('font-size', '10px')
1410
- .attr('fill', colors.primaryKeyText)
1411
- .text('🔑');
1412
- const pkNameEl = fieldGroup
1413
- .append('text')
1414
- .attr('class', 'field-name')
1415
- .attr('x', -d.width / 2 + 25)
1416
- .attr('y', currentY - 2)
1417
- .attr('font-size', '11px')
1418
- .attr('font-weight', 'bold')
1419
- .attr('fill', colors.primaryKeyText)
1420
- .text(pk.name || '')
1421
- .node();
1422
- if (pkNameEl)
1423
- this.truncateTextElement(pkNameEl, d.width - 33);
1424
- currentY += 20;
1425
- });
1426
- // Foreign keys
1427
- d.foreignKeys.forEach((fk) => {
1428
- const fieldGroup = group.append('g').attr('class', 'field-group foreign-key');
1429
- fieldGroup
1430
- .append('rect')
1431
- .attr('class', 'field-bg')
1432
- .attr('x', -d.width / 2 + 2)
1433
- .attr('y', currentY - 15)
1434
- .attr('width', d.width - 4)
1435
- .attr('height', 18)
1436
- .attr('fill', colors.foreignKeyBackground);
1437
- fieldGroup
1438
- .append('text')
1439
- .attr('class', 'field-icon')
1440
- .attr('x', -d.width / 2 + 8)
1441
- .attr('y', currentY - 2)
1442
- .attr('font-size', '10px')
1443
- .attr('fill', colors.foreignKeyText)
1444
- .text('🔗');
1445
- const fkNameEl = fieldGroup
1446
- .append('text')
1447
- .attr('class', 'field-name')
1448
- .attr('x', -d.width / 2 + 25)
1449
- .attr('y', currentY - 2)
1450
- .attr('font-size', '11px')
1451
- .attr('fill', colors.foreignKeyText)
1452
- .text(fk.name || '')
1453
- .node();
1454
- if (fkNameEl)
1455
- this.truncateTextElement(fkNameEl, d.width - 33);
1456
- currentY += 20;
1457
- });
1458
- });
873
+ /** Whether a node should appear dimmed (highlight mode active, node not in set). */
874
+ isDimmed(nodeId) {
875
+ return !!this.highlightSet && !this.highlightSet.has(nodeId);
1459
876
  }
1460
- /** Truncate an SVG text element to fit within maxWidth, adding "…" if needed */
1461
- truncateTextElement(textEl, maxWidth) {
1462
- const original = textEl.textContent || '';
1463
- if (!original || textEl.getComputedTextLength() <= maxWidth)
1464
- return;
1465
- // Binary search for the longest fitting substring
1466
- let lo = 0;
1467
- let hi = original.length;
1468
- while (lo < hi) {
1469
- const mid = Math.ceil((lo + hi) / 2);
1470
- textEl.textContent = original.slice(0, mid) + '…';
1471
- if (textEl.getComputedTextLength() <= maxWidth) {
1472
- lo = mid;
1473
- }
1474
- else {
1475
- hi = mid - 1;
1476
- }
1477
- }
1478
- textEl.textContent = lo < original.length ? original.slice(0, lo) + '…' : original;
877
+ /** Whether a node is the current focus (strong border). */
878
+ isFocus(nodeId) {
879
+ return this.focusNodeId === nodeId;
1479
880
  }
1480
- attachNodeEventHandlers(nodeGroup) {
1481
- nodeGroup
1482
- .on('click', (event, d) => {
1483
- if (this.readOnly)
1484
- return;
1485
- event.stopPropagation();
1486
- // Emit click event with cancel pattern
1487
- const clickEvent = {
1488
- node: d.node,
1489
- mouseEvent: event,
1490
- cancel: false
1491
- };
1492
- this.nodeClick.emit(clickEvent);
1493
- if (clickEvent.cancel) {
1494
- return;
1495
- }
1496
- // Handle selection
1497
- if (this.selectedNodeId === d.node.id) {
1498
- this.nodeDeselected.emit();
1499
- }
1500
- else {
1501
- this.nodeSelected.emit(d.node);
1502
- // Auto-zoom if node is too small
1503
- if (this.svg) {
1504
- const currentTransform = d3.zoomTransform(this.svg.node());
1505
- const currentRenderedWidth = d.width * currentTransform.k;
1506
- if (currentRenderedWidth < 20) {
1507
- this.zoomToNode(d.node.id);
1508
- }
1509
- }
1510
- }
1511
- this.updateSelectionHighlighting();
1512
- })
1513
- .on('dblclick', (event, d) => {
1514
- event.stopPropagation();
1515
- const dblClickEvent = {
1516
- node: d.node,
1517
- mouseEvent: event,
1518
- cancel: false
1519
- };
1520
- this.nodeDoubleClick.emit(dblClickEvent);
1521
- })
1522
- .on('mouseenter', (event, d) => {
1523
- const relatedInfo = this.getRelatedNodes(d.node.id, 1);
1524
- const relatedNodes = relatedInfo.map(r => r.node);
1525
- const hoverEvent = {
1526
- node: d.node,
1527
- mouseEvent: event,
1528
- relatedNodes,
1529
- position: { x: event.clientX, y: event.clientY }
1530
- };
1531
- this.nodeHover.emit(hoverEvent);
1532
- })
1533
- .on('mouseleave', (_event, d) => {
1534
- this.nodeHoverEnd.emit(d.node);
1535
- })
1536
- .on('contextmenu', (event, d) => {
1537
- const contextEvent = {
1538
- node: d.node,
1539
- mouseEvent: event,
1540
- cancel: false,
1541
- position: { x: event.clientX, y: event.clientY }
1542
- };
1543
- this.nodeContextMenu.emit(contextEvent);
1544
- if (contextEvent.cancel) {
1545
- event.preventDefault();
1546
- }
1547
- });
881
+ /** Whether a node is a 1-hop neighbour of the focus (medium border). */
882
+ isNeighbour(nodeId) {
883
+ return !!this.highlightSet && this.highlightSet.has(nodeId) && !this.isFocus(nodeId);
1548
884
  }
1549
- attachLinkEventHandlers(linkGroup) {
1550
- linkGroup
1551
- .on('click', (event, d) => {
1552
- event.stopPropagation();
1553
- const sourceNode = d.source.node;
1554
- const targetNode = d.target.node;
1555
- const link = {
1556
- sourceNodeId: sourceNode.id,
1557
- targetNodeId: targetNode.id,
1558
- sourceField: d.sourceField,
1559
- targetField: d.targetField,
1560
- isSelfReference: d.isSelfReference
1561
- };
1562
- const clickEvent = {
1563
- link,
1564
- sourceNode,
1565
- targetNode,
1566
- mouseEvent: event,
1567
- cancel: false
1568
- };
1569
- this.linkClick.emit(clickEvent);
1570
- })
1571
- .on('mouseenter', (event, d) => {
1572
- const sourceNode = d.source.node;
1573
- const targetNode = d.target.node;
1574
- const link = {
1575
- sourceNodeId: sourceNode.id,
1576
- targetNodeId: targetNode.id,
1577
- sourceField: d.sourceField,
1578
- targetField: d.targetField,
1579
- isSelfReference: d.isSelfReference
1580
- };
1581
- const hoverEvent = {
1582
- link,
1583
- sourceNode,
1584
- targetNode,
1585
- mouseEvent: event,
1586
- position: { x: event.clientX, y: event.clientY }
1587
- };
1588
- this.linkHover.emit(hoverEvent);
1589
- })
1590
- .on('mouseleave', (_event, d) => {
1591
- const sourceNode = d.source.node;
1592
- const targetNode = d.target.node;
1593
- const link = {
1594
- sourceNodeId: sourceNode.id,
1595
- targetNodeId: targetNode.id,
1596
- sourceField: d.sourceField,
1597
- targetField: d.targetField,
1598
- isSelfReference: d.isSelfReference
1599
- };
1600
- this.linkHoverEnd.emit(link);
1601
- })
1602
- .on('contextmenu', (event, d) => {
1603
- const sourceNode = d.source.node;
1604
- const targetNode = d.target.node;
1605
- const link = {
1606
- sourceNodeId: sourceNode.id,
1607
- targetNodeId: targetNode.id,
1608
- sourceField: d.sourceField,
1609
- targetField: d.targetField,
1610
- isSelfReference: d.isSelfReference
1611
- };
1612
- const contextEvent = {
1613
- link,
1614
- sourceNode,
1615
- targetNode,
1616
- mouseEvent: event,
1617
- cancel: false,
1618
- position: { x: event.clientX, y: event.clientY }
1619
- };
1620
- this.linkContextMenu.emit(contextEvent);
1621
- if (contextEvent.cancel) {
1622
- event.preventDefault();
1623
- }
1624
- });
885
+ /** Whether an edge is part of the active highlight subgraph. */
886
+ isEdgeActive(edge) {
887
+ return !!this.highlightSet && this.highlightSet.has(edge.sourceId) && this.highlightSet.has(edge.targetId);
1625
888
  }
1626
- updatePositions(link, nodeGroup) {
1627
- const usesDagre = this.usesDagreLayout();
1628
- // Update link paths
1629
- link.select('path').attr('d', (d) => {
1630
- const source = d.source;
1631
- const target = d.target;
1632
- if (d.isSelfReference) {
1633
- // Create a larger loop that goes around the bottom of the entity
1634
- // Start from right side, loop down and around, return to left side
1635
- const startX = source.x + source.width / 2; // Right edge center
1636
- const startY = source.y;
1637
- const endY = source.y + source.height / 2; // Bottom edge Y
1638
- const loopExtent = 60; // How far out the loop extends
1639
- // Use a path that goes: right edge -> curves down-right -> bottom -> curves up to bottom edge
1640
- return `M ${startX} ${startY}
1641
- C ${startX + loopExtent} ${startY},
1642
- ${startX + loopExtent} ${endY + loopExtent},
1643
- ${source.x} ${endY + loopExtent}
1644
- C ${source.x - loopExtent} ${endY + loopExtent},
1645
- ${source.x - source.width / 2 - loopExtent / 2} ${endY},
1646
- ${source.x - source.width / 2} ${endY}`;
1647
- }
1648
- // Use dagre edge points if available (provides cleaner orthogonal paths)
1649
- if (usesDagre && d.points && d.points.length >= 2) {
1650
- return this.createPathFromDagrePoints(d.points);
1651
- }
1652
- // Fallback to manual orthogonal path
1653
- const sourcePoint = this.getSourceConnectionPoint(source, d.sourceField);
1654
- const targetPoint = this.getTargetConnectionPoint(target, d.targetField);
1655
- return this.createOrthogonalPath(sourcePoint, targetPoint);
1656
- });
1657
- // Update link labels and their background boxes
1658
- link.select('text').attr('transform', (d) => {
1659
- const source = d.source;
1660
- const target = d.target;
1661
- if (d.isSelfReference) {
1662
- // Position label at the bottom of the loop (below the entity)
1663
- const loopExtent = 60;
1664
- return `translate(${source.x}, ${source.y + source.height / 2 + loopExtent + 8})`;
1665
- }
1666
- // Use midpoint of dagre edge points if available
1667
- if (usesDagre && d.points && d.points.length >= 2) {
1668
- const midIndex = Math.floor(d.points.length / 2);
1669
- const midPoint = d.points[midIndex];
1670
- return `translate(${midPoint.x}, ${midPoint.y})`;
1671
- }
1672
- // Fallback to midpoint between nodes
1673
- const midX = (source.x + target.x) / 2;
1674
- const midY = (source.y + target.y) / 2;
1675
- return `translate(${midX}, ${midY - 8})`;
1676
- });
1677
- // Update label background rects to match text size and position
1678
- link.each(function () {
1679
- const group = d3.select(this);
1680
- const textEl = group.select('text.link-label').node();
1681
- const bgRect = group.select('rect.link-label-bg');
1682
- if (textEl && !bgRect.empty()) {
1683
- const bbox = textEl.getBBox();
1684
- const padding = 3;
1685
- // Get the transform from the text element
1686
- const transform = group.select('text.link-label').attr('transform');
1687
- bgRect
1688
- .attr('x', bbox.x - padding)
1689
- .attr('y', bbox.y - padding)
1690
- .attr('width', bbox.width + padding * 2)
1691
- .attr('height', bbox.height + padding * 2)
1692
- .attr('transform', transform);
1693
- }
1694
- });
1695
- // Update node positions
1696
- nodeGroup.attr('transform', (d) => `translate(${d.x},${d.y})`);
889
+ /** Whether an edge should appear dimmed. */
890
+ isEdgeDimmed(edge) {
891
+ return !!this.highlightSet && !this.isEdgeActive(edge);
1697
892
  }
1698
- // ============================================================================
1699
- // GEOMETRY HELPERS
1700
- // ============================================================================
1701
- getSourceConnectionPoint(sourceNode, field) {
1702
- let connectY = sourceNode.y;
1703
- if (field) {
1704
- const fkIndex = sourceNode.foreignKeys.findIndex((fk) => fk.id === field.id);
1705
- if (fkIndex >= 0) {
1706
- const fieldY = -sourceNode.height / 2 + 40 + sourceNode.primaryKeys.length * 20 + fkIndex * 20;
1707
- connectY = sourceNode.y + fieldY + 10;
1708
- }
1709
- else {
1710
- // Check if the field is a primary key that also serves as a foreign key (IS-A pattern)
1711
- const pkIndex = sourceNode.primaryKeys.findIndex((pk) => pk.id === field.id);
1712
- if (pkIndex >= 0) {
1713
- const fieldY = -sourceNode.height / 2 + 40 + pkIndex * 20;
1714
- connectY = sourceNode.y + fieldY + 10;
1715
- }
1716
- }
1717
- }
1718
- return {
1719
- x: sourceNode.x + sourceNode.width / 2,
1720
- y: connectY,
1721
- };
893
+ /** Zoom percentage string for the toolbar readout. */
894
+ get zoomPercent() {
895
+ return Math.round(this.transform.k * 100) + '%';
1722
896
  }
1723
- getTargetConnectionPoint(targetNode, targetField) {
1724
- let connectY = targetNode.y - targetNode.height / 2 + 40;
1725
- if (targetField && targetField.isPrimaryKey) {
1726
- const pkIndex = targetNode.primaryKeys.findIndex((pk) => pk.id === targetField.id);
1727
- if (pkIndex >= 0) {
1728
- const fieldY = -targetNode.height / 2 + 40 + pkIndex * 20;
1729
- connectY = targetNode.y + fieldY + 10;
1730
- }
1731
- }
1732
- return {
1733
- x: targetNode.x - targetNode.width / 2,
1734
- y: connectY,
1735
- };
897
+ /** Short friendly row-count string (e.g. 1248 → "1.2k"). */
898
+ formatCount(n) {
899
+ if (n == null)
900
+ return '';
901
+ if (n >= 1000)
902
+ return (n / 1000).toFixed(n >= 10000 ? 0 : 1) + 'k';
903
+ return n.toString();
1736
904
  }
1737
- createOrthogonalPath(source, target) {
1738
- const dx = target.x - source.x;
1739
- let midX;
1740
- if (dx > 0) {
1741
- midX = source.x + dx * 0.7;
1742
- }
1743
- else {
1744
- midX = source.x + Math.max(dx * 0.3, -50);
1745
- }
1746
- return `M ${source.x} ${source.y}
1747
- L ${midX} ${source.y}
1748
- L ${midX} ${target.y}
1749
- L ${target.x} ${target.y}`;
905
+ /** Number of PK fields — handy aggregate (kept for external API parity). */
906
+ pkCount(entity) {
907
+ return entity.fields.filter(f => f.isPrimaryKey).length;
1750
908
  }
1751
- /**
1752
- * Creates an SVG path string from dagre's edge points.
1753
- * Dagre provides an array of points for each edge that forms a clean orthogonal path.
1754
- * @param points Array of {x, y} coordinates from dagre layout
1755
- * @returns SVG path string
1756
- */
1757
- createPathFromDagrePoints(points) {
1758
- if (!points || points.length < 2) {
1759
- return '';
1760
- }
1761
- // Start with Move to first point
1762
- let path = `M ${points[0].x} ${points[0].y}`;
1763
- // Add Line to each subsequent point
1764
- for (let i = 1; i < points.length; i++) {
1765
- path += ` L ${points[i].x} ${points[i].y}`;
1766
- }
1767
- return path;
909
+ /** Number of FK fields — handy aggregate (kept for external API parity). */
910
+ fkCount(entity) {
911
+ return entity.fields.filter(f => !!f.relatedNodeId).length;
912
+ }
913
+ // ─── LEGACY API SHIMS ────────────────────────────────────────────────
914
+ // Kept so existing consumers (mj-entity-erd wrapper, admin ERD dashboard)
915
+ // continue to compile and work. The new implementation doesn't need
916
+ // most of these, but we preserve the shape.
917
+ zoomIn() { this.zoomBy(1.2); }
918
+ zoomOut() { this.zoomBy(0.83); }
919
+ resetZoom() { this.fitToView(); }
920
+ zoomToFit(_padding) { this.fitToView(); }
921
+ zoomToNode(nodeId, _scale) { this.centerOn(nodeId); }
922
+ triggerResize() { this.fitToView(); }
923
+ refresh() {
924
+ this.recompute();
925
+ queueMicrotask(() => this.fitToView());
1768
926
  }
1769
- screenToDiagramCoords(screenX, screenY) {
1770
- if (!this.svg)
1771
- return { x: 0, y: 0 };
1772
- const svgNode = this.svg.node();
1773
- if (!svgNode)
1774
- return { x: 0, y: 0 };
1775
- const rect = svgNode.getBoundingClientRect();
1776
- const transform = d3.zoomTransform(svgNode);
927
+ getState() {
1777
928
  return {
1778
- x: (screenX - rect.left - transform.x) / transform.k,
1779
- y: (screenY - rect.top - transform.y) / transform.k
929
+ selectedNodeId: this.selectedNodeId,
930
+ highlightedNodeIds: [...this.highlightedNodeIds],
931
+ zoomLevel: this.transform.k,
932
+ translateX: this.transform.x,
933
+ translateY: this.transform.y,
934
+ focusNodeId: this.focusNodeId,
935
+ focusDepth: this.focusDepth,
936
+ nodePositions: {},
937
+ layoutAlgorithm: this.activeLayout,
1780
938
  };
1781
939
  }
1782
- // ============================================================================
1783
- // HIGHLIGHTING
1784
- // ============================================================================
1785
- clearAllHighlighting() {
1786
- if (!this.svg)
1787
- return;
1788
- this.svg.selectAll('.node')
1789
- .classed('selected', false)
1790
- .classed('highlighted', false)
1791
- .classed('relationship-connected', false)
1792
- .classed('entity-connections-highlighted', false);
1793
- this.svg.selectAll('.entity-rect')
1794
- .classed('highlighted', false)
1795
- .classed('relationship-highlighted', false)
1796
- .classed('connection-highlighted', false)
1797
- .style('stroke', null)
1798
- .style('stroke-width', null)
1799
- .style('filter', null);
1800
- this.svg.selectAll('.link-group')
1801
- .classed('highlighted', false);
1802
- this.svg.selectAll('.link')
1803
- .classed('highlighted', false);
1804
- this.svg.selectAll('.link-label')
1805
- .classed('highlighted', false);
1806
- }
1807
- updateSelectionHighlighting() {
1808
- if (!this.svg)
1809
- return;
1810
- // Clear previous selection styling — remove inline styles so CSS classes take over
1811
- this.svg.selectAll('.node')
1812
- .classed('selected', false)
1813
- .select('.entity-rect')
1814
- .style('stroke', null)
1815
- .style('stroke-width', null)
1816
- .style('filter', null);
1817
- if (this.selectedNodeId) {
1818
- this.svg.selectAll('.node')
1819
- .filter((d) => d.id === this.selectedNodeId)
1820
- .classed('selected', true);
940
+ setState(state, _restorePositions) {
941
+ if (state.selectedNodeId !== undefined)
942
+ this.selectedNodeId = state.selectedNodeId;
943
+ if (state.highlightedNodeIds !== undefined)
944
+ this.highlightedNodeIds = [...state.highlightedNodeIds];
945
+ if (state.focusNodeId !== undefined)
946
+ this.focusNodeId = state.focusNodeId;
947
+ if (state.focusDepth !== undefined)
948
+ this.focusDepth = state.focusDepth;
949
+ if (state.zoomLevel !== undefined || state.translateX !== undefined || state.translateY !== undefined) {
950
+ this.transform = {
951
+ k: state.zoomLevel ?? this.transform.k,
952
+ x: state.translateX ?? this.transform.x,
953
+ y: state.translateY ?? this.transform.y,
954
+ };
1821
955
  }
1822
- }
1823
- updateHighlighting() {
1824
- if (!this.svg)
1825
- return;
1826
- const highlightSet = new Set(this.highlightedNodeIds);
1827
- this.svg.selectAll('.node')
1828
- .classed('highlighted', (d) => highlightSet.has(d.id))
1829
- .filter((d) => highlightSet.has(d.id))
1830
- .select('.entity-rect')
1831
- .classed('highlighted', true);
1832
- }
1833
- emitStateChange() {
1834
- const state = this.getState();
1835
- this.stateChange.emit(state);
1836
- }
1837
- // ============================================================================
1838
- // DRAG HANDLERS
1839
- // ============================================================================
1840
- dragstarted(event, d) {
1841
- if (this.isLayoutFrozen)
1842
- return;
1843
- const dragEvent = {
1844
- node: d.node,
1845
- startPosition: { x: d.x || 0, y: d.y || 0 },
1846
- currentPosition: { x: d.x || 0, y: d.y || 0 },
1847
- cancel: false
1848
- };
1849
- this.nodeDragStart.emit(dragEvent);
1850
- if (dragEvent.cancel)
1851
- return;
1852
- if (!event.active && this.simulation) {
1853
- this.simulation.alphaTarget(0.3).restart();
956
+ if (state.layoutAlgorithm && state.layoutAlgorithm !== this.activeLayout) {
957
+ this.activeLayout = state.layoutAlgorithm;
958
+ this.recompute();
1854
959
  }
1855
- d.fx = d.x;
1856
- d.fy = d.y;
1857
- }
1858
- dragged(event, d) {
1859
- if (this.isLayoutFrozen)
1860
- return;
1861
- d.fx = event.x;
1862
- d.fy = event.y;
960
+ this.updateHighlightSet();
961
+ this.updateSelectedEntity();
962
+ this.cdr.markForCheck();
1863
963
  }
1864
- dragended(event, d) {
1865
- if (this.isLayoutFrozen)
1866
- return;
1867
- const dragEvent = {
1868
- node: d.node,
1869
- startPosition: { x: 0, y: 0 }, // Start position not tracked in drag
1870
- currentPosition: { x: d.x || 0, y: d.y || 0 },
1871
- cancel: false
1872
- };
1873
- this.nodeDragEnd.emit(dragEvent);
1874
- if (!event.active && this.simulation) {
1875
- this.simulation.alphaTarget(0);
1876
- }
1877
- d.fx = null;
1878
- d.fy = null;
964
+ exportAsSVG() {
965
+ const svg = this.canvasRef?.nativeElement;
966
+ if (!svg)
967
+ return '';
968
+ return new XMLSerializer().serializeToString(svg);
1879
969
  }
970
+ // ─── TRACK-BYs ───────────────────────────────────────────────────────
971
+ trackByBand = (_, b) => b.schemaName;
972
+ trackByEdge = (_, e) => e.id;
973
+ trackByNode = (_, n) => n.id;
974
+ trackByField = (_, f) => f.id || f.name;
975
+ trackByChip = (_, c) => c.name;
1880
976
  static ɵfac = function ERDDiagramComponent_Factory(__ngFactoryType__) { return new (__ngFactoryType__ || ERDDiagramComponent)(); };
1881
977
  static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({ type: ERDDiagramComponent, selectors: [["mj-erd-diagram"]], viewQuery: function ERDDiagramComponent_Query(rf, ctx) { if (rf & 1) {
1882
978
  i0.ɵɵviewQuery(_c0, 5);
1883
979
  } if (rf & 2) {
1884
980
  let _t;
1885
- i0.ɵɵqueryRefresh(_t = i0.ɵɵloadQuery()) && (ctx.erdContainer = _t.first);
1886
- } }, inputs: { nodes: "nodes", selectedNodeId: "selectedNodeId", highlightedNodeIds: "highlightedNodeIds", focusNodeId: "focusNodeId", focusDepth: "focusDepth", isRefreshing: "isRefreshing", readOnly: "readOnly", config: "config", showHeader: "showHeader", headerTitle: "headerTitle" }, outputs: { nodeClick: "nodeClick", nodeDoubleClick: "nodeDoubleClick", nodeSelected: "nodeSelected", nodeDeselected: "nodeDeselected", linkClick: "linkClick", nodeHover: "nodeHover", nodeHoverEnd: "nodeHoverEnd", linkHover: "linkHover", linkHoverEnd: "linkHoverEnd", nodeContextMenu: "nodeContextMenu", linkContextMenu: "linkContextMenu", diagramContextMenu: "diagramContextMenu", nodeDragStart: "nodeDragStart", nodeDragEnd: "nodeDragEnd", zoomChange: "zoomChange", refreshRequested: "refreshRequested", layoutComplete: "layoutComplete", stateChange: "stateChange" }, standalone: false, features: [i0.ɵɵNgOnChangesFeature], decls: 5, vars: 2, consts: [["erdContainer", ""], [1, "erd-section"], [1, "section-header"], [1, "erd-container"], [1, "loading-overlay"], [1, "erd-controls"], ["title", "Zoom In", 1, "control-btn", 3, "click"], [1, "fa-solid", "fa-search-plus"], ["title", "Zoom Out", 1, "control-btn", 3, "click"], [1, "fa-solid", "fa-search-minus"], ["title", "Reset Zoom", 1, "control-btn", 3, "click"], [1, "fa-solid", "fa-expand"], ["title", "Refresh Diagram", 1, "control-btn", 3, "click"], [1, "fa-solid", "fa-refresh"], [1, "loading-content"], [1, "loading-spinner"], [1, "spinner-ring"], [1, "loading-text"]], template: function ERDDiagramComponent_Template(rf, ctx) { if (rf & 1) {
1887
- i0.ɵɵelementStart(0, "div", 1);
1888
- i0.ɵɵconditionalCreate(1, ERDDiagramComponent_Conditional_1_Template, 12, 1, "div", 2);
1889
- i0.ɵɵelementStart(2, "div", 3, 0);
1890
- i0.ɵɵconditionalCreate(4, ERDDiagramComponent_Conditional_4_Template, 8, 0, "div", 4);
981
+ i0.ɵɵqueryRefresh(_t = i0.ɵɵloadQuery()) && (ctx.canvasRef = _t.first);
982
+ } }, hostVars: 2, hostBindings: function ERDDiagramComponent_HostBindings(rf, ctx) { if (rf & 2) {
983
+ i0.ɵɵclassProp("erd-root", ctx.rootClass);
984
+ } }, inputs: { nodes: "nodes", selectedNodeId: "selectedNodeId", highlightedNodeIds: "highlightedNodeIds", focusNodeId: "focusNodeId", focusDepth: "focusDepth", isRefreshing: "isRefreshing", readOnly: "readOnly", config: "config", showHeader: "showHeader", headerTitle: "headerTitle" }, outputs: { nodeClick: "nodeClick", nodeDoubleClick: "nodeDoubleClick", nodeSelected: "nodeSelected", nodeDeselected: "nodeDeselected", linkClick: "linkClick", nodeHover: "nodeHover", nodeHoverEnd: "nodeHoverEnd", linkHover: "linkHover", linkHoverEnd: "linkHoverEnd", nodeContextMenu: "nodeContextMenu", linkContextMenu: "linkContextMenu", diagramContextMenu: "diagramContextMenu", nodeDragStart: "nodeDragStart", nodeDragEnd: "nodeDragEnd", zoomChange: "zoomChange", refreshRequested: "refreshRequested", layoutComplete: "layoutComplete", stateChange: "stateChange" }, standalone: false, features: [i0.ɵɵNgOnChangesFeature], decls: 24, vars: 7, consts: [["canvasRef", ""], [1, "erd-chrome"], [1, "erd-canvas-wrap"], [1, "erd-canvas", 3, "mousedown", "wheel"], ["id", "erd-arrow", "viewBox", "0 0 10 10", "refX", "9", "refY", "5", "markerWidth", "7", "markerHeight", "7", "orient", "auto"], ["d", "M 0 0 L 10 5 L 0 10 z", 1, "erd-marker"], ["id", "erd-arrow-active", "viewBox", "0 0 10 10", "refX", "9", "refY", "5", "markerWidth", "7", "markerHeight", "7", "orient", "auto"], ["d", "M 0 0 L 10 5 L 0 10 z", 1, "erd-marker", "erd-marker-active"], ["id", "erd-many", "viewBox", "-10 -6 14 12", "refX", "-8", "refY", "0", "markerWidth", "14", "markerHeight", "14", "orient", "auto"], ["d", "M 0 -5 L -8 0 L 0 5 M -8 0 L 4 0", 1, "erd-marker-stroke"], ["id", "erd-many-active", "viewBox", "-10 -6 14 12", "refX", "-8", "refY", "0", "markerWidth", "14", "markerHeight", "14", "orient", "auto"], ["d", "M 0 -5 L -8 0 L 0 5 M -8 0 L 4 0", 1, "erd-marker-stroke", "erd-marker-active"], [1, "erd-edges"], ["fill", "none", 1, "erd-edge", 3, "erd-edge-active", "erd-edge-dimmed"], [1, "erd-edge-labels"], [1, "erd-nodes"], [1, "erd-node", 3, "erd-node-focus", "erd-node-neighbour", "erd-node-dimmed"], [1, "erd-empty"], [1, "erd-loading-overlay"], [1, "erd-title-group"], [1, "erd-title"], [1, "erd-subtitle"], [1, "erd-count-pill"], [1, "erd-count-label"], [1, "erd-dot"], [1, "erd-search"], ["aria-hidden", "true", 1, "fa-solid", "fa-magnifying-glass"], ["type", "text", "placeholder", "Search entities, schemas, fields\u2026", "spellcheck", "false", "aria-label", "Search entities", 3, "input", "value"], ["type", "button", "aria-label", "Clear search", 1, "erd-search-clear"], [1, "erd-chrome-right"], ["role", "group", "aria-label", "Filter by schema", 1, "erd-schema-chips"], ["role", "group", "aria-label", "Layout mode", 1, "erd-toolbar"], ["type", "button", "title", "Schema grid \u2014 grouped by schema, predictable", "aria-label", "Schema-grid layout", 3, "click"], ["aria-hidden", "true", 1, "fa-solid", "fa-table-cells"], ["type", "button", "title", "Hierarchical \u2014 classic ERD, relationship-driven", "aria-label", "Hierarchical layout", 3, "click"], ["aria-hidden", "true", 1, "fa-solid", "fa-sitemap"], ["role", "group", "aria-label", "Zoom controls", 1, "erd-toolbar"], ["type", "button", "title", "Zoom in", "aria-label", "Zoom in", 3, "click"], ["aria-hidden", "true", 1, "fa-solid", "fa-magnifying-glass-plus"], ["type", "button", "title", "Zoom out", "aria-label", "Zoom out", 3, "click"], ["aria-hidden", "true", 1, "fa-solid", "fa-magnifying-glass-minus"], ["type", "button", "title", "Fit to view", "aria-label", "Fit to view", 3, "click"], ["aria-hidden", "true", 1, "fa-solid", "fa-expand"], [1, "erd-zoom-val"], ["type", "button", "aria-label", "Clear search", 1, "erd-search-clear", 3, "click"], ["type", "button", 1, "erd-chip", 3, "active"], ["type", "button", 1, "erd-chip", 3, "click"], [1, "erd-chip-label"], [1, "erd-chip-count"], [1, "erd-band"], ["rx", "16", 1, "erd-band-rect"], ["text-anchor", "middle", 1, "erd-band-title"], ["fill", "none", 1, "erd-edge"], [1, "erd-edge-label", 3, "erd-edge-label-active", "erd-edge-label-dimmed"], [1, "erd-edge-label"], ["height", "16", "rx", "3", 1, "erd-edge-label-bg"], ["text-anchor", "middle", 1, "erd-edge-label-text"], [1, "erd-node", 3, "click", "mouseenter", "mouseleave"], ["x", "0", "y", "2", "rx", "8", 1, "erd-node-shadow"], ["x", "0", "y", "0", "rx", "8", 1, "erd-node-body"], ["x", "0", "y", "0", "height", "36", "rx", "6", 1, "erd-node-header"], ["x", "14", "y", "8", "height", "22"], ["xmlns", "http://www.w3.org/1999/xhtml", 1, "erd-node-header-content"], [1, "erd-node-name", 3, "title"], [1, "erd-node-rows"], ["x1", "0", "y1", "36", "y2", "36", 1, "erd-node-separator"], ["x", "0", "height", "22"], ["x", "4", "height", "14"], ["x", "0", "y", "0", "rx", "8", 1, "erd-node-frame"], ["xmlns", "http://www.w3.org/1999/xhtml", 1, "erd-field-row"], [1, "erd-field-chip", "erd-field-chip-pk"], [1, "erd-field-chip", "erd-field-chip-fk"], [1, "erd-field-name", 3, "title"], [1, "erd-field-type", 3, "title"], ["x", "4", "height", "14", 3, "click"], ["xmlns", "http://www.w3.org/1999/xhtml", 1, "erd-field-more"], [1, "erd-empty-icon"], ["aria-hidden", "true"], [1, "erd-empty-title"], [1, "erd-empty-hint"], [1, "erd-loading-content"], ["aria-hidden", "true", 1, "fa-solid", "fa-circle-notch", "fa-spin"]], template: function ERDDiagramComponent_Template(rf, ctx) { if (rf & 1) {
985
+ const _r1 = i0.ɵɵgetCurrentView();
986
+ i0.ɵɵconditionalCreate(0, ERDDiagramComponent_Conditional_0_Template, 33, 12, "header", 1);
987
+ i0.ɵɵelementStart(1, "div", 2);
988
+ i0.ɵɵnamespaceSVG();
989
+ i0.ɵɵelementStart(2, "svg", 3, 0);
990
+ i0.ɵɵlistener("mousedown", function ERDDiagramComponent_Template_svg_mousedown_2_listener($event) { i0.ɵɵrestoreView(_r1); return i0.ɵɵresetView(ctx.onBackgroundMouseDown($event)); })("wheel", function ERDDiagramComponent_Template_svg_wheel_2_listener($event) { i0.ɵɵrestoreView(_r1); return i0.ɵɵresetView(ctx.onWheel($event)); });
991
+ i0.ɵɵelementStart(4, "defs")(5, "marker", 4);
992
+ i0.ɵɵelement(6, "path", 5);
993
+ i0.ɵɵelementEnd();
994
+ i0.ɵɵelementStart(7, "marker", 6);
995
+ i0.ɵɵelement(8, "path", 7);
996
+ i0.ɵɵelementEnd();
997
+ i0.ɵɵelementStart(9, "marker", 8);
998
+ i0.ɵɵelement(10, "path", 9);
999
+ i0.ɵɵelementEnd();
1000
+ i0.ɵɵelementStart(11, "marker", 10);
1001
+ i0.ɵɵelement(12, "path", 11);
1891
1002
  i0.ɵɵelementEnd()();
1003
+ i0.ɵɵelementStart(13, "g");
1004
+ i0.ɵɵconditionalCreate(14, ERDDiagramComponent_Conditional_14_Template, 2, 0);
1005
+ i0.ɵɵelementStart(15, "g", 12);
1006
+ i0.ɵɵrepeaterCreate(16, ERDDiagramComponent_For_17_Template, 1, 7, ":svg:path", 13, ctx.trackByEdge, true);
1007
+ i0.ɵɵelementEnd();
1008
+ i0.ɵɵconditionalCreate(18, ERDDiagramComponent_Conditional_18_Template, 3, 0, ":svg:g", 14);
1009
+ i0.ɵɵelementStart(19, "g", 15);
1010
+ i0.ɵɵrepeaterCreate(20, ERDDiagramComponent_For_21_Template, 14, 20, ":svg:g", 16, ctx.trackByNode, true);
1011
+ i0.ɵɵelementEnd()()();
1012
+ i0.ɵɵconditionalCreate(22, ERDDiagramComponent_Conditional_22_Template, 6, 4, "div", 17);
1013
+ i0.ɵɵconditionalCreate(23, ERDDiagramComponent_Conditional_23_Template, 5, 0, "div", 18);
1014
+ i0.ɵɵelementEnd();
1892
1015
  } if (rf & 2) {
1016
+ i0.ɵɵconditional(ctx.showHeader ? 0 : -1);
1017
+ i0.ɵɵadvance(2);
1018
+ i0.ɵɵattribute("data-panning", ctx.panning ? "true" : null);
1019
+ i0.ɵɵadvance(11);
1020
+ i0.ɵɵattribute("transform", "translate(" + ctx.transform.x + " " + ctx.transform.y + ") scale(" + ctx.transform.k + ")");
1893
1021
  i0.ɵɵadvance();
1894
- i0.ɵɵconditional(ctx.showHeader ? 1 : -1);
1895
- i0.ɵɵadvance(3);
1896
- i0.ɵɵconditional(ctx.isRefreshing ? 4 : -1);
1897
- } }, styles: ["\n\n\n\n\n\n[_nghost-%COMP%] {\n display: block;\n height: 100%;\n width: 100%;\n}\n\n.erd-section[_ngcontent-%COMP%] {\n background: var(--mj-bg-page);\n overflow: hidden;\n height: 100%;\n display: flex;\n flex-direction: column;\n min-height: 0;\n}\n\n.section-header[_ngcontent-%COMP%] {\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 12px 16px;\n background: var(--mj-bg-surface-sunken);\n border-bottom: 1px solid var(--mj-border-default);\n flex-shrink: 0;\n}\n\n.section-header[_ngcontent-%COMP%] h3[_ngcontent-%COMP%] {\n margin: 0;\n color: var(--mj-text-primary);\n}\n\n.erd-controls[_ngcontent-%COMP%] {\n display: flex;\n gap: 8px;\n}\n\n.control-btn[_ngcontent-%COMP%] {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n padding: 6px 12px;\n background: var(--mj-bg-page);\n color: var(--mj-text-secondary);\n border: 1px solid var(--mj-border-default);\n border-radius: 8px;\n cursor: pointer;\n transition: all 0.2s ease;\n}\n\n.control-btn[_ngcontent-%COMP%]:hover:not(:disabled) {\n background: var(--mj-bg-surface-sunken);\n border-color: var(--mj-border-strong);\n color: var(--mj-text-primary);\n}\n\n.control-btn[_ngcontent-%COMP%]:disabled {\n opacity: 0.38;\n cursor: not-allowed;\n}\n\n.erd-container[_ngcontent-%COMP%] {\n flex: 1;\n position: relative;\n background: var(--mj-bg-surface-sunken);\n overflow: hidden;\n width: 100%;\n min-height: 400px;\n box-sizing: border-box;\n}\n\n.erd-container[_ngcontent-%COMP%] svg[_ngcontent-%COMP%] {\n position: absolute;\n top: 0;\n left: 0;\n width: 100% !important;\n height: 100% !important;\n box-sizing: border-box;\n}\n\n\n\n.loading-overlay[_ngcontent-%COMP%] {\n position: absolute;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n background: var(--mj-bg-page);\n opacity: 0.95;\n backdrop-filter: blur(2px);\n display: flex;\n align-items: center;\n justify-content: center;\n z-index: 1000;\n animation: _ngcontent-%COMP%_fadeIn 0.3s ease-in-out;\n}\n\n.loading-content[_ngcontent-%COMP%] {\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: 16px;\n}\n\n.loading-spinner[_ngcontent-%COMP%] {\n position: relative;\n width: 60px;\n height: 60px;\n}\n\n.spinner-ring[_ngcontent-%COMP%] {\n position: absolute;\n width: 100%;\n height: 100%;\n border: 3px solid transparent;\n border-radius: 50%;\n animation: _ngcontent-%COMP%_spin 1.5s linear infinite;\n}\n\n.spinner-ring[_ngcontent-%COMP%]:nth-child(1) {\n border-top-color: var(--mj-brand-primary);\n animation-delay: 0s;\n}\n\n.spinner-ring[_ngcontent-%COMP%]:nth-child(2) {\n border-top-color: var(--mj-status-warning);\n animation-delay: 0.3s;\n transform: scale(0.8);\n}\n\n.spinner-ring[_ngcontent-%COMP%]:nth-child(3) {\n border-top-color: var(--mj-status-success);\n animation-delay: 0.6s;\n transform: scale(0.6);\n}\n\n.loading-text[_ngcontent-%COMP%] {\n color: var(--mj-text-secondary);\n font-weight: 500;\n text-align: center;\n}\n\n\n\n@keyframes _ngcontent-%COMP%_spin {\n 0% {\n transform: rotate(0deg);\n }\n 100% {\n transform: rotate(360deg);\n }\n}\n\n@keyframes _ngcontent-%COMP%_fadeIn {\n from {\n opacity: 0;\n }\n to {\n opacity: 1;\n }\n}\n\n\n\n[_nghost-%COMP%] .erd-container svg .node .entity-rect {\n fill: var(--mj-bg-surface-card);\n stroke: var(--mj-text-primary);\n}\n\n[_nghost-%COMP%] .erd-container svg .node .entity-header, \n[_nghost-%COMP%] .erd-container svg .node .entity-header-bottom {\n fill: var(--mj-brand-primary);\n}\n\n[_nghost-%COMP%] .erd-container svg .node .entity-name {\n fill: var(--mj-text-inverse);\n}\n\n\n\n[_nghost-%COMP%] .erd-container svg .field-group.primary-key .field-bg {\n fill: color-mix(in srgb, var(--mj-status-warning) 20%, var(--mj-bg-surface-card));\n}\n\n[_nghost-%COMP%] .erd-container svg .field-group.primary-key .field-name, \n[_nghost-%COMP%] .erd-container svg .field-group.primary-key .field-icon {\n fill: var(--mj-text-primary);\n}\n\n[_nghost-%COMP%] .erd-container svg .field-group.foreign-key .field-bg {\n fill: color-mix(in srgb, var(--mj-brand-primary) 15%, var(--mj-bg-surface-card));\n}\n\n[_nghost-%COMP%] .erd-container svg .field-group.foreign-key .field-name, \n[_nghost-%COMP%] .erd-container svg .field-group.foreign-key .field-icon {\n fill: var(--mj-text-primary);\n}\n\n\n\n[_nghost-%COMP%] .erd-container svg .link-group .link-label-bg {\n fill: var(--mj-bg-surface-card);\n stroke: var(--mj-text-muted);\n}\n\n[_nghost-%COMP%] .erd-container svg .link-group .link-label {\n fill: var(--mj-text-muted);\n}\n\n[_nghost-%COMP%] .erd-container svg .link-group .link {\n stroke: var(--mj-text-muted);\n}\n\n[_nghost-%COMP%] .erd-container svg #end-arrow path {\n fill: var(--mj-text-muted);\n}\n\n\n\n[_nghost-%COMP%] .erd-container svg .node .entity-rect.highlighted {\n stroke: var(--mj-status-warning) !important;\n stroke-width: 3px !important;\n filter: drop-shadow(0 0 6px color-mix(in srgb, var(--mj-status-warning) 50%, transparent));\n}\n\n[_nghost-%COMP%] .erd-container svg .node.selected .entity-rect {\n stroke: var(--mj-status-success) !important;\n stroke-width: 4px !important;\n filter: drop-shadow(0 0 8px color-mix(in srgb, var(--mj-status-success) 50%, transparent));\n}\n\n[_nghost-%COMP%] .erd-container svg .node.selected .entity-header {\n fill: color-mix(in srgb, var(--mj-status-success) 85%, black) !important;\n}\n\n[_nghost-%COMP%] .erd-container svg .node.selected .entity-header-bottom {\n fill: color-mix(in srgb, var(--mj-status-success) 85%, black) !important;\n}\n\n[_nghost-%COMP%] .erd-container svg .node.selected .entity-name {\n fill: var(--mj-text-inverse) !important;\n font-weight: bold !important;\n}\n\n\n\n[_nghost-%COMP%] .erd-container svg .node.relationship-connected .entity-rect.relationship-highlighted {\n stroke: var(--mj-status-success) !important;\n stroke-width: 3px !important;\n filter: drop-shadow(0 0 6px color-mix(in srgb, var(--mj-status-success) 50%, transparent));\n}\n\n[_nghost-%COMP%] .erd-container svg .node.relationship-connected .entity-header {\n fill: var(--mj-status-success) !important;\n}\n\n[_nghost-%COMP%] .erd-container svg .node.relationship-connected .entity-header-bottom {\n fill: var(--mj-status-success) !important;\n}\n\n[_nghost-%COMP%] .erd-container svg .node.entity-connections-highlighted .entity-rect.connection-highlighted {\n stroke: var(--mj-status-warning) !important;\n stroke-width: 3px !important;\n filter: drop-shadow(0 0 6px color-mix(in srgb, var(--mj-status-warning) 50%, transparent));\n opacity: 0.9;\n}\n\n[_nghost-%COMP%] .erd-container svg .node.entity-connections-highlighted .entity-header {\n fill: var(--mj-status-warning) !important;\n opacity: 0.8;\n}\n\n[_nghost-%COMP%] .erd-container svg .node.entity-connections-highlighted .entity-header-bottom {\n fill: var(--mj-status-warning) !important;\n opacity: 0.8;\n}\n\n\n\n[_nghost-%COMP%] .erd-container svg .link-group.self-reference .link-label-bg {\n fill: color-mix(in srgb, var(--mj-status-success) 15%, var(--mj-bg-surface-card));\n stroke: var(--mj-status-success);\n}\n\n[_nghost-%COMP%] .erd-container svg .link-group.self-reference .link-label {\n fill: color-mix(in srgb, var(--mj-status-success) 80%, var(--mj-text-primary));\n}\n\n\n\n[_nghost-%COMP%] .erd-container svg .link-group .link {\n stroke-width: 2px;\n transition: all 0.2s ease;\n cursor: pointer;\n}\n\n[_nghost-%COMP%] .erd-container svg .link-group .link:hover {\n stroke-width: 3px;\n stroke: var(--mj-status-success);\n opacity: 1;\n}\n\n[_nghost-%COMP%] .erd-container svg .link-group .link.highlighted {\n stroke: var(--mj-status-success) !important;\n stroke-width: 4px !important;\n opacity: 1 !important;\n filter: drop-shadow(0 0 6px color-mix(in srgb, var(--mj-status-success) 60%, transparent));\n}\n\n[_nghost-%COMP%] .erd-container svg .link-group .link-label {\n font-family: Roboto, -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;\n opacity: 0.8;\n pointer-events: none;\n transition: opacity 0.2s ease;\n}\n\n[_nghost-%COMP%] .erd-container svg .link-group .link-label.highlighted {\n opacity: 1;\n font-weight: bold;\n fill: var(--mj-status-success);\n}\n\n[_nghost-%COMP%] .erd-container svg .link-group.highlighted {\n z-index: 10;\n}\n\n\n\n[_nghost-%COMP%] .erd-container svg .field-group .field-bg {\n transition: fill 0.2s;\n}\n\n[_nghost-%COMP%] .erd-container svg .field-group:hover .field-bg {\n fill: color-mix(in srgb, var(--mj-brand-primary) 20%, var(--mj-bg-surface-card)) !important;\n}"] });
1022
+ i0.ɵɵconditional(ctx.config.showSchemaBands ?? true ? 14 : -1);
1023
+ i0.ɵɵadvance(2);
1024
+ i0.ɵɵrepeater(ctx.layout.edges);
1025
+ i0.ɵɵadvance(2);
1026
+ i0.ɵɵconditional(ctx.showEdgeLabels ? 18 : -1);
1027
+ i0.ɵɵadvance(2);
1028
+ i0.ɵɵrepeater(ctx.layout.nodes);
1029
+ i0.ɵɵadvance(2);
1030
+ i0.ɵɵconditional(ctx.visibleCount === 0 ? 22 : -1);
1031
+ i0.ɵɵadvance();
1032
+ i0.ɵɵconditional(ctx.isRefreshing ? 23 : -1);
1033
+ } }, styles: ["\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n[_nghost-%COMP%] {\n display: flex !important;\n flex-direction: column !important;\n height: 100%;\n width: 100%;\n overflow: hidden;\n background: var(--mj-bg-page);\n color: var(--mj-text-primary);\n --erd-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, \"Liberation Mono\", monospace;\n}\n\n\n\n\n.erd-chrome[_ngcontent-%COMP%] {\n display: flex;\n align-items: center;\n gap: 24px;\n padding: 14px 24px;\n border-bottom: 1px solid var(--mj-border-default);\n background: var(--mj-bg-surface);\n flex-shrink: 0;\n min-height: 64px;\n}\n\n.erd-title-group[_ngcontent-%COMP%] {\n display: flex;\n flex-direction: column;\n gap: 2px;\n flex-shrink: 0;\n}\n\n.erd-title[_ngcontent-%COMP%] {\n font-size: 15px;\n font-weight: 600;\n letter-spacing: -0.01em;\n color: var(--mj-text-primary);\n}\n\n.erd-subtitle[_ngcontent-%COMP%] {\n display: flex;\n align-items: center;\n gap: 8px;\n font-size: 12px;\n color: var(--mj-text-secondary);\n}\n\n.erd-count-pill[_ngcontent-%COMP%] {\n font-family: var(--erd-mono);\n font-size: 11px;\n font-weight: 600;\n padding: 2px 8px;\n border-radius: 10px;\n background: color-mix(in srgb, var(--mj-brand-primary) 12%, transparent);\n color: var(--mj-brand-primary);\n line-height: 1.3;\n}\n\n.erd-count-label[_ngcontent-%COMP%] { color: var(--mj-text-secondary); }\n.erd-dot[_ngcontent-%COMP%] { color: var(--mj-text-muted); }\n\n.erd-search[_ngcontent-%COMP%] {\n flex: 1;\n max-width: 440px;\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 8px 12px;\n border: 1px solid var(--mj-border-default);\n border-radius: 8px;\n background: var(--mj-bg-surface-card);\n transition: border-color .12s, background .12s, box-shadow .12s;\n}\n\n.erd-search[_ngcontent-%COMP%]:focus-within {\n border-color: var(--mj-brand-primary);\n background: var(--mj-bg-surface);\n box-shadow: 0 0 0 3px color-mix(in srgb, var(--mj-brand-primary) 15%, transparent);\n}\n\n.erd-search[_ngcontent-%COMP%] > i[_ngcontent-%COMP%] {\n color: var(--mj-text-muted);\n flex-shrink: 0;\n font-size: 13px;\n}\n\n.erd-search[_ngcontent-%COMP%] input[_ngcontent-%COMP%] {\n flex: 1;\n border: 0;\n outline: 0;\n background: transparent;\n font: inherit;\n font-size: 13px;\n color: var(--mj-text-primary);\n}\n\n.erd-search[_ngcontent-%COMP%] input[_ngcontent-%COMP%]::placeholder { color: var(--mj-text-muted); }\n\n.erd-search-clear[_ngcontent-%COMP%] {\n border: 0;\n background: transparent;\n color: var(--mj-text-muted);\n font-size: 18px;\n line-height: 1;\n cursor: pointer;\n padding: 0 4px;\n}\n\n.erd-search-clear[_ngcontent-%COMP%]:hover { color: var(--mj-text-primary); }\n\n.erd-chrome-right[_ngcontent-%COMP%] {\n display: flex;\n align-items: center;\n gap: 16px;\n margin-left: auto;\n}\n\n\n\n.erd-schema-chips[_ngcontent-%COMP%] {\n display: flex;\n gap: 4px;\n padding: 3px;\n background: var(--mj-bg-surface-card);\n border: 1px solid var(--mj-border-default);\n border-radius: 10px;\n}\n\n.erd-chip[_ngcontent-%COMP%] {\n display: flex;\n align-items: center;\n gap: 6px;\n padding: 5px 10px;\n border: 0;\n background: transparent;\n border-radius: 7px;\n font-family: var(--erd-mono);\n font-size: 11px;\n color: var(--mj-text-secondary);\n cursor: pointer;\n transition: background .1s, color .1s;\n}\n\n.erd-chip[_ngcontent-%COMP%]:hover {\n background: var(--mj-bg-surface-hover);\n color: var(--mj-text-primary);\n}\n\n.erd-chip.active[_ngcontent-%COMP%] {\n background: var(--mj-bg-surface);\n color: var(--mj-text-primary);\n box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);\n}\n\n.erd-chip-count[_ngcontent-%COMP%] {\n font-size: 10px;\n padding: 1px 5px;\n border-radius: 6px;\n background: color-mix(in srgb, var(--mj-bg-surface-card) 80%, transparent);\n color: var(--mj-text-muted);\n}\n\n\n\n.erd-toolbar[_ngcontent-%COMP%] {\n display: flex;\n align-items: center;\n gap: 2px;\n padding: 3px;\n background: var(--mj-bg-surface-card);\n border: 1px solid var(--mj-border-default);\n border-radius: 8px;\n}\n\n.erd-toolbar[_ngcontent-%COMP%] button[_ngcontent-%COMP%] {\n width: 28px;\n height: 28px;\n border: 0;\n background: transparent;\n border-radius: 5px;\n color: var(--mj-text-secondary);\n cursor: pointer;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n font-size: 12px;\n transition: background .1s, color .1s;\n}\n\n.erd-toolbar[_ngcontent-%COMP%] button[_ngcontent-%COMP%]:hover {\n background: var(--mj-bg-surface-hover);\n color: var(--mj-text-primary);\n}\n\n.erd-toolbar[_ngcontent-%COMP%] button.active[_ngcontent-%COMP%] {\n background: color-mix(in srgb, var(--mj-brand-primary) 14%, var(--mj-bg-surface));\n color: var(--mj-brand-primary);\n}\n\n.erd-zoom-val[_ngcontent-%COMP%] {\n padding: 0 8px;\n font-family: var(--erd-mono);\n font-size: 10px;\n color: var(--mj-text-secondary);\n min-width: 42px;\n text-align: right;\n}\n\n\n\n\n.erd-canvas-wrap[_ngcontent-%COMP%] {\n flex: 1;\n position: relative;\n overflow: hidden;\n background: var(--mj-bg-surface-sunken);\n min-height: 300px;\n}\n\n.erd-canvas[_ngcontent-%COMP%] {\n width: 100%;\n height: 100%;\n user-select: none;\n cursor: grab;\n display: block;\n}\n\n.erd-canvas[data-panning=\"true\"][_ngcontent-%COMP%] { cursor: grabbing; }\n\n.erd-canvas[_ngcontent-%COMP%] text[_ngcontent-%COMP%] { pointer-events: none; }\n\n\n\n.erd-band-rect[_ngcontent-%COMP%] {\n fill: color-mix(in srgb, var(--mj-text-primary) 4%, transparent);\n stroke: var(--mj-border-default);\n stroke-width: 1;\n}\n\n.erd-band-title[_ngcontent-%COMP%] {\n font-family: var(--erd-mono);\n font-size: 13px;\n font-weight: 600;\n letter-spacing: 0.08em;\n text-transform: uppercase;\n fill: var(--mj-text-muted);\n}\n\n\n\n.erd-edge[_ngcontent-%COMP%] {\n stroke: var(--mj-border-strong);\n stroke-width: 1.2;\n transition: stroke-width .15s, opacity .15s;\n}\n\n.erd-edge-active[_ngcontent-%COMP%] {\n stroke: var(--mj-brand-primary);\n stroke-width: 1.8;\n}\n\n.erd-edge-dimmed[_ngcontent-%COMP%] { opacity: 0.18; }\n\n\n\n\n.erd-edge-label[_ngcontent-%COMP%] {\n pointer-events: none;\n transition: opacity .15s;\n}\n\n.erd-edge-label-bg[_ngcontent-%COMP%] {\n fill: var(--mj-bg-surface);\n stroke: var(--mj-border-default);\n stroke-width: 0.75;\n}\n\n.erd-edge-label-text[_ngcontent-%COMP%] {\n font-family: var(--erd-mono);\n font-size: 11px;\n fill: var(--mj-text-secondary);\n}\n\n.erd-edge-label-active[_ngcontent-%COMP%] .erd-edge-label-bg[_ngcontent-%COMP%] {\n stroke: var(--mj-brand-primary);\n}\n\n.erd-edge-label-active[_ngcontent-%COMP%] .erd-edge-label-text[_ngcontent-%COMP%] {\n fill: var(--mj-brand-primary);\n}\n\n.erd-edge-label-dimmed[_ngcontent-%COMP%] { opacity: 0.18; }\n\n\n\n.erd-marker[_ngcontent-%COMP%] { fill: var(--mj-border-strong); }\n.erd-marker-active[_ngcontent-%COMP%], \n.erd-marker-active.erd-marker[_ngcontent-%COMP%], \n.erd-marker-active.erd-marker-stroke[_ngcontent-%COMP%] { fill: var(--mj-brand-primary); stroke: var(--mj-brand-primary); }\n.erd-marker-stroke[_ngcontent-%COMP%] { stroke: var(--mj-border-strong); stroke-width: 1.4; fill: none; stroke-linecap: round; }\n\n\n\n.erd-node[_ngcontent-%COMP%] {\n cursor: pointer;\n transition: opacity .15s;\n}\n\n.erd-node-dimmed[_ngcontent-%COMP%] { opacity: 0.22; }\n\n.erd-node-shadow[_ngcontent-%COMP%] {\n fill: color-mix(in srgb, var(--mj-text-primary) 8%, transparent);\n}\n\n.erd-node-body[_ngcontent-%COMP%] {\n fill: var(--mj-bg-surface);\n}\n\n\n\n\n\n.erd-node-frame[_ngcontent-%COMP%] {\n fill: none;\n stroke: var(--mj-border-default);\n stroke-width: 1;\n pointer-events: none;\n}\n\n.erd-node-focus[_ngcontent-%COMP%] .erd-node-frame[_ngcontent-%COMP%] {\n stroke: var(--mj-brand-primary);\n stroke-width: 2;\n}\n\n.erd-node-neighbour[_ngcontent-%COMP%] .erd-node-frame[_ngcontent-%COMP%] {\n stroke-width: 1.5;\n}\n\n.erd-node-header[_ngcontent-%COMP%] {\n fill: var(--mj-bg-surface-card);\n}\n\n.erd-node-separator[_ngcontent-%COMP%] {\n stroke: var(--mj-border-default);\n stroke-width: 1;\n}\n\n\n\n.erd-node-header-content[_ngcontent-%COMP%] {\n display: flex;\n align-items: center;\n gap: 8px;\n font-family: var(--erd-mono);\n line-height: 22px;\n height: 22px;\n color: var(--mj-text-primary);\n}\n\n.erd-node-name[_ngcontent-%COMP%] {\n font-size: 13px;\n font-weight: 600;\n flex: 1;\n min-width: 0;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.erd-node-rows[_ngcontent-%COMP%] {\n font-size: 10px;\n color: var(--mj-text-muted);\n flex-shrink: 0;\n}\n\n\n\n.erd-field-row[_ngcontent-%COMP%] {\n display: flex;\n align-items: center;\n gap: 8px;\n height: 22px;\n padding: 0 12px 0 8px;\n font-family: var(--erd-mono);\n line-height: 22px;\n box-sizing: border-box;\n}\n\n.erd-field-chip[_ngcontent-%COMP%] {\n flex-shrink: 0;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 22px;\n height: 14px;\n border-radius: 3px;\n font-size: 8px;\n font-weight: 700;\n letter-spacing: 0.05em;\n line-height: 1;\n}\n\n.erd-field-chip-pk[_ngcontent-%COMP%] {\n background: color-mix(in srgb, var(--mj-status-warning) 20%, var(--mj-bg-surface));\n color: var(--mj-status-warning-text, var(--mj-status-warning));\n}\n\n.erd-field-chip-fk[_ngcontent-%COMP%] {\n background: color-mix(in srgb, var(--mj-brand-primary) 18%, var(--mj-bg-surface));\n color: var(--mj-brand-primary);\n}\n\n.erd-field-name[_ngcontent-%COMP%] {\n flex: 1;\n min-width: 0;\n font-size: 11px;\n color: var(--mj-text-primary);\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.erd-field-type[_ngcontent-%COMP%] {\n flex-shrink: 0;\n font-size: 10px;\n color: var(--mj-text-muted);\n \n\n max-width: 45%;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n\n\n.erd-field-more[_ngcontent-%COMP%] {\n display: flex;\n align-items: center;\n justify-content: center;\n width: 100%;\n height: 14px;\n border-radius: 3px;\n background: var(--mj-bg-surface-card);\n opacity: 0.7;\n font-family: inherit;\n font-size: 9px;\n font-weight: 500;\n color: var(--mj-text-secondary);\n cursor: pointer;\n transition: opacity .1s;\n}\n\n.erd-field-more[_ngcontent-%COMP%]:hover { opacity: 1; }\n\n\n\n\n.erd-empty[_ngcontent-%COMP%] {\n position: absolute;\n inset: 0;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n gap: 8px;\n pointer-events: none;\n}\n\n.erd-empty-icon[_ngcontent-%COMP%] {\n font-size: 40px;\n color: var(--mj-text-muted);\n opacity: 0.45;\n}\n\n.erd-empty-title[_ngcontent-%COMP%] {\n font-size: 15px;\n font-weight: 600;\n color: var(--mj-text-primary);\n}\n\n.erd-empty-hint[_ngcontent-%COMP%] {\n font-size: 12px;\n color: var(--mj-text-secondary);\n}\n\n.erd-loading-overlay[_ngcontent-%COMP%] {\n position: absolute;\n inset: 0;\n background: color-mix(in srgb, var(--mj-bg-page) 90%, transparent);\n backdrop-filter: blur(2px);\n display: flex;\n align-items: center;\n justify-content: center;\n z-index: 100;\n}\n\n.erd-loading-content[_ngcontent-%COMP%] {\n display: flex;\n align-items: center;\n gap: 10px;\n color: var(--mj-text-secondary);\n font-size: 13px;\n}\n\n.erd-loading-content[_ngcontent-%COMP%] i[_ngcontent-%COMP%] {\n color: var(--mj-brand-primary);\n font-size: 20px;\n}"], changeDetection: 0 });
1898
1034
  }
1899
1035
  (() => { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassMetadata(ERDDiagramComponent, [{
1900
1036
  type: Component,
1901
- args: [{ standalone: false, selector: 'mj-erd-diagram', template: "<div class=\"erd-section\">\n @if (showHeader) {\n <div class=\"section-header\">\n <h3>{{ headerTitle }}</h3>\n <div class=\"erd-controls\">\n <button class=\"control-btn\" (click)=\"zoomIn()\" title=\"Zoom In\">\n <span class=\"fa-solid fa-search-plus\"></span>\n </button>\n <button class=\"control-btn\" (click)=\"zoomOut()\" title=\"Zoom Out\">\n <span class=\"fa-solid fa-search-minus\"></span>\n </button>\n <button class=\"control-btn\" (click)=\"resetZoom()\" title=\"Reset Zoom\">\n <span class=\"fa-solid fa-expand\"></span>\n </button>\n <button class=\"control-btn\" (click)=\"refresh(); refreshRequested.emit()\" title=\"Refresh Diagram\">\n <span class=\"fa-solid fa-refresh\"></span>\n </button>\n </div>\n </div>\n }\n <div class=\"erd-container\" #erdContainer>\n @if (isRefreshing) {\n <div class=\"loading-overlay\">\n <div class=\"loading-content\">\n <div class=\"loading-spinner\">\n <div class=\"spinner-ring\"></div>\n <div class=\"spinner-ring\"></div>\n <div class=\"spinner-ring\"></div>\n </div>\n <div class=\"loading-text\">Loading diagram...</div>\n </div>\n </div>\n }\n </div>\n</div>\n", styles: ["/**\n * ERD Diagram Component - MJ Design Tokens\n * MJ theming for the Entity Relationship Diagram visualization\n */\n\n:host {\n display: block;\n height: 100%;\n width: 100%;\n}\n\n.erd-section {\n background: var(--mj-bg-page);\n overflow: hidden;\n height: 100%;\n display: flex;\n flex-direction: column;\n min-height: 0;\n}\n\n.section-header {\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 12px 16px;\n background: var(--mj-bg-surface-sunken);\n border-bottom: 1px solid var(--mj-border-default);\n flex-shrink: 0;\n}\n\n.section-header h3 {\n margin: 0;\n color: var(--mj-text-primary);\n}\n\n.erd-controls {\n display: flex;\n gap: 8px;\n}\n\n.control-btn {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n padding: 6px 12px;\n background: var(--mj-bg-page);\n color: var(--mj-text-secondary);\n border: 1px solid var(--mj-border-default);\n border-radius: 8px;\n cursor: pointer;\n transition: all 0.2s ease;\n}\n\n.control-btn:hover:not(:disabled) {\n background: var(--mj-bg-surface-sunken);\n border-color: var(--mj-border-strong);\n color: var(--mj-text-primary);\n}\n\n.control-btn:disabled {\n opacity: 0.38;\n cursor: not-allowed;\n}\n\n.erd-container {\n flex: 1;\n position: relative;\n background: var(--mj-bg-surface-sunken);\n overflow: hidden;\n width: 100%;\n min-height: 400px;\n box-sizing: border-box;\n}\n\n.erd-container svg {\n position: absolute;\n top: 0;\n left: 0;\n width: 100% !important;\n height: 100% !important;\n box-sizing: border-box;\n}\n\n/* Loading overlay - using mj-loading component styling */\n.loading-overlay {\n position: absolute;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n background: var(--mj-bg-page);\n opacity: 0.95;\n backdrop-filter: blur(2px);\n display: flex;\n align-items: center;\n justify-content: center;\n z-index: 1000;\n animation: fadeIn 0.3s ease-in-out;\n}\n\n.loading-content {\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: 16px;\n}\n\n.loading-spinner {\n position: relative;\n width: 60px;\n height: 60px;\n}\n\n.spinner-ring {\n position: absolute;\n width: 100%;\n height: 100%;\n border: 3px solid transparent;\n border-radius: 50%;\n animation: spin 1.5s linear infinite;\n}\n\n.spinner-ring:nth-child(1) {\n border-top-color: var(--mj-brand-primary);\n animation-delay: 0s;\n}\n\n.spinner-ring:nth-child(2) {\n border-top-color: var(--mj-status-warning);\n animation-delay: 0.3s;\n transform: scale(0.8);\n}\n\n.spinner-ring:nth-child(3) {\n border-top-color: var(--mj-status-success);\n animation-delay: 0.6s;\n transform: scale(0.6);\n}\n\n.loading-text {\n color: var(--mj-text-secondary);\n font-weight: 500;\n text-align: center;\n}\n\n/* Animations */\n@keyframes spin {\n 0% {\n transform: rotate(0deg);\n }\n 100% {\n transform: rotate(360deg);\n }\n}\n\n@keyframes fadeIn {\n from {\n opacity: 0;\n }\n to {\n opacity: 1;\n }\n}\n\n/* \u2500\u2500 Base node colors (override D3 presentation attributes) \u2500\u2500 */\n:host ::ng-deep .erd-container svg .node .entity-rect {\n fill: var(--mj-bg-surface-card);\n stroke: var(--mj-text-primary);\n}\n\n:host ::ng-deep .erd-container svg .node .entity-header,\n:host ::ng-deep .erd-container svg .node .entity-header-bottom {\n fill: var(--mj-brand-primary);\n}\n\n:host ::ng-deep .erd-container svg .node .entity-name {\n fill: var(--mj-text-inverse);\n}\n\n/* Field backgrounds and text */\n:host ::ng-deep .erd-container svg .field-group.primary-key .field-bg {\n fill: color-mix(in srgb, var(--mj-status-warning) 20%, var(--mj-bg-surface-card));\n}\n\n:host ::ng-deep .erd-container svg .field-group.primary-key .field-name,\n:host ::ng-deep .erd-container svg .field-group.primary-key .field-icon {\n fill: var(--mj-text-primary);\n}\n\n:host ::ng-deep .erd-container svg .field-group.foreign-key .field-bg {\n fill: color-mix(in srgb, var(--mj-brand-primary) 15%, var(--mj-bg-surface-card));\n}\n\n:host ::ng-deep .erd-container svg .field-group.foreign-key .field-name,\n:host ::ng-deep .erd-container svg .field-group.foreign-key .field-icon {\n fill: var(--mj-text-primary);\n}\n\n/* \u2500\u2500 Link colors \u2500\u2500 */\n:host ::ng-deep .erd-container svg .link-group .link-label-bg {\n fill: var(--mj-bg-surface-card);\n stroke: var(--mj-text-muted);\n}\n\n:host ::ng-deep .erd-container svg .link-group .link-label {\n fill: var(--mj-text-muted);\n}\n\n:host ::ng-deep .erd-container svg .link-group .link {\n stroke: var(--mj-text-muted);\n}\n\n:host ::ng-deep .erd-container svg #end-arrow path {\n fill: var(--mj-text-muted);\n}\n\n/* \u2500\u2500 Node highlighting styles \u2500\u2500 */\n:host ::ng-deep .erd-container svg .node .entity-rect.highlighted {\n stroke: var(--mj-status-warning) !important;\n stroke-width: 3px !important;\n filter: drop-shadow(0 0 6px color-mix(in srgb, var(--mj-status-warning) 50%, transparent));\n}\n\n:host ::ng-deep .erd-container svg .node.selected .entity-rect {\n stroke: var(--mj-status-success) !important;\n stroke-width: 4px !important;\n filter: drop-shadow(0 0 8px color-mix(in srgb, var(--mj-status-success) 50%, transparent));\n}\n\n:host ::ng-deep .erd-container svg .node.selected .entity-header {\n fill: color-mix(in srgb, var(--mj-status-success) 85%, black) !important;\n}\n\n:host ::ng-deep .erd-container svg .node.selected .entity-header-bottom {\n fill: color-mix(in srgb, var(--mj-status-success) 85%, black) !important;\n}\n\n:host ::ng-deep .erd-container svg .node.selected .entity-name {\n fill: var(--mj-text-inverse) !important;\n font-weight: bold !important;\n}\n\n/* Related/connected nodes - using tertiary accent */\n:host ::ng-deep .erd-container svg .node.relationship-connected .entity-rect.relationship-highlighted {\n stroke: var(--mj-status-success) !important;\n stroke-width: 3px !important;\n filter: drop-shadow(0 0 6px color-mix(in srgb, var(--mj-status-success) 50%, transparent));\n}\n\n:host ::ng-deep .erd-container svg .node.relationship-connected .entity-header {\n fill: var(--mj-status-success) !important;\n}\n\n:host ::ng-deep .erd-container svg .node.relationship-connected .entity-header-bottom {\n fill: var(--mj-status-success) !important;\n}\n\n:host ::ng-deep .erd-container svg .node.entity-connections-highlighted .entity-rect.connection-highlighted {\n stroke: var(--mj-status-warning) !important;\n stroke-width: 3px !important;\n filter: drop-shadow(0 0 6px color-mix(in srgb, var(--mj-status-warning) 50%, transparent));\n opacity: 0.9;\n}\n\n:host ::ng-deep .erd-container svg .node.entity-connections-highlighted .entity-header {\n fill: var(--mj-status-warning) !important;\n opacity: 0.8;\n}\n\n:host ::ng-deep .erd-container svg .node.entity-connections-highlighted .entity-header-bottom {\n fill: var(--mj-status-warning) !important;\n opacity: 0.8;\n}\n\n/* Self-referencing links \u2014 distinct green accent */\n:host ::ng-deep .erd-container svg .link-group.self-reference .link-label-bg {\n fill: color-mix(in srgb, var(--mj-status-success) 15%, var(--mj-bg-surface-card));\n stroke: var(--mj-status-success);\n}\n\n:host ::ng-deep .erd-container svg .link-group.self-reference .link-label {\n fill: color-mix(in srgb, var(--mj-status-success) 80%, var(--mj-text-primary));\n}\n\n/* Link styles */\n:host ::ng-deep .erd-container svg .link-group .link {\n stroke-width: 2px;\n transition: all 0.2s ease;\n cursor: pointer;\n}\n\n:host ::ng-deep .erd-container svg .link-group .link:hover {\n stroke-width: 3px;\n stroke: var(--mj-status-success);\n opacity: 1;\n}\n\n:host ::ng-deep .erd-container svg .link-group .link.highlighted {\n stroke: var(--mj-status-success) !important;\n stroke-width: 4px !important;\n opacity: 1 !important;\n filter: drop-shadow(0 0 6px color-mix(in srgb, var(--mj-status-success) 60%, transparent));\n}\n\n:host ::ng-deep .erd-container svg .link-group .link-label {\n font-family: Roboto, -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;\n opacity: 0.8;\n pointer-events: none;\n transition: opacity 0.2s ease;\n}\n\n:host ::ng-deep .erd-container svg .link-group .link-label.highlighted {\n opacity: 1;\n font-weight: bold;\n fill: var(--mj-status-success);\n}\n\n:host ::ng-deep .erd-container svg .link-group.highlighted {\n z-index: 10;\n}\n\n/* Field hover */\n:host ::ng-deep .erd-container svg .field-group .field-bg {\n transition: fill 0.2s;\n}\n\n:host ::ng-deep .erd-container svg .field-group:hover .field-bg {\n fill: color-mix(in srgb, var(--mj-brand-primary) 20%, var(--mj-bg-surface-card)) !important;\n}\n\n"] }]
1902
- }], null, { erdContainer: [{
1903
- type: ViewChild,
1904
- args: ['erdContainer', { static: false }]
1905
- }], nodes: [{
1037
+ args: [{ standalone: false, selector: 'mj-erd-diagram', changeDetection: ChangeDetectionStrategy.OnPush, template: "<!-- \u2500\u2500 CHROME \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n@if (showHeader) {\n <header class=\"erd-chrome\">\n <div class=\"erd-title-group\">\n <div class=\"erd-title\">{{ headerTitle }}</div>\n <div class=\"erd-subtitle\">\n <span class=\"erd-count-pill\">{{ visibleCount }}</span>\n <span class=\"erd-count-label\">of {{ totalCount }} entities</span>\n <span class=\"erd-dot\">\u00B7</span>\n <span>{{ layout.edges.length }} relationships</span>\n </div>\n </div>\n\n <label class=\"erd-search\">\n <i class=\"fa-solid fa-magnifying-glass\" aria-hidden=\"true\"></i>\n <input\n type=\"text\"\n [value]=\"searchQuery\"\n (input)=\"onSearchChange($any($event.target).value)\"\n placeholder=\"Search entities, schemas, fields\u2026\"\n spellcheck=\"false\"\n aria-label=\"Search entities\" />\n @if (searchQuery) {\n <button type=\"button\" class=\"erd-search-clear\" (click)=\"onClearSearch()\" aria-label=\"Clear search\">\u00D7</button>\n }\n </label>\n\n <div class=\"erd-chrome-right\">\n @if (schemaChips.length > 1) {\n <div class=\"erd-schema-chips\" role=\"group\" aria-label=\"Filter by schema\">\n @for (chip of schemaChips; track trackByChip($index, chip)) {\n <button\n type=\"button\"\n class=\"erd-chip\"\n [class.active]=\"chip.active\"\n (click)=\"onSchemaChipToggle(chip.name)\"\n [attr.aria-pressed]=\"chip.active\">\n <span class=\"erd-chip-label\">{{ chip.name }}</span>\n <span class=\"erd-chip-count\">{{ chip.count }}</span>\n </button>\n }\n </div>\n }\n\n <div class=\"erd-toolbar\" role=\"group\" aria-label=\"Layout mode\">\n <button\n type=\"button\"\n [class.active]=\"activeLayout === 'schema-grid'\"\n (click)=\"setLayoutAlgorithm('schema-grid')\"\n title=\"Schema grid \u2014 grouped by schema, predictable\"\n aria-label=\"Schema-grid layout\">\n <i class=\"fa-solid fa-table-cells\" aria-hidden=\"true\"></i>\n </button>\n <button\n type=\"button\"\n [class.active]=\"activeLayout === 'dagre'\"\n (click)=\"setLayoutAlgorithm('dagre')\"\n title=\"Hierarchical \u2014 classic ERD, relationship-driven\"\n aria-label=\"Hierarchical layout\">\n <i class=\"fa-solid fa-sitemap\" aria-hidden=\"true\"></i>\n </button>\n </div>\n\n <div class=\"erd-toolbar\" role=\"group\" aria-label=\"Zoom controls\">\n <button type=\"button\" (click)=\"zoomBy(1.2)\" title=\"Zoom in\" aria-label=\"Zoom in\">\n <i class=\"fa-solid fa-magnifying-glass-plus\" aria-hidden=\"true\"></i>\n </button>\n <button type=\"button\" (click)=\"zoomBy(0.83)\" title=\"Zoom out\" aria-label=\"Zoom out\">\n <i class=\"fa-solid fa-magnifying-glass-minus\" aria-hidden=\"true\"></i>\n </button>\n <button type=\"button\" (click)=\"fitToView()\" title=\"Fit to view\" aria-label=\"Fit to view\">\n <i class=\"fa-solid fa-expand\" aria-hidden=\"true\"></i>\n </button>\n <span class=\"erd-zoom-val\">{{ zoomPercent }}</span>\n </div>\n </div>\n </header>\n}\n\n<!-- \u2500\u2500 CANVAS \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n<!-- Pan handling is window-level once mousedown fires (see component) so\n dragging continues even if the cursor leaves the canvas-wrap. -->\n<div class=\"erd-canvas-wrap\">\n\n <svg\n #canvasRef\n class=\"erd-canvas\"\n [attr.data-panning]=\"panning ? 'true' : null\"\n (mousedown)=\"onBackgroundMouseDown($event)\"\n (wheel)=\"onWheel($event)\">\n\n <defs>\n <marker id=\"erd-arrow\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto\">\n <path d=\"M 0 0 L 10 5 L 0 10 z\" class=\"erd-marker\" />\n </marker>\n <marker id=\"erd-arrow-active\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto\">\n <path d=\"M 0 0 L 10 5 L 0 10 z\" class=\"erd-marker erd-marker-active\" />\n </marker>\n <marker id=\"erd-many\" viewBox=\"-10 -6 14 12\" refX=\"-8\" refY=\"0\" markerWidth=\"14\" markerHeight=\"14\" orient=\"auto\">\n <path d=\"M 0 -5 L -8 0 L 0 5 M -8 0 L 4 0\" class=\"erd-marker-stroke\" />\n </marker>\n <marker id=\"erd-many-active\" viewBox=\"-10 -6 14 12\" refX=\"-8\" refY=\"0\" markerWidth=\"14\" markerHeight=\"14\" orient=\"auto\">\n <path d=\"M 0 -5 L -8 0 L 0 5 M -8 0 L 4 0\" class=\"erd-marker-stroke erd-marker-active\" />\n </marker>\n </defs>\n\n <g [attr.transform]=\"'translate(' + transform.x + ' ' + transform.y + ') scale(' + transform.k + ')'\">\n\n <!-- Schema bands -->\n @if ((config.showSchemaBands ?? true)) {\n @for (band of layout.bands; track trackByBand($index, band)) {\n <g class=\"erd-band\">\n <rect\n [attr.x]=\"band.x\" [attr.y]=\"band.y\"\n [attr.width]=\"band.width\" [attr.height]=\"band.height\"\n rx=\"16\"\n class=\"erd-band-rect\" />\n <text\n [attr.x]=\"band.x + band.width / 2\"\n [attr.y]=\"band.y + 32\"\n text-anchor=\"middle\"\n class=\"erd-band-title\">\n {{ band.schemaName }}\n </text>\n </g>\n }\n }\n\n <!-- Edges -->\n <g class=\"erd-edges\">\n @for (edge of layout.edges; track trackByEdge($index, edge)) {\n <path\n [attr.d]=\"edgePath(edge)\"\n fill=\"none\"\n class=\"erd-edge\"\n [class.erd-edge-active]=\"isEdgeActive(edge)\"\n [class.erd-edge-dimmed]=\"isEdgeDimmed(edge)\"\n [attr.marker-start]=\"(config.crowsFoot ?? true) ? (isEdgeActive(edge) ? 'url(#erd-many-active)' : 'url(#erd-many)') : null\"\n [attr.marker-end]=\"isEdgeActive(edge) ? 'url(#erd-arrow-active)' : 'url(#erd-arrow)'\" />\n }\n </g>\n\n <!-- Edge labels \u2014 FK field names rendered along edges. Only shown\n when zoom is high enough for the text to be legible, and only\n when config.showRelationshipLabels resolves true. -->\n @if (showEdgeLabels) {\n <g class=\"erd-edge-labels\">\n @for (edge of layout.edges; track trackByEdge($index, edge)) {\n @let pos = edgeLabelPosition(edge);\n @if (pos) {\n <g\n class=\"erd-edge-label\"\n [class.erd-edge-label-active]=\"isEdgeActive(edge)\"\n [class.erd-edge-label-dimmed]=\"isEdgeDimmed(edge)\">\n <rect\n [attr.x]=\"pos.x - pos.w / 2 - 4\"\n [attr.y]=\"pos.y - 8\"\n [attr.width]=\"pos.w + 8\"\n height=\"16\"\n rx=\"3\"\n class=\"erd-edge-label-bg\" />\n <text\n [attr.x]=\"pos.x\"\n [attr.y]=\"pos.y + 4\"\n text-anchor=\"middle\"\n class=\"erd-edge-label-text\">{{ edge.sourceField.name }}</text>\n </g>\n }\n }\n </g>\n }\n\n <!-- Nodes -->\n <g class=\"erd-nodes\">\n @for (node of layout.nodes; track trackByNode($index, node)) {\n <g\n class=\"erd-node\"\n [attr.transform]=\"'translate(' + node.x + ' ' + node.y + ')'\"\n [class.erd-node-focus]=\"isFocus(node.id)\"\n [class.erd-node-neighbour]=\"isNeighbour(node.id)\"\n [class.erd-node-dimmed]=\"isDimmed(node.id)\"\n (click)=\"onNodeClick(node, $event)\"\n (mouseenter)=\"onNodeMouseEnter(node, $event)\"\n (mouseleave)=\"onNodeMouseLeave(node)\">\n\n <!-- Card shadow -->\n <rect class=\"erd-node-shadow\"\n [attr.width]=\"node.width\" [attr.height]=\"node.height\"\n x=\"0\" y=\"2\" rx=\"8\" />\n\n <!-- Card body -->\n <rect class=\"erd-node-body\"\n [attr.width]=\"node.width\" [attr.height]=\"node.height\"\n x=\"0\" y=\"0\" rx=\"8\" />\n\n <!-- Header -->\n <rect class=\"erd-node-header\"\n x=\"0\" y=\"0\"\n [attr.width]=\"node.width\" height=\"36\"\n rx=\"6\" />\n <foreignObject x=\"14\" y=\"8\" [attr.width]=\"node.width - 28\" height=\"22\">\n <div xmlns=\"http://www.w3.org/1999/xhtml\" class=\"erd-node-header-content\">\n <span class=\"erd-node-name\" [title]=\"node.name\">{{ node.name }}</span>\n @if (node.customData && $any(node.customData).rows != null) {\n <span class=\"erd-node-rows\">{{ formatCount($any(node.customData).rows) }}</span>\n }\n </div>\n </foreignObject>\n\n <!-- Separator -->\n <line class=\"erd-node-separator\"\n x1=\"0\" [attr.x2]=\"node.width\" y1=\"36\" y2=\"36\" />\n\n <!-- Fields -->\n @for (field of node.visibleFields; track trackByField($index, field); let i = $index) {\n <foreignObject x=\"0\" [attr.y]=\"36 + i * 22\" [attr.width]=\"node.width\" height=\"22\">\n <div xmlns=\"http://www.w3.org/1999/xhtml\"\n class=\"erd-field-row\"\n [class.erd-field-pk]=\"field.isPrimaryKey\"\n [class.erd-field-fk]=\"!field.isPrimaryKey && field.relatedNodeId\">\n @if (field.isPrimaryKey) {\n <span class=\"erd-field-chip erd-field-chip-pk\">PK</span>\n } @else if (field.relatedNodeId) {\n <span class=\"erd-field-chip erd-field-chip-fk\">FK</span>\n }\n <span class=\"erd-field-name\" [title]=\"field.name\">{{ field.name }}</span>\n @if (field.type) {\n <span class=\"erd-field-type\" [title]=\"field.type\">{{ field.type }}</span>\n }\n </div>\n </foreignObject>\n }\n\n <!-- +N more toggle -->\n @if (node.hasMore) {\n <foreignObject\n x=\"4\"\n [attr.y]=\"36 + node.visibleFields.length * 22 + 6\"\n [attr.width]=\"node.width - 8\"\n height=\"14\"\n (click)=\"onToggleExpand(node.id, $event)\">\n <div xmlns=\"http://www.w3.org/1999/xhtml\" class=\"erd-field-more\">\n + {{ node.fields.length - node.visibleFields.length }} more fields\n </div>\n </foreignObject>\n }\n\n <!-- Outline frame \u2014 drawn last so the focus/neighbour stroke\n sits above the header instead of being clipped by it. -->\n <rect class=\"erd-node-frame\"\n [attr.width]=\"node.width\" [attr.height]=\"node.height\"\n x=\"0\" y=\"0\" rx=\"8\" />\n </g>\n }\n </g>\n </g>\n </svg>\n\n <!-- Focus details are rendered by the consumer's side panel\n (mj-entity-details inside mj-erd-composite); the in-canvas\n floating focus card was removed as it duplicated that content. -->\n\n <!-- \u2500\u2500 Empty state \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n @if (visibleCount === 0) {\n <div class=\"erd-empty\">\n <div class=\"erd-empty-icon\">\n <i [class]=\"config.emptyStateIcon || 'fa-solid fa-diagram-project'\" aria-hidden=\"true\"></i>\n </div>\n <div class=\"erd-empty-title\">\n {{ totalCount === 0 ? (config.emptyStateMessage || 'No entities to display') : 'No entities match' }}\n </div>\n @if (totalCount > 0) {\n <div class=\"erd-empty-hint\">Try adjusting schema filters or clearing search</div>\n }\n </div>\n }\n\n <!-- \u2500\u2500 Loading overlay \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n @if (isRefreshing) {\n <div class=\"erd-loading-overlay\">\n <div class=\"erd-loading-content\">\n <i class=\"fa-solid fa-circle-notch fa-spin\" aria-hidden=\"true\"></i>\n <span>Loading\u2026</span>\n </div>\n </div>\n }\n</div>\n", styles: ["/**\n * ERD Diagram Component \u2014 MJ Design Tokens.\n *\n * Uses MJ semantic tokens for all colors so the diagram adapts automatically\n * to light/dark mode via the app's global theme. Per-schema accent colors\n * are OKLCH computed from a hash of the schema name (categorical chart\n * colors, one of the few intentional exceptions to the token rule).\n *\n * Typography: MJ's default UI font inherits; entity/field names use a\n * platform monospace stack so they read as technical identifiers.\n */\n\n/* !important on display + direction keeps consumer classes (e.g. a host\n `.something { display: block }`) from collapsing the flex layout \u2014 without\n this the chrome + canvas-wrap stop being flex items and .erd-canvas-wrap's\n `flex: 1` has no effect, causing the SVG to shrink to its min-height. */\n:host {\n display: flex !important;\n flex-direction: column !important;\n height: 100%;\n width: 100%;\n overflow: hidden;\n background: var(--mj-bg-page);\n color: var(--mj-text-primary);\n --erd-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, \"Liberation Mono\", monospace;\n}\n\n/* \u2550\u2550\u2550 CHROME \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 */\n\n.erd-chrome {\n display: flex;\n align-items: center;\n gap: 24px;\n padding: 14px 24px;\n border-bottom: 1px solid var(--mj-border-default);\n background: var(--mj-bg-surface);\n flex-shrink: 0;\n min-height: 64px;\n}\n\n.erd-title-group {\n display: flex;\n flex-direction: column;\n gap: 2px;\n flex-shrink: 0;\n}\n\n.erd-title {\n font-size: 15px;\n font-weight: 600;\n letter-spacing: -0.01em;\n color: var(--mj-text-primary);\n}\n\n.erd-subtitle {\n display: flex;\n align-items: center;\n gap: 8px;\n font-size: 12px;\n color: var(--mj-text-secondary);\n}\n\n.erd-count-pill {\n font-family: var(--erd-mono);\n font-size: 11px;\n font-weight: 600;\n padding: 2px 8px;\n border-radius: 10px;\n background: color-mix(in srgb, var(--mj-brand-primary) 12%, transparent);\n color: var(--mj-brand-primary);\n line-height: 1.3;\n}\n\n.erd-count-label { color: var(--mj-text-secondary); }\n.erd-dot { color: var(--mj-text-muted); }\n\n.erd-search {\n flex: 1;\n max-width: 440px;\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 8px 12px;\n border: 1px solid var(--mj-border-default);\n border-radius: 8px;\n background: var(--mj-bg-surface-card);\n transition: border-color .12s, background .12s, box-shadow .12s;\n}\n\n.erd-search:focus-within {\n border-color: var(--mj-brand-primary);\n background: var(--mj-bg-surface);\n box-shadow: 0 0 0 3px color-mix(in srgb, var(--mj-brand-primary) 15%, transparent);\n}\n\n.erd-search > i {\n color: var(--mj-text-muted);\n flex-shrink: 0;\n font-size: 13px;\n}\n\n.erd-search input {\n flex: 1;\n border: 0;\n outline: 0;\n background: transparent;\n font: inherit;\n font-size: 13px;\n color: var(--mj-text-primary);\n}\n\n.erd-search input::placeholder { color: var(--mj-text-muted); }\n\n.erd-search-clear {\n border: 0;\n background: transparent;\n color: var(--mj-text-muted);\n font-size: 18px;\n line-height: 1;\n cursor: pointer;\n padding: 0 4px;\n}\n\n.erd-search-clear:hover { color: var(--mj-text-primary); }\n\n.erd-chrome-right {\n display: flex;\n align-items: center;\n gap: 16px;\n margin-left: auto;\n}\n\n/* Schema chips */\n.erd-schema-chips {\n display: flex;\n gap: 4px;\n padding: 3px;\n background: var(--mj-bg-surface-card);\n border: 1px solid var(--mj-border-default);\n border-radius: 10px;\n}\n\n.erd-chip {\n display: flex;\n align-items: center;\n gap: 6px;\n padding: 5px 10px;\n border: 0;\n background: transparent;\n border-radius: 7px;\n font-family: var(--erd-mono);\n font-size: 11px;\n color: var(--mj-text-secondary);\n cursor: pointer;\n transition: background .1s, color .1s;\n}\n\n.erd-chip:hover {\n background: var(--mj-bg-surface-hover);\n color: var(--mj-text-primary);\n}\n\n.erd-chip.active {\n background: var(--mj-bg-surface);\n color: var(--mj-text-primary);\n box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);\n}\n\n.erd-chip-count {\n font-size: 10px;\n padding: 1px 5px;\n border-radius: 6px;\n background: color-mix(in srgb, var(--mj-bg-surface-card) 80%, transparent);\n color: var(--mj-text-muted);\n}\n\n/* Toolbar */\n.erd-toolbar {\n display: flex;\n align-items: center;\n gap: 2px;\n padding: 3px;\n background: var(--mj-bg-surface-card);\n border: 1px solid var(--mj-border-default);\n border-radius: 8px;\n}\n\n.erd-toolbar button {\n width: 28px;\n height: 28px;\n border: 0;\n background: transparent;\n border-radius: 5px;\n color: var(--mj-text-secondary);\n cursor: pointer;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n font-size: 12px;\n transition: background .1s, color .1s;\n}\n\n.erd-toolbar button:hover {\n background: var(--mj-bg-surface-hover);\n color: var(--mj-text-primary);\n}\n\n.erd-toolbar button.active {\n background: color-mix(in srgb, var(--mj-brand-primary) 14%, var(--mj-bg-surface));\n color: var(--mj-brand-primary);\n}\n\n.erd-zoom-val {\n padding: 0 8px;\n font-family: var(--erd-mono);\n font-size: 10px;\n color: var(--mj-text-secondary);\n min-width: 42px;\n text-align: right;\n}\n\n/* \u2550\u2550\u2550 CANVAS \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 */\n\n.erd-canvas-wrap {\n flex: 1;\n position: relative;\n overflow: hidden;\n background: var(--mj-bg-surface-sunken);\n min-height: 300px;\n}\n\n.erd-canvas {\n width: 100%;\n height: 100%;\n user-select: none;\n cursor: grab;\n display: block;\n}\n\n.erd-canvas[data-panning=\"true\"] { cursor: grabbing; }\n\n.erd-canvas text { pointer-events: none; }\n\n/* Schema band \u2014 subtle neutral grouping (no per-schema color) */\n.erd-band-rect {\n fill: color-mix(in srgb, var(--mj-text-primary) 4%, transparent);\n stroke: var(--mj-border-default);\n stroke-width: 1;\n}\n\n.erd-band-title {\n font-family: var(--erd-mono);\n font-size: 13px;\n font-weight: 600;\n letter-spacing: 0.08em;\n text-transform: uppercase;\n fill: var(--mj-text-muted);\n}\n\n/* Edges */\n.erd-edge {\n stroke: var(--mj-border-strong);\n stroke-width: 1.2;\n transition: stroke-width .15s, opacity .15s;\n}\n\n.erd-edge-active {\n stroke: var(--mj-brand-primary);\n stroke-width: 1.8;\n}\n\n.erd-edge-dimmed { opacity: 0.18; }\n\n/* Edge labels \u2014 FK field name on a small chip along the edge.\n Background uses surface so the text is legible over crossings. */\n.erd-edge-label {\n pointer-events: none;\n transition: opacity .15s;\n}\n\n.erd-edge-label-bg {\n fill: var(--mj-bg-surface);\n stroke: var(--mj-border-default);\n stroke-width: 0.75;\n}\n\n.erd-edge-label-text {\n font-family: var(--erd-mono);\n font-size: 11px;\n fill: var(--mj-text-secondary);\n}\n\n.erd-edge-label-active .erd-edge-label-bg {\n stroke: var(--mj-brand-primary);\n}\n\n.erd-edge-label-active .erd-edge-label-text {\n fill: var(--mj-brand-primary);\n}\n\n.erd-edge-label-dimmed { opacity: 0.18; }\n\n/* Edge markers (crow's-foot and arrow) */\n.erd-marker { fill: var(--mj-border-strong); }\n.erd-marker-active,\n.erd-marker-active.erd-marker,\n.erd-marker-active.erd-marker-stroke { fill: var(--mj-brand-primary); stroke: var(--mj-brand-primary); }\n.erd-marker-stroke { stroke: var(--mj-border-strong); stroke-width: 1.4; fill: none; stroke-linecap: round; }\n\n/* Node card */\n.erd-node {\n cursor: pointer;\n transition: opacity .15s;\n}\n\n.erd-node-dimmed { opacity: 0.22; }\n\n.erd-node-shadow {\n fill: color-mix(in srgb, var(--mj-text-primary) 8%, transparent);\n}\n\n.erd-node-body {\n fill: var(--mj-bg-surface);\n}\n\n/* Outline rendered as a separate rect on top of the header so the focus\n stroke is visible all the way around the card (otherwise the header\n fill clips the top edge of the body's stroke). */\n.erd-node-frame {\n fill: none;\n stroke: var(--mj-border-default);\n stroke-width: 1;\n pointer-events: none;\n}\n\n.erd-node-focus .erd-node-frame {\n stroke: var(--mj-brand-primary);\n stroke-width: 2;\n}\n\n.erd-node-neighbour .erd-node-frame {\n stroke-width: 1.5;\n}\n\n.erd-node-header {\n fill: var(--mj-bg-surface-card);\n}\n\n.erd-node-separator {\n stroke: var(--mj-border-default);\n stroke-width: 1;\n}\n\n/* Header content (foreignObject) \u2014 entity name + row count on one line */\n.erd-node-header-content {\n display: flex;\n align-items: center;\n gap: 8px;\n font-family: var(--erd-mono);\n line-height: 22px;\n height: 22px;\n color: var(--mj-text-primary);\n}\n\n.erd-node-name {\n font-size: 13px;\n font-weight: 600;\n flex: 1;\n min-width: 0;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.erd-node-rows {\n font-size: 10px;\n color: var(--mj-text-muted);\n flex-shrink: 0;\n}\n\n/* Field row (foreignObject) \u2014 chip + name + type, all on one line */\n.erd-field-row {\n display: flex;\n align-items: center;\n gap: 8px;\n height: 22px;\n padding: 0 12px 0 8px;\n font-family: var(--erd-mono);\n line-height: 22px;\n box-sizing: border-box;\n}\n\n.erd-field-chip {\n flex-shrink: 0;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 22px;\n height: 14px;\n border-radius: 3px;\n font-size: 8px;\n font-weight: 700;\n letter-spacing: 0.05em;\n line-height: 1;\n}\n\n.erd-field-chip-pk {\n background: color-mix(in srgb, var(--mj-status-warning) 20%, var(--mj-bg-surface));\n color: var(--mj-status-warning-text, var(--mj-status-warning));\n}\n\n.erd-field-chip-fk {\n background: color-mix(in srgb, var(--mj-brand-primary) 18%, var(--mj-bg-surface));\n color: var(--mj-brand-primary);\n}\n\n.erd-field-name {\n flex: 1;\n min-width: 0;\n font-size: 11px;\n color: var(--mj-text-primary);\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.erd-field-type {\n flex-shrink: 0;\n font-size: 10px;\n color: var(--mj-text-muted);\n /* Cap very long type strings so the field name still has room */\n max-width: 45%;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n/* +N more toggle (foreignObject) */\n.erd-field-more {\n display: flex;\n align-items: center;\n justify-content: center;\n width: 100%;\n height: 14px;\n border-radius: 3px;\n background: var(--mj-bg-surface-card);\n opacity: 0.7;\n font-family: inherit;\n font-size: 9px;\n font-weight: 500;\n color: var(--mj-text-secondary);\n cursor: pointer;\n transition: opacity .1s;\n}\n\n.erd-field-more:hover { opacity: 1; }\n\n/* \u2550\u2550\u2550 EMPTY + LOADING \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 */\n\n.erd-empty {\n position: absolute;\n inset: 0;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n gap: 8px;\n pointer-events: none;\n}\n\n.erd-empty-icon {\n font-size: 40px;\n color: var(--mj-text-muted);\n opacity: 0.45;\n}\n\n.erd-empty-title {\n font-size: 15px;\n font-weight: 600;\n color: var(--mj-text-primary);\n}\n\n.erd-empty-hint {\n font-size: 12px;\n color: var(--mj-text-secondary);\n}\n\n.erd-loading-overlay {\n position: absolute;\n inset: 0;\n background: color-mix(in srgb, var(--mj-bg-page) 90%, transparent);\n backdrop-filter: blur(2px);\n display: flex;\n align-items: center;\n justify-content: center;\n z-index: 100;\n}\n\n.erd-loading-content {\n display: flex;\n align-items: center;\n gap: 10px;\n color: var(--mj-text-secondary);\n font-size: 13px;\n}\n\n.erd-loading-content i {\n color: var(--mj-brand-primary);\n font-size: 20px;\n}\n"] }]
1038
+ }], null, { nodes: [{
1906
1039
  type: Input
1907
1040
  }], selectedNodeId: [{
1908
1041
  type: Input
@@ -1958,6 +1091,12 @@ export class ERDDiagramComponent {
1958
1091
  type: Output
1959
1092
  }], stateChange: [{
1960
1093
  type: Output
1094
+ }], canvasRef: [{
1095
+ type: ViewChild,
1096
+ args: ['canvasRef', { static: false }]
1097
+ }], rootClass: [{
1098
+ type: HostBinding,
1099
+ args: ['class.erd-root']
1961
1100
  }] }); })();
1962
- (() => { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassDebugInfo(ERDDiagramComponent, { className: "ERDDiagramComponent", filePath: "src/lib/components/erd-diagram.component.ts", lineNumber: 195 }); })();
1101
+ (() => { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassDebugInfo(ERDDiagramComponent, { className: "ERDDiagramComponent", filePath: "src/lib/components/erd-diagram.component.ts", lineNumber: 86 }); })();
1963
1102
  //# sourceMappingURL=erd-diagram.component.js.map