@memberjunction/ng-clustering 0.0.1 → 5.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +441 -27
  2. package/dist/__tests__/clustering-service.test.d.ts +2 -0
  3. package/dist/__tests__/clustering-service.test.d.ts.map +1 -0
  4. package/dist/__tests__/clustering-service.test.js +487 -0
  5. package/dist/__tests__/clustering-service.test.js.map +1 -0
  6. package/dist/__tests__/clustering-types.test.d.ts +2 -0
  7. package/dist/__tests__/clustering-types.test.d.ts.map +1 -0
  8. package/dist/__tests__/clustering-types.test.js +254 -0
  9. package/dist/__tests__/clustering-types.test.js.map +1 -0
  10. package/dist/__tests__/index.test.d.ts +2 -0
  11. package/dist/__tests__/index.test.d.ts.map +1 -0
  12. package/dist/__tests__/index.test.js +7 -0
  13. package/dist/__tests__/index.test.js.map +1 -0
  14. package/dist/__tests__/scatter-component-logic.test.d.ts +2 -0
  15. package/dist/__tests__/scatter-component-logic.test.d.ts.map +1 -0
  16. package/dist/__tests__/scatter-component-logic.test.js +494 -0
  17. package/dist/__tests__/scatter-component-logic.test.js.map +1 -0
  18. package/dist/lib/cluster-config-panel.component.d.ts +274 -0
  19. package/dist/lib/cluster-config-panel.component.d.ts.map +1 -0
  20. package/dist/lib/cluster-config-panel.component.js +732 -0
  21. package/dist/lib/cluster-config-panel.component.js.map +1 -0
  22. package/dist/lib/cluster-scatter.component.d.ts +514 -0
  23. package/dist/lib/cluster-scatter.component.d.ts.map +1 -0
  24. package/dist/lib/cluster-scatter.component.js +1357 -0
  25. package/dist/lib/cluster-scatter.component.js.map +1 -0
  26. package/dist/lib/clustering.module.d.ts +11 -0
  27. package/dist/lib/clustering.module.d.ts.map +1 -0
  28. package/dist/lib/clustering.module.js +34 -0
  29. package/dist/lib/clustering.module.js.map +1 -0
  30. package/dist/lib/clustering.service.d.ts +43 -0
  31. package/dist/lib/clustering.service.d.ts.map +1 -0
  32. package/dist/lib/clustering.service.js +253 -0
  33. package/dist/lib/clustering.service.js.map +1 -0
  34. package/dist/lib/clustering.types.d.ts +271 -0
  35. package/dist/lib/clustering.types.d.ts.map +1 -0
  36. package/dist/lib/clustering.types.js +74 -0
  37. package/dist/lib/clustering.types.js.map +1 -0
  38. package/dist/public-api.d.ts +6 -0
  39. package/dist/public-api.d.ts.map +1 -0
  40. package/dist/public-api.js +11 -0
  41. package/dist/public-api.js.map +1 -0
  42. package/package.json +40 -6
@@ -0,0 +1,1357 @@
1
+ /**
2
+ * @fileoverview SVG scatter plot component for cluster visualization.
3
+ *
4
+ * `ClusterScatterComponent` renders an interactive 2D scatter plot where each
5
+ * dot represents a clustered entity record. It supports:
6
+ *
7
+ * - **Zoom / pan** via mouse wheel and drag (SVG viewBox manipulation)
8
+ * - **Tooltips** on hover showing point metadata
9
+ * - **Legend** overlay in the bottom-left corner
10
+ * - **Radial gradient glow** per cluster centroid
11
+ * - **Cancelable `BeforeXXX` events** so consumers can intercept interactions
12
+ * - **Programmatic selection and zoom** via public methods
13
+ * - **Full customization** of dot radius, opacity, color palette, and more
14
+ *
15
+ * @example
16
+ * ```html
17
+ * <mj-cluster-scatter
18
+ * [Points]="result.Points"
19
+ * [Clusters]="result.Clusters"
20
+ * [DotRadius]="6"
21
+ * [ShowTooltip]="true"
22
+ * [ColorPalette]="['#ff0000','#00ff00','#0000ff']"
23
+ * (BeforePointClick)="onBeforeClick($event)"
24
+ * (PointClicked)="onPointClicked($event)"
25
+ * (AfterClusteringComplete)="onClustered($event)"
26
+ * (SelectionChanged)="onSelectionChanged($event)">
27
+ * </mj-cluster-scatter>
28
+ * ```
29
+ */
30
+ import { Component, Input, Output, EventEmitter, ElementRef, ChangeDetectorRef, ViewChild, ViewEncapsulation, inject, } from '@angular/core';
31
+ import { CLUSTER_COLORS, } from './clustering.types';
32
+ import * as i0 from "@angular/core";
33
+ const _c0 = ["svgElement"];
34
+ const _forTrack0 = ($index, $item) => $item.Key;
35
+ function ClusterScatterComponent_Conditional_1_Template(rf, ctx) { if (rf & 1) {
36
+ i0.ɵɵelementStart(0, "div", 2)(1, "div", 8);
37
+ i0.ɵɵelement(2, "i", 9);
38
+ i0.ɵɵelementStart(3, "span");
39
+ i0.ɵɵtext(4, "Computing clusters...");
40
+ i0.ɵɵelementEnd()()();
41
+ } }
42
+ function ClusterScatterComponent_Conditional_2_Template(rf, ctx) { if (rf & 1) {
43
+ i0.ɵɵelementStart(0, "div", 3);
44
+ i0.ɵɵelement(1, "i", 10);
45
+ i0.ɵɵelementStart(2, "p");
46
+ i0.ɵɵtext(3, "No data to display");
47
+ i0.ɵɵelementEnd();
48
+ i0.ɵɵelementStart(4, "p", 11);
49
+ i0.ɵɵtext(5, "Configure and run clustering to see results");
50
+ i0.ɵɵelementEnd()();
51
+ } }
52
+ function ClusterScatterComponent_Conditional_3_For_4_Template(rf, ctx) { if (rf & 1) {
53
+ i0.ɵɵnamespaceSVG();
54
+ i0.ɵɵelementStart(0, "radialGradient", 13);
55
+ i0.ɵɵelement(1, "stop", 16)(2, "stop", 17);
56
+ i0.ɵɵelementEnd();
57
+ } if (rf & 2) {
58
+ const centroid_r3 = ctx.$implicit;
59
+ i0.ɵɵattribute("id", "cluster-glow-" + centroid_r3.ClusterId);
60
+ i0.ɵɵadvance();
61
+ i0.ɵɵattribute("stop-color", centroid_r3.Color);
62
+ i0.ɵɵadvance();
63
+ i0.ɵɵattribute("stop-color", centroid_r3.Color);
64
+ } }
65
+ function ClusterScatterComponent_Conditional_3_For_7_Template(rf, ctx) { if (rf & 1) {
66
+ i0.ɵɵnamespaceSVG();
67
+ i0.ɵɵelement(0, "line");
68
+ } if (rf & 2) {
69
+ const y_r4 = ctx.$implicit;
70
+ i0.ɵɵattribute("x1", 0)("y1", y_r4)("x2", 1000)("y2", y_r4);
71
+ } }
72
+ function ClusterScatterComponent_Conditional_3_For_9_Template(rf, ctx) { if (rf & 1) {
73
+ i0.ɵɵnamespaceSVG();
74
+ i0.ɵɵelement(0, "line");
75
+ } if (rf & 2) {
76
+ const x_r5 = ctx.$implicit;
77
+ i0.ɵɵattribute("x1", x_r5)("y1", 0)("x2", x_r5)("y2", 700);
78
+ } }
79
+ function ClusterScatterComponent_Conditional_3_For_11_Template(rf, ctx) { if (rf & 1) {
80
+ i0.ɵɵnamespaceSVG();
81
+ i0.ɵɵelement(0, "circle");
82
+ } if (rf & 2) {
83
+ const centroid_r6 = ctx.$implicit;
84
+ i0.ɵɵattribute("cx", centroid_r6.Cx)("cy", centroid_r6.Cy)("r", centroid_r6.R)("fill", "url(#cluster-glow-" + centroid_r6.ClusterId + ")");
85
+ } }
86
+ function ClusterScatterComponent_Conditional_3_For_13_Conditional_0_Template(rf, ctx) { if (rf & 1) {
87
+ i0.ɵɵnamespaceSVG();
88
+ i0.ɵɵelementStart(0, "text", 18);
89
+ i0.ɵɵtext(1);
90
+ i0.ɵɵelementEnd();
91
+ } if (rf & 2) {
92
+ const centroid_r7 = i0.ɵɵnextContext().$implicit;
93
+ i0.ɵɵattribute("x", centroid_r7.Cx)("y", centroid_r7.Cy - centroid_r7.R - 8)("fill", centroid_r7.Color);
94
+ i0.ɵɵadvance();
95
+ i0.ɵɵtextInterpolate(ctx);
96
+ } }
97
+ function ClusterScatterComponent_Conditional_3_For_13_Template(rf, ctx) { if (rf & 1) {
98
+ i0.ɵɵconditionalCreate(0, ClusterScatterComponent_Conditional_3_For_13_Conditional_0_Template, 2, 4, ":svg:text", 18);
99
+ } if (rf & 2) {
100
+ let tmp_12_0;
101
+ const centroid_r7 = ctx.$implicit;
102
+ const ctx_r1 = i0.ɵɵnextContext(2);
103
+ i0.ɵɵconditional((tmp_12_0 = ctx_r1.GetClusterLabelById(centroid_r7.ClusterId)) ? 0 : -1, tmp_12_0);
104
+ } }
105
+ function ClusterScatterComponent_Conditional_3_For_15_Template(rf, ctx) { if (rf & 1) {
106
+ const _r8 = i0.ɵɵgetCurrentView();
107
+ i0.ɵɵnamespaceSVG();
108
+ i0.ɵɵelementStart(0, "circle", 19);
109
+ i0.ɵɵlistener("mouseenter", function ClusterScatterComponent_Conditional_3_For_15_Template_circle_mouseenter_0_listener($event) { const point_r9 = i0.ɵɵrestoreView(_r8).$implicit; const ctx_r1 = i0.ɵɵnextContext(2); return i0.ɵɵresetView(ctx_r1.OnPointMouseEnter(point_r9, $event)); })("mouseleave", function ClusterScatterComponent_Conditional_3_For_15_Template_circle_mouseleave_0_listener() { i0.ɵɵrestoreView(_r8); const ctx_r1 = i0.ɵɵnextContext(2); return i0.ɵɵresetView(ctx_r1.OnPointMouseLeave()); })("click", function ClusterScatterComponent_Conditional_3_For_15_Template_circle_click_0_listener($event) { const point_r9 = i0.ɵɵrestoreView(_r8).$implicit; const ctx_r1 = i0.ɵɵnextContext(2); ctx_r1.OnPointClick(point_r9); return i0.ɵɵresetView($event.stopPropagation()); });
110
+ i0.ɵɵelementEnd();
111
+ } if (rf & 2) {
112
+ const point_r9 = ctx.$implicit;
113
+ const ctx_r1 = i0.ɵɵnextContext(2);
114
+ i0.ɵɵattribute("cx", point_r9.X)("cy", point_r9.Y)("r", ctx_r1.GetPointRadius(point_r9))("fill", ctx_r1.GetPointColor(point_r9))("opacity", ctx_r1.GetPointOpacity(point_r9));
115
+ } }
116
+ function ClusterScatterComponent_Conditional_3_For_17_Conditional_0_Template(rf, ctx) { if (rf & 1) {
117
+ i0.ɵɵnamespaceSVG();
118
+ i0.ɵɵelement(0, "circle", 20);
119
+ } if (rf & 2) {
120
+ const point_r10 = i0.ɵɵnextContext().$implicit;
121
+ const ctx_r1 = i0.ɵɵnextContext(2);
122
+ i0.ɵɵattribute("cx", point_r10.X)("cy", point_r10.Y)("r", ctx_r1.HighlightRadius + 2)("stroke", ctx_r1.GetPointColor(point_r10));
123
+ } }
124
+ function ClusterScatterComponent_Conditional_3_For_17_Template(rf, ctx) { if (rf & 1) {
125
+ i0.ɵɵconditionalCreate(0, ClusterScatterComponent_Conditional_3_For_17_Conditional_0_Template, 1, 4, ":svg:circle", 20);
126
+ } if (rf & 2) {
127
+ const point_r10 = ctx.$implicit;
128
+ const ctx_r1 = i0.ɵɵnextContext(2);
129
+ i0.ɵɵconditional(ctx_r1.IsSelected(point_r10) ? 0 : -1);
130
+ } }
131
+ function ClusterScatterComponent_Conditional_3_Conditional_18_For_1_Conditional_0_Template(rf, ctx) { if (rf & 1) {
132
+ i0.ɵɵnamespaceSVG();
133
+ i0.ɵɵelement(0, "circle", 21);
134
+ } if (rf & 2) {
135
+ const point_r11 = i0.ɵɵnextContext().$implicit;
136
+ const ctx_r1 = i0.ɵɵnextContext(3);
137
+ i0.ɵɵattribute("cx", point_r11.X)("cy", point_r11.Y)("r", ctx_r1.HighlightRadius + 4)("stroke", ctx_r1.GetPointColor(point_r11));
138
+ } }
139
+ function ClusterScatterComponent_Conditional_3_Conditional_18_For_1_Template(rf, ctx) { if (rf & 1) {
140
+ i0.ɵɵconditionalCreate(0, ClusterScatterComponent_Conditional_3_Conditional_18_For_1_Conditional_0_Template, 1, 4, ":svg:circle", 21);
141
+ } if (rf & 2) {
142
+ const point_r11 = ctx.$implicit;
143
+ const ctx_r1 = i0.ɵɵnextContext(3);
144
+ i0.ɵɵconditional(ctx_r1.IsHighlighted(point_r11) ? 0 : -1);
145
+ } }
146
+ function ClusterScatterComponent_Conditional_3_Conditional_18_Template(rf, ctx) { if (rf & 1) {
147
+ i0.ɵɵrepeaterCreate(0, ClusterScatterComponent_Conditional_3_Conditional_18_For_1_Template, 1, 1, null, null, i0.ɵɵcomponentInstance().TrackPointBy, true);
148
+ } if (rf & 2) {
149
+ const ctx_r1 = i0.ɵɵnextContext(2);
150
+ i0.ɵɵrepeater(ctx_r1.Points);
151
+ } }
152
+ function ClusterScatterComponent_Conditional_3_Template(rf, ctx) { if (rf & 1) {
153
+ const _r1 = i0.ɵɵgetCurrentView();
154
+ i0.ɵɵnamespaceSVG();
155
+ i0.ɵɵelementStart(0, "svg", 12, 0);
156
+ i0.ɵɵlistener("wheel", function ClusterScatterComponent_Conditional_3_Template_svg_wheel_0_listener($event) { i0.ɵɵrestoreView(_r1); const ctx_r1 = i0.ɵɵnextContext(); return i0.ɵɵresetView(ctx_r1.OnWheel($event)); })("mousedown", function ClusterScatterComponent_Conditional_3_Template_svg_mousedown_0_listener($event) { i0.ɵɵrestoreView(_r1); const ctx_r1 = i0.ɵɵnextContext(); return i0.ɵɵresetView(ctx_r1.OnMouseDown($event)); });
157
+ i0.ɵɵelementStart(2, "defs");
158
+ i0.ɵɵrepeaterCreate(3, ClusterScatterComponent_Conditional_3_For_4_Template, 3, 3, ":svg:radialGradient", 13, i0.ɵɵcomponentInstance().TrackCentroidBy, true);
159
+ i0.ɵɵelementEnd();
160
+ i0.ɵɵelementStart(5, "g", 14);
161
+ i0.ɵɵrepeaterCreate(6, ClusterScatterComponent_Conditional_3_For_7_Template, 1, 4, ":svg:line", null, i0.ɵɵrepeaterTrackByIdentity);
162
+ i0.ɵɵrepeaterCreate(8, ClusterScatterComponent_Conditional_3_For_9_Template, 1, 4, ":svg:line", null, i0.ɵɵrepeaterTrackByIdentity);
163
+ i0.ɵɵelementEnd();
164
+ i0.ɵɵrepeaterCreate(10, ClusterScatterComponent_Conditional_3_For_11_Template, 1, 4, ":svg:circle", null, i0.ɵɵcomponentInstance().TrackCentroidBy, true);
165
+ i0.ɵɵrepeaterCreate(12, ClusterScatterComponent_Conditional_3_For_13_Template, 1, 1, null, null, i0.ɵɵcomponentInstance().TrackCentroidBy, true);
166
+ i0.ɵɵrepeaterCreate(14, ClusterScatterComponent_Conditional_3_For_15_Template, 1, 5, ":svg:circle", 15, i0.ɵɵcomponentInstance().TrackPointBy, true);
167
+ i0.ɵɵrepeaterCreate(16, ClusterScatterComponent_Conditional_3_For_17_Template, 1, 1, null, null, i0.ɵɵcomponentInstance().TrackPointBy, true);
168
+ i0.ɵɵconditionalCreate(18, ClusterScatterComponent_Conditional_3_Conditional_18_Template, 2, 0);
169
+ i0.ɵɵelementEnd();
170
+ } if (rf & 2) {
171
+ const ctx_r1 = i0.ɵɵnextContext();
172
+ i0.ɵɵclassProp("no-transitions", !ctx_r1.AnimateTransitions);
173
+ i0.ɵɵattribute("viewBox", ctx_r1.GetViewBoxString());
174
+ i0.ɵɵadvance(3);
175
+ i0.ɵɵrepeater(ctx_r1.ClusterCentroids);
176
+ i0.ɵɵadvance(3);
177
+ i0.ɵɵrepeater(ctx_r1.HorizontalGridLines);
178
+ i0.ɵɵadvance(2);
179
+ i0.ɵɵrepeater(ctx_r1.VerticalGridLines);
180
+ i0.ɵɵadvance(2);
181
+ i0.ɵɵrepeater(ctx_r1.ClusterCentroids);
182
+ i0.ɵɵadvance(2);
183
+ i0.ɵɵrepeater(ctx_r1.ClusterCentroids);
184
+ i0.ɵɵadvance(2);
185
+ i0.ɵɵrepeater(ctx_r1.Points);
186
+ i0.ɵɵadvance(2);
187
+ i0.ɵɵrepeater(ctx_r1.Points);
188
+ i0.ɵɵadvance(2);
189
+ i0.ɵɵconditional(ctx_r1.HighlightedKey ? 18 : -1);
190
+ } }
191
+ function ClusterScatterComponent_Conditional_4_For_4_Template(rf, ctx) { if (rf & 1) {
192
+ const _r12 = i0.ɵɵgetCurrentView();
193
+ i0.ɵɵelementStart(0, "div", 24);
194
+ i0.ɵɵlistener("click", function ClusterScatterComponent_Conditional_4_For_4_Template_div_click_0_listener() { const cluster_r13 = i0.ɵɵrestoreView(_r12).$implicit; const ctx_r1 = i0.ɵɵnextContext(2); return i0.ɵɵresetView(ctx_r1.OnClusterLegendClick(cluster_r13)); });
195
+ i0.ɵɵelement(1, "div", 25);
196
+ i0.ɵɵelementStart(2, "span", 26);
197
+ i0.ɵɵtext(3);
198
+ i0.ɵɵelementEnd();
199
+ i0.ɵɵelementStart(4, "span", 27);
200
+ i0.ɵɵtext(5);
201
+ i0.ɵɵelementEnd()();
202
+ } if (rf & 2) {
203
+ const cluster_r13 = ctx.$implicit;
204
+ i0.ɵɵadvance();
205
+ i0.ɵɵstyleProp("background", cluster_r13.Color);
206
+ i0.ɵɵadvance(2);
207
+ i0.ɵɵtextInterpolate(cluster_r13.Label);
208
+ i0.ɵɵadvance(2);
209
+ i0.ɵɵtextInterpolate(cluster_r13.MemberCount);
210
+ } }
211
+ function ClusterScatterComponent_Conditional_4_Template(rf, ctx) { if (rf & 1) {
212
+ i0.ɵɵelementStart(0, "div", 5)(1, "div", 22);
213
+ i0.ɵɵtext(2, "Clusters");
214
+ i0.ɵɵelementEnd();
215
+ i0.ɵɵrepeaterCreate(3, ClusterScatterComponent_Conditional_4_For_4_Template, 6, 4, "div", 23, i0.ɵɵcomponentInstance().TrackClusterBy, true);
216
+ i0.ɵɵelementEnd();
217
+ } if (rf & 2) {
218
+ const ctx_r1 = i0.ɵɵnextContext();
219
+ i0.ɵɵadvance(3);
220
+ i0.ɵɵrepeater(ctx_r1.Clusters);
221
+ } }
222
+ function ClusterScatterComponent_Conditional_5_Conditional_2_Template(rf, ctx) { if (rf & 1) {
223
+ i0.ɵɵelementStart(0, "div", 30);
224
+ i0.ɵɵelement(1, "i");
225
+ i0.ɵɵelementEnd();
226
+ } if (rf & 2) {
227
+ const ctx_r1 = i0.ɵɵnextContext(2);
228
+ i0.ɵɵadvance();
229
+ i0.ɵɵclassMap(ctx_r1.HoveredPoint.Metadata["EntityIcon"]);
230
+ } }
231
+ function ClusterScatterComponent_Conditional_5_Conditional_6_Template(rf, ctx) { if (rf & 1) {
232
+ i0.ɵɵelementStart(0, "div", 33);
233
+ i0.ɵɵtext(1);
234
+ i0.ɵɵelementEnd();
235
+ } if (rf & 2) {
236
+ const ctx_r1 = i0.ɵɵnextContext(2);
237
+ i0.ɵɵadvance();
238
+ i0.ɵɵtextInterpolate(ctx_r1.HoveredPoint.Metadata["Entity"]);
239
+ } }
240
+ function ClusterScatterComponent_Conditional_5_Conditional_10_For_2_Conditional_0_Template(rf, ctx) { if (rf & 1) {
241
+ i0.ɵɵelementStart(0, "div", 39);
242
+ i0.ɵɵtext(1);
243
+ i0.ɵɵelementEnd();
244
+ } if (rf & 2) {
245
+ const entry_r14 = i0.ɵɵnextContext().$implicit;
246
+ i0.ɵɵadvance();
247
+ i0.ɵɵtextInterpolate(entry_r14.Value);
248
+ } }
249
+ function ClusterScatterComponent_Conditional_5_Conditional_10_For_2_Template(rf, ctx) { if (rf & 1) {
250
+ i0.ɵɵconditionalCreate(0, ClusterScatterComponent_Conditional_5_Conditional_10_For_2_Conditional_0_Template, 2, 1, "div", 39);
251
+ } if (rf & 2) {
252
+ const entry_r14 = ctx.$implicit;
253
+ i0.ɵɵconditional(entry_r14.Key !== "Entity" && entry_r14.Key !== "EntityIcon" && entry_r14.Key !== "RecordID" && entry_r14.Key !== "Name" && entry_r14.Key !== "TemplateID" && entry_r14.Key !== "__mj_UpdatedAt" ? 0 : -1);
254
+ } }
255
+ function ClusterScatterComponent_Conditional_5_Conditional_10_Template(rf, ctx) { if (rf & 1) {
256
+ i0.ɵɵelementStart(0, "div", 36);
257
+ i0.ɵɵrepeaterCreate(1, ClusterScatterComponent_Conditional_5_Conditional_10_For_2_Template, 1, 1, null, null, _forTrack0);
258
+ i0.ɵɵelementEnd();
259
+ } if (rf & 2) {
260
+ const ctx_r1 = i0.ɵɵnextContext(2);
261
+ i0.ɵɵadvance();
262
+ i0.ɵɵrepeater(ctx_r1.TooltipEntries);
263
+ } }
264
+ function ClusterScatterComponent_Conditional_5_Template(rf, ctx) { if (rf & 1) {
265
+ i0.ɵɵelementStart(0, "div", 28)(1, "div", 29);
266
+ i0.ɵɵconditionalCreate(2, ClusterScatterComponent_Conditional_5_Conditional_2_Template, 2, 2, "div", 30);
267
+ i0.ɵɵelementStart(3, "div", 31)(4, "div", 32);
268
+ i0.ɵɵtext(5);
269
+ i0.ɵɵelementEnd();
270
+ i0.ɵɵconditionalCreate(6, ClusterScatterComponent_Conditional_5_Conditional_6_Template, 2, 1, "div", 33);
271
+ i0.ɵɵelementEnd()();
272
+ i0.ɵɵelementStart(7, "div", 34);
273
+ i0.ɵɵelement(8, "i", 35);
274
+ i0.ɵɵtext(9);
275
+ i0.ɵɵelementEnd();
276
+ i0.ɵɵconditionalCreate(10, ClusterScatterComponent_Conditional_5_Conditional_10_Template, 3, 0, "div", 36);
277
+ i0.ɵɵelementStart(11, "div", 37);
278
+ i0.ɵɵelement(12, "i", 38);
279
+ i0.ɵɵtext(13, " Click to view details ");
280
+ i0.ɵɵelementEnd()();
281
+ } if (rf & 2) {
282
+ const ctx_r1 = i0.ɵɵnextContext();
283
+ i0.ɵɵstyleProp("left", ctx_r1.TooltipX, "px")("top", ctx_r1.TooltipY, "px");
284
+ i0.ɵɵadvance(2);
285
+ i0.ɵɵconditional((ctx_r1.HoveredPoint.Metadata == null ? null : ctx_r1.HoveredPoint.Metadata["EntityIcon"]) ? 2 : -1);
286
+ i0.ɵɵadvance(3);
287
+ i0.ɵɵtextInterpolate(ctx_r1.HoveredPoint.Label);
288
+ i0.ɵɵadvance();
289
+ i0.ɵɵconditional((ctx_r1.HoveredPoint.Metadata == null ? null : ctx_r1.HoveredPoint.Metadata["Entity"]) ? 6 : -1);
290
+ i0.ɵɵadvance(3);
291
+ i0.ɵɵtextInterpolate1(" ", ctx_r1.GetClusterLabel(ctx_r1.HoveredPoint), " ");
292
+ i0.ɵɵadvance();
293
+ i0.ɵɵconditional(ctx_r1.TooltipEntries.length > 0 ? 10 : -1);
294
+ } }
295
+ function ClusterScatterComponent_Conditional_7_Conditional_2_Template(rf, ctx) { if (rf & 1) {
296
+ i0.ɵɵelementStart(0, "div", 42);
297
+ i0.ɵɵelement(1, "i");
298
+ i0.ɵɵelementEnd();
299
+ } if (rf & 2) {
300
+ const ctx_r1 = i0.ɵɵnextContext(2);
301
+ i0.ɵɵadvance();
302
+ i0.ɵɵclassMap(ctx_r1.SelectedPoint.Metadata["EntityIcon"]);
303
+ } }
304
+ function ClusterScatterComponent_Conditional_7_Conditional_6_Template(rf, ctx) { if (rf & 1) {
305
+ i0.ɵɵelementStart(0, "span", 45);
306
+ i0.ɵɵtext(1);
307
+ i0.ɵɵelementEnd();
308
+ } if (rf & 2) {
309
+ const ctx_r1 = i0.ɵɵnextContext(2);
310
+ i0.ɵɵadvance();
311
+ i0.ɵɵtextInterpolate(ctx_r1.SelectedPoint.Metadata["Entity"]);
312
+ } }
313
+ function ClusterScatterComponent_Conditional_7_Conditional_12_For_2_Template(rf, ctx) { if (rf & 1) {
314
+ i0.ɵɵelementStart(0, "div", 58)(1, "div", 59);
315
+ i0.ɵɵtext(2);
316
+ i0.ɵɵelementEnd();
317
+ i0.ɵɵelementStart(3, "div", 60);
318
+ i0.ɵɵtext(4);
319
+ i0.ɵɵelementEnd()();
320
+ } if (rf & 2) {
321
+ const entry_r16 = ctx.$implicit;
322
+ i0.ɵɵadvance(2);
323
+ i0.ɵɵtextInterpolate(entry_r16.Key);
324
+ i0.ɵɵadvance(2);
325
+ i0.ɵɵtextInterpolate(entry_r16.Value);
326
+ } }
327
+ function ClusterScatterComponent_Conditional_7_Conditional_12_Template(rf, ctx) { if (rf & 1) {
328
+ i0.ɵɵelementStart(0, "div", 50);
329
+ i0.ɵɵrepeaterCreate(1, ClusterScatterComponent_Conditional_7_Conditional_12_For_2_Template, 5, 2, "div", 58, _forTrack0);
330
+ i0.ɵɵelementEnd();
331
+ } if (rf & 2) {
332
+ const ctx_r1 = i0.ɵɵnextContext(2);
333
+ i0.ɵɵadvance();
334
+ i0.ɵɵrepeater(ctx_r1.DetailEntries);
335
+ } }
336
+ function ClusterScatterComponent_Conditional_7_Conditional_20_For_2_Conditional_1_Template(rf, ctx) { if (rf & 1) {
337
+ i0.ɵɵelement(0, "i", 65);
338
+ } if (rf & 2) {
339
+ const member_r18 = i0.ɵɵnextContext().$implicit;
340
+ i0.ɵɵclassMap(member_r18.Metadata["EntityIcon"]);
341
+ } }
342
+ function ClusterScatterComponent_Conditional_7_Conditional_20_For_2_Template(rf, ctx) { if (rf & 1) {
343
+ const _r17 = i0.ɵɵgetCurrentView();
344
+ i0.ɵɵelementStart(0, "div", 62);
345
+ i0.ɵɵlistener("click", function ClusterScatterComponent_Conditional_7_Conditional_20_For_2_Template_div_click_0_listener() { const member_r18 = i0.ɵɵrestoreView(_r17).$implicit; const ctx_r1 = i0.ɵɵnextContext(3); return i0.ɵɵresetView(ctx_r1.OnClusterMemberClick(member_r18)); });
346
+ i0.ɵɵconditionalCreate(1, ClusterScatterComponent_Conditional_7_Conditional_20_For_2_Conditional_1_Template, 1, 2, "i", 63);
347
+ i0.ɵɵelementStart(2, "span", 64);
348
+ i0.ɵɵtext(3);
349
+ i0.ɵɵelementEnd()();
350
+ } if (rf & 2) {
351
+ const member_r18 = ctx.$implicit;
352
+ const ctx_r1 = i0.ɵɵnextContext(3);
353
+ i0.ɵɵclassProp("active", member_r18.VectorKey === ctx_r1.SelectedPoint.VectorKey);
354
+ i0.ɵɵadvance();
355
+ i0.ɵɵconditional((member_r18.Metadata == null ? null : member_r18.Metadata["EntityIcon"]) ? 1 : -1);
356
+ i0.ɵɵadvance(2);
357
+ i0.ɵɵtextInterpolate(member_r18.Label);
358
+ } }
359
+ function ClusterScatterComponent_Conditional_7_Conditional_20_Template(rf, ctx) { if (rf & 1) {
360
+ i0.ɵɵelementStart(0, "div", 55);
361
+ i0.ɵɵrepeaterCreate(1, ClusterScatterComponent_Conditional_7_Conditional_20_For_2_Template, 4, 4, "div", 61, i0.ɵɵcomponentInstance().TrackMemberBy, true);
362
+ i0.ɵɵelementEnd();
363
+ } if (rf & 2) {
364
+ const ctx_r1 = i0.ɵɵnextContext(2);
365
+ i0.ɵɵadvance();
366
+ i0.ɵɵrepeater(ctx_r1.ClusterMembers);
367
+ } }
368
+ function ClusterScatterComponent_Conditional_7_Template(rf, ctx) { if (rf & 1) {
369
+ const _r15 = i0.ɵɵgetCurrentView();
370
+ i0.ɵɵelementStart(0, "div", 40)(1, "div", 41);
371
+ i0.ɵɵconditionalCreate(2, ClusterScatterComponent_Conditional_7_Conditional_2_Template, 2, 2, "div", 42);
372
+ i0.ɵɵelementStart(3, "div", 43)(4, "div", 44);
373
+ i0.ɵɵtext(5);
374
+ i0.ɵɵelementEnd();
375
+ i0.ɵɵconditionalCreate(6, ClusterScatterComponent_Conditional_7_Conditional_6_Template, 2, 1, "span", 45);
376
+ i0.ɵɵelementEnd()();
377
+ i0.ɵɵelementStart(7, "button", 46);
378
+ i0.ɵɵlistener("click", function ClusterScatterComponent_Conditional_7_Template_button_click_7_listener() { i0.ɵɵrestoreView(_r15); const ctx_r1 = i0.ɵɵnextContext(); return i0.ɵɵresetView(ctx_r1.CloseDetailPanel()); });
379
+ i0.ɵɵelement(8, "i", 47);
380
+ i0.ɵɵelementEnd()();
381
+ i0.ɵɵelementStart(9, "div", 48);
382
+ i0.ɵɵelement(10, "span", 49);
383
+ i0.ɵɵtext(11);
384
+ i0.ɵɵelementEnd();
385
+ i0.ɵɵconditionalCreate(12, ClusterScatterComponent_Conditional_7_Conditional_12_Template, 3, 0, "div", 50);
386
+ i0.ɵɵelementStart(13, "div", 51)(14, "button", 52);
387
+ i0.ɵɵlistener("click", function ClusterScatterComponent_Conditional_7_Template_button_click_14_listener() { i0.ɵɵrestoreView(_r15); const ctx_r1 = i0.ɵɵnextContext(); return i0.ɵɵresetView(ctx_r1.ToggleClusterMembers()); });
388
+ i0.ɵɵelement(15, "i", 53);
389
+ i0.ɵɵelementStart(16, "span");
390
+ i0.ɵɵtext(17, "Cluster Members");
391
+ i0.ɵɵelementEnd();
392
+ i0.ɵɵelementStart(18, "span", 54);
393
+ i0.ɵɵtext(19);
394
+ i0.ɵɵelementEnd()();
395
+ i0.ɵɵconditionalCreate(20, ClusterScatterComponent_Conditional_7_Conditional_20_Template, 3, 0, "div", 55);
396
+ i0.ɵɵelementEnd();
397
+ i0.ɵɵelementStart(21, "div", 56)(22, "button", 57);
398
+ i0.ɵɵlistener("click", function ClusterScatterComponent_Conditional_7_Template_button_click_22_listener() { i0.ɵɵrestoreView(_r15); const ctx_r1 = i0.ɵɵnextContext(); return i0.ɵɵresetView(ctx_r1.OnOpenRecordClick()); });
399
+ i0.ɵɵelement(23, "i", 38);
400
+ i0.ɵɵtext(24, " Open Record ");
401
+ i0.ɵɵelementEnd()();
402
+ } if (rf & 2) {
403
+ const ctx_r1 = i0.ɵɵnextContext();
404
+ i0.ɵɵadvance(2);
405
+ i0.ɵɵconditional((ctx_r1.SelectedPoint.Metadata == null ? null : ctx_r1.SelectedPoint.Metadata["EntityIcon"]) ? 2 : -1);
406
+ i0.ɵɵadvance(3);
407
+ i0.ɵɵtextInterpolate(ctx_r1.SelectedPoint.Label);
408
+ i0.ɵɵadvance();
409
+ i0.ɵɵconditional((ctx_r1.SelectedPoint.Metadata == null ? null : ctx_r1.SelectedPoint.Metadata["Entity"]) ? 6 : -1);
410
+ i0.ɵɵadvance(4);
411
+ i0.ɵɵstyleProp("background", ctx_r1.GetClusterColor(ctx_r1.SelectedPoint.ClusterId));
412
+ i0.ɵɵadvance();
413
+ i0.ɵɵtextInterpolate1(" ", ctx_r1.GetClusterLabel(ctx_r1.SelectedPoint), " ");
414
+ i0.ɵɵadvance();
415
+ i0.ɵɵconditional(ctx_r1.DetailEntries.length > 0 ? 12 : -1);
416
+ i0.ɵɵadvance(3);
417
+ i0.ɵɵclassProp("fa-chevron-down", ctx_r1.ClusterMembersExpanded)("fa-chevron-right", !ctx_r1.ClusterMembersExpanded);
418
+ i0.ɵɵadvance(4);
419
+ i0.ɵɵtextInterpolate(ctx_r1.ClusterMembers.length);
420
+ i0.ɵɵadvance();
421
+ i0.ɵɵconditional(ctx_r1.ClusterMembersExpanded ? 20 : -1);
422
+ } }
423
+ export class ClusterScatterComponent {
424
+ constructor() {
425
+ this.cdr = inject(ChangeDetectorRef);
426
+ // ================================================================
427
+ // Data Inputs
428
+ // ================================================================
429
+ /**
430
+ * Array of 2D-projected points to render.
431
+ *
432
+ * Each point carries its cluster assignment, label, and metadata.
433
+ * Typically produced by `ClusteringService.RunPipeline()`.
434
+ */
435
+ this.Points = [];
436
+ /**
437
+ * Cluster summary information used for the legend and color mapping.
438
+ */
439
+ this.Clusters = [];
440
+ /**
441
+ * When `true`, a semi-transparent loading overlay is displayed
442
+ * on top of the scatter plot.
443
+ */
444
+ this.IsLoading = false;
445
+ // ================================================================
446
+ // Customization Inputs
447
+ // ================================================================
448
+ /**
449
+ * Base radius (in SVG units) for each data point dot.
450
+ *
451
+ * @default 5
452
+ */
453
+ this.DotRadius = 5;
454
+ /**
455
+ * Base opacity for data point dots (0 to 1).
456
+ *
457
+ * When a point is hovered, non-hovered points are dimmed below this value.
458
+ *
459
+ * @default 0.75
460
+ */
461
+ this.DotOpacity = 0.75;
462
+ /**
463
+ * Radius used for the highlight ring on hovered or selected points.
464
+ *
465
+ * @default 8
466
+ */
467
+ this.HighlightRadius = 8;
468
+ /**
469
+ * Whether to show the color-coded cluster legend overlay.
470
+ *
471
+ * @default true
472
+ */
473
+ this.ShowLegend = true;
474
+ /**
475
+ * Whether to show the tooltip popup on point hover.
476
+ *
477
+ * When `false`, `BeforePointHover` and `PointHovered` still fire but
478
+ * the built-in tooltip DOM is not rendered.
479
+ *
480
+ * @default true
481
+ */
482
+ this.ShowTooltip = true;
483
+ /**
484
+ * Which metadata keys to display inside the tooltip.
485
+ *
486
+ * When empty (the default), **all** metadata keys are shown.
487
+ *
488
+ * @default []
489
+ *
490
+ * @example
491
+ * ```html
492
+ * <mj-cluster-scatter [TooltipFields]="['EntityRecordID', 'Score']">
493
+ * </mj-cluster-scatter>
494
+ * ```
495
+ */
496
+ this.TooltipFields = [];
497
+ /**
498
+ * Override the default cluster color palette.
499
+ *
500
+ * Colors are assigned to clusters by index: cluster 0 gets
501
+ * `ColorPalette[0]`, cluster 1 gets `ColorPalette[1]`, etc.
502
+ * Wraps around when there are more clusters than colors.
503
+ *
504
+ * @default CLUSTER_COLORS (10 accessible colors)
505
+ */
506
+ this.ColorPalette = [];
507
+ /**
508
+ * Enable or disable zoom via mouse wheel.
509
+ *
510
+ * @default true
511
+ */
512
+ this.EnableZoom = true;
513
+ /**
514
+ * Enable or disable panning via click-and-drag.
515
+ *
516
+ * @default true
517
+ */
518
+ this.EnablePan = true;
519
+ /**
520
+ * Whether scatter plot transitions (zoom, highlight) should be animated.
521
+ *
522
+ * @default true
523
+ */
524
+ this.AnimateTransitions = true;
525
+ /**
526
+ * Externally controlled set of selected point IDs (by `VectorKey`).
527
+ *
528
+ * When provided, the component renders selection rings for matching
529
+ * points and emits `SelectionChanged` when the user modifies the set
530
+ * interactively.
531
+ *
532
+ * @default (empty Set)
533
+ *
534
+ * @example
535
+ * ```html
536
+ * <mj-cluster-scatter
537
+ * [SelectedPointIds]="mySelectedIds"
538
+ * (SelectionChanged)="mySelectedIds = $event">
539
+ * </mj-cluster-scatter>
540
+ * ```
541
+ */
542
+ this.SelectedPointIds = new Set();
543
+ /**
544
+ * Minimum zoom level (smallest allowed viewBox multiplier).
545
+ *
546
+ * A value of `0.5` means the user can zoom in until the viewBox is
547
+ * half of the default 1000x700 size.
548
+ *
549
+ * @default 0.5
550
+ */
551
+ this.MinZoom = 0.5;
552
+ /**
553
+ * Maximum zoom level (largest allowed viewBox multiplier).
554
+ *
555
+ * A value of `10` means the user can zoom out until the viewBox is
556
+ * 10x the default 1000x700 size.
557
+ *
558
+ * @default 10
559
+ */
560
+ this.MaxZoom = 10;
561
+ // ================================================================
562
+ // Outputs (standard events)
563
+ // ================================================================
564
+ /**
565
+ * Emitted when a point is clicked (after `BeforePointClick` completes
566
+ * without cancellation).
567
+ */
568
+ this.PointClicked = new EventEmitter();
569
+ /**
570
+ * Emitted when the user clicks "Open Record" in the detail panel.
571
+ * The parent should wire this to `NavigationService.OpenEntityRecord()`.
572
+ */
573
+ this.OpenRecordRequested = new EventEmitter();
574
+ /**
575
+ * Emitted when the mouse enters or leaves a data point.
576
+ * Emits `null` on mouse-leave.
577
+ */
578
+ this.PointHovered = new EventEmitter();
579
+ // ================================================================
580
+ // Outputs (cancelable "Before" events)
581
+ // ================================================================
582
+ /**
583
+ * Fires **before** `PointClicked`. Set `event.Cancel = true` to
584
+ * suppress the click entirely.
585
+ *
586
+ * @example
587
+ * ```typescript
588
+ * onBeforeClick(event: CancelableEvent<ClusterPoint>): void {
589
+ * if (event.Data.ClusterId === -1) {
590
+ * event.Cancel = true; // ignore outlier clicks
591
+ * }
592
+ * }
593
+ * ```
594
+ */
595
+ this.BeforePointClick = new EventEmitter();
596
+ /**
597
+ * Fires **before** the tooltip is shown on hover. Set
598
+ * `event.Cancel = true` to suppress the tooltip for this point.
599
+ */
600
+ this.BeforePointHover = new EventEmitter();
601
+ /**
602
+ * Fires **before** a zoom or pan operation is applied. Set
603
+ * `event.Cancel = true` to reject the viewport change.
604
+ *
605
+ * The payload contains the **proposed** viewport rectangle.
606
+ */
607
+ this.BeforeZoom = new EventEmitter();
608
+ // ================================================================
609
+ // Outputs (informational events)
610
+ // ================================================================
611
+ /**
612
+ * Fires after new `Points` and `Clusters` are received and centroids
613
+ * have been computed, delivering the full visualization result.
614
+ */
615
+ this.AfterClusteringComplete = new EventEmitter();
616
+ /**
617
+ * Fires when the user clicks a cluster label or legend swatch.
618
+ */
619
+ this.ClusterSelected = new EventEmitter();
620
+ /**
621
+ * Fires whenever the set of selected point IDs changes
622
+ * (via user click or programmatic `SelectPoints` / `ClearSelection`).
623
+ */
624
+ this.SelectionChanged = new EventEmitter();
625
+ /**
626
+ * Fires after every zoom or pan that changes the visible viewport.
627
+ */
628
+ this.ViewportChanged = new EventEmitter();
629
+ // ================================================================
630
+ // Internal State
631
+ // ================================================================
632
+ /** SVG viewBox parameters: [minX, minY, width, height]. */
633
+ this.ViewBox = [0, 0, 1000, 700];
634
+ /** Currently hovered point (null when idle). */
635
+ this.HoveredPoint = null;
636
+ /** Screen-space tooltip X position. */
637
+ this.TooltipX = 0;
638
+ /** Screen-space tooltip Y position. */
639
+ this.TooltipY = 0;
640
+ /** Currently highlighted (single-clicked) point key. */
641
+ this.HighlightedKey = null;
642
+ /** Background grid lines for visual orientation. */
643
+ this.HorizontalGridLines = [175, 350, 525];
644
+ /** Background vertical grid lines. */
645
+ this.VerticalGridLines = [250, 500, 750];
646
+ /** Computed cluster centroids used for radial glow effects. */
647
+ this.ClusterCentroids = [];
648
+ /** Filtered metadata entries for the tooltip (computed on hover). */
649
+ this.TooltipEntries = [];
650
+ // ================================================================
651
+ // Detail Panel State
652
+ // ================================================================
653
+ /** The currently selected point shown in the detail panel. */
654
+ this.SelectedPoint = null;
655
+ /** Whether the detail panel is visible. */
656
+ this.ShowDetailPanel = false;
657
+ /** Metadata entries for the detail panel (computed on selection). */
658
+ this.DetailEntries = [];
659
+ /** Cluster members for the selected point's cluster. */
660
+ this.ClusterMembers = [];
661
+ /** Whether the cluster members list is expanded. */
662
+ this.ClusterMembersExpanded = true;
663
+ // Pan/zoom state
664
+ this.isPanning = false;
665
+ this.panStartX = 0;
666
+ this.panStartY = 0;
667
+ this.panStartViewBox = [0, 0, 1000, 700];
668
+ this.boundOnMouseMove = null;
669
+ this.boundOnMouseUp = null;
670
+ /** Default viewBox dimensions used for zoom-limit calculations. */
671
+ this.defaultWidth = 1000;
672
+ this.defaultHeight = 700;
673
+ }
674
+ /** Get the current viewport transform for saving/restoring. */
675
+ GetViewportTransform() {
676
+ return {
677
+ TranslateX: this.ViewBox[0],
678
+ TranslateY: this.ViewBox[1],
679
+ Scale: this.defaultWidth / this.ViewBox[2],
680
+ };
681
+ }
682
+ /** Restore a previously saved viewport transform. */
683
+ SetViewportTransform(vt) {
684
+ const width = this.defaultWidth / vt.Scale;
685
+ const height = this.defaultHeight / vt.Scale;
686
+ this.ViewBox = [vt.TranslateX, vt.TranslateY, width, height];
687
+ }
688
+ // ================================================================
689
+ // Lifecycle
690
+ // ================================================================
691
+ ngAfterViewInit() {
692
+ this.boundOnMouseMove = this.onMouseMove.bind(this);
693
+ this.boundOnMouseUp = this.onMouseUp.bind(this);
694
+ document.addEventListener('mousemove', this.boundOnMouseMove);
695
+ document.addEventListener('mouseup', this.boundOnMouseUp);
696
+ }
697
+ ngOnDestroy() {
698
+ if (this.boundOnMouseMove)
699
+ document.removeEventListener('mousemove', this.boundOnMouseMove);
700
+ if (this.boundOnMouseUp)
701
+ document.removeEventListener('mouseup', this.boundOnMouseUp);
702
+ }
703
+ ngOnChanges(changes) {
704
+ this.computeCentroids();
705
+ // Emit AfterClusteringComplete when new data arrives
706
+ if ((changes['Points'] || changes['Clusters']) && this.Points.length > 0 && this.Clusters.length > 0) {
707
+ this.AfterClusteringComplete.emit({
708
+ Points: this.Points,
709
+ Clusters: this.Clusters,
710
+ Metrics: {
711
+ SilhouetteScore: 0,
712
+ ClusterCount: this.Clusters.length,
713
+ ComputationTimeMs: 0,
714
+ RecordCount: this.Points.length,
715
+ OutlierCount: this.Points.filter(p => p.ClusterId < 0).length,
716
+ },
717
+ Config: {
718
+ EntityName: '',
719
+ EntityDocumentID: '',
720
+ Algorithm: 'kmeans',
721
+ K: this.Clusters.length,
722
+ Epsilon: 0,
723
+ MinPoints: 0,
724
+ DistanceMetric: 'cosine',
725
+ MaxRecords: this.Points.length,
726
+ Filter: '',
727
+ },
728
+ });
729
+ }
730
+ }
731
+ // ================================================================
732
+ // Template Helpers
733
+ // ================================================================
734
+ /**
735
+ * Returns the current SVG `viewBox` as a space-separated string.
736
+ *
737
+ * @returns e.g. `"0 0 1000 700"`
738
+ */
739
+ GetViewBoxString() {
740
+ return this.ViewBox.join(' ');
741
+ }
742
+ /**
743
+ * Resolve the display color for a given point based on its cluster
744
+ * assignment and the active `ColorPalette`.
745
+ *
746
+ * @param point The point to colorize.
747
+ * @returns CSS color string.
748
+ */
749
+ GetPointColor(point) {
750
+ if (point.ClusterId < 0)
751
+ return '#64748b'; // outlier gray
752
+ const palette = this.getActivePalette();
753
+ const cluster = this.Clusters.find(c => c.Id === point.ClusterId);
754
+ return cluster?.Color ?? palette[point.ClusterId % palette.length];
755
+ }
756
+ /**
757
+ * Compute the SVG radius for a data point.
758
+ *
759
+ * Selected points use the larger `HighlightRadius`; all others use `DotRadius`.
760
+ *
761
+ * @param point The point to size.
762
+ * @returns Radius in SVG units.
763
+ */
764
+ GetPointRadius(point) {
765
+ if (this.SelectedPointIds.has(point.VectorKey)) {
766
+ return this.HighlightRadius;
767
+ }
768
+ return this.DotRadius;
769
+ }
770
+ /**
771
+ * Compute the fill opacity for a data point.
772
+ *
773
+ * Non-hovered points are dimmed when another point is hovered.
774
+ *
775
+ * @param point The point to evaluate.
776
+ * @returns Opacity value between 0 and 1.
777
+ */
778
+ GetPointOpacity(point) {
779
+ if (this.HoveredPoint && this.HoveredPoint.VectorKey !== point.VectorKey) {
780
+ return this.DotOpacity * 0.5;
781
+ }
782
+ return this.DotOpacity;
783
+ }
784
+ /**
785
+ * Handle mouseenter on a data point: show tooltip, fire hover events.
786
+ *
787
+ * @param point The hovered point.
788
+ * @param event The native mouse event (used for tooltip positioning).
789
+ */
790
+ OnPointMouseEnter(point, event) {
791
+ const cancelable = { Data: point, Cancel: false };
792
+ this.BeforePointHover.emit(cancelable);
793
+ if (cancelable.Cancel)
794
+ return;
795
+ this.HoveredPoint = point;
796
+ this.TooltipX = event.clientX + 16;
797
+ this.TooltipY = event.clientY - 10;
798
+ this.computeTooltipEntries(point);
799
+ this.PointHovered.emit(point);
800
+ this.cdr.detectChanges();
801
+ }
802
+ /**
803
+ * Handle mouseleave on a data point: hide tooltip, emit null hover.
804
+ */
805
+ OnPointMouseLeave() {
806
+ this.HoveredPoint = null;
807
+ this.TooltipEntries = [];
808
+ this.PointHovered.emit(null);
809
+ this.cdr.detectChanges();
810
+ }
811
+ /**
812
+ * Handle click on a data point: fires `BeforePointClick`, toggles
813
+ * selection, opens the detail panel, then fires `PointClicked` and
814
+ * `SelectionChanged`.
815
+ *
816
+ * @param point The clicked point.
817
+ */
818
+ OnPointClick(point) {
819
+ const cancelable = { Data: point, Cancel: false };
820
+ this.BeforePointClick.emit(cancelable);
821
+ if (cancelable.Cancel)
822
+ return;
823
+ this.HighlightedKey = point.VectorKey;
824
+ // Toggle selection
825
+ const newSelection = new Set(this.SelectedPointIds);
826
+ if (newSelection.has(point.VectorKey)) {
827
+ newSelection.delete(point.VectorKey);
828
+ }
829
+ else {
830
+ newSelection.add(point.VectorKey);
831
+ }
832
+ this.SelectedPointIds = newSelection;
833
+ this.SelectionChanged.emit(newSelection);
834
+ // Open detail panel for the clicked point
835
+ this.selectPointForDetail(point);
836
+ this.PointClicked.emit(point);
837
+ this.cdr.detectChanges();
838
+ }
839
+ /**
840
+ * Whether the given point is the currently highlighted (last-clicked) point.
841
+ *
842
+ * @param point The point to check.
843
+ * @returns `true` if highlighted.
844
+ */
845
+ IsHighlighted(point) {
846
+ return this.HighlightedKey === point.VectorKey;
847
+ }
848
+ /**
849
+ * Whether the given point is in the current selection set.
850
+ *
851
+ * @param point The point to check.
852
+ * @returns `true` if selected.
853
+ */
854
+ IsSelected(point) {
855
+ return this.SelectedPointIds.has(point.VectorKey);
856
+ }
857
+ /**
858
+ * Handle mouse wheel events for zooming.
859
+ *
860
+ * Fires `BeforeZoom` and `ViewportChanged`. Respects `EnableZoom`,
861
+ * `MinZoom`, and `MaxZoom` constraints.
862
+ *
863
+ * @param event The native wheel event.
864
+ */
865
+ OnWheel(event) {
866
+ event.preventDefault();
867
+ if (!this.EnableZoom)
868
+ return;
869
+ const zoomFactor = event.deltaY > 0 ? 1.1 : 0.9;
870
+ const [minX, minY, width, height] = this.ViewBox;
871
+ const minWidth = this.defaultWidth * this.MinZoom;
872
+ const maxWidth = this.defaultWidth * this.MaxZoom;
873
+ const minHeight = this.defaultHeight * this.MinZoom;
874
+ const maxHeight = this.defaultHeight * this.MaxZoom;
875
+ const newWidth = Math.max(minWidth, Math.min(maxWidth, width * zoomFactor));
876
+ const newHeight = Math.max(minHeight, Math.min(maxHeight, height * zoomFactor));
877
+ const centerX = minX + width / 2;
878
+ const centerY = minY + height / 2;
879
+ const proposed = {
880
+ MinX: centerX - newWidth / 2,
881
+ MinY: centerY - newHeight / 2,
882
+ Width: newWidth,
883
+ Height: newHeight,
884
+ };
885
+ const cancelable = { Data: proposed, Cancel: false };
886
+ this.BeforeZoom.emit(cancelable);
887
+ if (cancelable.Cancel)
888
+ return;
889
+ this.ViewBox = [proposed.MinX, proposed.MinY, proposed.Width, proposed.Height];
890
+ this.ViewportChanged.emit(proposed);
891
+ this.cdr.detectChanges();
892
+ }
893
+ /**
894
+ * Start panning on left-mousedown.
895
+ *
896
+ * @param event The native mouse event.
897
+ */
898
+ OnMouseDown(event) {
899
+ if (event.button !== 0 || !this.EnablePan)
900
+ return;
901
+ this.isPanning = true;
902
+ this.panStartX = event.clientX;
903
+ this.panStartY = event.clientY;
904
+ this.panStartViewBox = [...this.ViewBox];
905
+ }
906
+ /**
907
+ * Handle a click on a cluster legend item.
908
+ *
909
+ * @param cluster The cluster that was clicked.
910
+ */
911
+ OnClusterLegendClick(cluster) {
912
+ this.ClusterSelected.emit({
913
+ ClusterId: cluster.Id,
914
+ Label: cluster.Label,
915
+ Color: cluster.Color,
916
+ MemberCount: cluster.MemberCount,
917
+ });
918
+ }
919
+ /**
920
+ * Resolve the human-readable label for a point's cluster.
921
+ *
922
+ * @param point The point to look up.
923
+ * @returns Cluster label string, or `"Outlier"` for noise points.
924
+ */
925
+ GetClusterLabel(point) {
926
+ if (point.ClusterId < 0)
927
+ return 'Outlier';
928
+ const cluster = this.Clusters.find(c => c.Id === point.ClusterId);
929
+ return cluster?.Label ?? `Cluster ${point.ClusterId + 1}`;
930
+ }
931
+ /** Get the label for a cluster by ID (used for SVG text labels on the chart). */
932
+ GetClusterLabelById(clusterId) {
933
+ const cluster = this.Clusters.find(c => c.Id === clusterId);
934
+ return cluster?.Label ?? '';
935
+ }
936
+ /** @internal TrackBy function for point loops. */
937
+ TrackPointBy(_index, point) {
938
+ return point.VectorKey;
939
+ }
940
+ /** @internal TrackBy function for cluster loops. */
941
+ TrackClusterBy(_index, cluster) {
942
+ return cluster.Id;
943
+ }
944
+ /** @internal TrackBy function for centroid loops. */
945
+ TrackCentroidBy(_index, centroid) {
946
+ return centroid.ClusterId;
947
+ }
948
+ // ================================================================
949
+ // Public API Methods
950
+ // ================================================================
951
+ /**
952
+ * Animate the viewport to center on a specific cluster's centroid.
953
+ *
954
+ * The zoom level is adjusted so that all cluster members are visible
955
+ * with some padding.
956
+ *
957
+ * @param clusterId The numeric ID of the cluster to zoom into.
958
+ *
959
+ * @example
960
+ * ```typescript
961
+ * @ViewChild(ClusterScatterComponent) scatter!: ClusterScatterComponent;
962
+ * this.scatter.ZoomToCluster(2);
963
+ * ```
964
+ */
965
+ ZoomToCluster(clusterId) {
966
+ const members = this.Points.filter(p => p.ClusterId === clusterId);
967
+ if (members.length === 0)
968
+ return;
969
+ const bounds = this.computeBounds(members);
970
+ const padding = 80;
971
+ const proposed = {
972
+ MinX: bounds.minX - padding,
973
+ MinY: bounds.minY - padding,
974
+ Width: (bounds.maxX - bounds.minX) + padding * 2,
975
+ Height: (bounds.maxY - bounds.minY) + padding * 2,
976
+ };
977
+ // Ensure minimum dimensions
978
+ if (proposed.Width < 200) {
979
+ const cx = proposed.MinX + proposed.Width / 2;
980
+ proposed.Width = 200;
981
+ proposed.MinX = cx - 100;
982
+ }
983
+ if (proposed.Height < 140) {
984
+ const cy = proposed.MinY + proposed.Height / 2;
985
+ proposed.Height = 140;
986
+ proposed.MinY = cy - 70;
987
+ }
988
+ this.ViewBox = [proposed.MinX, proposed.MinY, proposed.Width, proposed.Height];
989
+ this.ViewportChanged.emit(proposed);
990
+ this.cdr.detectChanges();
991
+ }
992
+ /**
993
+ * Reset the viewport to the default zoom level (full view).
994
+ *
995
+ * @example
996
+ * ```typescript
997
+ * this.scatter.ResetZoom();
998
+ * ```
999
+ */
1000
+ ResetZoom() {
1001
+ const proposed = {
1002
+ MinX: 0,
1003
+ MinY: 0,
1004
+ Width: this.defaultWidth,
1005
+ Height: this.defaultHeight,
1006
+ };
1007
+ this.ViewBox = [0, 0, this.defaultWidth, this.defaultHeight];
1008
+ this.ViewportChanged.emit(proposed);
1009
+ this.cdr.detectChanges();
1010
+ }
1011
+ /**
1012
+ * Return all points currently visible within the viewport bounds.
1013
+ *
1014
+ * @returns Array of `ClusterPoint` objects whose X/Y fall inside the
1015
+ * current viewBox.
1016
+ */
1017
+ GetVisiblePoints() {
1018
+ const [minX, minY, width, height] = this.ViewBox;
1019
+ const maxX = minX + width;
1020
+ const maxY = minY + height;
1021
+ return this.Points.filter(p => p.X >= minX && p.X <= maxX && p.Y >= minY && p.Y <= maxY);
1022
+ }
1023
+ /**
1024
+ * Programmatically select points by their `VectorKey` IDs.
1025
+ *
1026
+ * Merges with the existing selection. Use `ClearSelection()` first
1027
+ * to replace the selection entirely.
1028
+ *
1029
+ * @param ids Array of `VectorKey` strings to select.
1030
+ *
1031
+ * @example
1032
+ * ```typescript
1033
+ * this.scatter.SelectPoints(['abc-123', 'def-456']);
1034
+ * ```
1035
+ */
1036
+ SelectPoints(ids) {
1037
+ const newSelection = new Set(this.SelectedPointIds);
1038
+ for (const id of ids) {
1039
+ newSelection.add(id);
1040
+ }
1041
+ this.SelectedPointIds = newSelection;
1042
+ this.SelectionChanged.emit(newSelection);
1043
+ this.cdr.detectChanges();
1044
+ }
1045
+ /**
1046
+ * Clear all selected points.
1047
+ */
1048
+ ClearSelection() {
1049
+ this.SelectedPointIds = new Set();
1050
+ this.HighlightedKey = null;
1051
+ this.SelectionChanged.emit(this.SelectedPointIds);
1052
+ this.cdr.detectChanges();
1053
+ }
1054
+ /**
1055
+ * Export the current scatter plot as an SVG string.
1056
+ *
1057
+ * The returned string is a self-contained `<svg>` element that can be
1058
+ * saved to a file or embedded in HTML.
1059
+ *
1060
+ * @returns SVG markup string, or empty string if the SVG element is
1061
+ * not yet rendered.
1062
+ */
1063
+ ExportSVG() {
1064
+ if (!this.svgRef?.nativeElement)
1065
+ return '';
1066
+ const svgElement = this.svgRef.nativeElement;
1067
+ const serializer = new XMLSerializer();
1068
+ return serializer.serializeToString(svgElement);
1069
+ }
1070
+ /**
1071
+ * Visually emphasize all points belonging to a cluster by increasing
1072
+ * their radius and dimming non-members.
1073
+ *
1074
+ * Call `ResetZoom()` or re-assign `Points` to clear the highlight.
1075
+ *
1076
+ * @param clusterId The numeric cluster ID to emphasize.
1077
+ */
1078
+ HighlightCluster(clusterId) {
1079
+ const memberKeys = this.Points
1080
+ .filter(p => p.ClusterId === clusterId)
1081
+ .map(p => p.VectorKey);
1082
+ this.SelectedPointIds = new Set(memberKeys);
1083
+ this.SelectionChanged.emit(this.SelectedPointIds);
1084
+ this.cdr.detectChanges();
1085
+ }
1086
+ // ================================================================
1087
+ // Detail Panel Methods
1088
+ // ================================================================
1089
+ /**
1090
+ * Close the detail panel.
1091
+ */
1092
+ CloseDetailPanel() {
1093
+ this.ShowDetailPanel = false;
1094
+ this.SelectedPoint = null;
1095
+ this.DetailEntries = [];
1096
+ this.ClusterMembers = [];
1097
+ this.cdr.detectChanges();
1098
+ }
1099
+ /**
1100
+ * Emit the `OpenRecordRequested` event for the currently selected point
1101
+ * (triggered by the "Open Record" button in the detail panel).
1102
+ */
1103
+ OnOpenRecordClick() {
1104
+ if (this.SelectedPoint) {
1105
+ this.OpenRecordRequested.emit(this.SelectedPoint);
1106
+ }
1107
+ }
1108
+ /**
1109
+ * Select a point from the cluster members list, updating the detail
1110
+ * panel and highlighting the point on the chart.
1111
+ *
1112
+ * @param point The cluster member point to select.
1113
+ */
1114
+ OnClusterMemberClick(point) {
1115
+ this.HighlightedKey = point.VectorKey;
1116
+ this.selectPointForDetail(point);
1117
+ this.cdr.detectChanges();
1118
+ }
1119
+ /**
1120
+ * Toggle the cluster members list expansion state.
1121
+ */
1122
+ ToggleClusterMembers() {
1123
+ this.ClusterMembersExpanded = !this.ClusterMembersExpanded;
1124
+ }
1125
+ /**
1126
+ * Get the color for a cluster by its ID, used in the detail panel.
1127
+ *
1128
+ * @param clusterId The cluster ID.
1129
+ * @returns CSS color string.
1130
+ */
1131
+ GetClusterColor(clusterId) {
1132
+ if (clusterId < 0)
1133
+ return '#64748b';
1134
+ const cluster = this.Clusters.find(c => c.Id === clusterId);
1135
+ if (cluster?.Color)
1136
+ return cluster.Color;
1137
+ const palette = this.getActivePalette();
1138
+ return palette[clusterId % palette.length];
1139
+ }
1140
+ /**
1141
+ * Return the metadata keys that should be hidden in the detail panel.
1142
+ */
1143
+ get internalMetadataKeys() {
1144
+ return new Set([
1145
+ 'Name', 'Entity', 'EntityIcon', 'RecordID',
1146
+ 'TemplateID', '__mj_UpdatedAt', '__mj_CreatedAt',
1147
+ ]);
1148
+ }
1149
+ /**
1150
+ * Select a point and populate the detail panel state.
1151
+ */
1152
+ selectPointForDetail(point) {
1153
+ this.SelectedPoint = point;
1154
+ this.ShowDetailPanel = true;
1155
+ this.computeDetailEntries(point);
1156
+ this.computeClusterMembers(point);
1157
+ }
1158
+ /**
1159
+ * Compute the metadata entries for the detail panel, excluding internal fields.
1160
+ */
1161
+ computeDetailEntries(point) {
1162
+ const hidden = this.internalMetadataKeys;
1163
+ this.DetailEntries = Object.entries(point.Metadata)
1164
+ .filter(([key]) => !hidden.has(key))
1165
+ .filter(([, value]) => value != null && String(value).trim() !== '')
1166
+ .map(([Key, Value]) => ({ Key, Value }));
1167
+ }
1168
+ /**
1169
+ * Compute the list of cluster members for the selected point's cluster.
1170
+ */
1171
+ computeClusterMembers(point) {
1172
+ this.ClusterMembers = this.Points
1173
+ .filter(p => p.ClusterId === point.ClusterId)
1174
+ .sort((a, b) => a.Label.localeCompare(b.Label));
1175
+ }
1176
+ /** @internal TrackBy function for cluster member loops. */
1177
+ TrackMemberBy(_index, point) {
1178
+ return point.VectorKey;
1179
+ }
1180
+ // ================================================================
1181
+ // Private Methods
1182
+ // ================================================================
1183
+ onMouseMove(event) {
1184
+ if (!this.isPanning || !this.svgRef)
1185
+ return;
1186
+ const svg = this.svgRef.nativeElement;
1187
+ const rect = svg.getBoundingClientRect();
1188
+ const scaleX = this.panStartViewBox[2] / rect.width;
1189
+ const scaleY = this.panStartViewBox[3] / rect.height;
1190
+ const dx = (event.clientX - this.panStartX) * scaleX;
1191
+ const dy = (event.clientY - this.panStartY) * scaleY;
1192
+ const proposed = {
1193
+ MinX: this.panStartViewBox[0] - dx,
1194
+ MinY: this.panStartViewBox[1] - dy,
1195
+ Width: this.panStartViewBox[2],
1196
+ Height: this.panStartViewBox[3],
1197
+ };
1198
+ this.ViewBox = [proposed.MinX, proposed.MinY, proposed.Width, proposed.Height];
1199
+ this.ViewportChanged.emit(proposed);
1200
+ this.cdr.detectChanges();
1201
+ }
1202
+ onMouseUp(_event) {
1203
+ this.isPanning = false;
1204
+ }
1205
+ computeCentroids() {
1206
+ if (!this.Points.length || !this.Clusters.length) {
1207
+ this.ClusterCentroids = [];
1208
+ return;
1209
+ }
1210
+ this.ClusterCentroids = this.Clusters.map(cluster => {
1211
+ const members = this.Points.filter(p => p.ClusterId === cluster.Id);
1212
+ if (members.length === 0) {
1213
+ return { ClusterId: cluster.Id, Cx: 500, Cy: 350, R: 80, Color: cluster.Color };
1214
+ }
1215
+ const cx = members.reduce((s, p) => s + p.X, 0) / members.length;
1216
+ const cy = members.reduce((s, p) => s + p.Y, 0) / members.length;
1217
+ const maxDist = Math.max(...members.map(p => Math.sqrt((p.X - cx) ** 2 + (p.Y - cy) ** 2)), 40);
1218
+ return {
1219
+ ClusterId: cluster.Id,
1220
+ Cx: cx,
1221
+ Cy: cy,
1222
+ R: Math.min(maxDist + 30, 200),
1223
+ Color: cluster.Color,
1224
+ };
1225
+ });
1226
+ }
1227
+ /**
1228
+ * Compute the bounding box for a set of points.
1229
+ */
1230
+ computeBounds(points) {
1231
+ let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
1232
+ for (const p of points) {
1233
+ if (p.X < minX)
1234
+ minX = p.X;
1235
+ if (p.X > maxX)
1236
+ maxX = p.X;
1237
+ if (p.Y < minY)
1238
+ minY = p.Y;
1239
+ if (p.Y > maxY)
1240
+ maxY = p.Y;
1241
+ }
1242
+ return { minX, minY, maxX, maxY };
1243
+ }
1244
+ /**
1245
+ * Resolve the effective color palette (custom override or default).
1246
+ */
1247
+ getActivePalette() {
1248
+ return this.ColorPalette.length > 0 ? this.ColorPalette : CLUSTER_COLORS;
1249
+ }
1250
+ /**
1251
+ * Compute the tooltip entries based on TooltipFields filter.
1252
+ */
1253
+ computeTooltipEntries(point) {
1254
+ const entries = Object.entries(point.Metadata);
1255
+ if (this.TooltipFields.length > 0) {
1256
+ this.TooltipEntries = entries
1257
+ .filter(([key]) => this.TooltipFields.includes(key))
1258
+ .map(([Key, Value]) => ({ Key, Value }));
1259
+ }
1260
+ else {
1261
+ this.TooltipEntries = entries.map(([Key, Value]) => ({ Key, Value }));
1262
+ }
1263
+ }
1264
+ static { this.ɵfac = function ClusterScatterComponent_Factory(__ngFactoryType__) { return new (__ngFactoryType__ || ClusterScatterComponent)(); }; }
1265
+ static { this.ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({ type: ClusterScatterComponent, selectors: [["mj-cluster-scatter"]], viewQuery: function ClusterScatterComponent_Query(rf, ctx) { if (rf & 1) {
1266
+ i0.ɵɵviewQuery(_c0, 5);
1267
+ } if (rf & 2) {
1268
+ let _t;
1269
+ i0.ɵɵqueryRefresh(_t = i0.ɵɵloadQuery()) && (ctx.svgRef = _t.first);
1270
+ } }, inputs: { Points: "Points", Clusters: "Clusters", IsLoading: "IsLoading", DotRadius: "DotRadius", DotOpacity: "DotOpacity", HighlightRadius: "HighlightRadius", ShowLegend: "ShowLegend", ShowTooltip: "ShowTooltip", TooltipFields: "TooltipFields", ColorPalette: "ColorPalette", EnableZoom: "EnableZoom", EnablePan: "EnablePan", AnimateTransitions: "AnimateTransitions", SelectedPointIds: "SelectedPointIds", MinZoom: "MinZoom", MaxZoom: "MaxZoom" }, outputs: { PointClicked: "PointClicked", OpenRecordRequested: "OpenRecordRequested", PointHovered: "PointHovered", BeforePointClick: "BeforePointClick", BeforePointHover: "BeforePointHover", BeforeZoom: "BeforeZoom", AfterClusteringComplete: "AfterClusteringComplete", ClusterSelected: "ClusterSelected", SelectionChanged: "SelectionChanged", ViewportChanged: "ViewportChanged" }, standalone: false, features: [i0.ɵɵNgOnChangesFeature], decls: 8, vars: 8, consts: [["svgElement", ""], [1, "scatter-container"], [1, "loading-overlay"], [1, "empty-state"], ["preserveAspectRatio", "xMidYMid meet", 1, "scatter-svg", 3, "no-transitions"], [1, "legend"], [1, "tooltip", 3, "left", "top"], [1, "detail-panel"], [1, "loading-spinner"], [1, "fa-solid", "fa-circle-nodes", "fa-spin"], [1, "fa-solid", "fa-chart-scatter"], [1, "empty-hint"], ["preserveAspectRatio", "xMidYMid meet", 1, "scatter-svg", 3, "wheel", "mousedown"], ["cx", "50%", "cy", "50%", "r", "50%"], [1, "grid-lines"], [1, "data-point"], ["offset", "0%", "stop-opacity", "0.12"], ["offset", "100%", "stop-opacity", "0"], ["text-anchor", "middle", 1, "cluster-label"], [1, "data-point", 3, "mouseenter", "mouseleave", "click"], ["fill", "none", "stroke-width", "2", "opacity", "0.8", 1, "selection-ring"], ["fill", "none", "stroke-width", "2.5", "opacity", "0.9"], [1, "legend-title"], [1, "legend-item"], [1, "legend-item", 3, "click"], [1, "legend-dot"], [1, "legend-label"], [1, "legend-count"], [1, "tooltip"], [1, "tt-header"], [1, "tt-icon"], [1, "tt-header-text"], [1, "tt-name"], [1, "tt-entity"], [1, "tt-cluster"], [1, "fa-solid", "fa-circle", 2, "font-size", "7px", "vertical-align", "middle"], [1, "tt-fields"], [1, "tt-action"], [1, "fa-solid", "fa-arrow-up-right-from-square"], [1, "tt-field-row"], [1, "detail-header"], [1, "detail-header-content"], [1, "detail-icon"], [1, "detail-header-text"], [1, "detail-name"], [1, "detail-entity-badge"], ["title", "Close", 1, "detail-close", 3, "click"], [1, "fa-solid", "fa-times"], [1, "detail-cluster"], [1, "detail-cluster-dot"], [1, "detail-fields"], [1, "detail-members-section"], [1, "detail-members-toggle", 3, "click"], [1, "fa-solid"], [1, "detail-members-count"], [1, "detail-members-list"], [1, "detail-footer"], [1, "detail-open-btn", 3, "click"], [1, "detail-field"], [1, "detail-field-key"], [1, "detail-field-value"], [1, "detail-member-row", 3, "active"], [1, "detail-member-row", 3, "click"], [1, "member-icon", 3, "class"], [1, "member-label"], [1, "member-icon"]], template: function ClusterScatterComponent_Template(rf, ctx) { if (rf & 1) {
1271
+ i0.ɵɵelementStart(0, "div", 1);
1272
+ i0.ɵɵconditionalCreate(1, ClusterScatterComponent_Conditional_1_Template, 5, 0, "div", 2);
1273
+ i0.ɵɵconditionalCreate(2, ClusterScatterComponent_Conditional_2_Template, 6, 0, "div", 3);
1274
+ i0.ɵɵconditionalCreate(3, ClusterScatterComponent_Conditional_3_Template, 19, 4, ":svg:svg", 4);
1275
+ i0.ɵɵconditionalCreate(4, ClusterScatterComponent_Conditional_4_Template, 5, 0, "div", 5);
1276
+ i0.ɵɵconditionalCreate(5, ClusterScatterComponent_Conditional_5_Template, 14, 9, "div", 6);
1277
+ i0.ɵɵelementStart(6, "div", 7);
1278
+ i0.ɵɵconditionalCreate(7, ClusterScatterComponent_Conditional_7_Template, 25, 13);
1279
+ i0.ɵɵelementEnd()();
1280
+ } if (rf & 2) {
1281
+ i0.ɵɵadvance();
1282
+ i0.ɵɵconditional(ctx.IsLoading ? 1 : -1);
1283
+ i0.ɵɵadvance();
1284
+ i0.ɵɵconditional(!ctx.IsLoading && ctx.Points.length === 0 ? 2 : -1);
1285
+ i0.ɵɵadvance();
1286
+ i0.ɵɵconditional(ctx.Points.length > 0 ? 3 : -1);
1287
+ i0.ɵɵadvance();
1288
+ i0.ɵɵconditional(ctx.ShowLegend && ctx.Clusters.length > 0 ? 4 : -1);
1289
+ i0.ɵɵadvance();
1290
+ i0.ɵɵconditional(ctx.ShowTooltip && ctx.HoveredPoint ? 5 : -1);
1291
+ i0.ɵɵadvance();
1292
+ i0.ɵɵclassProp("detail-panel-open", ctx.ShowDetailPanel && ctx.SelectedPoint);
1293
+ i0.ɵɵadvance();
1294
+ i0.ɵɵconditional(ctx.SelectedPoint ? 7 : -1);
1295
+ } }, styles: [":host {\n display: block;\n width: 100%;\n height: 100%;\n position: relative;\n}\n\n.scatter-container {\n width: 100%;\n height: 100%;\n position: relative;\n overflow: hidden;\n background: var(--mj-bg-page);\n}\n\n/* SVG */\n.scatter-svg {\n width: 100%;\n height: 100%;\n cursor: grab;\n user-select: none;\n}\n\n.scatter-svg:active {\n cursor: grabbing;\n}\n\n/* Grid lines */\n.grid-lines line {\n stroke: var(--mj-border-subtle);\n stroke-width: 0.5;\n}\n\n/* Data points */\n.data-point {\n cursor: pointer;\n transition: opacity 0.15s ease, r 0.15s ease;\n}\n\n.data-point:hover {\n filter: brightness(1.2);\n}\n\n/* Selection rings */\n.selection-ring {\n pointer-events: none;\n transition: r 0.15s ease;\n}\n\n/* Disable transitions when AnimateTransitions is false */\n.no-transitions .data-point,\n.no-transitions .selection-ring {\n transition: none;\n}\n\n/* Loading overlay */\n.loading-overlay {\n position: absolute;\n inset: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n background: color-mix(in srgb, var(--mj-bg-page) 80%, transparent);\n z-index: 5;\n}\n\n.loading-spinner {\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: 12px;\n color: var(--mj-text-secondary);\n font-size: 0.9rem;\n}\n\n.loading-spinner i {\n font-size: 2rem;\n color: var(--mj-brand-primary);\n}\n\n/* Empty state */\n.empty-state {\n position: absolute;\n inset: 0;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n color: var(--mj-text-muted);\n gap: 8px;\n}\n\n.empty-state i {\n font-size: 3rem;\n margin-bottom: 8px;\n opacity: 0.4;\n}\n\n.empty-state p {\n font-size: 1rem;\n margin: 0;\n}\n\n.empty-hint {\n font-size: 0.8rem !important;\n opacity: 0.7;\n}\n\n/* Legend */\n.legend {\n position: absolute;\n bottom: 20px;\n left: 20px;\n background: color-mix(in srgb, var(--mj-bg-surface) 92%, transparent);\n backdrop-filter: blur(12px);\n border: 1px solid var(--mj-border-default);\n border-radius: 10px;\n padding: 14px 18px;\n z-index: 3;\n}\n\n.legend-title {\n font-size: 0.7rem;\n color: var(--mj-text-muted);\n text-transform: uppercase;\n letter-spacing: 0.5px;\n margin-bottom: 8px;\n}\n\n.legend-item {\n display: flex;\n align-items: center;\n gap: 8px;\n font-size: 0.78rem;\n margin-bottom: 4px;\n color: var(--mj-text-primary);\n cursor: pointer;\n border-radius: 4px;\n padding: 2px 4px;\n margin-left: -4px;\n margin-right: -4px;\n transition: background 0.15s ease;\n}\n\n.legend-item:hover {\n background: var(--mj-bg-surface-hover);\n}\n\n.legend-dot {\n width: 10px;\n height: 10px;\n border-radius: 50%;\n flex-shrink: 0;\n}\n\n.legend-label {\n flex: 1;\n}\n\n.legend-count {\n color: var(--mj-text-muted);\n font-size: 0.7rem;\n margin-left: auto;\n padding-left: 16px;\n}\n\n/* Cluster labels on chart */\n.cluster-label {\n font-size: 14px;\n font-weight: 600;\n opacity: 0.85;\n pointer-events: none;\n text-shadow:\n 0 0 6px var(--mj-bg-page),\n 0 0 12px var(--mj-bg-page),\n 1px 1px 2px var(--mj-bg-page);\n}\n\n/* Tooltip */\n.tooltip {\n position: fixed;\n background: color-mix(in srgb, var(--mj-bg-surface-elevated) 96%, transparent);\n backdrop-filter: blur(16px);\n border: 1px solid var(--mj-border-strong);\n border-radius: 10px;\n padding: 14px 16px;\n width: 280px;\n box-shadow: 0 12px 40px color-mix(in srgb, var(--mj-bg-page) 40%, transparent);\n pointer-events: none;\n z-index: 50;\n}\n\n.tt-header {\n display: flex;\n align-items: flex-start;\n gap: 10px;\n margin-bottom: 6px;\n}\n\n.tt-icon {\n width: 2rem;\n height: 2rem;\n display: flex;\n align-items: center;\n justify-content: center;\n border-radius: 6px;\n background: color-mix(in srgb, var(--mj-brand-primary) 12%, var(--mj-bg-surface));\n color: var(--mj-brand-primary);\n font-size: 0.85rem;\n flex-shrink: 0;\n}\n\n.tt-header-text {\n min-width: 0;\n flex: 1;\n}\n\n.tt-name {\n font-size: 0.85rem;\n font-weight: 600;\n color: var(--mj-text-primary);\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.tt-entity {\n font-size: 0.7rem;\n color: var(--mj-text-muted);\n margin-top: 1px;\n}\n\n.tt-cluster {\n font-size: 0.72rem;\n color: var(--mj-brand-primary);\n margin-bottom: 6px;\n}\n\n.tt-fields {\n font-size: 0.72rem;\n color: var(--mj-text-secondary);\n line-height: 1.6;\n}\n\n.tt-field-row {\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.tt-action {\n margin-top: 8px;\n padding-top: 8px;\n border-top: 1px solid var(--mj-border-default);\n font-size: 0.7rem;\n color: var(--mj-text-muted);\n}\n\n.tt-action i {\n margin-right: 4px;\n}\n\n/* ================================================================\n Detail Panel (right slide-in)\n ================================================================ */\n\n.detail-panel {\n position: absolute;\n top: 0;\n right: 0;\n bottom: 0;\n width: 320px;\n background: color-mix(in srgb, var(--mj-bg-surface) 92%, transparent);\n backdrop-filter: blur(16px);\n border-left: 1px solid var(--mj-border-default);\n z-index: 10;\n display: flex;\n flex-direction: column;\n transform: translateX(100%);\n transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);\n box-shadow: -4px 0 24px color-mix(in srgb, var(--mj-bg-page) 30%, transparent);\n overflow: hidden;\n}\n\n.detail-panel-open {\n transform: translateX(0);\n}\n\n/* Header */\n.detail-header {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n padding: 16px 16px 12px;\n border-bottom: 1px solid var(--mj-border-subtle);\n}\n\n.detail-header-content {\n display: flex;\n align-items: flex-start;\n gap: 10px;\n min-width: 0;\n flex: 1;\n}\n\n.detail-icon {\n width: 2.25rem;\n height: 2.25rem;\n display: flex;\n align-items: center;\n justify-content: center;\n border-radius: 8px;\n background: color-mix(in srgb, var(--mj-brand-primary) 12%, var(--mj-bg-surface));\n color: var(--mj-brand-primary);\n font-size: 0.95rem;\n flex-shrink: 0;\n}\n\n.detail-header-text {\n min-width: 0;\n flex: 1;\n}\n\n.detail-name {\n font-size: 0.95rem;\n font-weight: 600;\n color: var(--mj-text-primary);\n line-height: 1.3;\n word-wrap: break-word;\n}\n\n.detail-entity-badge {\n display: inline-block;\n margin-top: 4px;\n padding: 1px 8px;\n border-radius: 10px;\n font-size: 0.68rem;\n font-weight: 500;\n background: color-mix(in srgb, var(--mj-brand-primary) 10%, var(--mj-bg-surface));\n color: var(--mj-brand-primary);\n}\n\n.detail-close {\n background: none;\n border: none;\n cursor: pointer;\n color: var(--mj-text-muted);\n font-size: 0.85rem;\n padding: 4px 6px;\n border-radius: 4px;\n transition: color 0.15s ease, background 0.15s ease;\n flex-shrink: 0;\n}\n\n.detail-close:hover {\n color: var(--mj-text-primary);\n background: var(--mj-bg-surface-hover);\n}\n\n/* Cluster badge */\n.detail-cluster {\n display: flex;\n align-items: center;\n gap: 6px;\n padding: 8px 16px;\n font-size: 0.78rem;\n font-weight: 500;\n color: var(--mj-text-secondary);\n}\n\n.detail-cluster-dot {\n width: 8px;\n height: 8px;\n border-radius: 50%;\n flex-shrink: 0;\n}\n\n/* Metadata fields */\n.detail-fields {\n padding: 4px 16px 8px;\n overflow-y: auto;\n flex-shrink: 0;\n}\n\n.detail-field {\n padding: 6px 0;\n border-bottom: 1px solid var(--mj-border-subtle);\n}\n\n.detail-field:last-child {\n border-bottom: none;\n}\n\n.detail-field-key {\n font-size: 0.68rem;\n font-weight: 500;\n color: var(--mj-text-muted);\n text-transform: uppercase;\n letter-spacing: 0.3px;\n margin-bottom: 2px;\n}\n\n.detail-field-value {\n font-size: 0.8rem;\n color: var(--mj-text-primary);\n line-height: 1.4;\n word-wrap: break-word;\n}\n\n/* Cluster Members Section */\n.detail-members-section {\n flex: 1;\n min-height: 0;\n display: flex;\n flex-direction: column;\n border-top: 1px solid var(--mj-border-default);\n}\n\n.detail-members-toggle {\n display: flex;\n align-items: center;\n gap: 8px;\n width: 100%;\n padding: 10px 16px;\n background: none;\n border: none;\n cursor: pointer;\n font-size: 0.78rem;\n font-weight: 600;\n color: var(--mj-text-secondary);\n text-align: left;\n transition: background 0.15s ease;\n}\n\n.detail-members-toggle:hover {\n background: var(--mj-bg-surface-hover);\n}\n\n.detail-members-toggle i {\n font-size: 0.65rem;\n width: 12px;\n text-align: center;\n color: var(--mj-text-muted);\n}\n\n.detail-members-count {\n margin-left: auto;\n font-size: 0.7rem;\n font-weight: 400;\n color: var(--mj-text-muted);\n background: var(--mj-bg-surface-sunken);\n padding: 1px 7px;\n border-radius: 8px;\n}\n\n.detail-members-list {\n flex: 1;\n overflow-y: auto;\n padding: 0 8px 8px;\n}\n\n.detail-member-row {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 6px 8px;\n border-radius: 6px;\n cursor: pointer;\n font-size: 0.78rem;\n color: var(--mj-text-primary);\n transition: background 0.12s ease;\n}\n\n.detail-member-row:hover {\n background: var(--mj-bg-surface-hover);\n}\n\n.detail-member-row.active {\n background: color-mix(in srgb, var(--mj-brand-primary) 10%, var(--mj-bg-surface));\n color: var(--mj-brand-primary);\n font-weight: 500;\n}\n\n.detail-member-row .member-icon {\n font-size: 0.72rem;\n color: var(--mj-text-muted);\n width: 16px;\n text-align: center;\n flex-shrink: 0;\n}\n\n.detail-member-row.active .member-icon {\n color: var(--mj-brand-primary);\n}\n\n.member-label {\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n/* Footer */\n.detail-footer {\n padding: 12px 16px;\n border-top: 1px solid var(--mj-border-default);\n flex-shrink: 0;\n}\n\n.detail-open-btn {\n display: flex;\n align-items: center;\n justify-content: center;\n gap: 8px;\n width: 100%;\n padding: 8px 16px;\n border: none;\n border-radius: 8px;\n background: var(--mj-brand-primary);\n color: var(--mj-text-inverse);\n font-size: 0.82rem;\n font-weight: 500;\n cursor: pointer;\n transition: background 0.15s ease;\n}\n\n.detail-open-btn:hover {\n background: var(--mj-brand-primary-hover);\n}\n\n.detail-open-btn i {\n font-size: 0.75rem;\n}\n"], encapsulation: 2 }); }
1296
+ }
1297
+ (() => { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassMetadata(ClusterScatterComponent, [{
1298
+ type: Component,
1299
+ args: [{ standalone: false, selector: 'mj-cluster-scatter', encapsulation: ViewEncapsulation.None, template: "<div class=\"scatter-container\">\n <!-- Loading overlay -->\n @if (IsLoading) {\n <div class=\"loading-overlay\">\n <div class=\"loading-spinner\">\n <i class=\"fa-solid fa-circle-nodes fa-spin\"></i>\n <span>Computing clusters...</span>\n </div>\n </div>\n }\n\n <!-- Empty state -->\n @if (!IsLoading && Points.length === 0) {\n <div class=\"empty-state\">\n <i class=\"fa-solid fa-chart-scatter\"></i>\n <p>No data to display</p>\n <p class=\"empty-hint\">Configure and run clustering to see results</p>\n </div>\n }\n\n <!-- SVG scatter plot -->\n @if (Points.length > 0) {\n <svg #svgElement\n class=\"scatter-svg\"\n [class.no-transitions]=\"!AnimateTransitions\"\n [attr.viewBox]=\"GetViewBoxString()\"\n preserveAspectRatio=\"xMidYMid meet\"\n (wheel)=\"OnWheel($event)\"\n (mousedown)=\"OnMouseDown($event)\">\n\n <!-- Gradient definitions for cluster glow -->\n <defs>\n @for (centroid of ClusterCentroids; track TrackCentroidBy($index, centroid)) {\n <radialGradient [attr.id]=\"'cluster-glow-' + centroid.ClusterId\"\n cx=\"50%\" cy=\"50%\" r=\"50%\">\n <stop offset=\"0%\" [attr.stop-color]=\"centroid.Color\" stop-opacity=\"0.12\"/>\n <stop offset=\"100%\" [attr.stop-color]=\"centroid.Color\" stop-opacity=\"0\"/>\n </radialGradient>\n }\n </defs>\n\n <!-- Background grid lines -->\n <g class=\"grid-lines\">\n @for (y of HorizontalGridLines; track y) {\n <line [attr.x1]=\"0\" [attr.y1]=\"y\" [attr.x2]=\"1000\" [attr.y2]=\"y\"/>\n }\n @for (x of VerticalGridLines; track x) {\n <line [attr.x1]=\"x\" [attr.y1]=\"0\" [attr.x2]=\"x\" [attr.y2]=\"700\"/>\n }\n </g>\n\n <!-- Cluster glow circles -->\n @for (centroid of ClusterCentroids; track TrackCentroidBy($index, centroid)) {\n <circle [attr.cx]=\"centroid.Cx\"\n [attr.cy]=\"centroid.Cy\"\n [attr.r]=\"centroid.R\"\n [attr.fill]=\"'url(#cluster-glow-' + centroid.ClusterId + ')'\"/>\n }\n\n <!-- Cluster labels (positioned above centroids) -->\n @for (centroid of ClusterCentroids; track TrackCentroidBy($index, centroid)) {\n @if (GetClusterLabelById(centroid.ClusterId); as label) {\n <text class=\"cluster-label\"\n [attr.x]=\"centroid.Cx\"\n [attr.y]=\"centroid.Cy - centroid.R - 8\"\n text-anchor=\"middle\"\n [attr.fill]=\"centroid.Color\">{{ label }}</text>\n }\n }\n\n <!-- Data points -->\n @for (point of Points; track TrackPointBy($index, point)) {\n <circle class=\"data-point\"\n [attr.cx]=\"point.X\"\n [attr.cy]=\"point.Y\"\n [attr.r]=\"GetPointRadius(point)\"\n [attr.fill]=\"GetPointColor(point)\"\n [attr.opacity]=\"GetPointOpacity(point)\"\n (mouseenter)=\"OnPointMouseEnter(point, $event)\"\n (mouseleave)=\"OnPointMouseLeave()\"\n (click)=\"OnPointClick(point); $event.stopPropagation()\"/>\n }\n\n <!-- Selection rings for selected points -->\n @for (point of Points; track TrackPointBy($index, point)) {\n @if (IsSelected(point)) {\n <circle class=\"selection-ring\"\n [attr.cx]=\"point.X\"\n [attr.cy]=\"point.Y\"\n [attr.r]=\"HighlightRadius + 2\"\n fill=\"none\"\n [attr.stroke]=\"GetPointColor(point)\"\n stroke-width=\"2\"\n opacity=\"0.8\"/>\n }\n }\n\n <!-- Highlight ring for last-clicked point -->\n @if (HighlightedKey) {\n @for (point of Points; track TrackPointBy($index, point)) {\n @if (IsHighlighted(point)) {\n <circle [attr.cx]=\"point.X\"\n [attr.cy]=\"point.Y\"\n [attr.r]=\"HighlightRadius + 4\"\n fill=\"none\"\n [attr.stroke]=\"GetPointColor(point)\"\n stroke-width=\"2.5\"\n opacity=\"0.9\"/>\n }\n }\n }\n </svg>\n }\n\n <!-- Legend overlay -->\n @if (ShowLegend && Clusters.length > 0) {\n <div class=\"legend\">\n <div class=\"legend-title\">Clusters</div>\n @for (cluster of Clusters; track TrackClusterBy($index, cluster)) {\n <div class=\"legend-item\" (click)=\"OnClusterLegendClick(cluster)\">\n <div class=\"legend-dot\" [style.background]=\"cluster.Color\"></div>\n <span class=\"legend-label\">{{ cluster.Label }}</span>\n <span class=\"legend-count\">{{ cluster.MemberCount }}</span>\n </div>\n }\n </div>\n }\n\n <!-- Tooltip -->\n @if (ShowTooltip && HoveredPoint) {\n <div class=\"tooltip\"\n [style.left.px]=\"TooltipX\"\n [style.top.px]=\"TooltipY\">\n <div class=\"tt-header\">\n @if (HoveredPoint.Metadata?.['EntityIcon']) {\n <div class=\"tt-icon\">\n <i [class]=\"HoveredPoint.Metadata['EntityIcon']\"></i>\n </div>\n }\n <div class=\"tt-header-text\">\n <div class=\"tt-name\">{{ HoveredPoint.Label }}</div>\n @if (HoveredPoint.Metadata?.['Entity']) {\n <div class=\"tt-entity\">{{ HoveredPoint.Metadata['Entity'] }}</div>\n }\n </div>\n </div>\n <div class=\"tt-cluster\">\n <i class=\"fa-solid fa-circle\" style=\"font-size: 7px; vertical-align: middle\"></i>\n {{ GetClusterLabel(HoveredPoint) }}\n </div>\n @if (TooltipEntries.length > 0) {\n <div class=\"tt-fields\">\n @for (entry of TooltipEntries; track entry.Key) {\n @if (entry.Key !== 'Entity' && entry.Key !== 'EntityIcon' && entry.Key !== 'RecordID' && entry.Key !== 'Name' && entry.Key !== 'TemplateID' && entry.Key !== '__mj_UpdatedAt') {\n <div class=\"tt-field-row\">{{ entry.Value }}</div>\n }\n }\n </div>\n }\n <div class=\"tt-action\">\n <i class=\"fa-solid fa-arrow-up-right-from-square\"></i> Click to view details\n </div>\n </div>\n }\n\n <!-- Detail Panel (slide-in from right) -->\n <div class=\"detail-panel\" [class.detail-panel-open]=\"ShowDetailPanel && SelectedPoint\">\n @if (SelectedPoint) {\n <!-- Panel Header -->\n <div class=\"detail-header\">\n <div class=\"detail-header-content\">\n @if (SelectedPoint.Metadata?.['EntityIcon']) {\n <div class=\"detail-icon\">\n <i [class]=\"SelectedPoint.Metadata['EntityIcon']\"></i>\n </div>\n }\n <div class=\"detail-header-text\">\n <div class=\"detail-name\">{{ SelectedPoint.Label }}</div>\n @if (SelectedPoint.Metadata?.['Entity']) {\n <span class=\"detail-entity-badge\">{{ SelectedPoint.Metadata['Entity'] }}</span>\n }\n </div>\n </div>\n <button class=\"detail-close\" (click)=\"CloseDetailPanel()\" title=\"Close\">\n <i class=\"fa-solid fa-times\"></i>\n </button>\n </div>\n\n <!-- Cluster badge -->\n <div class=\"detail-cluster\">\n <span class=\"detail-cluster-dot\"\n [style.background]=\"GetClusterColor(SelectedPoint.ClusterId)\"></span>\n {{ GetClusterLabel(SelectedPoint) }}\n </div>\n\n <!-- Metadata fields -->\n @if (DetailEntries.length > 0) {\n <div class=\"detail-fields\">\n @for (entry of DetailEntries; track entry.Key) {\n <div class=\"detail-field\">\n <div class=\"detail-field-key\">{{ entry.Key }}</div>\n <div class=\"detail-field-value\">{{ entry.Value }}</div>\n </div>\n }\n </div>\n }\n\n <!-- Cluster Members -->\n <div class=\"detail-members-section\">\n <button class=\"detail-members-toggle\" (click)=\"ToggleClusterMembers()\">\n <i class=\"fa-solid\"\n [class.fa-chevron-down]=\"ClusterMembersExpanded\"\n [class.fa-chevron-right]=\"!ClusterMembersExpanded\"></i>\n <span>Cluster Members</span>\n <span class=\"detail-members-count\">{{ ClusterMembers.length }}</span>\n </button>\n\n @if (ClusterMembersExpanded) {\n <div class=\"detail-members-list\">\n @for (member of ClusterMembers; track TrackMemberBy($index, member)) {\n <div class=\"detail-member-row\"\n [class.active]=\"member.VectorKey === SelectedPoint.VectorKey\"\n (click)=\"OnClusterMemberClick(member)\">\n @if (member.Metadata?.['EntityIcon']) {\n <i [class]=\"member.Metadata['EntityIcon']\" class=\"member-icon\"></i>\n }\n <span class=\"member-label\">{{ member.Label }}</span>\n </div>\n }\n </div>\n }\n </div>\n\n <!-- Open Record button -->\n <div class=\"detail-footer\">\n <button class=\"detail-open-btn\" (click)=\"OnOpenRecordClick()\">\n <i class=\"fa-solid fa-arrow-up-right-from-square\"></i>\n Open Record\n </button>\n </div>\n }\n </div>\n</div>\n", styles: [":host {\n display: block;\n width: 100%;\n height: 100%;\n position: relative;\n}\n\n.scatter-container {\n width: 100%;\n height: 100%;\n position: relative;\n overflow: hidden;\n background: var(--mj-bg-page);\n}\n\n/* SVG */\n.scatter-svg {\n width: 100%;\n height: 100%;\n cursor: grab;\n user-select: none;\n}\n\n.scatter-svg:active {\n cursor: grabbing;\n}\n\n/* Grid lines */\n.grid-lines line {\n stroke: var(--mj-border-subtle);\n stroke-width: 0.5;\n}\n\n/* Data points */\n.data-point {\n cursor: pointer;\n transition: opacity 0.15s ease, r 0.15s ease;\n}\n\n.data-point:hover {\n filter: brightness(1.2);\n}\n\n/* Selection rings */\n.selection-ring {\n pointer-events: none;\n transition: r 0.15s ease;\n}\n\n/* Disable transitions when AnimateTransitions is false */\n.no-transitions .data-point,\n.no-transitions .selection-ring {\n transition: none;\n}\n\n/* Loading overlay */\n.loading-overlay {\n position: absolute;\n inset: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n background: color-mix(in srgb, var(--mj-bg-page) 80%, transparent);\n z-index: 5;\n}\n\n.loading-spinner {\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: 12px;\n color: var(--mj-text-secondary);\n font-size: 0.9rem;\n}\n\n.loading-spinner i {\n font-size: 2rem;\n color: var(--mj-brand-primary);\n}\n\n/* Empty state */\n.empty-state {\n position: absolute;\n inset: 0;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n color: var(--mj-text-muted);\n gap: 8px;\n}\n\n.empty-state i {\n font-size: 3rem;\n margin-bottom: 8px;\n opacity: 0.4;\n}\n\n.empty-state p {\n font-size: 1rem;\n margin: 0;\n}\n\n.empty-hint {\n font-size: 0.8rem !important;\n opacity: 0.7;\n}\n\n/* Legend */\n.legend {\n position: absolute;\n bottom: 20px;\n left: 20px;\n background: color-mix(in srgb, var(--mj-bg-surface) 92%, transparent);\n backdrop-filter: blur(12px);\n border: 1px solid var(--mj-border-default);\n border-radius: 10px;\n padding: 14px 18px;\n z-index: 3;\n}\n\n.legend-title {\n font-size: 0.7rem;\n color: var(--mj-text-muted);\n text-transform: uppercase;\n letter-spacing: 0.5px;\n margin-bottom: 8px;\n}\n\n.legend-item {\n display: flex;\n align-items: center;\n gap: 8px;\n font-size: 0.78rem;\n margin-bottom: 4px;\n color: var(--mj-text-primary);\n cursor: pointer;\n border-radius: 4px;\n padding: 2px 4px;\n margin-left: -4px;\n margin-right: -4px;\n transition: background 0.15s ease;\n}\n\n.legend-item:hover {\n background: var(--mj-bg-surface-hover);\n}\n\n.legend-dot {\n width: 10px;\n height: 10px;\n border-radius: 50%;\n flex-shrink: 0;\n}\n\n.legend-label {\n flex: 1;\n}\n\n.legend-count {\n color: var(--mj-text-muted);\n font-size: 0.7rem;\n margin-left: auto;\n padding-left: 16px;\n}\n\n/* Cluster labels on chart */\n.cluster-label {\n font-size: 14px;\n font-weight: 600;\n opacity: 0.85;\n pointer-events: none;\n text-shadow:\n 0 0 6px var(--mj-bg-page),\n 0 0 12px var(--mj-bg-page),\n 1px 1px 2px var(--mj-bg-page);\n}\n\n/* Tooltip */\n.tooltip {\n position: fixed;\n background: color-mix(in srgb, var(--mj-bg-surface-elevated) 96%, transparent);\n backdrop-filter: blur(16px);\n border: 1px solid var(--mj-border-strong);\n border-radius: 10px;\n padding: 14px 16px;\n width: 280px;\n box-shadow: 0 12px 40px color-mix(in srgb, var(--mj-bg-page) 40%, transparent);\n pointer-events: none;\n z-index: 50;\n}\n\n.tt-header {\n display: flex;\n align-items: flex-start;\n gap: 10px;\n margin-bottom: 6px;\n}\n\n.tt-icon {\n width: 2rem;\n height: 2rem;\n display: flex;\n align-items: center;\n justify-content: center;\n border-radius: 6px;\n background: color-mix(in srgb, var(--mj-brand-primary) 12%, var(--mj-bg-surface));\n color: var(--mj-brand-primary);\n font-size: 0.85rem;\n flex-shrink: 0;\n}\n\n.tt-header-text {\n min-width: 0;\n flex: 1;\n}\n\n.tt-name {\n font-size: 0.85rem;\n font-weight: 600;\n color: var(--mj-text-primary);\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.tt-entity {\n font-size: 0.7rem;\n color: var(--mj-text-muted);\n margin-top: 1px;\n}\n\n.tt-cluster {\n font-size: 0.72rem;\n color: var(--mj-brand-primary);\n margin-bottom: 6px;\n}\n\n.tt-fields {\n font-size: 0.72rem;\n color: var(--mj-text-secondary);\n line-height: 1.6;\n}\n\n.tt-field-row {\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.tt-action {\n margin-top: 8px;\n padding-top: 8px;\n border-top: 1px solid var(--mj-border-default);\n font-size: 0.7rem;\n color: var(--mj-text-muted);\n}\n\n.tt-action i {\n margin-right: 4px;\n}\n\n/* ================================================================\n Detail Panel (right slide-in)\n ================================================================ */\n\n.detail-panel {\n position: absolute;\n top: 0;\n right: 0;\n bottom: 0;\n width: 320px;\n background: color-mix(in srgb, var(--mj-bg-surface) 92%, transparent);\n backdrop-filter: blur(16px);\n border-left: 1px solid var(--mj-border-default);\n z-index: 10;\n display: flex;\n flex-direction: column;\n transform: translateX(100%);\n transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);\n box-shadow: -4px 0 24px color-mix(in srgb, var(--mj-bg-page) 30%, transparent);\n overflow: hidden;\n}\n\n.detail-panel-open {\n transform: translateX(0);\n}\n\n/* Header */\n.detail-header {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n padding: 16px 16px 12px;\n border-bottom: 1px solid var(--mj-border-subtle);\n}\n\n.detail-header-content {\n display: flex;\n align-items: flex-start;\n gap: 10px;\n min-width: 0;\n flex: 1;\n}\n\n.detail-icon {\n width: 2.25rem;\n height: 2.25rem;\n display: flex;\n align-items: center;\n justify-content: center;\n border-radius: 8px;\n background: color-mix(in srgb, var(--mj-brand-primary) 12%, var(--mj-bg-surface));\n color: var(--mj-brand-primary);\n font-size: 0.95rem;\n flex-shrink: 0;\n}\n\n.detail-header-text {\n min-width: 0;\n flex: 1;\n}\n\n.detail-name {\n font-size: 0.95rem;\n font-weight: 600;\n color: var(--mj-text-primary);\n line-height: 1.3;\n word-wrap: break-word;\n}\n\n.detail-entity-badge {\n display: inline-block;\n margin-top: 4px;\n padding: 1px 8px;\n border-radius: 10px;\n font-size: 0.68rem;\n font-weight: 500;\n background: color-mix(in srgb, var(--mj-brand-primary) 10%, var(--mj-bg-surface));\n color: var(--mj-brand-primary);\n}\n\n.detail-close {\n background: none;\n border: none;\n cursor: pointer;\n color: var(--mj-text-muted);\n font-size: 0.85rem;\n padding: 4px 6px;\n border-radius: 4px;\n transition: color 0.15s ease, background 0.15s ease;\n flex-shrink: 0;\n}\n\n.detail-close:hover {\n color: var(--mj-text-primary);\n background: var(--mj-bg-surface-hover);\n}\n\n/* Cluster badge */\n.detail-cluster {\n display: flex;\n align-items: center;\n gap: 6px;\n padding: 8px 16px;\n font-size: 0.78rem;\n font-weight: 500;\n color: var(--mj-text-secondary);\n}\n\n.detail-cluster-dot {\n width: 8px;\n height: 8px;\n border-radius: 50%;\n flex-shrink: 0;\n}\n\n/* Metadata fields */\n.detail-fields {\n padding: 4px 16px 8px;\n overflow-y: auto;\n flex-shrink: 0;\n}\n\n.detail-field {\n padding: 6px 0;\n border-bottom: 1px solid var(--mj-border-subtle);\n}\n\n.detail-field:last-child {\n border-bottom: none;\n}\n\n.detail-field-key {\n font-size: 0.68rem;\n font-weight: 500;\n color: var(--mj-text-muted);\n text-transform: uppercase;\n letter-spacing: 0.3px;\n margin-bottom: 2px;\n}\n\n.detail-field-value {\n font-size: 0.8rem;\n color: var(--mj-text-primary);\n line-height: 1.4;\n word-wrap: break-word;\n}\n\n/* Cluster Members Section */\n.detail-members-section {\n flex: 1;\n min-height: 0;\n display: flex;\n flex-direction: column;\n border-top: 1px solid var(--mj-border-default);\n}\n\n.detail-members-toggle {\n display: flex;\n align-items: center;\n gap: 8px;\n width: 100%;\n padding: 10px 16px;\n background: none;\n border: none;\n cursor: pointer;\n font-size: 0.78rem;\n font-weight: 600;\n color: var(--mj-text-secondary);\n text-align: left;\n transition: background 0.15s ease;\n}\n\n.detail-members-toggle:hover {\n background: var(--mj-bg-surface-hover);\n}\n\n.detail-members-toggle i {\n font-size: 0.65rem;\n width: 12px;\n text-align: center;\n color: var(--mj-text-muted);\n}\n\n.detail-members-count {\n margin-left: auto;\n font-size: 0.7rem;\n font-weight: 400;\n color: var(--mj-text-muted);\n background: var(--mj-bg-surface-sunken);\n padding: 1px 7px;\n border-radius: 8px;\n}\n\n.detail-members-list {\n flex: 1;\n overflow-y: auto;\n padding: 0 8px 8px;\n}\n\n.detail-member-row {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 6px 8px;\n border-radius: 6px;\n cursor: pointer;\n font-size: 0.78rem;\n color: var(--mj-text-primary);\n transition: background 0.12s ease;\n}\n\n.detail-member-row:hover {\n background: var(--mj-bg-surface-hover);\n}\n\n.detail-member-row.active {\n background: color-mix(in srgb, var(--mj-brand-primary) 10%, var(--mj-bg-surface));\n color: var(--mj-brand-primary);\n font-weight: 500;\n}\n\n.detail-member-row .member-icon {\n font-size: 0.72rem;\n color: var(--mj-text-muted);\n width: 16px;\n text-align: center;\n flex-shrink: 0;\n}\n\n.detail-member-row.active .member-icon {\n color: var(--mj-brand-primary);\n}\n\n.member-label {\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n/* Footer */\n.detail-footer {\n padding: 12px 16px;\n border-top: 1px solid var(--mj-border-default);\n flex-shrink: 0;\n}\n\n.detail-open-btn {\n display: flex;\n align-items: center;\n justify-content: center;\n gap: 8px;\n width: 100%;\n padding: 8px 16px;\n border: none;\n border-radius: 8px;\n background: var(--mj-brand-primary);\n color: var(--mj-text-inverse);\n font-size: 0.82rem;\n font-weight: 500;\n cursor: pointer;\n transition: background 0.15s ease;\n}\n\n.detail-open-btn:hover {\n background: var(--mj-brand-primary-hover);\n}\n\n.detail-open-btn i {\n font-size: 0.75rem;\n}\n"] }]
1300
+ }], null, { Points: [{
1301
+ type: Input
1302
+ }], Clusters: [{
1303
+ type: Input
1304
+ }], IsLoading: [{
1305
+ type: Input
1306
+ }], DotRadius: [{
1307
+ type: Input
1308
+ }], DotOpacity: [{
1309
+ type: Input
1310
+ }], HighlightRadius: [{
1311
+ type: Input
1312
+ }], ShowLegend: [{
1313
+ type: Input
1314
+ }], ShowTooltip: [{
1315
+ type: Input
1316
+ }], TooltipFields: [{
1317
+ type: Input
1318
+ }], ColorPalette: [{
1319
+ type: Input
1320
+ }], EnableZoom: [{
1321
+ type: Input
1322
+ }], EnablePan: [{
1323
+ type: Input
1324
+ }], AnimateTransitions: [{
1325
+ type: Input
1326
+ }], SelectedPointIds: [{
1327
+ type: Input
1328
+ }], MinZoom: [{
1329
+ type: Input
1330
+ }], MaxZoom: [{
1331
+ type: Input
1332
+ }], PointClicked: [{
1333
+ type: Output
1334
+ }], OpenRecordRequested: [{
1335
+ type: Output
1336
+ }], PointHovered: [{
1337
+ type: Output
1338
+ }], BeforePointClick: [{
1339
+ type: Output
1340
+ }], BeforePointHover: [{
1341
+ type: Output
1342
+ }], BeforeZoom: [{
1343
+ type: Output
1344
+ }], AfterClusteringComplete: [{
1345
+ type: Output
1346
+ }], ClusterSelected: [{
1347
+ type: Output
1348
+ }], SelectionChanged: [{
1349
+ type: Output
1350
+ }], ViewportChanged: [{
1351
+ type: Output
1352
+ }], svgRef: [{
1353
+ type: ViewChild,
1354
+ args: ['svgElement', { static: false }]
1355
+ }] }); })();
1356
+ (() => { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassDebugInfo(ClusterScatterComponent, { className: "ClusterScatterComponent", filePath: "src/lib/cluster-scatter.component.ts", lineNumber: 64 }); })();
1357
+ //# sourceMappingURL=cluster-scatter.component.js.map