@memberjunction/ng-clustering 5.39.0 → 5.40.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 (49) hide show
  1. package/dist/__tests__/cluster-view-type.test.d.ts +2 -0
  2. package/dist/__tests__/cluster-view-type.test.d.ts.map +1 -0
  3. package/dist/__tests__/cluster-view-type.test.js +63 -0
  4. package/dist/__tests__/cluster-view-type.test.js.map +1 -0
  5. package/dist/lib/cluster-config-panel.component.d.ts +39 -1
  6. package/dist/lib/cluster-config-panel.component.d.ts.map +1 -1
  7. package/dist/lib/cluster-config-panel.component.js +287 -88
  8. package/dist/lib/cluster-config-panel.component.js.map +1 -1
  9. package/dist/lib/cluster-scatter.component.d.ts +53 -1
  10. package/dist/lib/cluster-scatter.component.d.ts.map +1 -1
  11. package/dist/lib/cluster-scatter.component.js +385 -135
  12. package/dist/lib/cluster-scatter.component.js.map +1 -1
  13. package/dist/lib/clustering.module.d.ts +7 -4
  14. package/dist/lib/clustering.module.d.ts.map +1 -1
  15. package/dist/lib/clustering.module.js +20 -3
  16. package/dist/lib/clustering.module.js.map +1 -1
  17. package/dist/lib/clustering.service.d.ts +63 -3
  18. package/dist/lib/clustering.service.d.ts.map +1 -1
  19. package/dist/lib/clustering.service.js +169 -48
  20. package/dist/lib/clustering.service.js.map +1 -1
  21. package/dist/lib/clustering.types.d.ts +17 -2
  22. package/dist/lib/clustering.types.d.ts.map +1 -1
  23. package/dist/lib/clustering.types.js +3 -0
  24. package/dist/lib/clustering.types.js.map +1 -1
  25. package/dist/lib/view-type/cluster-view-prop-sheet.component.d.ts +31 -0
  26. package/dist/lib/view-type/cluster-view-prop-sheet.component.d.ts.map +1 -0
  27. package/dist/lib/view-type/cluster-view-prop-sheet.component.js +185 -0
  28. package/dist/lib/view-type/cluster-view-prop-sheet.component.js.map +1 -0
  29. package/dist/lib/view-type/cluster-view-renderer.component.d.ts +143 -0
  30. package/dist/lib/view-type/cluster-view-renderer.component.d.ts.map +1 -0
  31. package/dist/lib/view-type/cluster-view-renderer.component.js +508 -0
  32. package/dist/lib/view-type/cluster-view-renderer.component.js.map +1 -0
  33. package/dist/lib/view-type/cluster-view-type.d.ts +35 -0
  34. package/dist/lib/view-type/cluster-view-type.d.ts.map +1 -0
  35. package/dist/lib/view-type/cluster-view-type.js +51 -0
  36. package/dist/lib/view-type/cluster-view-type.js.map +1 -0
  37. package/dist/lib/view-type/cluster-view.types.d.ts +31 -0
  38. package/dist/lib/view-type/cluster-view.types.d.ts.map +1 -0
  39. package/dist/lib/view-type/cluster-view.types.js +29 -0
  40. package/dist/lib/view-type/cluster-view.types.js.map +1 -0
  41. package/dist/lib/view-type/entity-document-availability.engine.d.ts +80 -0
  42. package/dist/lib/view-type/entity-document-availability.engine.d.ts.map +1 -0
  43. package/dist/lib/view-type/entity-document-availability.engine.js +149 -0
  44. package/dist/lib/view-type/entity-document-availability.engine.js.map +1 -0
  45. package/dist/public-api.d.ts +5 -0
  46. package/dist/public-api.d.ts.map +1 -1
  47. package/dist/public-api.js +6 -0
  48. package/dist/public-api.js.map +1 -1
  49. package/package.json +11 -3
@@ -0,0 +1,508 @@
1
+ import { Component, Input, Output, EventEmitter, ChangeDetectorRef, ViewEncapsulation, inject } from '@angular/core';
2
+ import { Subject } from 'rxjs';
3
+ import { debounceTime, takeUntil } from 'rxjs/operators';
4
+ import { EntityInfo } from '@memberjunction/core';
5
+ import { UUIDsEqual } from '@memberjunction/global';
6
+ import { GraphQLDataProvider, GraphQLAIClient } from '@memberjunction/graphql-dataprovider';
7
+ import { AIEngineBase } from '@memberjunction/ai-engine-base';
8
+ import { BaseAngularComponent } from '@memberjunction/ng-base-types';
9
+ import { ClusteringService } from '../clustering.service';
10
+ import { DefaultClusterConfig, } from '../clustering.types';
11
+ import { toClusterViewConfig } from './cluster-view.types';
12
+ import { EntityDocumentAvailabilityEngine } from './entity-document-availability.engine';
13
+ import * as i0 from "@angular/core";
14
+ import * as i1 from "../cluster-scatter.component";
15
+ function ClusterViewRendererComponent_Conditional_1_Template(rf, ctx) { if (rf & 1) {
16
+ i0.ɵɵelementStart(0, "div", 1);
17
+ i0.ɵɵelement(1, "i", 4);
18
+ i0.ɵɵelementStart(2, "span");
19
+ i0.ɵɵtext(3);
20
+ i0.ɵɵelementEnd()();
21
+ } if (rf & 2) {
22
+ const ctx_r0 = i0.ɵɵnextContext();
23
+ i0.ɵɵadvance(3);
24
+ i0.ɵɵtextInterpolate(ctx_r0.errorMessage);
25
+ } }
26
+ function ClusterViewRendererComponent_Conditional_2_Template(rf, ctx) { if (rf & 1) {
27
+ i0.ɵɵelementStart(0, "div", 2);
28
+ i0.ɵɵelement(1, "i", 5);
29
+ i0.ɵɵelementStart(2, "span");
30
+ i0.ɵɵtext(3, "No vectors available to cluster for this entity yet.");
31
+ i0.ɵɵelementEnd()();
32
+ } }
33
+ function ClusterViewRendererComponent_Conditional_3_Template(rf, ctx) { if (rf & 1) {
34
+ const _r2 = i0.ɵɵgetCurrentView();
35
+ i0.ɵɵelementStart(0, "mj-cluster-scatter", 6);
36
+ i0.ɵɵlistener("PointClicked", function ClusterViewRendererComponent_Conditional_3_Template_mj_cluster_scatter_PointClicked_0_listener($event) { i0.ɵɵrestoreView(_r2); const ctx_r0 = i0.ɵɵnextContext(); return i0.ɵɵresetView(ctx_r0.onPointClicked($event)); })("OpenRecordRequested", function ClusterViewRendererComponent_Conditional_3_Template_mj_cluster_scatter_OpenRecordRequested_0_listener($event) { i0.ɵɵrestoreView(_r2); const ctx_r0 = i0.ɵɵnextContext(); return i0.ɵɵresetView(ctx_r0.onOpenRecordRequested($event)); });
37
+ i0.ɵɵelementEnd();
38
+ } if (rf & 2) {
39
+ const ctx_r0 = i0.ɵɵnextContext();
40
+ i0.ɵɵproperty("Points", ctx_r0.points)("Clusters", ctx_r0.clusters)("ColorBy", ctx_r0.activeConfig.colorBy)("EntityName", (ctx_r0.entity == null ? null : ctx_r0.entity.Name) ?? null)("FieldPriority", ctx_r0.fieldPriority)("FieldDisplayNames", ctx_r0.fieldDisplayNames)("IsLoading", ctx_r0.isLoading);
41
+ } }
42
+ /**
43
+ * ClusterViewRendererComponent
44
+ * ----------------------------
45
+ * The Cluster **view type** renderer — a thin {@link IViewRenderer} adapter that lets any
46
+ * entity with vectors be visualized as a cluster scatter directly inside `mj-entity-viewer`,
47
+ * reusing the exact same {@link ClusteringService} + `mj-cluster-scatter` the Knowledge Hub
48
+ * "Visualize" cluster surface uses.
49
+ *
50
+ * The host feeds it the standard renderer inputs (entity / records / filterText / config) via
51
+ * `setInput`, and listens for `recordSelected` / `recordOpened`. When the entity or config
52
+ * changes it re-runs the clustering pipeline and rebinds the scatter.
53
+ *
54
+ * **Why this mirrors the Knowledge Hub resource rather than the server `RunClusterAnalysis`
55
+ * path:** the server analysis returns points keyed only by record ID (so labels render as
56
+ * "Unknown"), produces generic "Cluster N" names, and picks an Entity Document
57
+ * non-deterministically when none is supplied (causing flip-flop "no vectors" results). This
58
+ * renderer instead:
59
+ * 1. Resolves the entity's **first active Entity Document explicitly** (deterministic source).
60
+ * 2. Fetches that document's vectors (with their JSON metadata) via {@link GraphQLAIClient.FetchEntityVectors}.
61
+ * 3. Builds real point labels from the entity's `IsNameField` fields ({@link buildLabel}).
62
+ * 4. Clusters in-browser via {@link ClusteringService.RunClustering} (client owns vectors + labels).
63
+ * 5. Requests + applies **LLM cluster names** so clusters read semantically, not "Cluster 1..5".
64
+ *
65
+ * Inputs use the camelCase names mandated by the {@link IViewRenderer} contract (the host
66
+ * binds them by those exact names), rather than MJ's usual PascalCase for public members.
67
+ */
68
+ export class ClusterViewRendererComponent extends BaseAngularComponent {
69
+ set entity(value) {
70
+ const changed = !UUIDsEqual(value?.ID, this._entity?.ID);
71
+ this._entity = value;
72
+ if (changed) {
73
+ this.scheduleRecluster();
74
+ }
75
+ }
76
+ get entity() {
77
+ return this._entity;
78
+ }
79
+ set config(value) {
80
+ const next = toClusterViewConfig(value ?? {});
81
+ // Only re-cluster when the clustering-relevant config actually changed. The host re-pushes the
82
+ // (unchanged) config on unrelated events — notably when a point is clicked / selection changes —
83
+ // and re-clustering on every push made clicking a point recompute the whole scatter. Comparing
84
+ // the normalized config keeps clustering stable across those no-op re-pushes.
85
+ const changed = JSON.stringify(next) !== JSON.stringify(this.activeConfig);
86
+ this._config = value ?? {};
87
+ this.activeConfig = next;
88
+ if (changed) {
89
+ this.scheduleRecluster();
90
+ }
91
+ }
92
+ get config() {
93
+ return this._config;
94
+ }
95
+ constructor(cdr) {
96
+ super();
97
+ this.cdr = cdr;
98
+ // ---- IViewRenderer inputs (camelCase per the host contract) ----
99
+ this._entity = null;
100
+ /** The view's records — used to map a clicked point back to its record object. */
101
+ this.records = [];
102
+ this.selectedRecordId = null;
103
+ this.filterText = null;
104
+ this._config = {};
105
+ // ---- IViewRenderer outputs ----
106
+ this.recordSelected = new EventEmitter();
107
+ this.recordOpened = new EventEmitter();
108
+ this.configChanged = new EventEmitter();
109
+ // ---- Render state ----
110
+ /** The current parsed config (defaults applied). */
111
+ this.activeConfig = toClusterViewConfig({});
112
+ this.points = [];
113
+ this.clusters = [];
114
+ /** Ordered field keys for prioritized display in the scatter tooltip / detail panel. */
115
+ this.fieldPriority = [];
116
+ /** Map of field names → human-readable display names for the scatter tooltip / detail panel. */
117
+ this.fieldDisplayNames = {};
118
+ this.isLoading = false;
119
+ this.errorMessage = null;
120
+ this.recluster$ = new Subject();
121
+ this.destroy$ = new Subject();
122
+ this.clusteringService = inject(ClusteringService);
123
+ /**
124
+ * Monotonically increasing token captured at the start of each run. Because runs are async
125
+ * and overlapping (entity/config can change mid-flight), each run compares the token it
126
+ * captured against the latest before applying results — a stale run silently bails so it
127
+ * cannot clobber a newer one.
128
+ */
129
+ this.runToken = 0;
130
+ }
131
+ ngOnInit() {
132
+ // Debounce so the entity + config inputs that arrive together on mount cause one run.
133
+ this.recluster$.pipe(debounceTime(50), takeUntil(this.destroy$)).subscribe(() => void this.runClustering());
134
+ // Kick an initial run AFTER the subscription exists. The component is dynamic-mounted, so the
135
+ // host sets `entity`/`config` via setInput BEFORE ngOnInit runs — those setters fire
136
+ // scheduleRecluster() into recluster$ while there is no subscriber yet, so the initial emission
137
+ // is dropped (a plain Subject doesn't replay). Without this, the very first Cluster open after
138
+ // app load renders nothing. runClustering() no-ops gracefully if no entity is set yet.
139
+ this.scheduleRecluster();
140
+ }
141
+ ngOnDestroy() {
142
+ this.destroy$.next();
143
+ this.destroy$.complete();
144
+ }
145
+ scheduleRecluster() {
146
+ this.recluster$.next();
147
+ }
148
+ /**
149
+ * Run the clustering pipeline for the current entity + config and rebind the scatter.
150
+ *
151
+ * Mirrors the Knowledge Hub "Visualize" flow: resolve a deterministic Entity Document →
152
+ * fetch its vectors with metadata → build real labels → cluster client-side → request LLM
153
+ * cluster names. Guarded by {@link runToken} so an overlapping/stale run never clobbers a
154
+ * newer one.
155
+ */
156
+ async runClustering() {
157
+ const token = ++this.runToken;
158
+ const entity = this._entity;
159
+ if (!entity) {
160
+ this.points = [];
161
+ this.clusters = [];
162
+ this.cdr.detectChanges();
163
+ return;
164
+ }
165
+ this.isLoading = true;
166
+ this.errorMessage = null;
167
+ this.cdr.detectChanges();
168
+ try {
169
+ const gqlProvider = this.resolveGraphQLProvider();
170
+ if (!gqlProvider) {
171
+ this.applyError(token, 'Clustering requires a GraphQL data provider, which is not available here.');
172
+ return;
173
+ }
174
+ const docID = await this.resolveEntityDocumentID(entity);
175
+ if (this.isStale(token)) {
176
+ return;
177
+ }
178
+ if (!docID) {
179
+ // No active Entity Document for this entity → nothing to cluster (empty state).
180
+ this.applyResult(token, [], []);
181
+ return;
182
+ }
183
+ const vectors = await this.fetchVectors(gqlProvider, entity, docID);
184
+ if (this.isStale(token)) {
185
+ return;
186
+ }
187
+ if (vectors.length === 0) {
188
+ this.applyResult(token, [], []);
189
+ return;
190
+ }
191
+ const cfg = this.buildClusterConfig(entity, docID);
192
+ const result = await this.clusteringService.RunClustering(vectors, cfg);
193
+ if (this.isStale(token)) {
194
+ return;
195
+ }
196
+ this.fieldPriority = this.computeFieldPriority(entity);
197
+ this.applyResult(token, result.Points, result.Clusters);
198
+ // Fire LLM cluster naming in the background (non-blocking) when enabled. Clusters render
199
+ // immediately with provisional names; semantic labels appear when the LLM responds.
200
+ if (this.activeConfig.nameClusters) {
201
+ void this.requestClusterLabelsFromLLM(gqlProvider, token, result);
202
+ }
203
+ }
204
+ catch (err) {
205
+ this.applyError(token, err instanceof Error ? err.message : String(err));
206
+ }
207
+ }
208
+ /** True when a newer run has started since `token` was captured — the caller should bail. */
209
+ isStale(token) {
210
+ return token !== this.runToken;
211
+ }
212
+ /** Apply a successful clustering result to the view (no-op if the run is stale). */
213
+ applyResult(token, points, clusters) {
214
+ if (this.isStale(token)) {
215
+ return;
216
+ }
217
+ this.points = points;
218
+ this.clusters = clusters;
219
+ this.isLoading = false;
220
+ this.cdr.detectChanges();
221
+ }
222
+ /** Apply an error state to the view (no-op if the run is stale). */
223
+ applyError(token, message) {
224
+ if (this.isStale(token)) {
225
+ return;
226
+ }
227
+ this.errorMessage = message;
228
+ this.points = [];
229
+ this.clusters = [];
230
+ this.isLoading = false;
231
+ this.cdr.detectChanges();
232
+ }
233
+ /**
234
+ * Resolve the {@link GraphQLDataProvider} backing this component's metadata provider, or
235
+ * `null` when the active provider is not a GraphQL transport (e.g. non-browser).
236
+ */
237
+ resolveGraphQLProvider() {
238
+ const p = this.ProviderToUse;
239
+ return p instanceof GraphQLDataProvider ? p : null;
240
+ }
241
+ /**
242
+ * Resolve the entity's first active Entity Document ID **explicitly** (deterministic),
243
+ * loading the availability engine cache if needed. Returns `null` when the entity has no
244
+ * active document.
245
+ */
246
+ async resolveEntityDocumentID(entity) {
247
+ const engine = EntityDocumentAvailabilityEngine.Instance;
248
+ const p = this.ProviderToUse;
249
+ await engine.Config(false, p.CurrentUser, p);
250
+ const docs = engine.GetActiveEntityDocumentsForEntity(entity);
251
+ return docs.length > 0 ? docs[0].ID : null;
252
+ }
253
+ /**
254
+ * Fetch the entity document's vectors (with metadata) and build {@link ClusterInputVector}s,
255
+ * deriving each point's display label from the entity's name fields.
256
+ */
257
+ async fetchVectors(gqlProvider, entity, docID) {
258
+ const aiClient = new GraphQLAIClient(gqlProvider);
259
+ const result = await aiClient.FetchEntityVectors({
260
+ entityDocumentID: docID,
261
+ maxRecords: this.activeConfig.maxRecords,
262
+ filter: this.filterText?.trim() ? this.filterText.trim() : undefined,
263
+ });
264
+ if (!result.Success || result.Results.length === 0) {
265
+ return [];
266
+ }
267
+ const vectors = [];
268
+ for (const item of result.Results) {
269
+ if (!item.Values || item.Values.length === 0) {
270
+ continue;
271
+ }
272
+ const metadata = this.parseVectorMetadata(item.Metadata);
273
+ vectors.push({
274
+ Key: item.ID,
275
+ Label: this.buildLabel(entity, metadata),
276
+ Vector: item.Values,
277
+ Metadata: metadata,
278
+ });
279
+ }
280
+ return vectors;
281
+ }
282
+ /** Parse the JSON metadata string stored alongside a vector into a flat string map. */
283
+ parseVectorMetadata(metadataJson) {
284
+ try {
285
+ return JSON.parse(metadataJson);
286
+ }
287
+ catch {
288
+ return {};
289
+ }
290
+ }
291
+ /**
292
+ * Build a human-readable label for a point from the entity's name-field metadata. Combines
293
+ * all `IsNameField` fields in Sequence order (e.g. "Sarah Chen" from FirstName + LastName),
294
+ * then falls back to the single NameField, then to common heuristic keys, then to the record
295
+ * ID — so points show real names instead of "Unknown".
296
+ */
297
+ buildLabel(entity, metadata) {
298
+ const nameFields = entity.Fields.filter((f) => f.IsNameField).sort((a, b) => (a.Sequence ?? 9999) - (b.Sequence ?? 9999));
299
+ if (nameFields.length > 0) {
300
+ const parts = nameFields.map((f) => metadata[f.Name]).filter((v) => v != null && v.trim() !== '');
301
+ if (parts.length > 0) {
302
+ return parts.join(' ');
303
+ }
304
+ }
305
+ if (entity.NameField && metadata[entity.NameField.Name]) {
306
+ return metadata[entity.NameField.Name];
307
+ }
308
+ return metadata['Name'] || metadata['Title'] || metadata['Description']?.substring(0, 60) || metadata['RecordID'] || 'Unknown';
309
+ }
310
+ /**
311
+ * Compute the prioritized field order + display names for the scatter tooltip / detail panel.
312
+ * Orders: name fields first, then default-in-view fields, then the rest by Sequence; skips
313
+ * internal/virtual/primary-key fields. Also populates {@link fieldDisplayNames}.
314
+ */
315
+ computeFieldPriority(entity) {
316
+ const internalKeys = new Set(['ID', 'Entity', 'EntityIcon', 'RecordID', 'TemplateID', '__mj_UpdatedAt', '__mj_CreatedAt']);
317
+ const displayNames = {};
318
+ for (const f of entity.Fields) {
319
+ displayNames[f.Name] = f.DisplayNameOrName;
320
+ }
321
+ this.fieldDisplayNames = displayNames;
322
+ return entity.Fields.filter((f) => !internalKeys.has(f.Name) && !f.IsVirtual && !f.IsPrimaryKey)
323
+ .sort((a, b) => {
324
+ if (a.IsNameField !== b.IsNameField)
325
+ return a.IsNameField ? -1 : 1;
326
+ if (a.DefaultInView !== b.DefaultInView)
327
+ return a.DefaultInView ? -1 : 1;
328
+ return (a.Sequence ?? 9999) - (b.Sequence ?? 9999);
329
+ })
330
+ .map((f) => f.Name);
331
+ }
332
+ /** Build the engine {@link ClusterConfig} from the entity + the view-type config. */
333
+ buildClusterConfig(entity, docID) {
334
+ const c = this.activeConfig;
335
+ return {
336
+ ...DefaultClusterConfig(),
337
+ EntityName: entity.Name,
338
+ EntityDocumentID: docID,
339
+ ColorBy: c.colorBy,
340
+ Dimensions: c.dimensions,
341
+ Algorithm: c.algorithm,
342
+ K: c.k,
343
+ MaxRecords: c.maxRecords,
344
+ Filter: this.filterText?.trim() ?? '',
345
+ };
346
+ }
347
+ // ================================================================
348
+ // LLM cluster naming
349
+ // ================================================================
350
+ /**
351
+ * After clustering completes, send sample records per cluster to the "Cluster Naming" AI
352
+ * prompt and apply the returned semantic labels to {@link clusters}. Best-effort: failures
353
+ * are logged and leave the provisional "Cluster N" labels in place. No-op if the run became
354
+ * stale while the LLM call was in flight.
355
+ */
356
+ async requestClusterLabelsFromLLM(gqlProvider, token, result) {
357
+ try {
358
+ const clusterData = this.buildClusterDataForPrompt(result);
359
+ if (!clusterData) {
360
+ return;
361
+ }
362
+ // AIEngineBase is deferred at startup; ensure it's loaded before reading .Prompts.
363
+ await AIEngineBase.Instance.EnsureLoaded();
364
+ const promptEntity = AIEngineBase.Instance.Prompts.find((p) => p.Name === 'Cluster Naming');
365
+ if (!promptEntity) {
366
+ console.warn('[ClusterViewRenderer] "Cluster Naming" prompt not found — run metadata sync');
367
+ return;
368
+ }
369
+ const aiClient = new GraphQLAIClient(gqlProvider);
370
+ const promptResult = await aiClient.RunAIPrompt({
371
+ promptId: promptEntity.ID,
372
+ data: { clusterData },
373
+ });
374
+ if (this.isStale(token) || !promptResult.success || !promptResult.parsedResult) {
375
+ return;
376
+ }
377
+ const labels = promptResult.parsedResult;
378
+ const clusterLabels = labels.map((l) => ({
379
+ ClusterId: l.clusterId,
380
+ Label: l.label,
381
+ IsUserEdited: false,
382
+ }));
383
+ this.applyLabelsToClusters(clusterLabels);
384
+ this.cdr.detectChanges();
385
+ }
386
+ catch (error) {
387
+ console.warn('[ClusterViewRenderer] LLM cluster naming failed:', error);
388
+ }
389
+ }
390
+ /** Build a text block describing sample records per cluster for the LLM naming prompt. */
391
+ buildClusterDataForPrompt(result) {
392
+ if (!result.Clusters || result.Clusters.length === 0) {
393
+ return null;
394
+ }
395
+ const lines = [];
396
+ for (const cluster of result.Clusters) {
397
+ const clusterPoints = result.Points.filter((p) => p.ClusterId === cluster.Id);
398
+ const samples = clusterPoints.slice(0, 5);
399
+ if (samples.length === 0) {
400
+ continue;
401
+ }
402
+ lines.push(`### Cluster ${cluster.Id} (${clusterPoints.length} records)`);
403
+ for (const point of samples) {
404
+ const meta = (point.Metadata ?? {});
405
+ const fields = ['Name', 'Title', 'Description', 'Entity', 'Status', 'Type']
406
+ .filter((f) => meta[f])
407
+ .map((f) => `${f}: ${meta[f]}`)
408
+ .join(', ');
409
+ lines.push(`- ${fields || point.Label}`);
410
+ }
411
+ lines.push('');
412
+ }
413
+ return lines.join('\n');
414
+ }
415
+ /** Apply LLM-generated labels onto the live {@link clusters} array by cluster ID. */
416
+ applyLabelsToClusters(labels) {
417
+ for (const label of labels) {
418
+ const cluster = this.clusters.find((c) => c.Id === label.ClusterId);
419
+ if (cluster) {
420
+ cluster.Label = label.Label;
421
+ }
422
+ }
423
+ }
424
+ // ================================================================
425
+ // Point → record mapping
426
+ // ================================================================
427
+ onPointClicked(point) {
428
+ this.recordSelected.emit(this.recordForPoint(point) ?? { ID: point.VectorKey });
429
+ }
430
+ onOpenRecordRequested(point) {
431
+ this.recordOpened.emit(this.recordForPoint(point) ?? { ID: point.VectorKey });
432
+ }
433
+ /**
434
+ * Resolve the host record corresponding to a scatter point. The vector key is the Entity
435
+ * Record Document ID, so match on the point metadata's `RecordID` (the underlying entity
436
+ * record's primary key) first, then fall back to a direct key match.
437
+ */
438
+ recordForPoint(point) {
439
+ const meta = (point.Metadata ?? {});
440
+ const recordID = typeof meta['RecordID'] === 'string' ? meta['RecordID'] : null;
441
+ const key = recordID ?? point.VectorKey;
442
+ if (!key) {
443
+ return null;
444
+ }
445
+ return (this.records.find((r) => {
446
+ const id = r['ID'];
447
+ return typeof id === 'string' && UUIDsEqual(id, key);
448
+ }) ?? null);
449
+ }
450
+ static { this.ɵfac = function ClusterViewRendererComponent_Factory(__ngFactoryType__) { return new (__ngFactoryType__ || ClusterViewRendererComponent)(i0.ɵɵdirectiveInject(i0.ChangeDetectorRef)); }; }
451
+ static { this.ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({ type: ClusterViewRendererComponent, selectors: [["mj-cluster-view-renderer"]], inputs: { entity: "entity", records: "records", selectedRecordId: "selectedRecordId", filterText: "filterText", config: "config" }, outputs: { recordSelected: "recordSelected", recordOpened: "recordOpened", configChanged: "configChanged" }, standalone: false, features: [i0.ɵɵInheritDefinitionFeature], decls: 4, vars: 1, consts: [[1, "cluster-view-renderer"], [1, "cluster-view-message", "cluster-view-error"], [1, "cluster-view-message"], [3, "Points", "Clusters", "ColorBy", "EntityName", "FieldPriority", "FieldDisplayNames", "IsLoading"], [1, "fa-solid", "fa-triangle-exclamation"], [1, "fa-solid", "fa-diagram-project"], [3, "PointClicked", "OpenRecordRequested", "Points", "Clusters", "ColorBy", "EntityName", "FieldPriority", "FieldDisplayNames", "IsLoading"]], template: function ClusterViewRendererComponent_Template(rf, ctx) { if (rf & 1) {
452
+ i0.ɵɵelementStart(0, "div", 0);
453
+ i0.ɵɵconditionalCreate(1, ClusterViewRendererComponent_Conditional_1_Template, 4, 1, "div", 1)(2, ClusterViewRendererComponent_Conditional_2_Template, 4, 0, "div", 2)(3, ClusterViewRendererComponent_Conditional_3_Template, 1, 7, "mj-cluster-scatter", 3);
454
+ i0.ɵɵelementEnd();
455
+ } if (rf & 2) {
456
+ i0.ɵɵadvance();
457
+ i0.ɵɵconditional(ctx.errorMessage ? 1 : !ctx.isLoading && ctx.points.length === 0 ? 2 : 3);
458
+ } }, dependencies: [i1.ClusterScatterComponent], styles: ["\n .cluster-view-renderer {\n height: 100%;\n width: 100%;\n display: flex;\n flex-direction: column;\n }\n .cluster-view-renderer mj-cluster-scatter {\n flex: 1 1 auto;\n min-height: 0;\n }\n .cluster-view-message {\n flex: 1 1 auto;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n gap: 12px;\n color: var(--mj-text-muted);\n padding: 32px;\n text-align: center;\n }\n .cluster-view-message i {\n font-size: 32px;\n opacity: 0.6;\n }\n .cluster-view-error {\n color: var(--mj-status-error-text);\n }\n "], encapsulation: 2 }); }
459
+ }
460
+ (() => { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassMetadata(ClusterViewRendererComponent, [{
461
+ type: Component,
462
+ args: [{ standalone: false, selector: 'mj-cluster-view-renderer', encapsulation: ViewEncapsulation.None, template: `
463
+ <div class="cluster-view-renderer">
464
+ @if (errorMessage) {
465
+ <div class="cluster-view-message cluster-view-error">
466
+ <i class="fa-solid fa-triangle-exclamation"></i>
467
+ <span>{{ errorMessage }}</span>
468
+ </div>
469
+ } @else if (!isLoading && points.length === 0) {
470
+ <div class="cluster-view-message">
471
+ <i class="fa-solid fa-diagram-project"></i>
472
+ <span>No vectors available to cluster for this entity yet.</span>
473
+ </div>
474
+ } @else {
475
+ <mj-cluster-scatter
476
+ [Points]="points"
477
+ [Clusters]="clusters"
478
+ [ColorBy]="activeConfig.colorBy"
479
+ [EntityName]="entity?.Name ?? null"
480
+ [FieldPriority]="fieldPriority"
481
+ [FieldDisplayNames]="fieldDisplayNames"
482
+ [IsLoading]="isLoading"
483
+ (PointClicked)="onPointClicked($event)"
484
+ (OpenRecordRequested)="onOpenRecordRequested($event)"
485
+ >
486
+ </mj-cluster-scatter>
487
+ }
488
+ </div>
489
+ `, styles: ["\n .cluster-view-renderer {\n height: 100%;\n width: 100%;\n display: flex;\n flex-direction: column;\n }\n .cluster-view-renderer mj-cluster-scatter {\n flex: 1 1 auto;\n min-height: 0;\n }\n .cluster-view-message {\n flex: 1 1 auto;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n gap: 12px;\n color: var(--mj-text-muted);\n padding: 32px;\n text-align: center;\n }\n .cluster-view-message i {\n font-size: 32px;\n opacity: 0.6;\n }\n .cluster-view-error {\n color: var(--mj-status-error-text);\n }\n "] }]
490
+ }], () => [{ type: i0.ChangeDetectorRef }], { entity: [{
491
+ type: Input
492
+ }], records: [{
493
+ type: Input
494
+ }], selectedRecordId: [{
495
+ type: Input
496
+ }], filterText: [{
497
+ type: Input
498
+ }], config: [{
499
+ type: Input
500
+ }], recordSelected: [{
501
+ type: Output
502
+ }], recordOpened: [{
503
+ type: Output
504
+ }], configChanged: [{
505
+ type: Output
506
+ }] }); })();
507
+ (() => { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassDebugInfo(ClusterViewRendererComponent, { className: "ClusterViewRendererComponent", filePath: "src/lib/view-type/cluster-view-renderer.component.ts", lineNumber: 114 }); })();
508
+ //# sourceMappingURL=cluster-view-renderer.component.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cluster-view-renderer.component.js","sourceRoot":"","sources":["../../../src/lib/view-type/cluster-view-renderer.component.ts"],"names":[],"mappings":"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","sourcesContent":["import { Component, Input, Output, EventEmitter, ChangeDetectorRef, OnInit, OnDestroy, ViewEncapsulation, inject } from '@angular/core';\nimport { Subject } from 'rxjs';\nimport { debounceTime, takeUntil } from 'rxjs/operators';\nimport { EntityInfo, IMetadataProvider } from '@memberjunction/core';\nimport { UUIDsEqual } from '@memberjunction/global';\nimport { GraphQLDataProvider, GraphQLAIClient } from '@memberjunction/graphql-dataprovider';\nimport { AIEngineBase } from '@memberjunction/ai-engine-base';\nimport { BaseAngularComponent } from '@memberjunction/ng-base-types';\nimport { IViewRenderer } from '@memberjunction/ng-entity-viewer';\nimport { ClusteringService } from '../clustering.service';\nimport {\n ClusterConfig,\n ClusterPoint,\n ClusterInfo,\n ClusterInputVector,\n ClusterLabel,\n ClusterVisualizationResult,\n DefaultClusterConfig,\n} from '../clustering.types';\nimport { ClusterViewConfig, toClusterViewConfig } from './cluster-view.types';\nimport { EntityDocumentAvailabilityEngine } from './entity-document-availability.engine';\n\n/**\n * ClusterViewRendererComponent\n * ----------------------------\n * The Cluster **view type** renderer — a thin {@link IViewRenderer} adapter that lets any\n * entity with vectors be visualized as a cluster scatter directly inside `mj-entity-viewer`,\n * reusing the exact same {@link ClusteringService} + `mj-cluster-scatter` the Knowledge Hub\n * \"Visualize\" cluster surface uses.\n *\n * The host feeds it the standard renderer inputs (entity / records / filterText / config) via\n * `setInput`, and listens for `recordSelected` / `recordOpened`. When the entity or config\n * changes it re-runs the clustering pipeline and rebinds the scatter.\n *\n * **Why this mirrors the Knowledge Hub resource rather than the server `RunClusterAnalysis`\n * path:** the server analysis returns points keyed only by record ID (so labels render as\n * \"Unknown\"), produces generic \"Cluster N\" names, and picks an Entity Document\n * non-deterministically when none is supplied (causing flip-flop \"no vectors\" results). This\n * renderer instead:\n * 1. Resolves the entity's **first active Entity Document explicitly** (deterministic source).\n * 2. Fetches that document's vectors (with their JSON metadata) via {@link GraphQLAIClient.FetchEntityVectors}.\n * 3. Builds real point labels from the entity's `IsNameField` fields ({@link buildLabel}).\n * 4. Clusters in-browser via {@link ClusteringService.RunClustering} (client owns vectors + labels).\n * 5. Requests + applies **LLM cluster names** so clusters read semantically, not \"Cluster 1..5\".\n *\n * Inputs use the camelCase names mandated by the {@link IViewRenderer} contract (the host\n * binds them by those exact names), rather than MJ's usual PascalCase for public members.\n */\n@Component({\n standalone: false,\n selector: 'mj-cluster-view-renderer',\n encapsulation: ViewEncapsulation.None,\n template: `\n <div class=\"cluster-view-renderer\">\n @if (errorMessage) {\n <div class=\"cluster-view-message cluster-view-error\">\n <i class=\"fa-solid fa-triangle-exclamation\"></i>\n <span>{{ errorMessage }}</span>\n </div>\n } @else if (!isLoading && points.length === 0) {\n <div class=\"cluster-view-message\">\n <i class=\"fa-solid fa-diagram-project\"></i>\n <span>No vectors available to cluster for this entity yet.</span>\n </div>\n } @else {\n <mj-cluster-scatter\n [Points]=\"points\"\n [Clusters]=\"clusters\"\n [ColorBy]=\"activeConfig.colorBy\"\n [EntityName]=\"entity?.Name ?? null\"\n [FieldPriority]=\"fieldPriority\"\n [FieldDisplayNames]=\"fieldDisplayNames\"\n [IsLoading]=\"isLoading\"\n (PointClicked)=\"onPointClicked($event)\"\n (OpenRecordRequested)=\"onOpenRecordRequested($event)\"\n >\n </mj-cluster-scatter>\n }\n </div>\n `,\n styles: [\n `\n .cluster-view-renderer {\n height: 100%;\n width: 100%;\n display: flex;\n flex-direction: column;\n }\n .cluster-view-renderer mj-cluster-scatter {\n flex: 1 1 auto;\n min-height: 0;\n }\n .cluster-view-message {\n flex: 1 1 auto;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n gap: 12px;\n color: var(--mj-text-muted);\n padding: 32px;\n text-align: center;\n }\n .cluster-view-message i {\n font-size: 32px;\n opacity: 0.6;\n }\n .cluster-view-error {\n color: var(--mj-status-error-text);\n }\n `,\n ],\n})\nexport class ClusterViewRendererComponent extends BaseAngularComponent implements IViewRenderer<Record<string, unknown>>, OnInit, OnDestroy {\n // ---- IViewRenderer inputs (camelCase per the host contract) ----\n\n private _entity: EntityInfo | null = null;\n @Input()\n set entity(value: EntityInfo | null) {\n const changed = !UUIDsEqual(value?.ID, this._entity?.ID);\n this._entity = value;\n if (changed) {\n this.scheduleRecluster();\n }\n }\n get entity(): EntityInfo | null {\n return this._entity;\n }\n\n /** The view's records — used to map a clicked point back to its record object. */\n @Input() records: Record<string, unknown>[] = [];\n\n @Input() selectedRecordId: string | null = null;\n\n @Input() filterText: string | null = null;\n\n private _config: Record<string, unknown> = {};\n @Input()\n set config(value: Record<string, unknown>) {\n const next = toClusterViewConfig(value ?? {});\n // Only re-cluster when the clustering-relevant config actually changed. The host re-pushes the\n // (unchanged) config on unrelated events — notably when a point is clicked / selection changes —\n // and re-clustering on every push made clicking a point recompute the whole scatter. Comparing\n // the normalized config keeps clustering stable across those no-op re-pushes.\n const changed = JSON.stringify(next) !== JSON.stringify(this.activeConfig);\n this._config = value ?? {};\n this.activeConfig = next;\n if (changed) {\n this.scheduleRecluster();\n }\n }\n get config(): Record<string, unknown> {\n return this._config;\n }\n\n // ---- IViewRenderer outputs ----\n\n @Output() recordSelected = new EventEmitter<unknown>();\n @Output() recordOpened = new EventEmitter<unknown>();\n @Output() configChanged = new EventEmitter<Record<string, unknown>>();\n\n // ---- Render state ----\n\n /** The current parsed config (defaults applied). */\n public activeConfig: ClusterViewConfig = toClusterViewConfig({});\n public points: ClusterPoint[] = [];\n public clusters: ClusterInfo[] = [];\n /** Ordered field keys for prioritized display in the scatter tooltip / detail panel. */\n public fieldPriority: string[] = [];\n /** Map of field names → human-readable display names for the scatter tooltip / detail panel. */\n public fieldDisplayNames: Record<string, string> = {};\n public isLoading = false;\n public errorMessage: string | null = null;\n\n private recluster$ = new Subject<void>();\n private destroy$ = new Subject<void>();\n private clusteringService = inject(ClusteringService);\n\n /**\n * Monotonically increasing token captured at the start of each run. Because runs are async\n * and overlapping (entity/config can change mid-flight), each run compares the token it\n * captured against the latest before applying results — a stale run silently bails so it\n * cannot clobber a newer one.\n */\n private runToken = 0;\n\n constructor(private cdr: ChangeDetectorRef) {\n super();\n }\n\n ngOnInit(): void {\n // Debounce so the entity + config inputs that arrive together on mount cause one run.\n this.recluster$.pipe(debounceTime(50), takeUntil(this.destroy$)).subscribe(() => void this.runClustering());\n // Kick an initial run AFTER the subscription exists. The component is dynamic-mounted, so the\n // host sets `entity`/`config` via setInput BEFORE ngOnInit runs — those setters fire\n // scheduleRecluster() into recluster$ while there is no subscriber yet, so the initial emission\n // is dropped (a plain Subject doesn't replay). Without this, the very first Cluster open after\n // app load renders nothing. runClustering() no-ops gracefully if no entity is set yet.\n this.scheduleRecluster();\n }\n\n ngOnDestroy(): void {\n this.destroy$.next();\n this.destroy$.complete();\n }\n\n private scheduleRecluster(): void {\n this.recluster$.next();\n }\n\n /**\n * Run the clustering pipeline for the current entity + config and rebind the scatter.\n *\n * Mirrors the Knowledge Hub \"Visualize\" flow: resolve a deterministic Entity Document →\n * fetch its vectors with metadata → build real labels → cluster client-side → request LLM\n * cluster names. Guarded by {@link runToken} so an overlapping/stale run never clobbers a\n * newer one.\n */\n private async runClustering(): Promise<void> {\n const token = ++this.runToken;\n const entity = this._entity;\n if (!entity) {\n this.points = [];\n this.clusters = [];\n this.cdr.detectChanges();\n return;\n }\n\n this.isLoading = true;\n this.errorMessage = null;\n this.cdr.detectChanges();\n\n try {\n const gqlProvider = this.resolveGraphQLProvider();\n if (!gqlProvider) {\n this.applyError(token, 'Clustering requires a GraphQL data provider, which is not available here.');\n return;\n }\n\n const docID = await this.resolveEntityDocumentID(entity);\n if (this.isStale(token)) {\n return;\n }\n if (!docID) {\n // No active Entity Document for this entity → nothing to cluster (empty state).\n this.applyResult(token, [], []);\n return;\n }\n\n const vectors = await this.fetchVectors(gqlProvider, entity, docID);\n if (this.isStale(token)) {\n return;\n }\n if (vectors.length === 0) {\n this.applyResult(token, [], []);\n return;\n }\n\n const cfg = this.buildClusterConfig(entity, docID);\n const result = await this.clusteringService.RunClustering(vectors, cfg);\n if (this.isStale(token)) {\n return;\n }\n\n this.fieldPriority = this.computeFieldPriority(entity);\n this.applyResult(token, result.Points, result.Clusters);\n\n // Fire LLM cluster naming in the background (non-blocking) when enabled. Clusters render\n // immediately with provisional names; semantic labels appear when the LLM responds.\n if (this.activeConfig.nameClusters) {\n void this.requestClusterLabelsFromLLM(gqlProvider, token, result);\n }\n } catch (err) {\n this.applyError(token, err instanceof Error ? err.message : String(err));\n }\n }\n\n /** True when a newer run has started since `token` was captured — the caller should bail. */\n private isStale(token: number): boolean {\n return token !== this.runToken;\n }\n\n /** Apply a successful clustering result to the view (no-op if the run is stale). */\n private applyResult(token: number, points: ClusterPoint[], clusters: ClusterInfo[]): void {\n if (this.isStale(token)) {\n return;\n }\n this.points = points;\n this.clusters = clusters;\n this.isLoading = false;\n this.cdr.detectChanges();\n }\n\n /** Apply an error state to the view (no-op if the run is stale). */\n private applyError(token: number, message: string): void {\n if (this.isStale(token)) {\n return;\n }\n this.errorMessage = message;\n this.points = [];\n this.clusters = [];\n this.isLoading = false;\n this.cdr.detectChanges();\n }\n\n /**\n * Resolve the {@link GraphQLDataProvider} backing this component's metadata provider, or\n * `null` when the active provider is not a GraphQL transport (e.g. non-browser).\n */\n private resolveGraphQLProvider(): GraphQLDataProvider | null {\n const p: IMetadataProvider = this.ProviderToUse;\n return p instanceof GraphQLDataProvider ? p : null;\n }\n\n /**\n * Resolve the entity's first active Entity Document ID **explicitly** (deterministic),\n * loading the availability engine cache if needed. Returns `null` when the entity has no\n * active document.\n */\n private async resolveEntityDocumentID(entity: EntityInfo): Promise<string | null> {\n const engine = EntityDocumentAvailabilityEngine.Instance;\n const p = this.ProviderToUse;\n await engine.Config(false, p.CurrentUser, p);\n const docs = engine.GetActiveEntityDocumentsForEntity(entity);\n return docs.length > 0 ? docs[0].ID : null;\n }\n\n /**\n * Fetch the entity document's vectors (with metadata) and build {@link ClusterInputVector}s,\n * deriving each point's display label from the entity's name fields.\n */\n private async fetchVectors(gqlProvider: GraphQLDataProvider, entity: EntityInfo, docID: string): Promise<ClusterInputVector[]> {\n const aiClient = new GraphQLAIClient(gqlProvider);\n const result = await aiClient.FetchEntityVectors({\n entityDocumentID: docID,\n maxRecords: this.activeConfig.maxRecords,\n filter: this.filterText?.trim() ? this.filterText.trim() : undefined,\n });\n\n if (!result.Success || result.Results.length === 0) {\n return [];\n }\n\n const vectors: ClusterInputVector[] = [];\n for (const item of result.Results) {\n if (!item.Values || item.Values.length === 0) {\n continue;\n }\n const metadata = this.parseVectorMetadata(item.Metadata);\n vectors.push({\n Key: item.ID,\n Label: this.buildLabel(entity, metadata),\n Vector: item.Values,\n Metadata: metadata,\n });\n }\n return vectors;\n }\n\n /** Parse the JSON metadata string stored alongside a vector into a flat string map. */\n private parseVectorMetadata(metadataJson: string): Record<string, string> {\n try {\n return JSON.parse(metadataJson) as Record<string, string>;\n } catch {\n return {};\n }\n }\n\n /**\n * Build a human-readable label for a point from the entity's name-field metadata. Combines\n * all `IsNameField` fields in Sequence order (e.g. \"Sarah Chen\" from FirstName + LastName),\n * then falls back to the single NameField, then to common heuristic keys, then to the record\n * ID — so points show real names instead of \"Unknown\".\n */\n private buildLabel(entity: EntityInfo, metadata: Record<string, string>): string {\n const nameFields = entity.Fields.filter((f) => f.IsNameField).sort((a, b) => (a.Sequence ?? 9999) - (b.Sequence ?? 9999));\n if (nameFields.length > 0) {\n const parts = nameFields.map((f) => metadata[f.Name]).filter((v) => v != null && v.trim() !== '');\n if (parts.length > 0) {\n return parts.join(' ');\n }\n }\n if (entity.NameField && metadata[entity.NameField.Name]) {\n return metadata[entity.NameField.Name];\n }\n return metadata['Name'] || metadata['Title'] || metadata['Description']?.substring(0, 60) || metadata['RecordID'] || 'Unknown';\n }\n\n /**\n * Compute the prioritized field order + display names for the scatter tooltip / detail panel.\n * Orders: name fields first, then default-in-view fields, then the rest by Sequence; skips\n * internal/virtual/primary-key fields. Also populates {@link fieldDisplayNames}.\n */\n private computeFieldPriority(entity: EntityInfo): string[] {\n const internalKeys = new Set(['ID', 'Entity', 'EntityIcon', 'RecordID', 'TemplateID', '__mj_UpdatedAt', '__mj_CreatedAt']);\n\n const displayNames: Record<string, string> = {};\n for (const f of entity.Fields) {\n displayNames[f.Name] = f.DisplayNameOrName;\n }\n this.fieldDisplayNames = displayNames;\n\n return entity.Fields.filter((f) => !internalKeys.has(f.Name) && !f.IsVirtual && !f.IsPrimaryKey)\n .sort((a, b) => {\n if (a.IsNameField !== b.IsNameField) return a.IsNameField ? -1 : 1;\n if (a.DefaultInView !== b.DefaultInView) return a.DefaultInView ? -1 : 1;\n return (a.Sequence ?? 9999) - (b.Sequence ?? 9999);\n })\n .map((f) => f.Name);\n }\n\n /** Build the engine {@link ClusterConfig} from the entity + the view-type config. */\n private buildClusterConfig(entity: EntityInfo, docID: string): ClusterConfig {\n const c = this.activeConfig;\n return {\n ...DefaultClusterConfig(),\n EntityName: entity.Name,\n EntityDocumentID: docID,\n ColorBy: c.colorBy,\n Dimensions: c.dimensions,\n Algorithm: c.algorithm,\n K: c.k,\n MaxRecords: c.maxRecords,\n Filter: this.filterText?.trim() ?? '',\n };\n }\n\n // ================================================================\n // LLM cluster naming\n // ================================================================\n\n /**\n * After clustering completes, send sample records per cluster to the \"Cluster Naming\" AI\n * prompt and apply the returned semantic labels to {@link clusters}. Best-effort: failures\n * are logged and leave the provisional \"Cluster N\" labels in place. No-op if the run became\n * stale while the LLM call was in flight.\n */\n private async requestClusterLabelsFromLLM(gqlProvider: GraphQLDataProvider, token: number, result: ClusterVisualizationResult): Promise<void> {\n try {\n const clusterData = this.buildClusterDataForPrompt(result);\n if (!clusterData) {\n return;\n }\n\n // AIEngineBase is deferred at startup; ensure it's loaded before reading .Prompts.\n await AIEngineBase.Instance.EnsureLoaded();\n const promptEntity = AIEngineBase.Instance.Prompts.find((p) => p.Name === 'Cluster Naming');\n if (!promptEntity) {\n console.warn('[ClusterViewRenderer] \"Cluster Naming\" prompt not found — run metadata sync');\n return;\n }\n\n const aiClient = new GraphQLAIClient(gqlProvider);\n const promptResult = await aiClient.RunAIPrompt({\n promptId: promptEntity.ID,\n data: { clusterData },\n });\n\n if (this.isStale(token) || !promptResult.success || !promptResult.parsedResult) {\n return;\n }\n\n const labels = promptResult.parsedResult as Array<{ clusterId: number; label: string }>;\n const clusterLabels: ClusterLabel[] = labels.map((l) => ({\n ClusterId: l.clusterId,\n Label: l.label,\n IsUserEdited: false,\n }));\n this.applyLabelsToClusters(clusterLabels);\n this.cdr.detectChanges();\n } catch (error) {\n console.warn('[ClusterViewRenderer] LLM cluster naming failed:', error);\n }\n }\n\n /** Build a text block describing sample records per cluster for the LLM naming prompt. */\n private buildClusterDataForPrompt(result: ClusterVisualizationResult): string | null {\n if (!result.Clusters || result.Clusters.length === 0) {\n return null;\n }\n\n const lines: string[] = [];\n for (const cluster of result.Clusters) {\n const clusterPoints = result.Points.filter((p) => p.ClusterId === cluster.Id);\n const samples = clusterPoints.slice(0, 5);\n if (samples.length === 0) {\n continue;\n }\n\n lines.push(`### Cluster ${cluster.Id} (${clusterPoints.length} records)`);\n for (const point of samples) {\n const meta = (point.Metadata ?? {}) as Record<string, unknown>;\n const fields = ['Name', 'Title', 'Description', 'Entity', 'Status', 'Type']\n .filter((f) => meta[f])\n .map((f) => `${f}: ${meta[f]}`)\n .join(', ');\n lines.push(`- ${fields || point.Label}`);\n }\n lines.push('');\n }\n\n return lines.join('\\n');\n }\n\n /** Apply LLM-generated labels onto the live {@link clusters} array by cluster ID. */\n private applyLabelsToClusters(labels: ClusterLabel[]): void {\n for (const label of labels) {\n const cluster = this.clusters.find((c) => c.Id === label.ClusterId);\n if (cluster) {\n cluster.Label = label.Label;\n }\n }\n }\n\n // ================================================================\n // Point → record mapping\n // ================================================================\n\n onPointClicked(point: ClusterPoint): void {\n this.recordSelected.emit(this.recordForPoint(point) ?? { ID: point.VectorKey });\n }\n\n onOpenRecordRequested(point: ClusterPoint): void {\n this.recordOpened.emit(this.recordForPoint(point) ?? { ID: point.VectorKey });\n }\n\n /**\n * Resolve the host record corresponding to a scatter point. The vector key is the Entity\n * Record Document ID, so match on the point metadata's `RecordID` (the underlying entity\n * record's primary key) first, then fall back to a direct key match.\n */\n private recordForPoint(point: ClusterPoint): Record<string, unknown> | null {\n const meta = (point.Metadata ?? {}) as Record<string, unknown>;\n const recordID = typeof meta['RecordID'] === 'string' ? (meta['RecordID'] as string) : null;\n const key = recordID ?? point.VectorKey;\n if (!key) {\n return null;\n }\n return (\n this.records.find((r) => {\n const id = r['ID'];\n return typeof id === 'string' && UUIDsEqual(id, key);\n }) ?? null\n );\n }\n}\n"]}
@@ -0,0 +1,35 @@
1
+ import { Type } from '@angular/core';
2
+ import { EntityInfo, IMetadataProvider } from '@memberjunction/core';
3
+ import { BaseViewTypeDescriptor } from '@memberjunction/ng-entity-viewer';
4
+ /**
5
+ * ClusterViewType
6
+ * ---------------
7
+ * The Cluster **view-type plug-in** descriptor. This is the proof that a feature package
8
+ * (ng-clustering) can contribute a brand-new entity-viewer view type by depending only on the
9
+ * host's contracts (`@memberjunction/ng-entity-viewer`) — the host needs zero changes.
10
+ *
11
+ * - **RendererComponent** → {@link ClusterViewRendererComponent} (wraps `mj-cluster-scatter`).
12
+ * - **PropSheetComponent** → {@link ClusterViewPropSheetComponent} (algorithm/K/2D-3D/etc.).
13
+ * - **IsAvailableFor** → true only for entities that have an active Entity Document (vectors),
14
+ * read synchronously from {@link EntityDocumentAvailabilityEngine}.
15
+ * - **EnsureAvailabilityData** → preloads that engine so the sync predicate has data.
16
+ *
17
+ * Registered with the ClassFactory under `'ClusterViewType'`, matching the `DriverClass` of the
18
+ * seeded `MJ: View Types` "Cluster" row.
19
+ */
20
+ export declare class ClusterViewType extends BaseViewTypeDescriptor {
21
+ readonly Name = "ClusterViewType";
22
+ readonly DisplayName = "Cluster";
23
+ readonly Icon = "fa-solid fa-diagram-project";
24
+ readonly RendererComponent: Type<unknown>;
25
+ readonly PropSheetComponent: Type<unknown>;
26
+ IsAvailableFor(entity: EntityInfo, _provider?: IMetadataProvider): boolean;
27
+ EnsureAvailabilityData(provider?: IMetadataProvider): Promise<void>;
28
+ }
29
+ /**
30
+ * Tree-shaking guard. ng-clustering sets `"sideEffects": false`, so without an explicit
31
+ * reference the bundler can drop this module's `@RegisterClass` side effect. Call this once
32
+ * from app bootstrap (or import the ClusteringModule) to guarantee the descriptor registers.
33
+ */
34
+ export declare function LoadClusterViewType(): void;
35
+ //# sourceMappingURL=cluster-view-type.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cluster-view-type.d.ts","sourceRoot":"","sources":["../../../src/lib/view-type/cluster-view-type.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,eAAe,CAAC;AACrC,OAAO,EAAE,UAAU,EAAE,iBAAiB,EAAE,MAAM,sBAAsB,CAAC;AAErE,OAAO,EAAE,sBAAsB,EAAE,MAAM,kCAAkC,CAAC;AAK1E;;;;;;;;;;;;;;;GAeG;AACH,qBACa,eAAgB,SAAQ,sBAAsB;IACzD,QAAQ,CAAC,IAAI,qBAAqB;IAClC,QAAQ,CAAC,WAAW,aAAa;IACjC,QAAQ,CAAC,IAAI,iCAAiC;IAC9C,QAAQ,CAAC,iBAAiB,EAAE,IAAI,CAAC,OAAO,CAAC,CAAgC;IACzE,SAAkB,kBAAkB,EAAE,IAAI,CAAC,OAAO,CAAC,CAAiC;IAE3E,cAAc,CAAC,MAAM,EAAE,UAAU,EAAE,SAAS,CAAC,EAAE,iBAAiB,GAAG,OAAO;IAIpE,sBAAsB,CAAC,QAAQ,CAAC,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC;CAGnF;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,IAAI,IAAI,CAE1C"}