@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.
- package/dist/__tests__/compute-erd-layout.test.d.ts +2 -0
- package/dist/__tests__/compute-erd-layout.test.d.ts.map +1 -0
- package/dist/__tests__/compute-erd-layout.test.js +207 -0
- package/dist/__tests__/compute-erd-layout.test.js.map +1 -0
- package/dist/lib/components/entity-details/entity-details.component.d.ts +2 -1
- package/dist/lib/components/entity-details/entity-details.component.d.ts.map +1 -1
- package/dist/lib/components/entity-details/entity-details.component.js +7 -7
- package/dist/lib/components/entity-details/entity-details.component.js.map +1 -1
- package/dist/lib/components/entity-filter-panel/entity-filter-panel.component.d.ts +10 -4
- package/dist/lib/components/entity-filter-panel/entity-filter-panel.component.d.ts.map +1 -1
- package/dist/lib/components/entity-filter-panel/entity-filter-panel.component.js +60 -72
- package/dist/lib/components/entity-filter-panel/entity-filter-panel.component.js.map +1 -1
- package/dist/lib/components/erd-composite/erd-composite.component.d.ts +4 -1
- package/dist/lib/components/erd-composite/erd-composite.component.d.ts.map +1 -1
- package/dist/lib/components/erd-composite/erd-composite.component.js +22 -8
- package/dist/lib/components/erd-composite/erd-composite.component.js.map +1 -1
- package/dist/lib/components/erd-diagram.component.d.ts +169 -443
- package/dist/lib/components/erd-diagram.component.d.ts.map +1 -1
- package/dist/lib/components/erd-diagram.component.js +916 -1777
- package/dist/lib/components/erd-diagram.component.js.map +1 -1
- package/dist/lib/components/mj-entity-erd.component.d.ts +3 -2
- package/dist/lib/components/mj-entity-erd.component.d.ts.map +1 -1
- package/dist/lib/components/mj-entity-erd.component.js +6 -6
- package/dist/lib/components/mj-entity-erd.component.js.map +1 -1
- package/dist/lib/entity-relationship-diagram.module.d.ts +2 -1
- package/dist/lib/entity-relationship-diagram.module.d.ts.map +1 -1
- package/dist/lib/entity-relationship-diagram.module.js +9 -3
- package/dist/lib/entity-relationship-diagram.module.js.map +1 -1
- package/dist/lib/interfaces/erd-types.d.ts +38 -7
- package/dist/lib/interfaces/erd-types.d.ts.map +1 -1
- package/dist/lib/interfaces/erd-types.js.map +1 -1
- package/dist/lib/layout/compute-dagre-layout.d.ts +32 -0
- package/dist/lib/layout/compute-dagre-layout.d.ts.map +1 -0
- package/dist/lib/layout/compute-dagre-layout.js +168 -0
- package/dist/lib/layout/compute-dagre-layout.js.map +1 -0
- package/dist/lib/layout/compute-erd-layout.d.ts +103 -0
- package/dist/lib/layout/compute-erd-layout.d.ts.map +1 -0
- package/dist/lib/layout/compute-erd-layout.js +254 -0
- package/dist/lib/layout/compute-erd-layout.js.map +1 -0
- package/package.json +6 -5
|
@@ -1,1908 +1,1041 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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 = ["
|
|
6
|
-
function
|
|
7
|
-
const
|
|
8
|
-
i0.ɵɵelementStart(0, "
|
|
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, "
|
|
12
|
-
i0.ɵɵ
|
|
13
|
-
i0.ɵɵ
|
|
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(
|
|
16
|
-
i0.ɵɵ
|
|
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(
|
|
20
|
-
i0.ɵɵ
|
|
21
|
-
i0.ɵɵelement(9, "span", 11);
|
|
65
|
+
i0.ɵɵelementStart(7, "span", 23);
|
|
66
|
+
i0.ɵɵtext(8);
|
|
22
67
|
i0.ɵɵelementEnd();
|
|
23
|
-
i0.ɵɵelementStart(
|
|
24
|
-
i0.ɵɵ
|
|
25
|
-
i0.ɵɵ
|
|
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
|
|
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.ɵɵ
|
|
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
|
|
33
|
-
i0.ɵɵ
|
|
34
|
-
|
|
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
|
-
|
|
37
|
-
i0.ɵɵ
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
// INPUTS
|
|
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
|
-
*
|
|
400
|
-
*
|
|
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
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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
|
-
|
|
415
|
-
|
|
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
|
-
|
|
430
|
-
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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['
|
|
451
|
-
this.
|
|
452
|
-
this.
|
|
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
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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
|
-
|
|
480
|
-
|
|
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
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
if (this.
|
|
493
|
-
|
|
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
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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
|
-
*
|
|
524
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
529
|
-
if (
|
|
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
|
-
|
|
562
|
-
const
|
|
563
|
-
this.
|
|
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
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
606
|
-
|
|
607
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
616
|
-
*
|
|
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
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
this.
|
|
622
|
-
this.
|
|
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
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
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
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
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
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
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
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
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
|
-
|
|
747
|
-
|
|
748
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
805
|
-
const
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
const
|
|
814
|
-
const
|
|
815
|
-
const
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
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
|
-
|
|
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
|
-
|
|
879
|
-
|
|
880
|
-
this.
|
|
881
|
-
this.
|
|
882
|
-
this.
|
|
883
|
-
this.
|
|
884
|
-
this.
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
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
|
-
|
|
900
|
-
|
|
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
|
-
|
|
908
|
-
|
|
678
|
+
fitToView() {
|
|
679
|
+
const svg = this.canvasRef?.nativeElement;
|
|
680
|
+
if (!svg)
|
|
909
681
|
return;
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
const
|
|
914
|
-
|
|
915
|
-
|
|
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.
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
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
|
-
|
|
931
|
-
|
|
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
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
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
|
-
|
|
955
|
-
|
|
956
|
-
this.
|
|
957
|
-
|
|
958
|
-
|
|
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
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
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
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
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
|
-
|
|
767
|
+
this.updateHighlightSet();
|
|
768
|
+
this.updateSelectedEntity();
|
|
769
|
+
this.cdr.markForCheck();
|
|
1012
770
|
}
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
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
|
-
|
|
1035
|
-
this.
|
|
1036
|
-
|
|
1037
|
-
this.
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
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
|
-
*
|
|
1062
|
-
*
|
|
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
|
-
|
|
1065
|
-
const
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
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
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
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
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
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
|
-
/**
|
|
1461
|
-
|
|
1462
|
-
|
|
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
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
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
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
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
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1724
|
-
|
|
1725
|
-
if (
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
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
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
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
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1779
|
-
|
|
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
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
.
|
|
1791
|
-
|
|
1792
|
-
.
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
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
|
-
|
|
1824
|
-
|
|
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
|
-
|
|
1856
|
-
|
|
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
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
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.
|
|
1886
|
-
} },
|
|
1887
|
-
i0.ɵɵ
|
|
1888
|
-
|
|
1889
|
-
i0.ɵɵ
|
|
1890
|
-
i0.ɵɵconditionalCreate(
|
|
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.
|
|
1895
|
-
i0.ɵɵadvance(
|
|
1896
|
-
i0.ɵɵ
|
|
1897
|
-
|
|
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, {
|
|
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:
|
|
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
|