@naniteninja/profile-comparison-lib 1.0.22 → 1.0.24

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.
@@ -1,5 +1,6 @@
1
1
  import * as i0 from '@angular/core';
2
- import { InjectionToken, Injectable, Optional, Inject, inject, EventEmitter, ViewChild, Output, Input, Component, NgModule } from '@angular/core';
2
+ import { InjectionToken, Injectable, Optional, Inject, inject, EventEmitter, HostListener, ViewChild, Output, Input, Component, NgModule } from '@angular/core';
3
+ import { forceSimulation, forceLink, forceCollide, forceX, forceY } from 'd3-force';
3
4
  import { switchMap as switchMap$1, map as map$1, catchError, tap as tap$1 } from 'rxjs/operators';
4
5
  import { from, switchMap, map, Observable, of, BehaviorSubject, throwError, tap, retryWhen, concatMap, timer, catchError as catchError$1, forkJoin } from 'rxjs';
5
6
  import * as i1 from '@angular/common/http';
@@ -13,12 +14,12 @@ var BackendMode;
13
14
  (function (BackendMode) {
14
15
  /** Use the real deployed backend (default). */
15
16
  BackendMode[BackendMode["Real"] = 0] = "Real";
16
- /** Use a local mock server at localhost:3000. */
17
+ /** Use a local mock server at localhost:44101. */
17
18
  BackendMode[BackendMode["Mock"] = 1] = "Mock";
18
19
  })(BackendMode || (BackendMode = {}));
19
20
  const BACKEND_MODE_URLS = {
20
21
  [BackendMode.Real]: 'https://api.test.thecyrano.app/api/profile-comparison/api',
21
- [BackendMode.Mock]: 'http://localhost:3000/api',
22
+ [BackendMode.Mock]: 'http://localhost:44101/api',
22
23
  };
23
24
  /**
24
25
  * Legacy injection token for the backend base URL.
@@ -202,10 +203,10 @@ class CachePersistenceService {
202
203
  static STORE_OPENAI_EMBEDDINGS = STORE_OPENAI_EMBEDDINGS;
203
204
  static STORE_PROFILE_FACE = STORE_PROFILE_FACE;
204
205
  static STORE_PROFILE_COMPARE = STORE_PROFILE_COMPARE;
205
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: CachePersistenceService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
206
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: CachePersistenceService, providedIn: 'root' });
206
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.24", ngImport: i0, type: CachePersistenceService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
207
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.24", ngImport: i0, type: CachePersistenceService, providedIn: 'root' });
207
208
  }
208
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: CachePersistenceService, decorators: [{
209
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.24", ngImport: i0, type: CachePersistenceService, decorators: [{
209
210
  type: Injectable,
210
211
  args: [{ providedIn: 'root' }]
211
212
  }] });
@@ -313,10 +314,10 @@ class FileConversionService {
313
314
  const mime = this.getMimeFromFilename(imageStr);
314
315
  return this.urlToFile(imageStr, fileBaseName, mime);
315
316
  }
316
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: FileConversionService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
317
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: FileConversionService, providedIn: 'root' });
317
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.24", ngImport: i0, type: FileConversionService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
318
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.24", ngImport: i0, type: FileConversionService, providedIn: 'root' });
318
319
  }
319
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: FileConversionService, decorators: [{
320
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.24", ngImport: i0, type: FileConversionService, decorators: [{
320
321
  type: Injectable,
321
322
  args: [{
322
323
  providedIn: 'root'
@@ -432,10 +433,10 @@ class ImageCompressionService {
432
433
  return `${base}.webp`;
433
434
  return `${base}.jpg`;
434
435
  }
435
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: ImageCompressionService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
436
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: ImageCompressionService, providedIn: 'root' });
436
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.24", ngImport: i0, type: ImageCompressionService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
437
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.24", ngImport: i0, type: ImageCompressionService, providedIn: 'root' });
437
438
  }
438
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: ImageCompressionService, decorators: [{
439
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.24", ngImport: i0, type: ImageCompressionService, decorators: [{
439
440
  type: Injectable,
440
441
  args: [{
441
442
  providedIn: 'root'
@@ -1214,10 +1215,10 @@ class OpenAIEmbeddingService {
1214
1215
  this.persistence.clear(CachePersistenceService.STORE_OPENAI_SIMILARITY);
1215
1216
  this.persistence.clear(CachePersistenceService.STORE_OPENAI_EMBEDDINGS);
1216
1217
  }
1217
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: OpenAIEmbeddingService, deps: [{ token: i1.HttpClient }, { token: PROFILE_COMPARISON_API_BASE_URL, optional: true }, { token: CachePersistenceService }], target: i0.ɵɵFactoryTarget.Injectable });
1218
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: OpenAIEmbeddingService, providedIn: 'root' });
1218
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.24", ngImport: i0, type: OpenAIEmbeddingService, deps: [{ token: i1.HttpClient }, { token: PROFILE_COMPARISON_API_BASE_URL, optional: true }, { token: CachePersistenceService }], target: i0.ɵɵFactoryTarget.Injectable });
1219
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.24", ngImport: i0, type: OpenAIEmbeddingService, providedIn: 'root' });
1219
1220
  }
1220
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: OpenAIEmbeddingService, decorators: [{
1221
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.24", ngImport: i0, type: OpenAIEmbeddingService, decorators: [{
1221
1222
  type: Injectable,
1222
1223
  args: [{
1223
1224
  providedIn: 'root'
@@ -1249,10 +1250,10 @@ class ProfileComparisonLibService {
1249
1250
  getVersion() {
1250
1251
  return '0.0.0';
1251
1252
  }
1252
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: ProfileComparisonLibService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1253
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: ProfileComparisonLibService, providedIn: 'root' });
1253
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.24", ngImport: i0, type: ProfileComparisonLibService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1254
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.24", ngImport: i0, type: ProfileComparisonLibService, providedIn: 'root' });
1254
1255
  }
1255
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: ProfileComparisonLibService, decorators: [{
1256
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.24", ngImport: i0, type: ProfileComparisonLibService, decorators: [{
1256
1257
  type: Injectable,
1257
1258
  args: [{
1258
1259
  providedIn: 'root'
@@ -1506,10 +1507,10 @@ class ProfileService {
1506
1507
  this.compareInterestsCache.clear();
1507
1508
  this.persistence.clear(CachePersistenceService.STORE_PROFILE_COMPARE);
1508
1509
  }
1509
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: ProfileService, deps: [{ token: i1.HttpClient }, { token: PROFILE_COMPARISON_API_BASE_URL, optional: true }, { token: CachePersistenceService }], target: i0.ɵɵFactoryTarget.Injectable });
1510
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: ProfileService, providedIn: 'root' });
1510
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.24", ngImport: i0, type: ProfileService, deps: [{ token: i1.HttpClient }, { token: PROFILE_COMPARISON_API_BASE_URL, optional: true }, { token: CachePersistenceService }], target: i0.ɵɵFactoryTarget.Injectable });
1511
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.24", ngImport: i0, type: ProfileService, providedIn: 'root' });
1511
1512
  }
1512
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: ProfileService, decorators: [{
1513
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.24", ngImport: i0, type: ProfileService, decorators: [{
1513
1514
  type: Injectable,
1514
1515
  args: [{ providedIn: 'root' }]
1515
1516
  }], ctorParameters: () => [{ type: i1.HttpClient }, { type: undefined, decorators: [{
@@ -1565,10 +1566,10 @@ class ProfileComparisonBackendService {
1565
1566
  return throwError(() => err);
1566
1567
  }));
1567
1568
  }
1568
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: ProfileComparisonBackendService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1569
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: ProfileComparisonBackendService, providedIn: 'root' });
1569
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.24", ngImport: i0, type: ProfileComparisonBackendService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1570
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.24", ngImport: i0, type: ProfileComparisonBackendService, providedIn: 'root' });
1570
1571
  }
1571
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: ProfileComparisonBackendService, decorators: [{
1572
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.24", ngImport: i0, type: ProfileComparisonBackendService, decorators: [{
1572
1573
  type: Injectable,
1573
1574
  args: [{ providedIn: 'root' }]
1574
1575
  }], ctorParameters: () => [] });
@@ -1579,6 +1580,8 @@ class ProfileComparisonLibComponent {
1579
1580
  fileConversionService;
1580
1581
  imageCompressionService;
1581
1582
  cdr;
1583
+ ngZone;
1584
+ hostRef;
1582
1585
  getVerboseLogging;
1583
1586
  static DEFAULT_OBJECT_POSITION = '50% 50%';
1584
1587
  static DEFAULT_EYE_ALIGNMENT_BIAS_PX = 50;
@@ -1706,6 +1709,36 @@ class ProfileComparisonLibComponent {
1706
1709
  similarity = 0;
1707
1710
  similarityMatrixCsv = '';
1708
1711
  matrixData = null;
1712
+ // ── Force-directed chip layout ──────────────────────────────────────────
1713
+ static SPRING_THRESHOLD = 0.32;
1714
+ static CROSS_LINK_MIN_SIMILARITY = 0.5;
1715
+ static INTRA_GROUP_THRESHOLD = 0.5;
1716
+ static SHARED_PROMOTION_THRESHOLD = 0.75;
1717
+ static MAX_CROSS_LINKS_PER_NODE = 4;
1718
+ static MIN_CROSS_LINKS_PER_NODE = 0;
1719
+ static MAX_INTRA_LINKS_PER_NODE = 2;
1720
+ static MAX_RELEVANT_INTERESTS_PER_SIDE = 6;
1721
+ static CHIP_RADIUS = 18; // half chip height for collision
1722
+ static CHIP_COLLISION_BORDER_PADDING = 6;
1723
+ static MAX_LINK_DISTANCE = 120;
1724
+ static CANVAS_PADDING = 40; // px below lowest node
1725
+ forceNodes = [];
1726
+ forceLinks = [];
1727
+ forceCanvasHeight = 300;
1728
+ chipFontSizePx = 14;
1729
+ chipPaddingXPx = 18;
1730
+ chipPaddingYPx = 6;
1731
+ chipLineHeight = 1.4;
1732
+ hoveredNode = null;
1733
+ clickedNode = null;
1734
+ draggedNode = null;
1735
+ isDragging = false;
1736
+ simulation;
1737
+ layoutRebuildRaf = null;
1738
+ nodeDragBounds = new Map();
1739
+ get activeNode() {
1740
+ return this.hoveredNode || this.clickedNode;
1741
+ }
1709
1742
  /** When false, backend URL is not provided — show "Configure backend". */
1710
1743
  backendConfigured = false;
1711
1744
  /** The resolved backend URL computed from inputs and legacy token. */
@@ -1737,12 +1770,15 @@ class ProfileComparisonLibComponent {
1737
1770
  shapeTextLeft;
1738
1771
  shapeTextRight;
1739
1772
  shapeTextCenter;
1740
- constructor(backendService, renderer, fileConversionService, imageCompressionService, cdr, getVerboseLogging) {
1773
+ chipsGrid;
1774
+ constructor(backendService, renderer, fileConversionService, imageCompressionService, cdr, ngZone, hostRef, getVerboseLogging) {
1741
1775
  this.backendService = backendService;
1742
1776
  this.renderer = renderer;
1743
1777
  this.fileConversionService = fileConversionService;
1744
1778
  this.imageCompressionService = imageCompressionService;
1745
1779
  this.cdr = cdr;
1780
+ this.ngZone = ngZone;
1781
+ this.hostRef = hostRef;
1746
1782
  this.getVerboseLogging = getVerboseLogging;
1747
1783
  }
1748
1784
  log(...args) {
@@ -1793,6 +1829,15 @@ class ProfileComparisonLibComponent {
1793
1829
  }
1794
1830
  });
1795
1831
  }
1832
+ ngOnDestroy() {
1833
+ if (this.simulation) {
1834
+ this.simulation.stop();
1835
+ }
1836
+ if (this.layoutRebuildRaf !== null) {
1837
+ cancelAnimationFrame(this.layoutRebuildRaf);
1838
+ this.layoutRebuildRaf = null;
1839
+ }
1840
+ }
1796
1841
  ngAfterViewInit() {
1797
1842
  this.waitForImagesAndInitDrag();
1798
1843
  }
@@ -1885,13 +1930,14 @@ class ProfileComparisonLibComponent {
1885
1930
  this.alignedPerson2Interests = [];
1886
1931
  this.displayPerson1Interests = this.person1Interests;
1887
1932
  this.displayPerson2Interests = this.person2Interests;
1888
- this.centerItem = payload.centerItem ?? [];
1933
+ this.centerItem = this.filterCenterItemsByThreshold(payload.centerItem ?? [], this.person1Interests ?? [], this.person2Interests ?? []);
1889
1934
  }
1890
1935
  else {
1891
1936
  const rawCenter = payload.centerItem ?? [];
1892
1937
  const list1ToUse = this.person1Interests?.length ? this.person1Interests : a1;
1893
1938
  const list2ToUse = this.person2Interests?.length ? this.person2Interests : a2;
1894
- const reordered = this.reorderInterestsByMatrix(list1ToUse, list2ToUse, rawCenter);
1939
+ const filteredCenter = this.filterCenterItemsByThreshold(rawCenter, list1ToUse, list2ToUse);
1940
+ const reordered = this.reorderInterestsByMatrix(list1ToUse, list2ToUse, filteredCenter);
1895
1941
  this.alignedPerson1Interests = reordered.p1;
1896
1942
  this.alignedPerson2Interests = reordered.p2;
1897
1943
  this.displayPerson1Interests = reordered.p1;
@@ -1914,6 +1960,7 @@ class ProfileComparisonLibComponent {
1914
1960
  this.updateObjectPositionForFace(2);
1915
1961
  if (this.firstImageData && this.secondImageData)
1916
1962
  this.calculateFaceAlignment(this.firstImageData, this.secondImageData);
1963
+ this.buildForceGraph();
1917
1964
  this.cdr.detectChanges();
1918
1965
  },
1919
1966
  error: (err) => {
@@ -2250,25 +2297,841 @@ class ProfileComparisonLibComponent {
2250
2297
  });
2251
2298
  });
2252
2299
  }
2300
+ normalizeSimilarityToken(value) {
2301
+ return String(value ?? '')
2302
+ .trim()
2303
+ .toLowerCase()
2304
+ .replace(/[^a-z0-9]+/g, '');
2305
+ }
2306
+ findLegendEntry(label) {
2307
+ const legend = this.matrixData?.legend ?? [];
2308
+ if (!legend.length)
2309
+ return undefined;
2310
+ const exact = String(label).trim().toLowerCase();
2311
+ const exactMatch = legend.find(l => String(l.full).trim().toLowerCase() === exact);
2312
+ if (exactMatch)
2313
+ return exactMatch;
2314
+ const normalized = this.normalizeSimilarityToken(label);
2315
+ if (!normalized)
2316
+ return undefined;
2317
+ return legend.find(l => {
2318
+ const full = this.normalizeSimilarityToken(l.full);
2319
+ const abbr = this.normalizeSimilarityToken(l.abbr);
2320
+ return full === normalized || abbr === normalized;
2321
+ });
2322
+ }
2323
+ lookupSimilarity(rowAbbr, colAbbr) {
2324
+ if (!this.matrixData)
2325
+ return null;
2326
+ const colIdx = this.matrixData.headers?.indexOf(colAbbr);
2327
+ if (colIdx === -1 || colIdx === undefined)
2328
+ return null;
2329
+ const row = this.matrixData.rows?.find(r => r.label === rowAbbr);
2330
+ if (!row || !row.values || colIdx >= row.values.length)
2331
+ return null;
2332
+ const val = parseFloat(row.values[colIdx]);
2333
+ return isNaN(val) ? null : val;
2334
+ }
2253
2335
  getSimilarity(item1, item2) {
2254
2336
  if (!this.matrixData || !item1 || !item2)
2255
2337
  return 0;
2256
- const n1 = String(item1).trim().toLowerCase();
2257
- const n2 = String(item2).trim().toLowerCase();
2338
+ const n1 = this.normalizeSimilarityToken(item1);
2339
+ const n2 = this.normalizeSimilarityToken(item2);
2340
+ if (!n1 || !n2)
2341
+ return 0;
2258
2342
  if (n1 === n2)
2259
2343
  return 1.0;
2260
- const leg1 = this.matrixData.legend?.find(l => l.full.trim().toLowerCase() === n1);
2261
- const leg2 = this.matrixData.legend?.find(l => l.full.trim().toLowerCase() === n2);
2344
+ const leg1 = this.findLegendEntry(item1);
2345
+ const leg2 = this.findLegendEntry(item2);
2262
2346
  if (!leg1 || !leg2)
2263
2347
  return 0;
2264
- const colIdx = this.matrixData.headers?.indexOf(leg2.abbr);
2265
- if (colIdx === -1 || colIdx === undefined)
2266
- return 0;
2267
- const row = this.matrixData.rows?.find(r => r.label === leg1.abbr);
2268
- if (!row || !row.values || colIdx >= row.values.length)
2269
- return 0;
2270
- const val = parseFloat(row.values[colIdx]);
2271
- return isNaN(val) ? 0 : val;
2348
+ const direct = this.lookupSimilarity(leg1.abbr, leg2.abbr);
2349
+ if (direct != null)
2350
+ return direct;
2351
+ const reverse = this.lookupSimilarity(leg2.abbr, leg1.abbr);
2352
+ if (reverse != null)
2353
+ return reverse;
2354
+ return 0;
2355
+ }
2356
+ // ── Force-directed chip layout ──────────────────────────────────────────
2357
+ orderBySemanticBarycenter(sourceItems, targetItems) {
2358
+ const source = [...sourceItems];
2359
+ if (source.length <= 1 || targetItems.length === 0)
2360
+ return source;
2361
+ const ranked = source.map((label, index) => {
2362
+ let weightedTargetIndex = 0;
2363
+ let totalWeight = 0;
2364
+ for (let i = 0; i < targetItems.length; i++) {
2365
+ const sim = Math.max(0, this.getSimilarity(label, targetItems[i]));
2366
+ if (sim <= 0)
2367
+ continue;
2368
+ weightedTargetIndex += i * sim;
2369
+ totalWeight += sim;
2370
+ }
2371
+ return {
2372
+ label,
2373
+ index,
2374
+ barycenter: totalWeight > 0 ? weightedTargetIndex / totalWeight : index,
2375
+ totalWeight,
2376
+ };
2377
+ });
2378
+ ranked.sort((a, b) => (a.barycenter - b.barycenter) ||
2379
+ (b.totalWeight - a.totalWeight) ||
2380
+ (a.index - b.index));
2381
+ return ranked.map(item => item.label);
2382
+ }
2383
+ orderSharedBySemanticPull(sharedItems, p1Items, p2Items) {
2384
+ const shared = [...sharedItems];
2385
+ if (shared.length <= 1)
2386
+ return shared;
2387
+ const context = [...p1Items, ...p2Items];
2388
+ if (context.length === 0)
2389
+ return shared;
2390
+ const ranked = shared.map((label, index) => {
2391
+ let weightedIndex = 0;
2392
+ let totalWeight = 0;
2393
+ let strongest = 0;
2394
+ for (let i = 0; i < context.length; i++) {
2395
+ const sim = Math.max(0, this.getSimilarity(label, context[i]));
2396
+ if (sim <= 0)
2397
+ continue;
2398
+ weightedIndex += i * sim;
2399
+ totalWeight += sim;
2400
+ strongest = Math.max(strongest, sim);
2401
+ }
2402
+ return {
2403
+ label,
2404
+ index,
2405
+ barycenter: totalWeight > 0 ? weightedIndex / totalWeight : index,
2406
+ strongest,
2407
+ };
2408
+ });
2409
+ ranked.sort((a, b) => (b.strongest - a.strongest) ||
2410
+ (a.barycenter - b.barycenter) ||
2411
+ (a.index - b.index));
2412
+ return ranked.map(item => item.label);
2413
+ }
2414
+ canLinkNodes(a, b) {
2415
+ if (a.group === 'shared' && b.group === 'shared')
2416
+ return true;
2417
+ if (a.group === b.group)
2418
+ return false;
2419
+ if (a.group === 'shared' || b.group === 'shared')
2420
+ return true;
2421
+ return (a.group === 'p1' && b.group === 'p2') || (a.group === 'p2' && b.group === 'p1');
2422
+ }
2423
+ getLinkNodeId(ref) {
2424
+ return typeof ref === 'object' ? ref.id : String(ref);
2425
+ }
2426
+ getLinkPairKey(a, b) {
2427
+ const idA = this.getLinkNodeId(a);
2428
+ const idB = this.getLinkNodeId(b);
2429
+ return idA < idB ? `${idA}|${idB}` : `${idB}|${idA}`;
2430
+ }
2431
+ selectLinksWithCaps(candidates, maxPerNode, minPerNode = 0) {
2432
+ if (!candidates.length || maxPerNode <= 0)
2433
+ return [];
2434
+ const sorted = [...candidates].sort((a, b) => (b.similarity - a.similarity) ||
2435
+ (b.strength - a.strength));
2436
+ const counts = new Map();
2437
+ const selected = [];
2438
+ const selectedKeys = new Set();
2439
+ const getCount = (id) => counts.get(id) ?? 0;
2440
+ const increment = (id) => {
2441
+ counts.set(id, getCount(id) + 1);
2442
+ };
2443
+ const canAdd = (link) => {
2444
+ const sourceId = this.getLinkNodeId(link.source);
2445
+ const targetId = this.getLinkNodeId(link.target);
2446
+ if (getCount(sourceId) >= maxPerNode)
2447
+ return false;
2448
+ if (getCount(targetId) >= maxPerNode)
2449
+ return false;
2450
+ return true;
2451
+ };
2452
+ const add = (link) => {
2453
+ const key = this.getLinkPairKey(link.source, link.target);
2454
+ if (selectedKeys.has(key) || !canAdd(link))
2455
+ return false;
2456
+ selectedKeys.add(key);
2457
+ selected.push(link);
2458
+ const sourceId = this.getLinkNodeId(link.source);
2459
+ const targetId = this.getLinkNodeId(link.target);
2460
+ increment(sourceId);
2461
+ increment(targetId);
2462
+ return true;
2463
+ };
2464
+ const nodeIds = new Set();
2465
+ const perNodeCandidates = new Map();
2466
+ sorted.forEach(link => {
2467
+ const sourceId = this.getLinkNodeId(link.source);
2468
+ const targetId = this.getLinkNodeId(link.target);
2469
+ nodeIds.add(sourceId);
2470
+ nodeIds.add(targetId);
2471
+ const sourceBucket = perNodeCandidates.get(sourceId) ?? [];
2472
+ sourceBucket.push(link);
2473
+ perNodeCandidates.set(sourceId, sourceBucket);
2474
+ const targetBucket = perNodeCandidates.get(targetId) ?? [];
2475
+ targetBucket.push(link);
2476
+ perNodeCandidates.set(targetId, targetBucket);
2477
+ });
2478
+ // Phase 1: satisfy minimum significant relationships before dense global fill.
2479
+ if (minPerNode > 0) {
2480
+ let progressed = true;
2481
+ while (progressed) {
2482
+ progressed = false;
2483
+ nodeIds.forEach(nodeId => {
2484
+ while (getCount(nodeId) < minPerNode) {
2485
+ const next = (perNodeCandidates.get(nodeId) ?? []).find(link => {
2486
+ const key = this.getLinkPairKey(link.source, link.target);
2487
+ return !selectedKeys.has(key) && canAdd(link);
2488
+ });
2489
+ if (!next)
2490
+ break;
2491
+ if (!add(next))
2492
+ break;
2493
+ progressed = true;
2494
+ }
2495
+ });
2496
+ }
2497
+ }
2498
+ // Phase 2: fill remaining slots globally by strongest links.
2499
+ sorted.forEach(link => {
2500
+ add(link);
2501
+ });
2502
+ if (minPerNode > 0) {
2503
+ // Best-effort soft fallback for low-degree nodes if they were blocked by caps.
2504
+ nodeIds.forEach(nodeId => {
2505
+ if (getCount(nodeId) >= minPerNode)
2506
+ return;
2507
+ (perNodeCandidates.get(nodeId) ?? []).forEach(link => {
2508
+ if (getCount(nodeId) >= minPerNode)
2509
+ return;
2510
+ const key = this.getLinkPairKey(link.source, link.target);
2511
+ if (selectedKeys.has(key))
2512
+ return;
2513
+ const sourceId = this.getLinkNodeId(link.source);
2514
+ const targetId = this.getLinkNodeId(link.target);
2515
+ // Allow a slight overflow (+1) only to prevent isolated visual outliers.
2516
+ if ((getCount(sourceId) >= maxPerNode + 1) || (getCount(targetId) >= maxPerNode + 1))
2517
+ return;
2518
+ add(link);
2519
+ });
2520
+ });
2521
+ }
2522
+ return selected;
2523
+ }
2524
+ filterCenterItemsByThreshold(centerItems, p1Items, p2Items) {
2525
+ if (!centerItems?.length)
2526
+ return [];
2527
+ const threshold = ProfileComparisonLibComponent.SHARED_PROMOTION_THRESHOLD;
2528
+ const uniq = new Set();
2529
+ const selected = [];
2530
+ const normalize = (s) => this.normalizeSimilarityToken(s);
2531
+ const p1Norm = new Set((p1Items ?? []).map(normalize));
2532
+ const p2Norm = new Set((p2Items ?? []).map(normalize));
2533
+ for (const raw of centerItems) {
2534
+ const item = String(raw ?? '').trim();
2535
+ if (!item)
2536
+ continue;
2537
+ const key = normalize(item);
2538
+ if (!key || uniq.has(key))
2539
+ continue;
2540
+ const existsInP1 = p1Norm.has(key);
2541
+ const existsInP2 = p2Norm.has(key);
2542
+ let maxCrossSim = 0;
2543
+ // If the value exists on the left, compare against right-side terms.
2544
+ if (existsInP1 || (!existsInP1 && !existsInP2)) {
2545
+ for (const right of p2Items ?? []) {
2546
+ maxCrossSim = Math.max(maxCrossSim, this.getSimilarity(item, right));
2547
+ }
2548
+ }
2549
+ // If the value exists on the right, compare against left-side terms.
2550
+ if (existsInP2 || (!existsInP1 && !existsInP2)) {
2551
+ for (const left of p1Items ?? []) {
2552
+ maxCrossSim = Math.max(maxCrossSim, this.getSimilarity(item, left));
2553
+ }
2554
+ }
2555
+ if (maxCrossSim >= threshold) {
2556
+ selected.push(item);
2557
+ uniq.add(key);
2558
+ }
2559
+ }
2560
+ return selected;
2561
+ }
2562
+ scheduleForceGraphRebuild() {
2563
+ if (this.layoutRebuildRaf !== null) {
2564
+ cancelAnimationFrame(this.layoutRebuildRaf);
2565
+ this.layoutRebuildRaf = null;
2566
+ }
2567
+ this.ngZone.runOutsideAngular(() => {
2568
+ this.layoutRebuildRaf = requestAnimationFrame(() => {
2569
+ this.layoutRebuildRaf = null;
2570
+ this.ngZone.run(() => {
2571
+ if (!this.backendConfigured || !this.forceNodes.length)
2572
+ return;
2573
+ this.buildForceGraph();
2574
+ });
2575
+ });
2576
+ });
2577
+ }
2578
+ onWindowResize() {
2579
+ this.scheduleForceGraphRebuild();
2580
+ }
2581
+ buildForceGraph() {
2582
+ const rawP1 = this.getPerson1UniqueInterests();
2583
+ const rawShared = (this.centerItem || []).filter(c => c && c !== 'empty' && c !== '-' && c !== '----');
2584
+ const rawP2 = this.getPerson2UniqueInterests();
2585
+ const capped = this.selectTopSharedInterestsPerSide(rawP1, rawP2);
2586
+ const cappedRawP1 = capped.p1;
2587
+ const cappedRawP2 = capped.p2;
2588
+ if (cappedRawP1.length === 0 && rawShared.length === 0 && cappedRawP2.length === 0) {
2589
+ this.forceNodes = [];
2590
+ this.forceLinks = [];
2591
+ this.forceCanvasHeight = 180;
2592
+ if (this.simulation)
2593
+ this.simulation.stop();
2594
+ return;
2595
+ }
2596
+ const p1Items = this.orderBySemanticBarycenter(cappedRawP1, cappedRawP2);
2597
+ const p2Items = this.orderBySemanticBarycenter(cappedRawP2, cappedRawP1);
2598
+ const shared = this.orderSharedBySemanticPull(rawShared, p1Items, p2Items);
2599
+ // --- Read container width robustly ---
2600
+ let containerWidth = 0;
2601
+ try {
2602
+ containerWidth = this.chipsGrid?.nativeElement?.clientWidth ?? 0;
2603
+ }
2604
+ catch { /* */ }
2605
+ if (!containerWidth) {
2606
+ try {
2607
+ containerWidth = this.hostRef.nativeElement.getBoundingClientRect().width;
2608
+ }
2609
+ catch { /* */ }
2610
+ }
2611
+ if (!containerWidth) {
2612
+ try {
2613
+ const el = this.profileFlex?.nativeElement;
2614
+ containerWidth = el ? (el.clientWidth || el.getBoundingClientRect().width || 0) : 0;
2615
+ }
2616
+ catch { /* */ }
2617
+ }
2618
+ if (!containerWidth || containerWidth < 100) {
2619
+ this.ngZone.runOutsideAngular(() => {
2620
+ requestAnimationFrame(() => {
2621
+ this.ngZone.run(() => this.buildForceGraph());
2622
+ });
2623
+ });
2624
+ return;
2625
+ }
2626
+ const maxRows = Math.max(p1Items.length, shared.length, p2Items.length, 1);
2627
+ let availableHeight = 0;
2628
+ try {
2629
+ availableHeight = this.chipsGrid?.nativeElement?.clientHeight ?? 0;
2630
+ }
2631
+ catch { /* */ }
2632
+ if (!availableHeight) {
2633
+ try {
2634
+ const el = this.profileFlex?.nativeElement;
2635
+ const h = el ? (el.clientHeight || el.getBoundingClientRect().height || 0) : 0;
2636
+ availableHeight = Math.max(120, h - 90);
2637
+ }
2638
+ catch { /* */ }
2639
+ }
2640
+ if (!availableHeight || availableHeight < 120)
2641
+ availableHeight = 320;
2642
+ const baseChipH = ProfileComparisonLibComponent.CHIP_RADIUS * 2;
2643
+ const baseStartY = 34;
2644
+ const baseRowGapExtra = 14;
2645
+ const baseBottomPad = 30;
2646
+ const requiredBaseHeight = baseStartY + baseBottomPad + maxRows * baseChipH + Math.max(0, maxRows - 1) * baseRowGapExtra;
2647
+ const fitScale = Math.min(1, Math.max(0.35, availableHeight / Math.max(requiredBaseHeight, 1)));
2648
+ const chipH = Math.max(14, Math.round(baseChipH * fitScale));
2649
+ const chipHalfH = chipH / 2;
2650
+ const startY = Math.max(12, Math.round(baseStartY * fitScale));
2651
+ const rowGap = chipH + Math.max(5, Math.round(baseRowGapExtra * fitScale));
2652
+ const bottomPad = Math.max(10, Math.round(baseBottomPad * fitScale));
2653
+ const canvasPad = Math.max(8, Math.round(ProfileComparisonLibComponent.CANVAS_PADDING * fitScale * 0.75));
2654
+ // Keep rendered chip dimensions in sync with force-layout math.
2655
+ this.chipFontSizePx = Math.max(9.5, Number((14 * Math.max(fitScale, 0.68)).toFixed(2)));
2656
+ this.chipPaddingXPx = Math.max(8, Math.round(18 * Math.max(fitScale, 0.58)));
2657
+ this.chipPaddingYPx = Math.max(2, Math.round(6 * Math.max(fitScale, 0.5)));
2658
+ this.chipLineHeight = fitScale < 0.55 ? 1.15 : (fitScale < 0.8 ? 1.22 : 1.3);
2659
+ const approxCharWidth = this.chipFontSizePx * 0.53;
2660
+ const horizontalPadding = this.chipPaddingXPx * 2;
2661
+ const getHalfWidth = (label) => (label.length * approxCharWidth + horizontalPadding) / 2;
2662
+ const EDGE_PADDING = 20;
2663
+ const getNodeMinX = (label) => getHalfWidth(label) + EDGE_PADDING;
2664
+ const getNodeMaxX = (label) => containerWidth - getHalfWidth(label) - EDGE_PADDING;
2665
+ const maxHalfP1 = p1Items.length ? Math.max(...p1Items.map(getHalfWidth)) : chipHalfH;
2666
+ const maxHalfShared = shared.length ? Math.max(...shared.map(getHalfWidth)) : chipHalfH;
2667
+ const maxHalfP2 = p2Items.length ? Math.max(...p2Items.map(getHalfWidth)) : chipHalfH;
2668
+ let zoneP1 = maxHalfP1 + EDGE_PADDING;
2669
+ let zoneP2 = containerWidth - maxHalfP2 - EDGE_PADDING;
2670
+ if (zoneP2 - zoneP1 < 140) {
2671
+ const center = containerWidth / 2;
2672
+ zoneP1 = Math.max(maxHalfP1 + EDGE_PADDING, center - 70);
2673
+ zoneP2 = Math.min(containerWidth - maxHalfP2 - EDGE_PADDING, center + 70);
2674
+ }
2675
+ const minShared = Math.max(zoneP1 + Math.max(maxHalfP1, maxHalfShared) * 0.6, maxHalfShared + EDGE_PADDING);
2676
+ const maxShared = Math.min(zoneP2 - Math.max(maxHalfP2, maxHalfShared) * 0.6, containerWidth - maxHalfShared - EDGE_PADDING);
2677
+ const zoneShared = Math.max(minShared, Math.min(maxShared, containerWidth * 0.5));
2678
+ const computedMaxY = startY + chipHalfH + (maxRows - 1) * rowGap + chipHalfH + bottomPad;
2679
+ const safeMaxY = Math.max(startY + chipHalfH, availableHeight - chipHalfH - 8);
2680
+ const maxY = Math.min(computedMaxY, safeMaxY);
2681
+ const minCenterY = startY + chipHalfH;
2682
+ const verticalSpan = Math.max(0, maxY - minCenterY);
2683
+ const centerGuard = Math.max(12, Math.min(26, (zoneP2 - zoneP1) * 0.12));
2684
+ // --- Scan matrix for max similarity to normalize spring distances ---
2685
+ let matrixMax = 0;
2686
+ if (this.matrixData?.rows) {
2687
+ for (const row of this.matrixData.rows) {
2688
+ for (const v of row.values) {
2689
+ const n = parseFloat(v);
2690
+ if (!isNaN(n) && n > matrixMax)
2691
+ matrixMax = n;
2692
+ }
2693
+ }
2694
+ }
2695
+ const springThreshold = ProfileComparisonLibComponent.SPRING_THRESHOLD;
2696
+ const crossLinkThreshold = ProfileComparisonLibComponent.CROSS_LINK_MIN_SIMILARITY;
2697
+ if (matrixMax <= springThreshold)
2698
+ matrixMax = 1.0;
2699
+ const simRange = matrixMax - springThreshold;
2700
+ const distributedYForIndex = (index, total) => {
2701
+ if (total <= 1)
2702
+ return minCenterY + verticalSpan * 0.5;
2703
+ const t = index / Math.max(1, total - 1);
2704
+ return minCenterY + verticalSpan * t;
2705
+ };
2706
+ const nodes = [];
2707
+ const addNodes = (items, group, zoneX) => {
2708
+ const total = items.length;
2709
+ items.forEach((label, i) => {
2710
+ const targetY = distributedYForIndex(i, total);
2711
+ nodes.push({
2712
+ id: `${group}-${i}-${label}`,
2713
+ label,
2714
+ group,
2715
+ x: zoneX,
2716
+ y: targetY,
2717
+ targetX: zoneX,
2718
+ targetY,
2719
+ });
2720
+ });
2721
+ };
2722
+ addNodes(p1Items, 'p1', zoneP1);
2723
+ addNodes(shared, 'shared', zoneShared);
2724
+ addNodes(p2Items, 'p2', zoneP2);
2725
+ // Pull very high-similarity opposite-side chips toward the shared lane.
2726
+ const sharedThreshold = ProfileComparisonLibComponent.SHARED_PROMOTION_THRESHOLD;
2727
+ const sharedRange = Math.max(0.001, 1 - sharedThreshold);
2728
+ const oppositePool = (node) => {
2729
+ if (node.group === 'p1')
2730
+ return nodes.filter(n => n.group === 'p2' || n.group === 'shared');
2731
+ if (node.group === 'p2')
2732
+ return nodes.filter(n => n.group === 'p1' || n.group === 'shared');
2733
+ return [];
2734
+ };
2735
+ nodes.forEach(node => {
2736
+ if (node.group === 'shared') {
2737
+ node.targetX = zoneShared;
2738
+ return;
2739
+ }
2740
+ let maxCross = 0;
2741
+ oppositePool(node).forEach(other => {
2742
+ maxCross = Math.max(maxCross, this.getSimilarity(node.label, other.label));
2743
+ });
2744
+ if (maxCross >= sharedThreshold) {
2745
+ const centerPull = 0.55 + Math.min(0.4, ((maxCross - sharedThreshold) / sharedRange) * 0.4);
2746
+ node.targetX = node.group === 'p1'
2747
+ ? zoneP1 + (zoneShared - zoneP1) * centerPull
2748
+ : zoneP2 + (zoneShared - zoneP2) * centerPull;
2749
+ }
2750
+ });
2751
+ // Vertical placement should be driven by cross-list similarity, not by
2752
+ // whichever list happens to have fewer items.
2753
+ const semanticPool = (node) => {
2754
+ if (node.group === 'p1')
2755
+ return nodes.filter(n => n.group === 'p2' || n.group === 'shared');
2756
+ if (node.group === 'p2')
2757
+ return nodes.filter(n => n.group === 'p1' || n.group === 'shared');
2758
+ return nodes.filter(n => n.group === 'p1' || n.group === 'p2');
2759
+ };
2760
+ const ySimilarityFloor = Math.max(0.18, springThreshold - 0.06);
2761
+ for (let pass = 0; pass < 3; pass++) {
2762
+ const snapshotY = new Map();
2763
+ nodes.forEach(node => {
2764
+ snapshotY.set(node.id, node.targetY ?? (minCenterY + verticalSpan * 0.5));
2765
+ });
2766
+ nodes.forEach(node => {
2767
+ const pool = semanticPool(node);
2768
+ if (!pool.length)
2769
+ return;
2770
+ let weightedY = 0;
2771
+ let totalWeight = 0;
2772
+ pool.forEach(other => {
2773
+ const similarity = this.getSimilarity(node.label, other.label);
2774
+ if (similarity < ySimilarityFloor)
2775
+ return;
2776
+ const weight = Math.pow(Math.max(0.001, similarity - ySimilarityFloor + 0.05), 2);
2777
+ const otherY = snapshotY.get(other.id) ?? (other.targetY ?? minCenterY);
2778
+ weightedY += otherY * weight;
2779
+ totalWeight += weight;
2780
+ });
2781
+ if (totalWeight <= 0)
2782
+ return;
2783
+ const semanticY = weightedY / totalWeight;
2784
+ const currentY = snapshotY.get(node.id) ?? semanticY;
2785
+ const blend = node.group === 'shared' ? 0.72 : 0.62;
2786
+ node.targetY = Math.max(minCenterY, Math.min(maxY, currentY + (semanticY - currentY) * blend));
2787
+ });
2788
+ }
2789
+ const groupCounts = {
2790
+ p1: p1Items.length,
2791
+ shared: shared.length,
2792
+ p2: p2Items.length,
2793
+ };
2794
+ const fullVerticalSpan = Math.max(rowGap, maxY - minCenterY);
2795
+ const buildNodeBounds = (node) => {
2796
+ const halfW = getHalfWidth(node.label);
2797
+ const globalMinX = getNodeMinX(node.label);
2798
+ const globalMaxX = getNodeMaxX(node.label);
2799
+ const leftLimit = zoneShared - halfW - centerGuard;
2800
+ const rightLimit = zoneShared + halfW + centerGuard;
2801
+ let minX = globalMinX;
2802
+ let maxX = globalMaxX;
2803
+ if (node.group === 'p1') {
2804
+ maxX = Math.min(globalMaxX, leftLimit);
2805
+ if (maxX < minX)
2806
+ maxX = minX;
2807
+ }
2808
+ else if (node.group === 'p2') {
2809
+ minX = Math.max(globalMinX, rightLimit);
2810
+ if (minX > maxX)
2811
+ minX = maxX;
2812
+ }
2813
+ else {
2814
+ // Shared chips should align directly under the centered Shared title.
2815
+ const centerX = Math.max(globalMinX, Math.min(globalMaxX, zoneShared));
2816
+ minX = centerX;
2817
+ maxX = centerX;
2818
+ }
2819
+ const baseY = node.targetY ?? (minCenterY + fullVerticalSpan * 0.5);
2820
+ const groupCount = groupCounts[node.group];
2821
+ let ySlack = rowGap * 2.2;
2822
+ if (node.group === 'shared') {
2823
+ ySlack = Math.max(rowGap * 4.2, fullVerticalSpan * 0.58);
2824
+ }
2825
+ else {
2826
+ const laneSpan = groupCount > 1 ? fullVerticalSpan / (groupCount - 1) : fullVerticalSpan;
2827
+ ySlack = Math.min(fullVerticalSpan, Math.max(rowGap * 2.4, laneSpan * 0.95));
2828
+ }
2829
+ let minY = Math.max(minCenterY, baseY - ySlack);
2830
+ let maxYForNode = Math.min(maxY, baseY + ySlack);
2831
+ if (minY > maxYForNode) {
2832
+ minY = minCenterY;
2833
+ maxYForNode = maxY;
2834
+ }
2835
+ return { minX, maxX, minY, maxY: maxYForNode };
2836
+ };
2837
+ const nodeBounds = new Map();
2838
+ nodes.forEach(node => nodeBounds.set(node.id, buildNodeBounds(node)));
2839
+ this.nodeDragBounds = new Map(nodeBounds);
2840
+ const clampNodeToBounds = (node) => {
2841
+ const bounds = nodeBounds.get(node.id) ?? buildNodeBounds(node);
2842
+ node.x = Math.max(bounds.minX, Math.min(bounds.maxX, node.x ?? (node.targetX ?? zoneShared)));
2843
+ node.y = Math.max(bounds.minY, Math.min(bounds.maxY, node.y ?? (node.targetY ?? minCenterY)));
2844
+ };
2845
+ const crossCandidates = [];
2846
+ const intraCandidates = [];
2847
+ const MIN_DIST = 62;
2848
+ const SPAN = 92;
2849
+ for (let i = 0; i < nodes.length; i++) {
2850
+ for (let j = i + 1; j < nodes.length; j++) {
2851
+ if (!this.canLinkNodes(nodes[i], nodes[j]))
2852
+ continue;
2853
+ const similarity = this.getSimilarity(nodes[i].label, nodes[j].label);
2854
+ if (similarity < crossLinkThreshold)
2855
+ continue;
2856
+ const normSim = Math.min(1, (similarity - springThreshold) / simRange);
2857
+ const distance = MIN_DIST + (1 - normSim) * SPAN;
2858
+ crossCandidates.push({
2859
+ kind: 'cross',
2860
+ source: nodes[i],
2861
+ target: nodes[j],
2862
+ strength: 0.32 + normSim * 0.68,
2863
+ similarity,
2864
+ distance,
2865
+ });
2866
+ }
2867
+ }
2868
+ const intraThreshold = ProfileComparisonLibComponent.INTRA_GROUP_THRESHOLD;
2869
+ const intraRange = Math.max(0.001, matrixMax - intraThreshold);
2870
+ ['p1', 'p2'].forEach(group => {
2871
+ const groupNodes = nodes.filter(n => n.group === group);
2872
+ for (let i = 0; i < groupNodes.length; i++) {
2873
+ for (let j = i + 1; j < groupNodes.length; j++) {
2874
+ const similarity = this.getSimilarity(groupNodes[i].label, groupNodes[j].label);
2875
+ if (similarity <= intraThreshold)
2876
+ continue;
2877
+ const normSim = Math.min(1, (similarity - intraThreshold) / intraRange);
2878
+ intraCandidates.push({
2879
+ kind: 'intra',
2880
+ source: groupNodes[i],
2881
+ target: groupNodes[j],
2882
+ strength: 0.08 + normSim * 0.2,
2883
+ similarity,
2884
+ distance: 50 + (1 - normSim) * 42,
2885
+ });
2886
+ }
2887
+ }
2888
+ });
2889
+ const crossLinks = this.selectLinksWithCaps(crossCandidates, ProfileComparisonLibComponent.MAX_CROSS_LINKS_PER_NODE, ProfileComparisonLibComponent.MIN_CROSS_LINKS_PER_NODE);
2890
+ // Shared chips can otherwise become vertical outliers if greedy caps saturate
2891
+ // their best opposite-side neighbors too early. Ensure a minimum local anchor set.
2892
+ const crossCounts = new Map();
2893
+ const crossKeys = new Set();
2894
+ const countCross = (id) => crossCounts.get(id) ?? 0;
2895
+ const bumpCross = (id) => {
2896
+ crossCounts.set(id, countCross(id) + 1);
2897
+ };
2898
+ const recordCross = (link) => {
2899
+ const sourceId = this.getLinkNodeId(link.source);
2900
+ const targetId = this.getLinkNodeId(link.target);
2901
+ const key = this.getLinkPairKey(sourceId, targetId);
2902
+ if (crossKeys.has(key))
2903
+ return;
2904
+ crossKeys.add(key);
2905
+ crossLinks.push(link);
2906
+ bumpCross(sourceId);
2907
+ bumpCross(targetId);
2908
+ };
2909
+ crossLinks.forEach(link => {
2910
+ const sourceId = this.getLinkNodeId(link.source);
2911
+ const targetId = this.getLinkNodeId(link.target);
2912
+ const key = this.getLinkPairKey(sourceId, targetId);
2913
+ crossKeys.add(key);
2914
+ bumpCross(sourceId);
2915
+ bumpCross(targetId);
2916
+ });
2917
+ if (ProfileComparisonLibComponent.MIN_CROSS_LINKS_PER_NODE > 0) {
2918
+ const sharedNodesForAnchors = nodes.filter(n => n.group === 'shared');
2919
+ sharedNodesForAnchors.forEach(sharedNode => {
2920
+ const sharedId = sharedNode.id;
2921
+ const incidentCandidates = crossCandidates
2922
+ .filter(link => {
2923
+ const sourceId = this.getLinkNodeId(link.source);
2924
+ const targetId = this.getLinkNodeId(link.target);
2925
+ return sourceId === sharedId || targetId === sharedId;
2926
+ })
2927
+ .sort((a, b) => (b.similarity - a.similarity) || (b.strength - a.strength));
2928
+ const requiredAnchors = Math.min(3, incidentCandidates.length);
2929
+ for (const candidate of incidentCandidates) {
2930
+ if (countCross(sharedId) >= requiredAnchors)
2931
+ break;
2932
+ const sourceId = this.getLinkNodeId(candidate.source);
2933
+ const targetId = this.getLinkNodeId(candidate.target);
2934
+ const key = this.getLinkPairKey(sourceId, targetId);
2935
+ if (crossKeys.has(key))
2936
+ continue;
2937
+ const otherId = sourceId === sharedId ? targetId : sourceId;
2938
+ if (countCross(otherId) > ProfileComparisonLibComponent.MAX_CROSS_LINKS_PER_NODE + 1)
2939
+ continue;
2940
+ recordCross(candidate);
2941
+ }
2942
+ });
2943
+ }
2944
+ const intraLinks = this.selectLinksWithCaps(intraCandidates, ProfileComparisonLibComponent.MAX_INTRA_LINKS_PER_NODE);
2945
+ const links = [...crossLinks, ...intraLinks];
2946
+ const nodeById = new Map(nodes.map(n => [n.id, n]));
2947
+ const crossAdjacency = new Map();
2948
+ const addAdjacency = (source, target, similarity) => {
2949
+ const bucket = crossAdjacency.get(source.id) ?? [];
2950
+ bucket.push({ node: target, similarity });
2951
+ crossAdjacency.set(source.id, bucket);
2952
+ };
2953
+ crossLinks.forEach(link => {
2954
+ const source = typeof link.source === 'object' ? link.source : nodeById.get(String(link.source));
2955
+ const target = typeof link.target === 'object' ? link.target : nodeById.get(String(link.target));
2956
+ if (!source || !target)
2957
+ return;
2958
+ addAdjacency(source, target, link.similarity);
2959
+ addAdjacency(target, source, link.similarity);
2960
+ });
2961
+ const crossAlignForce = (alpha) => {
2962
+ crossAdjacency.forEach((neighbors, nodeId) => {
2963
+ const node = nodeById.get(nodeId);
2964
+ if (!node || !neighbors.length)
2965
+ return;
2966
+ let weightedY = 0;
2967
+ let totalWeight = 0;
2968
+ neighbors.forEach(({ node: other, similarity }) => {
2969
+ const weight = Math.max(0.05, similarity);
2970
+ weightedY += (other.y ?? node.y ?? 0) * weight;
2971
+ totalWeight += weight;
2972
+ });
2973
+ if (totalWeight <= 0)
2974
+ return;
2975
+ const targetY = weightedY / totalWeight;
2976
+ const dy = targetY - (node.y ?? targetY);
2977
+ const alignStrength = node.group === 'shared'
2978
+ ? (neighbors.length <= 2 ? 0.62 : 0.52)
2979
+ : 0.36;
2980
+ node.vy = (node.vy ?? 0) + dy * alignStrength * alpha;
2981
+ });
2982
+ };
2983
+ this.ngZone.runOutsideAngular(() => {
2984
+ const sim = forceSimulation(nodes)
2985
+ .alphaDecay(0.02)
2986
+ .stop()
2987
+ .force('link', forceLink(links)
2988
+ .id(d => d.id)
2989
+ .distance(d => d.distance)
2990
+ .strength(d => d.kind === 'cross' ? d.strength * 0.9 : d.strength * 0.65))
2991
+ .force('collide', forceCollide((d) => {
2992
+ const baseRadius = getHalfWidth(d.label) * 0.55 + ProfileComparisonLibComponent.CHIP_COLLISION_BORDER_PADDING;
2993
+ return Math.min(58, Math.max(ProfileComparisonLibComponent.CHIP_RADIUS + 8, baseRadius));
2994
+ })
2995
+ .strength(1)
2996
+ .iterations(2))
2997
+ .force('xBound', forceX(d => d.targetX ?? zoneShared)
2998
+ .strength(d => d.group === 'shared' ? 1.15 : 0.85))
2999
+ .force('yTargetSeed', forceY(d => d.targetY ?? minCenterY).strength(0.34))
3000
+ .force('crossAlign', crossAlignForce);
3001
+ for (let i = 0; i < 420 && sim.alpha() > 0.0035; i++) {
3002
+ sim.tick();
3003
+ nodes.forEach(n => {
3004
+ clampNodeToBounds(n);
3005
+ });
3006
+ }
3007
+ const enforceGroupStack = (group) => {
3008
+ const groupNodes = nodes
3009
+ .filter(n => n.group === group)
3010
+ .sort((a, b) => (a.y ?? minCenterY) - (b.y ?? minCenterY));
3011
+ for (let i = 1; i < groupNodes.length; i++) {
3012
+ const prevY = groupNodes[i - 1].y ?? minCenterY;
3013
+ const minAllowed = prevY + rowGap * 0.92;
3014
+ if ((groupNodes[i].y ?? minCenterY) < minAllowed) {
3015
+ groupNodes[i].y = minAllowed;
3016
+ }
3017
+ }
3018
+ };
3019
+ enforceGroupStack('p1');
3020
+ enforceGroupStack('shared');
3021
+ enforceGroupStack('p2');
3022
+ nodes.forEach(n => {
3023
+ clampNodeToBounds(n);
3024
+ n.originalFx = n.x;
3025
+ n.originalFy = n.y;
3026
+ });
3027
+ sim.force('xBound', null);
3028
+ sim.force('yTargetSeed', null);
3029
+ sim.force('crossAlign', null);
3030
+ sim.force('xTarget', forceX(d => d.originalFx ?? d.x ?? 0)
3031
+ .strength(d => d.group === 'shared' ? 0.16 : 0.075));
3032
+ sim.force('yTarget', forceY(d => d.originalFy ?? d.y ?? 0).strength(0.085));
3033
+ this.simulation = sim;
3034
+ this.ngZone.run(() => {
3035
+ this.forceNodes = [...nodes];
3036
+ this.forceLinks = links;
3037
+ const maxBottom = Math.max(...nodes.map(n => (n.y ?? 0) + chipHalfH));
3038
+ this.forceCanvasHeight = Math.ceil(Math.min(maxBottom + canvasPad, availableHeight));
3039
+ this.cdr.detectChanges();
3040
+ });
3041
+ sim.on('tick', () => {
3042
+ this.ngZone.run(() => {
3043
+ nodes.forEach(n => {
3044
+ clampNodeToBounds(n);
3045
+ });
3046
+ this.forceNodes = [...nodes];
3047
+ const maxBottom = Math.max(...nodes.map(n => (n.y ?? 0) + chipHalfH));
3048
+ this.forceCanvasHeight = Math.ceil(Math.min(maxBottom + canvasPad, availableHeight));
3049
+ this.cdr.detectChanges();
3050
+ });
3051
+ });
3052
+ });
3053
+ }
3054
+ selectTopSharedInterestsPerSide(person1Items, person2Items) {
3055
+ const limit = ProfileComparisonLibComponent.MAX_RELEVANT_INTERESTS_PER_SIDE;
3056
+ const sanitize = (items) => {
3057
+ const seen = new Set();
3058
+ const cleaned = [];
3059
+ (items || []).forEach(item => {
3060
+ const value = String(item ?? '').trim();
3061
+ if (!value)
3062
+ return;
3063
+ const key = value.toLowerCase();
3064
+ if (seen.has(key))
3065
+ return;
3066
+ seen.add(key);
3067
+ cleaned.push(value);
3068
+ });
3069
+ return cleaned;
3070
+ };
3071
+ const p1 = sanitize(person1Items);
3072
+ const p2 = sanitize(person2Items);
3073
+ if (limit <= 0)
3074
+ return { p1: [], p2: [] };
3075
+ if (!p1.length || !p2.length) {
3076
+ return {
3077
+ p1: p1.slice(0, limit),
3078
+ p2: p2.slice(0, limit),
3079
+ };
3080
+ }
3081
+ const pairs = [];
3082
+ p1.forEach(item1 => {
3083
+ p2.forEach(item2 => {
3084
+ pairs.push({ p1: item1, p2: item2, similarity: this.getSimilarity(item1, item2) });
3085
+ });
3086
+ });
3087
+ pairs.sort((a, b) => b.similarity - a.similarity);
3088
+ const pickedP1 = new Set();
3089
+ const pickedP2 = new Set();
3090
+ const orderedP1 = [];
3091
+ const orderedP2 = [];
3092
+ for (const pair of pairs) {
3093
+ if (orderedP1.length >= limit && orderedP2.length >= limit)
3094
+ break;
3095
+ const canAddP1 = !pickedP1.has(pair.p1) && orderedP1.length < limit;
3096
+ const canAddP2 = !pickedP2.has(pair.p2) && orderedP2.length < limit;
3097
+ if (!canAddP1 && !canAddP2)
3098
+ continue;
3099
+ if (canAddP1) {
3100
+ pickedP1.add(pair.p1);
3101
+ orderedP1.push(pair.p1);
3102
+ }
3103
+ if (canAddP2) {
3104
+ pickedP2.add(pair.p2);
3105
+ orderedP2.push(pair.p2);
3106
+ }
3107
+ }
3108
+ const rankRemaining = (items, opposite, selected) => {
3109
+ return items
3110
+ .filter(item => !selected.has(item))
3111
+ .map((item, index) => ({
3112
+ item,
3113
+ index,
3114
+ score: Math.max(...opposite.map(other => this.getSimilarity(item, other)), 0),
3115
+ }))
3116
+ .sort((a, b) => (b.score - a.score) || (a.index - b.index))
3117
+ .map(entry => entry.item);
3118
+ };
3119
+ rankRemaining(p1, p2, pickedP1).forEach(item => {
3120
+ if (orderedP1.length >= limit)
3121
+ return;
3122
+ pickedP1.add(item);
3123
+ orderedP1.push(item);
3124
+ });
3125
+ rankRemaining(p2, p1, pickedP2).forEach(item => {
3126
+ if (orderedP2.length >= limit)
3127
+ return;
3128
+ pickedP2.add(item);
3129
+ orderedP2.push(item);
3130
+ });
3131
+ return {
3132
+ p1: orderedP1.slice(0, limit),
3133
+ p2: orderedP2.slice(0, limit),
3134
+ };
2272
3135
  }
2273
3136
  reorderInterestsByMatrix(p1List, p2List, centerList) {
2274
3137
  if (!this.matrixData)
@@ -2572,13 +3435,292 @@ class ProfileComparisonLibComponent {
2572
3435
  isValidFaceData(face) {
2573
3436
  return !!face && typeof face.x === 'number' && face.width > 0 && face.height > 0;
2574
3437
  }
2575
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: ProfileComparisonLibComponent, deps: [{ token: ProfileComparisonBackendService }, { token: i0.Renderer2 }, { token: FileConversionService }, { token: ImageCompressionService }, { token: i0.ChangeDetectorRef }, { token: PROFILE_COMPARISON_VERBOSE_LOGGING, optional: true }], target: i0.ɵɵFactoryTarget.Component });
2576
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.25", type: ProfileComparisonLibComponent, isStandalone: false, selector: "lib-profile-comparison", inputs: { config: "config", user1File: "user1File", user2File: "user2File", backendMode: "backendMode", backendUrl: "backendUrl", fadeAllEdges: "fadeAllEdges", person1Name: "person1Name", person2Name: "person2Name", imageProxyPath: "imageProxyPath" }, outputs: { matrixDataChange: "matrixDataChange", rawLLMOutputChange: "rawLLMOutputChange", viewProfileClick: "viewProfileClick" }, viewQueries: [{ propertyName: "leftContainer", first: true, predicate: ["leftContainer"], descendants: true }, { propertyName: "rightContainer", first: true, predicate: ["rightContainer"], descendants: true }, { propertyName: "profileFlex", first: true, predicate: ["profileFlex"], descendants: true }, { propertyName: "profileImgLeft", first: true, predicate: ["profileImgLeft"], descendants: true }, { propertyName: "profileImgRight", first: true, predicate: ["profileImgRight"], descendants: true }, { propertyName: "shapeContainer", first: true, predicate: ["shapeContainer"], descendants: true }, { propertyName: "shapeBg", first: true, predicate: ["shapeBg"], descendants: true }, { propertyName: "shapeBg1", first: true, predicate: ["shapeBg1"], descendants: true }, { propertyName: "shapeBg2", first: true, predicate: ["shapeBg2"], descendants: true }, { propertyName: "shapeTextLeft", first: true, predicate: ["shapeTextLeft"], descendants: true }, { propertyName: "shapeTextRight", first: true, predicate: ["shapeTextRight"], descendants: true }, { propertyName: "shapeTextCenter", first: true, predicate: ["shapeTextCenter"], descendants: true }], usesOnChanges: true, ngImport: i0, template: "<div class=\"configure-backend-message\" *ngIf=\"!backendConfigured\">\n Configure backend\n</div>\n\n<div class=\"profile-screen\" *ngIf=\"backendConfigured\">\n <div #profileFlex class=\"profile-flex\" [class.fade-all]=\"fadeAllEdges\">\n <svg class=\"image-noise-overlay\" style=\"position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; overflow: hidden; z-index: 0;\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 320 320\" fill=\"none\" preserveAspectRatio=\"none\">\n <g filter=\"url(#filter0_n_1_1753)\">\n <rect width=\"100%\" height=\"100%\" fill=\"url(#paint0_radial_1_1753)\" fill-opacity=\"0.33\"/>\n </g>\n <defs>\n <filter id=\"filter0_n_1_1753\" x=\"0%\" y=\"0%\" width=\"100%\" height=\"100%\" filterUnits=\"objectBoundingBox\" color-interpolation-filters=\"sRGB\">\n <feFlood flood-opacity=\"0\" result=\"BackgroundImageFix\"/>\n <feBlend mode=\"normal\" in=\"SourceGraphic\" in2=\"BackgroundImageFix\" result=\"shape\"/>\n <feTurbulence type=\"fractalNoise\" baseFrequency=\"0.10869565606117249 0.10869565606117249\" stitchTiles=\"stitch\" numOctaves=\"3\" result=\"noise\" seed=\"7339\"/>\n <feColorMatrix in=\"noise\" type=\"luminanceToAlpha\" result=\"alphaNoise\"/>\n <feComponentTransfer in=\"alphaNoise\" result=\"coloredNoise1\">\n <feFuncA type=\"discrete\" tableValues=\"0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 \"/>\n </feComponentTransfer>\n <feComposite operator=\"in\" in2=\"shape\" in=\"coloredNoise1\" result=\"noise1Clipped\"/>\n <feFlood flood-color=\"rgba(0, 0, 0, 0.25)\" result=\"color1Flood\"/>\n <feComposite operator=\"in\" in2=\"noise1Clipped\" in=\"color1Flood\" result=\"color1\"/>\n <feMerge result=\"effect1_noise_1_1753\">\n <feMergeNode in=\"shape\"/>\n <feMergeNode in=\"color1\"/>\n </feMerge>\n </filter>\n <radialGradient id=\"paint0_radial_1_1753\" cx=\"50%\" cy=\"50%\" r=\"50%\" fx=\"50%\" fy=\"50%\">\n <stop offset=\"0%\" stop-opacity=\"0\"/>\n <stop offset=\"100%\" stop-opacity=\"0.25\"/>\n </radialGradient>\n </defs>\n </svg>\n <div #profileImgLeft class=\"profile-img left\" [class.fade-all]=\"fadeAllEdges\">\n <img\n [src]=\"user1Image\"\n alt=\"User 1\"\n [style.transform]=\"user1Transform\"\n [style.top]=\"user1Top\"\n [style.height]=\"user1Height\"\n [style.object-position]=\"user1ObjectPosition\"\n (load)=\"onUserImageLoad(1, $event)\"\n />\n </div>\n <div #profileImgRight class=\"profile-img right\" [class.fade-all]=\"fadeAllEdges\">\n <img\n [src]=\"user2Image\"\n alt=\"User 2\"\n [style.transform]=\"user2Transform\"\n [style.top]=\"user2Top\"\n [style.height]=\"user2Height\"\n [style.object-position]=\"user2ObjectPosition\"\n (load)=\"onUserImageLoad(2, $event)\"\n />\n </div>\n <div class=\"image-shade-overlay\"></div>\n\n <!-- Frosted Glass Overlay Layer -->\n <div class=\"overlay-container\">\n <!-- Top Headers -->\n <div class=\"header-label left\">{{ person1Name }}</div>\n <div class=\"header-label center\">Shared</div>\n <div class=\"header-label right\">{{ person2Name }}</div>\n\n <!-- Chips Columns Grid -->\n <div class=\"chips-grid\">\n <!-- Person 1 Unique Interests -->\n <div class=\"chips-column left\">\n <ng-container *ngFor=\"let interest of getPerson1UniqueInterests(); let i = index\">\n <div class=\"frosted-chip person-a\" [style.margin-right.px]=\"getChipOffset(i, 'left')\" [style.margin-top.px]=\"(i > 0 ? 4 : 0)\">\n <span class=\"chip-label\">{{ interest }}</span>\n </div>\n </ng-container>\n </div>\n\n <!-- Shared Interests (Center Column) -->\n <div class=\"chips-column center\">\n <ng-container *ngFor=\"let interest of centerItem; let i = index\">\n <div *ngIf=\"interest !== 'empty'\" class=\"frosted-chip shared\" [style.margin-left.px]=\"getChipOffset(i, 'center')\" [style.margin-top.px]=\"(i > 0 ? 4 : 0)\">\n <span class=\"chip-label\">{{ interest }}</span>\n </div>\n <div *ngIf=\"interest === 'empty'\" class=\"frosted-chip ghost-chip shared\" [style.margin-left.px]=\"getChipOffset(i, 'center')\" [style.margin-top.px]=\"(i > 0 ? 4 : 0)\">\n <span class=\"chip-label\">&nbsp;</span>\n </div>\n </ng-container>\n </div>\n\n <!-- Person 2 Unique Interests -->\n <div class=\"chips-column right\">\n <ng-container *ngFor=\"let interest of getPerson2UniqueInterests(); let i = index\">\n <div class=\"frosted-chip person-b\" [style.margin-left.px]=\"getChipOffset(i, 'right')\" [style.margin-top.px]=\"(i > 0 ? 4 : 0)\">\n <span class=\"chip-label\">{{ interest }}</span>\n </div>\n </ng-container>\n </div>\n </div>\n\n <!-- Bottom Actions -->\n <div class=\"view-profile left\" (click)=\"onViewProfile('left')\">View Profile</div>\n <div class=\"view-profile right\" (click)=\"onViewProfile('right')\">View Profile</div>\n </div>\n\n <!-- Loading indicator for alignment process -->\n <div *ngIf=\"isAligning\" class=\"loading-indicator\">\n <div class=\"loading-spinner\"></div>\n </div>\n</div>\n\n\n", styles: [":host{display:block;width:100%;max-width:100%;overflow-x:hidden!important;box-sizing:border-box!important}.profile-img.left{position:absolute;top:0;bottom:0;left:0;width:58%;height:100%;margin:0;z-index:1;-webkit-mask-image:linear-gradient(to right,black 0%,black 45%,transparent 92%);mask-image:linear-gradient(to right,black 0%,black 45%,transparent 92%)}.profile-img.right{position:absolute;inset:0 0 0 auto;width:58%;height:100%;margin:0;z-index:2;-webkit-mask-image:linear-gradient(to right,transparent 8%,black 55%,black 100%);mask-image:linear-gradient(to right,transparent 8%,black 55%,black 100%)}@media(min-width:768px){.profile-img.left{width:65%;-webkit-mask-image:linear-gradient(to right,black 0%,black 40%,transparent 90%);mask-image:linear-gradient(to right,black 0%,black 40%,transparent 90%)}.profile-img.right{width:65%;-webkit-mask-image:linear-gradient(to right,transparent 10%,black 60%,black 100%);mask-image:linear-gradient(to right,transparent 10%,black 60%,black 100%)}}.configure-backend-message{padding:1rem;text-align:center;color:#bdc3c7;background:#34495e;border-radius:8px}.backend-error-message{padding:.75rem 1rem;text-align:center;color:#fef3c7;background:#b4530940;border:1px solid rgba(245,158,11,.5);border-radius:8px;margin-bottom:.5rem}.profile-screen{width:100%;max-width:768px;margin:0 auto;overflow:hidden;position:relative;border-radius:12px}.profile-flex{display:flex;width:100%;height:440px;overflow:hidden;background:transparent;gap:0;margin:0;padding:0;position:relative;box-shadow:none;border:none;--overlap-size: 40px}.profile-flex.fade-all{-webkit-mask-image:linear-gradient(to right,transparent 0%,black 18%,black 82%,transparent 100%),linear-gradient(to bottom,transparent 0%,black 15%,black 85%,transparent 100%);-webkit-mask-composite:intersect;mask-image:linear-gradient(to right,transparent 0%,black 18%,black 82%,transparent 100%),linear-gradient(to bottom,transparent 0%,black 15%,black 85%,transparent 100%);mask-composite:intersect}.profile-flex .image-shade-overlay{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:3;background:radial-gradient(ellipse at center,transparent 40%,rgba(41,37,45,.25) 100%)}.profile-flex .image-noise-overlay{position:absolute;inset:0;width:100%;height:100%;max-width:100%;max-height:100%;pointer-events:none;overflow:hidden;z-index:0}.shape-bg1,.shape-bg2{position:absolute;max-width:250px;opacity:1;transition:transform .2s ease-out}.shape-bg1{z-index:1;left:0}.shape-bg2{right:0}.shape-bg.fade-all .shape-bg1{-webkit-mask-image:linear-gradient(to right,transparent 0%,black 20%,black 100%);mask-image:linear-gradient(to right,transparent 0%,black 20%,black 100%)}.shape-bg.fade-all .shape-bg2{-webkit-mask-image:linear-gradient(to left,transparent 0%,black 20%,black 100%);mask-image:linear-gradient(to left,transparent 0%,black 20%,black 100%)}.shape-bg svg{cursor:grab}.shape-bg svg:active{cursor:grabbing}.profile-img{height:100%;position:relative;flex-shrink:0;background:transparent;margin:0;padding:0;left:0;top:0;border:none;box-sizing:border-box;overflow:hidden;box-shadow:none;outline:none}.profile-img img{transition:transform .3s ease;width:100%;height:100%;object-fit:cover;display:block;object-position:center center;opacity:1;transform-origin:center center;border:none;outline:none;box-shadow:none;position:relative;top:0;left:0;filter:contrast(1.2) brightness(1.1) saturate(1.1);image-rendering:auto}.overlay-container{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:10;-webkit-user-select:none;user-select:none;overflow:hidden;background:#29252da6;backdrop-filter:blur(.5px);-webkit-backdrop-filter:blur(.5px)}.header-label{position:absolute;top:20px;font-family:Gilroy,sans-serif;text-shadow:0 1px 8px rgba(0,0,0,.8);pointer-events:none}.header-label.left{left:5%;transform:none;font-size:14px;font-weight:400;color:#fffffff2}.header-label.center{left:50%;transform:translate(-50%);font-size:12px;font-weight:400;color:#fffc;letter-spacing:0}.header-label.right{right:5%;left:auto;transform:none;font-size:14px;font-weight:400;color:#fffffff2}.chips-grid{position:absolute;inset:45px 0;display:flex;padding:0;overflow-y:auto;overscroll-behavior:none;scrollbar-width:none;box-sizing:border-box}.chips-grid::-webkit-scrollbar{display:none}.chips-column{flex:1;display:flex;flex-direction:column;gap:8px;padding:4px 10px;box-sizing:border-box}.chips-column.left{align-items:flex-end}.chips-column.center{align-items:center}.chips-column.right{align-items:flex-start}.frosted-chip{position:relative;cursor:pointer;pointer-events:auto;transition:transform .2s ease,box-shadow .2s ease,background .2s ease;flex-shrink:0;padding:6px 18px;border-radius:999px;display:inline-flex;align-items:center;justify-content:center;border:none;background:#ffffff30;box-shadow:inset 0 1.5px 2px #ffffff59;backdrop-filter:blur(.5px);-webkit-backdrop-filter:blur(.5px)}.frosted-chip:hover{transform:scale(1.04);background:#ffffff29}.frosted-chip .chip-label{position:relative;color:#a8a8a8;text-align:center;font-family:Gilroy,sans-serif;font-size:14px;font-style:normal;font-weight:400;line-height:140%;letter-spacing:-.01em;text-shadow:none;white-space:nowrap;pointer-events:none;z-index:1}.ghost-chip{visibility:hidden!important;pointer-events:none!important}.view-profile{position:absolute;bottom:20px!important;color:#ffffff80;text-shadow:0 2px 4.2px #000;font-family:Calistoga,serif;font-size:18px;font-style:normal;font-weight:400;line-height:normal;cursor:pointer;pointer-events:auto;transition:opacity .2s ease,transform .2s ease}.view-profile:hover{opacity:.85}.view-profile.left{left:5%;transform:none}.view-profile.left:hover{transform:translateY(-2px)}.view-profile.right{right:5%;left:auto;transform:none}.view-profile.right:hover{transform:translateY(-2px)}#drag-preview{position:absolute;pointer-events:none;display:none;background:#000000b3;color:#fff;padding:5px 10px;border-radius:5px;z-index:9999;max-width:200px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.shape-text-container{width:70px;position:relative;overflow:hidden}.draggable{width:70px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:grab;-webkit-user-select:none;user-select:none;position:relative;display:inline-block}.dragging{width:auto;text-overflow:clip;cursor:grabbing;position:absolute;z-index:1000}.p-wrapper{width:70px;overflow-x:hidden}.shape-text-center{display:flex;justify-content:space-between;flex-direction:column;gap:10px;height:100%;margin-top:55px}.loading-indicator{position:absolute;inset:0;width:100%;height:100%;background:#00000040;backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px);color:#fff;z-index:100;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;pointer-events:auto}.loading-spinner{width:44px;height:44px;border:3.5px solid rgba(255,255,255,.25);border-top:3.5px solid #ffffff;border-radius:50%;animation:spin .8s linear infinite;box-shadow:0 0 15px #fff3}@keyframes spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.loading-indicator p{margin:0;font-size:14px;font-weight:500;color:#ffffffd9;letter-spacing:.02em}.api-key-modal-overlay{position:fixed;inset:0;background:#0f172a99;-webkit-backdrop-filter:blur(2px);backdrop-filter:blur(2px);display:flex;align-items:center;justify-content:center;z-index:2000}.api-key-modal{width:min(640px,92vw);background:#0f172a;color:#e2e8f0;border:1px solid #334155;border-radius:12px;box-shadow:0 20px 50px #00000073;padding:16px 18px}.api-key-modal-header{display:flex;align-items:center;justify-content:space-between;gap:12px}.api-key-modal-header h2{margin:0;font-size:20px;font-weight:700;color:#e2e8f0}.warning-icon{color:#fbbf24}.api-key-modal .close-btn{background:#0b1220;border:1px solid #334155;color:#94a3b8;border-radius:8px;padding:6px;cursor:pointer;line-height:0}.api-key-modal-body{margin-top:12px;display:flex;flex-direction:column;gap:10px}.modal-message,.modal-instruction{margin:0;color:#cbd5e1}.modal-message strong{color:#e5e7eb}.api-key-input-group{display:flex;align-items:center;gap:10px}.api-key-input{flex:1 1 auto;background:#0a1020;color:#e2e8f0;border:1px solid #334155;border-radius:8px;padding:10px 12px}.api-key-input:focus{outline:none;border-color:#2563eb;box-shadow:0 0 0 3px #2563eb33}.api-key-load-btn{background:#2563eb;color:#fff;border:none;border-radius:8px;padding:10px 12px;cursor:pointer;font-weight:600}.api-key-load-btn:hover{background:#1d4ed8}.modal-hint{display:flex;align-items:center;gap:8px;color:#94a3b8}.modal-hint a{color:#93c5fd}@media(max-width:1023px){.profile-screen{max-width:640px}.profile-flex{height:400px}.profile-img{height:550px}}@media(max-width:767px){.profile-screen,.profile-flex,.overlay-container{width:100%;max-width:100%;overflow-x:hidden;box-sizing:border-box}.profile-screen{border-radius:0}.profile-flex{height:360px}.profile-img{height:510px}.chips-grid{width:100%;max-width:100%;padding:0 4px;top:50px;bottom:50px;box-sizing:border-box}.chips-column{padding:2px}.chips-column.left{align-items:flex-end}.chips-column.center{align-items:center}.chips-column.right{align-items:flex-start}.frosted-chip{padding:4px 10px;max-width:100%;box-sizing:border-box}.frosted-chip .chip-label{font-size:11px;max-width:100%;overflow:hidden;text-overflow:ellipsis}.view-profile{bottom:14px}}@media(max-width:480px){.profile-flex{height:340px}.profile-img{height:480px}.frosted-chip{padding:3px 8px}.frosted-chip .chip-label{font-size:10px}.header-label{top:14px}.header-label.left{font-size:12px}.header-label.center{font-size:10px}.header-label.right{font-size:12px}}\n"], dependencies: [{ kind: "directive", type: i2_1.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i2_1.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }] });
3438
+ onNodeMouseEnter(node) {
3439
+ this.hoveredNode = node;
3440
+ }
3441
+ onNodeMouseLeave(node) {
3442
+ this.hoveredNode = null;
3443
+ }
3444
+ onNodeClick(node, event) {
3445
+ event.stopPropagation();
3446
+ if (this.clickedNode === node) {
3447
+ this.clickedNode = null;
3448
+ this.updateAttractionForce(null);
3449
+ }
3450
+ else {
3451
+ this.clickedNode = node;
3452
+ this.updateAttractionForce(node);
3453
+ }
3454
+ }
3455
+ onDocumentClick() {
3456
+ this.clickedNode = null;
3457
+ if (!this.isDragging) {
3458
+ this.updateAttractionForce(null);
3459
+ }
3460
+ }
3461
+ /**
3462
+ * Installs (or removes) a custom d3 force that pulls every linked node toward
3463
+ * the anchor, stopping at an orbit radius scaled by similarity:
3464
+ * highest-sim → MIN_ORBIT (touches anchor), lowest-sim → MAX_ORBIT.
3465
+ *
3466
+ * While active, the xTarget/yTarget restoring forces are muted so they don't
3467
+ * fight the attraction. They are restored the moment attraction is cleared.
3468
+ */
3469
+ updateAttractionForce(anchorNode) {
3470
+ if (!this.simulation)
3471
+ return;
3472
+ if (!anchorNode) {
3473
+ this.simulation.force('attraction', null);
3474
+ // Restore restoring forces so nodes spring back home
3475
+ const xT = this.simulation.force('xTarget');
3476
+ if (xT)
3477
+ xT.strength(0.1);
3478
+ const yT = this.simulation.force('yTarget');
3479
+ if (yT)
3480
+ yT.strength(0.1);
3481
+ const col = this.simulation.force('collide');
3482
+ if (col)
3483
+ col.strength(1.0);
3484
+ this.simulation.alphaTarget(0);
3485
+ this.simulation.alpha(0.6).restart();
3486
+ return;
3487
+ }
3488
+ // Collect connected node IDs and their similarity values
3489
+ const linkSim = new Map();
3490
+ this.forceLinks.forEach(link => {
3491
+ const srcId = typeof link.source === 'object' ? link.source.id : link.source;
3492
+ const tgtId = typeof link.target === 'object' ? link.target.id : link.target;
3493
+ const sim = Number(link.similarity ?? link.strength ?? 0.5);
3494
+ if (srcId === anchorNode.id)
3495
+ linkSim.set(String(tgtId), sim);
3496
+ if (tgtId === anchorNode.id)
3497
+ linkSim.set(String(srcId), sim);
3498
+ });
3499
+ const simValues = Array.from(linkSim.values());
3500
+ const maxSim = simValues.length ? Math.max(...simValues) : 1;
3501
+ // Closest chip (highest sim) touches the anchor (36px ≈ one chip height).
3502
+ // Others are spread tightly between 36–80px.
3503
+ const MIN_ORBIT = 36; // px — highest-sim chip: touching
3504
+ const MAX_ORBIT = 80; // px — lowest-sim chip: still very close
3505
+ const PULL = 0.5; // strong pull — overcomes any residual forces
3506
+ // Mute the forces that would fight the pull while attraction is active
3507
+ const xT = this.simulation.force('xTarget');
3508
+ if (xT)
3509
+ xT.strength(0);
3510
+ const yT = this.simulation.force('yTarget');
3511
+ if (yT)
3512
+ yT.strength(0);
3513
+ const col = this.simulation.force('collide');
3514
+ if (col)
3515
+ col.strength(0.35); // keep slight chip separation while still allowing orbiting
3516
+ const attractionForce = (alpha) => {
3517
+ const ax = anchorNode.x ?? 0;
3518
+ const ay = anchorNode.y ?? 0;
3519
+ this.forceNodes.forEach(n => {
3520
+ if (n.id === anchorNode.id)
3521
+ return;
3522
+ const sim = linkSim.get(n.id);
3523
+ if (sim === undefined)
3524
+ return; // not connected — leave untouched
3525
+ const normalizedSim = maxSim > 0 ? sim / maxSim : 0;
3526
+ const orbitDist = MIN_ORBIT + (MAX_ORBIT - MIN_ORBIT) * (1 - normalizedSim);
3527
+ const dx = ax - (n.x ?? 0);
3528
+ const dy = ay - (n.y ?? 0);
3529
+ const dist = Math.sqrt(dx * dx + dy * dy) || 1;
3530
+ if (dist > orbitDist) {
3531
+ // Pull toward orbit boundary — proportional to gap remaining
3532
+ const factor = PULL * alpha * (dist - orbitDist) / dist;
3533
+ n.vx = (n.vx ?? 0) + dx * factor;
3534
+ n.vy = (n.vy ?? 0) + dy * factor;
3535
+ }
3536
+ });
3537
+ };
3538
+ this.simulation.force('attraction', attractionForce);
3539
+ // alphaTarget 0.3 keeps alpha high so attraction stays strong indefinitely
3540
+ this.simulation.alphaTarget(0.3).restart();
3541
+ }
3542
+ isNodeConnected(node) {
3543
+ if (!this.activeNode)
3544
+ return true;
3545
+ if (this.activeNode === node)
3546
+ return true;
3547
+ return this.forceLinks.some(link => {
3548
+ const srcId = (link.source && typeof link.source === 'object') ? link.source.id : link.source;
3549
+ const tgtId = (link.target && typeof link.target === 'object') ? link.target.id : link.target;
3550
+ return (srcId === this.activeNode.id && tgtId === node.id) ||
3551
+ (tgtId === this.activeNode.id && srcId === node.id);
3552
+ });
3553
+ }
3554
+ isLinkConnectedToActive(link) {
3555
+ if (!this.activeNode)
3556
+ return false;
3557
+ const srcId = (link.source && typeof link.source === 'object') ? link.source.id : link.source;
3558
+ const tgtId = (link.target && typeof link.target === 'object') ? link.target.id : link.target;
3559
+ return srcId === this.activeNode.id || tgtId === this.activeNode.id;
3560
+ }
3561
+ /**
3562
+ * Returns true if the link connects a p1 node to a p2 node (a direct left↔right match),
3563
+ * as opposed to a connection involving a shared-pool chip.
3564
+ */
3565
+ isCrossGroupLink(link) {
3566
+ const src = this.findNode(link.source);
3567
+ const tgt = this.findNode(link.target);
3568
+ if (!src || !tgt)
3569
+ return false;
3570
+ return (src.group === 'p1' && tgt.group === 'p2') ||
3571
+ (src.group === 'p2' && tgt.group === 'p1');
3572
+ }
3573
+ /**
3574
+ * All force links sorted so secondary (shared-chip) links render first and
3575
+ * primary (p1↔p2 direct match) links render last — SVG paints last-child on top.
3576
+ */
3577
+ get sortedForceLinks() {
3578
+ return [...this.forceLinks].sort((a, b) => (this.isCrossGroupLink(a) ? 1 : 0) - (this.isCrossGroupLink(b) ? 1 : 0));
3579
+ }
3580
+ getLinkSourceX(link) {
3581
+ const node = this.findNode(link.source);
3582
+ return node ? (node.x ?? 0) : 0;
3583
+ }
3584
+ getLinkSourceY(link) {
3585
+ const node = this.findNode(link.source);
3586
+ return node ? (node.y ?? 0) : 0;
3587
+ }
3588
+ getLinkTargetX(link) {
3589
+ const node = this.findNode(link.target);
3590
+ return node ? (node.x ?? 0) : 0;
3591
+ }
3592
+ getLinkTargetY(link) {
3593
+ const node = this.findNode(link.target);
3594
+ return node ? (node.y ?? 0) : 0;
3595
+ }
3596
+ findNode(ref) {
3597
+ if (!ref)
3598
+ return undefined;
3599
+ const id = typeof ref === 'object' ? ref.id : ref;
3600
+ return this.forceNodes.find(n => n.id === id);
3601
+ }
3602
+ onNodeDragStart(node, event) {
3603
+ if (event.cancelable) {
3604
+ event.preventDefault();
3605
+ }
3606
+ event.stopPropagation();
3607
+ this.draggedNode = node;
3608
+ this.isDragging = true;
3609
+ this.clickedNode = node;
3610
+ const touchEvent = event;
3611
+ const mouseEvent = event;
3612
+ const isTouch = !!(touchEvent.touches && touchEvent.touches.length);
3613
+ const clientX = isTouch ? touchEvent.touches[0].clientX : mouseEvent.clientX;
3614
+ const clientY = isTouch ? touchEvent.touches[0].clientY : mouseEvent.clientY;
3615
+ const rect = this.chipsGrid.nativeElement.getBoundingClientRect();
3616
+ const scrollTop = this.chipsGrid.nativeElement.scrollTop;
3617
+ const relX = clientX - rect.left;
3618
+ const relY = clientY - rect.top + scrollTop;
3619
+ const bounds = this.nodeDragBounds.get(node.id);
3620
+ if (bounds) {
3621
+ node.fx = Math.max(bounds.minX, Math.min(bounds.maxX, relX));
3622
+ node.fy = Math.max(bounds.minY, Math.min(bounds.maxY, relY));
3623
+ }
3624
+ else {
3625
+ node.fx = relX;
3626
+ node.fy = relY;
3627
+ }
3628
+ // Restore interactive forces for dragging
3629
+ if (this.simulation) {
3630
+ const linkForce = this.simulation.force('link');
3631
+ if (linkForce)
3632
+ linkForce.strength((d) => d.strength * 0.6);
3633
+ const collideForce = this.simulation.force('collide');
3634
+ if (collideForce)
3635
+ collideForce.strength(1.0);
3636
+ const xTarget = this.simulation.force('xTarget');
3637
+ if (xTarget)
3638
+ xTarget.strength(0.08);
3639
+ const yTarget = this.simulation.force('yTarget');
3640
+ if (yTarget)
3641
+ yTarget.strength(0.08);
3642
+ }
3643
+ // Pull connected chips toward the dragged node
3644
+ this.updateAttractionForce(node);
3645
+ // Wake up the simulation target to keep it highly active during drag
3646
+ this.simulation.alphaTarget(0.3).restart();
3647
+ }
3648
+ onDocumentDragMove(event) {
3649
+ if (!this.isDragging || !this.draggedNode)
3650
+ return;
3651
+ if (event.cancelable) {
3652
+ event.preventDefault();
3653
+ }
3654
+ const touchEvent = event;
3655
+ const mouseEvent = event;
3656
+ const isTouch = !!(touchEvent.touches && touchEvent.touches.length);
3657
+ const clientX = isTouch ? touchEvent.touches[0].clientX : mouseEvent.clientX;
3658
+ const clientY = isTouch ? touchEvent.touches[0].clientY : mouseEvent.clientY;
3659
+ const rect = this.chipsGrid.nativeElement.getBoundingClientRect();
3660
+ const scrollTop = this.chipsGrid.nativeElement.scrollTop;
3661
+ // Clamp dragged position so chip can't go off-edge (same estimate used in layout pass).
3662
+ const charWidth = this.chipFontSizePx * 0.53;
3663
+ const halfW = (this.draggedNode.label.length * charWidth + this.chipPaddingXPx * 2) / 2;
3664
+ const edgePad = 20;
3665
+ const relX = clientX - rect.left;
3666
+ const relY = clientY - rect.top + scrollTop;
3667
+ const fallbackMinX = halfW + edgePad;
3668
+ const fallbackMaxX = rect.width - halfW - edgePad;
3669
+ const fallbackMinY = 30;
3670
+ const fallbackMaxY = Math.max(fallbackMinY, this.forceCanvasHeight - 30);
3671
+ const bounds = this.nodeDragBounds.get(this.draggedNode.id);
3672
+ const minX = bounds?.minX ?? fallbackMinX;
3673
+ const maxX = bounds?.maxX ?? fallbackMaxX;
3674
+ const minY = bounds?.minY ?? fallbackMinY;
3675
+ const maxY = bounds?.maxY ?? fallbackMaxY;
3676
+ const clampedX = Math.max(minX, Math.min(maxX, relX));
3677
+ const clampedY = Math.max(minY, Math.min(maxY, relY));
3678
+ const anchorX = this.draggedNode.originalFx ?? clampedX;
3679
+ const anchorY = this.draggedNode.originalFy ?? clampedY;
3680
+ const xElasticity = this.draggedNode.group === 'shared' ? 0.62 : 0.72;
3681
+ const yElasticity = this.draggedNode.group === 'shared' ? 0.58 : 0.68;
3682
+ this.draggedNode.fx = anchorX + (clampedX - anchorX) * xElasticity;
3683
+ this.draggedNode.fy = anchorY + (clampedY - anchorY) * yElasticity;
3684
+ // Keep active force simulation running
3685
+ this.simulation.alpha(0.3).restart();
3686
+ }
3687
+ onDocumentDragEnd() {
3688
+ if (!this.isDragging || !this.draggedNode)
3689
+ return;
3690
+ // Release the node. Clear fx and fy so it is no longer pinned to the mouse
3691
+ this.draggedNode.fx = undefined;
3692
+ this.draggedNode.fy = undefined;
3693
+ // Remove attraction so connected chips return home
3694
+ this.simulation.force('attraction', null);
3695
+ // Set other forces to 0 so all chips glide directly to their starting coordinates
3696
+ if (this.simulation) {
3697
+ const linkForce = this.simulation.force('link');
3698
+ if (linkForce)
3699
+ linkForce.strength(0);
3700
+ const collideForce = this.simulation.force('collide');
3701
+ if (collideForce)
3702
+ collideForce.strength(0.92);
3703
+ // Pull nodes smoothly and slowly back to their exact starting points
3704
+ const xTarget = this.simulation.force('xTarget');
3705
+ if (xTarget)
3706
+ xTarget.strength(0.13);
3707
+ const yTarget = this.simulation.force('yTarget');
3708
+ if (yTarget)
3709
+ yTarget.strength(0.13);
3710
+ }
3711
+ // Cool down simulation targets
3712
+ this.simulation.alphaTarget(0);
3713
+ this.simulation.alpha(0.6).restart();
3714
+ this.draggedNode = null;
3715
+ this.isDragging = false;
3716
+ }
3717
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.24", ngImport: i0, type: ProfileComparisonLibComponent, deps: [{ token: ProfileComparisonBackendService }, { token: i0.Renderer2 }, { token: FileConversionService }, { token: ImageCompressionService }, { token: i0.ChangeDetectorRef }, { token: i0.NgZone }, { token: i0.ElementRef }, { token: PROFILE_COMPARISON_VERBOSE_LOGGING, optional: true }], target: i0.ɵɵFactoryTarget.Component });
3718
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.24", type: ProfileComparisonLibComponent, isStandalone: false, selector: "lib-profile-comparison", inputs: { config: "config", user1File: "user1File", user2File: "user2File", backendMode: "backendMode", backendUrl: "backendUrl", fadeAllEdges: "fadeAllEdges", person1Name: "person1Name", person2Name: "person2Name", imageProxyPath: "imageProxyPath" }, outputs: { matrixDataChange: "matrixDataChange", rawLLMOutputChange: "rawLLMOutputChange", viewProfileClick: "viewProfileClick" }, host: { listeners: { "window:resize": "onWindowResize()", "document:click": "onDocumentClick()", "document:mousemove": "onDocumentDragMove($event)", "document:touchmove": "onDocumentDragMove($event)", "document:mouseup": "onDocumentDragEnd()", "document:touchend": "onDocumentDragEnd()" } }, viewQueries: [{ propertyName: "leftContainer", first: true, predicate: ["leftContainer"], descendants: true }, { propertyName: "rightContainer", first: true, predicate: ["rightContainer"], descendants: true }, { propertyName: "profileFlex", first: true, predicate: ["profileFlex"], descendants: true }, { propertyName: "profileImgLeft", first: true, predicate: ["profileImgLeft"], descendants: true }, { propertyName: "profileImgRight", first: true, predicate: ["profileImgRight"], descendants: true }, { propertyName: "shapeContainer", first: true, predicate: ["shapeContainer"], descendants: true }, { propertyName: "shapeBg", first: true, predicate: ["shapeBg"], descendants: true }, { propertyName: "shapeBg1", first: true, predicate: ["shapeBg1"], descendants: true }, { propertyName: "shapeBg2", first: true, predicate: ["shapeBg2"], descendants: true }, { propertyName: "shapeTextLeft", first: true, predicate: ["shapeTextLeft"], descendants: true }, { propertyName: "shapeTextRight", first: true, predicate: ["shapeTextRight"], descendants: true }, { propertyName: "shapeTextCenter", first: true, predicate: ["shapeTextCenter"], descendants: true }, { propertyName: "chipsGrid", first: true, predicate: ["chipsGrid"], descendants: true }], usesOnChanges: true, ngImport: i0, template: "<div class=\"configure-backend-message\" *ngIf=\"!backendConfigured\">\r\n Configure backend\r\n</div>\r\n\r\n<div class=\"profile-screen\" *ngIf=\"backendConfigured\">\r\n <div #profileFlex class=\"profile-flex\" [class.fade-all]=\"fadeAllEdges\">\r\n <svg class=\"image-noise-overlay\" style=\"position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; overflow: hidden; z-index: 0;\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 320 320\" fill=\"none\" preserveAspectRatio=\"none\">\r\n <g filter=\"url(#filter0_n_1_1753)\">\r\n <rect width=\"100%\" height=\"100%\" fill=\"url(#paint0_radial_1_1753)\" fill-opacity=\"0.33\"/>\r\n </g>\r\n <defs>\r\n <filter id=\"filter0_n_1_1753\" x=\"0%\" y=\"0%\" width=\"100%\" height=\"100%\" filterUnits=\"objectBoundingBox\" color-interpolation-filters=\"sRGB\">\r\n <feFlood flood-opacity=\"0\" result=\"BackgroundImageFix\"/>\r\n <feBlend mode=\"normal\" in=\"SourceGraphic\" in2=\"BackgroundImageFix\" result=\"shape\"/>\r\n <feTurbulence type=\"fractalNoise\" baseFrequency=\"0.10869565606117249 0.10869565606117249\" stitchTiles=\"stitch\" numOctaves=\"3\" result=\"noise\" seed=\"7339\"/>\r\n <feColorMatrix in=\"noise\" type=\"luminanceToAlpha\" result=\"alphaNoise\"/>\r\n <feComponentTransfer in=\"alphaNoise\" result=\"coloredNoise1\">\r\n <feFuncA type=\"discrete\" tableValues=\"0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 \"/>\r\n </feComponentTransfer>\r\n <feComposite operator=\"in\" in2=\"shape\" in=\"coloredNoise1\" result=\"noise1Clipped\"/>\r\n <feFlood flood-color=\"rgba(0, 0, 0, 0.25)\" result=\"color1Flood\"/>\r\n <feComposite operator=\"in\" in2=\"noise1Clipped\" in=\"color1Flood\" result=\"color1\"/>\r\n <feMerge result=\"effect1_noise_1_1753\">\r\n <feMergeNode in=\"shape\"/>\r\n <feMergeNode in=\"color1\"/>\r\n </feMerge>\r\n </filter>\r\n <radialGradient id=\"paint0_radial_1_1753\" cx=\"50%\" cy=\"50%\" r=\"50%\" fx=\"50%\" fy=\"50%\">\r\n <stop offset=\"0%\" stop-opacity=\"0\"/>\r\n <stop offset=\"100%\" stop-opacity=\"0.25\"/>\r\n </radialGradient>\r\n </defs>\r\n </svg>\r\n <div #profileImgLeft class=\"profile-img left\" [class.fade-all]=\"fadeAllEdges\">\r\n <img\r\n [src]=\"user1Image\"\r\n alt=\"User 1\"\r\n [style.transform]=\"user1Transform\"\r\n [style.top]=\"user1Top\"\r\n [style.height]=\"user1Height\"\r\n [style.object-position]=\"user1ObjectPosition\"\r\n (load)=\"onUserImageLoad(1, $event)\"\r\n />\r\n </div>\r\n <div #profileImgRight class=\"profile-img right\" [class.fade-all]=\"fadeAllEdges\">\r\n <img\r\n [src]=\"user2Image\"\r\n alt=\"User 2\"\r\n [style.transform]=\"user2Transform\"\r\n [style.top]=\"user2Top\"\r\n [style.height]=\"user2Height\"\r\n [style.object-position]=\"user2ObjectPosition\"\r\n (load)=\"onUserImageLoad(2, $event)\"\r\n />\r\n </div>\r\n <div class=\"image-shade-overlay\"></div>\r\n\r\n <!-- Frosted Glass Overlay Layer -->\r\n <div class=\"overlay-container\">\r\n <!-- Top Headers \u2014 absolutely positioned overlays, exactly as original -->\r\n <div class=\"header-label left\">{{ person1Name }}</div>\r\n <div class=\"header-label center\">Shared</div>\r\n <div class=\"header-label right\">{{ person2Name }}</div>\r\n\r\n <!-- Force-directed chip canvas (scrollable) -->\r\n <div class=\"chips-grid\" #chipsGrid>\r\n <div class=\"force-canvas\" [style.height.px]=\"forceCanvasHeight\">\r\n <!-- SVG Connections Layer -->\r\n <svg class=\"connections-svg\" [style.height.px]=\"forceCanvasHeight\">\r\n <line\r\n *ngFor=\"let link of sortedForceLinks\"\r\n [attr.x1]=\"getLinkSourceX(link)\"\r\n [attr.y1]=\"getLinkSourceY(link)\"\r\n [attr.x2]=\"getLinkTargetX(link)\"\r\n [attr.y2]=\"getLinkTargetY(link)\"\r\n class=\"connection-line\"\r\n [class.active]=\"isLinkConnectedToActive(link)\"\r\n [class.cross-group]=\"isCrossGroupLink(link)\"\r\n ></line>\r\n </svg>\r\n\r\n <div\r\n *ngFor=\"let node of forceNodes\"\r\n class=\"frosted-chip\"\r\n [class.person-a]=\"node.group === 'p1'\"\r\n [class.shared]=\"node.group === 'shared'\"\r\n [class.person-b]=\"node.group === 'p2'\"\r\n [class.active]=\"activeNode === node\"\r\n [class.dimmed]=\"activeNode && !isNodeConnected(node)\"\r\n [style.left.px]=\"node.x\"\r\n [style.top.px]=\"node.y\"\r\n [style.padding]=\"chipPaddingYPx + 'px ' + chipPaddingXPx + 'px'\"\r\n (mouseenter)=\"onNodeMouseEnter(node)\"\r\n (mouseleave)=\"onNodeMouseLeave(node)\"\r\n (click)=\"onNodeClick(node, $event)\"\r\n (mousedown)=\"onNodeDragStart(node, $event)\"\r\n (touchstart)=\"onNodeDragStart(node, $event)\"\r\n >\r\n <span\r\n class=\"chip-label\"\r\n [style.font-size.px]=\"chipFontSizePx\"\r\n [style.line-height]=\"chipLineHeight\"\r\n >{{ node.label }}</span>\r\n </div>\r\n </div>\r\n </div>\r\n\r\n <!-- Bottom Actions -->\r\n <div class=\"view-profile left\" (click)=\"onViewProfile('left')\">View Profile</div>\r\n <div class=\"view-profile right\" (click)=\"onViewProfile('right')\">View Profile</div>\r\n </div>\r\n\r\n <!-- Loading indicator for alignment process -->\r\n <div *ngIf=\"isAligning\" class=\"loading-indicator\">\r\n <div class=\"loading-spinner\"></div>\r\n </div>\r\n</div>\r\n\r\n\r\n", styles: ["@charset \"UTF-8\";:host{display:block;width:100%;max-width:100%;overflow-x:hidden!important;box-sizing:border-box!important}.profile-img.left{position:absolute;top:0;bottom:0;left:0;width:58%;height:100%;margin:0;z-index:1;-webkit-mask-image:linear-gradient(to right,black 0%,black 45%,transparent 92%);mask-image:linear-gradient(to right,black 0%,black 45%,transparent 92%)}.profile-img.right{position:absolute;inset:0 0 0 auto;width:58%;height:100%;margin:0;z-index:2;-webkit-mask-image:linear-gradient(to right,transparent 8%,black 55%,black 100%);mask-image:linear-gradient(to right,transparent 8%,black 55%,black 100%)}@media(min-width:768px){.profile-img.left{width:65%;-webkit-mask-image:linear-gradient(to right,black 0%,black 40%,transparent 90%);mask-image:linear-gradient(to right,black 0%,black 40%,transparent 90%)}.profile-img.right{width:65%;-webkit-mask-image:linear-gradient(to right,transparent 10%,black 60%,black 100%);mask-image:linear-gradient(to right,transparent 10%,black 60%,black 100%)}}.configure-backend-message{padding:1rem;text-align:center;color:#bdc3c7;background:#34495e;border-radius:8px}.backend-error-message{padding:.75rem 1rem;text-align:center;color:#fef3c7;background:#b4530940;border:1px solid rgba(245,158,11,.5);border-radius:8px;margin-bottom:.5rem}.profile-screen{width:100%;max-width:768px;margin:0 auto;overflow:hidden;position:relative;border-radius:12px}.profile-flex{display:flex;width:100%;height:440px;overflow:hidden;background:transparent;gap:0;margin:0;padding:0;position:relative;box-shadow:none;border:none;--overlap-size: 40px}.profile-flex.fade-all{-webkit-mask-image:linear-gradient(to right,transparent 0%,black 18%,black 82%,transparent 100%),linear-gradient(to bottom,transparent 0%,black 15%,black 85%,transparent 100%);-webkit-mask-composite:intersect;mask-image:linear-gradient(to right,transparent 0%,black 18%,black 82%,transparent 100%),linear-gradient(to bottom,transparent 0%,black 15%,black 85%,transparent 100%);mask-composite:intersect}.profile-flex .image-shade-overlay{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:3;background:radial-gradient(ellipse at center,transparent 40%,rgba(41,37,45,.25) 100%)}.profile-flex .image-noise-overlay{position:absolute;inset:0;width:100%;height:100%;max-width:100%;max-height:100%;pointer-events:none;overflow:hidden;z-index:0}.shape-bg1,.shape-bg2{position:absolute;max-width:250px;opacity:1;transition:transform .2s ease-out}.shape-bg1{z-index:1;left:0}.shape-bg2{right:0}.shape-bg.fade-all .shape-bg1{-webkit-mask-image:linear-gradient(to right,transparent 0%,black 20%,black 100%);mask-image:linear-gradient(to right,transparent 0%,black 20%,black 100%)}.shape-bg.fade-all .shape-bg2{-webkit-mask-image:linear-gradient(to left,transparent 0%,black 20%,black 100%);mask-image:linear-gradient(to left,transparent 0%,black 20%,black 100%)}.shape-bg svg{cursor:grab}.shape-bg svg:active{cursor:grabbing}.profile-img{height:100%;position:relative;flex-shrink:0;background:transparent;margin:0;padding:0;left:0;top:0;border:none;box-sizing:border-box;overflow:hidden;box-shadow:none;outline:none}.profile-img img{transition:transform .3s ease;width:100%;height:100%;object-fit:cover;display:block;object-position:center center;opacity:1;transform-origin:center center;border:none;outline:none;box-shadow:none;position:relative;top:0;left:0;filter:contrast(1.2) brightness(1.1) saturate(1.1);image-rendering:auto}.overlay-container{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:10;-webkit-user-select:none;user-select:none;overflow:hidden;background:#29252da6;backdrop-filter:blur(.5px);-webkit-backdrop-filter:blur(.5px);--name-label-font-size: 14px}.header-label{position:absolute;top:20px;font-family:Gilroy,sans-serif;text-shadow:0 1px 8px rgba(0,0,0,.8);pointer-events:none}.header-label.left{left:25%;transform:translate(-50%);font-size:var(--name-label-font-size);font-weight:400;color:#fffffff2;text-align:center}.header-label.center{left:50%;transform:translate(-50%);font-size:12px;font-weight:400;color:#fffc;letter-spacing:0}.header-label.right{left:75%;transform:translate(-50%);font-size:var(--name-label-font-size);font-weight:400;color:#fffffff2;text-align:center}.chips-grid{position:absolute;inset:45px 0;overflow-y:auto;overscroll-behavior:none;scrollbar-width:none;box-sizing:border-box}.chips-grid::-webkit-scrollbar{display:none}.force-canvas{position:relative;width:100%;box-sizing:border-box;min-height:120px}.connections-svg{position:absolute;top:0;left:0;width:100%;pointer-events:none;z-index:1}@keyframes lineGlow{0%{opacity:0;stroke-dashoffset:8}30%{opacity:.15}to{opacity:.6;stroke-dashoffset:0}}.connection-line{stroke:#fe3c72d1;stroke-width:1.5px;stroke-dasharray:4;opacity:0;pointer-events:none;transition:opacity .3s ease}.connection-line.active{animation:lineGlow .7s cubic-bezier(.4,0,.2,1) forwards}.frosted-chip{position:absolute;cursor:pointer;pointer-events:auto;transition:transform .2s ease,box-shadow .2s ease,background .2s ease,opacity .3s ease;flex-shrink:0;padding:6px 18px;border-radius:999px;display:inline-flex;align-items:center;justify-content:center;transform:translate(-50%,-50%);z-index:2;border:1px solid rgba(255,255,255,.24);background:#ffffff30;box-shadow:inset 0 1.5px 2px #ffffff59,0 0 0 1px #0f172a38;backdrop-filter:blur(.5px);-webkit-backdrop-filter:blur(.5px)}.frosted-chip:hover{transform:translate(-50%,-50%) scale(1.04);background:#ffffff29;cursor:grab}.frosted-chip.active{background:#ffffff4d;box-shadow:0 0 15px #fe3c7261,inset 0 1.5px 2px #ffffff80,0 0 0 1px #ffffff52;transform:translate(-50%,-50%) scale(1.06);z-index:10;cursor:grabbing}.frosted-chip.active .chip-label{color:#fff!important}.frosted-chip.dimmed{opacity:.35}.frosted-chip .chip-label{position:relative;color:#a8a8a8;text-align:center;font-family:Gilroy,sans-serif;font-size:14px;font-style:normal;font-weight:400;line-height:140%;letter-spacing:-.01em;text-shadow:none;white-space:nowrap;pointer-events:none;z-index:1}.ghost-chip{visibility:hidden!important;pointer-events:none!important}.view-profile{position:absolute;bottom:8px;color:#ffffff80;text-shadow:0 2px 4.2px #000;font-family:Calistoga,serif;font-size:var(--name-label-font-size);font-style:normal;font-weight:400;line-height:normal;cursor:pointer;pointer-events:auto;transition:opacity .2s ease,transform .2s ease}.view-profile:hover{opacity:.85}.view-profile.left{left:8%;width:34%;text-align:left;transform:none}.view-profile.left:hover{transform:translateY(-2px)}.view-profile.right{right:8%;width:34%;text-align:right;left:auto;transform:none}.view-profile.right:hover{transform:translateY(-2px)}#drag-preview{position:absolute;pointer-events:none;display:none;background:#000000b3;color:#fff;padding:5px 10px;border-radius:5px;z-index:9999;max-width:200px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.shape-text-container{width:70px;position:relative;overflow:hidden}.draggable{width:70px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:grab;-webkit-user-select:none;user-select:none;position:relative;display:inline-block}.dragging{width:auto;text-overflow:clip;cursor:grabbing;position:absolute;z-index:1000}.p-wrapper{width:70px;overflow-x:hidden}.shape-text-center{display:flex;justify-content:space-between;flex-direction:column;gap:10px;height:100%;margin-top:55px}.loading-indicator{position:absolute;inset:0;width:100%;height:100%;background:#00000040;backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px);color:#fff;z-index:100;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;pointer-events:auto}.loading-spinner{width:44px;height:44px;border:3.5px solid rgba(255,255,255,.25);border-top:3.5px solid #ffffff;border-radius:50%;animation:spin .8s linear infinite;box-shadow:0 0 15px #fff3}@keyframes spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.loading-indicator p{margin:0;font-size:14px;font-weight:500;color:#ffffffd9;letter-spacing:.02em}.api-key-modal-overlay{position:fixed;inset:0;background:#0f172a99;-webkit-backdrop-filter:blur(2px);backdrop-filter:blur(2px);display:flex;align-items:center;justify-content:center;z-index:2000}.api-key-modal{width:min(640px,92vw);background:#0f172a;color:#e2e8f0;border:1px solid #334155;border-radius:12px;box-shadow:0 20px 50px #00000073;padding:16px 18px}.api-key-modal-header{display:flex;align-items:center;justify-content:space-between;gap:12px}.api-key-modal-header h2{margin:0;font-size:20px;font-weight:700;color:#e2e8f0}.warning-icon{color:#fbbf24}.api-key-modal .close-btn{background:#0b1220;border:1px solid #334155;color:#94a3b8;border-radius:8px;padding:6px;cursor:pointer;line-height:0}.api-key-modal-body{margin-top:12px;display:flex;flex-direction:column;gap:10px}.modal-message,.modal-instruction{margin:0;color:#cbd5e1}.modal-message strong{color:#e5e7eb}.api-key-input-group{display:flex;align-items:center;gap:10px}.api-key-input{flex:1 1 auto;background:#0a1020;color:#e2e8f0;border:1px solid #334155;border-radius:8px;padding:10px 12px}.api-key-input:focus{outline:none;border-color:#2563eb;box-shadow:0 0 0 3px #2563eb33}.api-key-load-btn{background:#2563eb;color:#fff;border:none;border-radius:8px;padding:10px 12px;cursor:pointer;font-weight:600}.api-key-load-btn:hover{background:#1d4ed8}.modal-hint{display:flex;align-items:center;gap:8px;color:#94a3b8}.modal-hint a{color:#93c5fd}@media(max-width:1023px){.profile-screen{max-width:640px}.profile-flex{height:400px}.profile-img{height:550px}}@media(max-width:767px){.profile-screen,.profile-flex,.overlay-container{width:100%;max-width:100%;overflow-x:hidden;box-sizing:border-box}.profile-screen{border-radius:0}.profile-flex{height:360px}.profile-img{height:510px}.chips-grid{width:100%;max-width:100%;padding:0;top:45px;bottom:50px;box-sizing:border-box}.frosted-chip{padding:4px 10px;max-width:100%;box-sizing:border-box}.frosted-chip .chip-label{font-size:11px;max-width:100%;overflow:hidden;text-overflow:ellipsis}.view-profile{bottom:10px}}@media(max-width:480px){.overlay-container{--name-label-font-size: 12px}.profile-flex{height:340px}.profile-img{height:480px}.frosted-chip{padding:3px 8px}.frosted-chip .chip-label,.header-label.center{font-size:10px}}\n"], dependencies: [{ kind: "directive", type: i2_1.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i2_1.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }] });
2577
3719
  }
2578
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: ProfileComparisonLibComponent, decorators: [{
3720
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.24", ngImport: i0, type: ProfileComparisonLibComponent, decorators: [{
2579
3721
  type: Component,
2580
- args: [{ selector: 'lib-profile-comparison', standalone: false, template: "<div class=\"configure-backend-message\" *ngIf=\"!backendConfigured\">\n Configure backend\n</div>\n\n<div class=\"profile-screen\" *ngIf=\"backendConfigured\">\n <div #profileFlex class=\"profile-flex\" [class.fade-all]=\"fadeAllEdges\">\n <svg class=\"image-noise-overlay\" style=\"position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; overflow: hidden; z-index: 0;\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 320 320\" fill=\"none\" preserveAspectRatio=\"none\">\n <g filter=\"url(#filter0_n_1_1753)\">\n <rect width=\"100%\" height=\"100%\" fill=\"url(#paint0_radial_1_1753)\" fill-opacity=\"0.33\"/>\n </g>\n <defs>\n <filter id=\"filter0_n_1_1753\" x=\"0%\" y=\"0%\" width=\"100%\" height=\"100%\" filterUnits=\"objectBoundingBox\" color-interpolation-filters=\"sRGB\">\n <feFlood flood-opacity=\"0\" result=\"BackgroundImageFix\"/>\n <feBlend mode=\"normal\" in=\"SourceGraphic\" in2=\"BackgroundImageFix\" result=\"shape\"/>\n <feTurbulence type=\"fractalNoise\" baseFrequency=\"0.10869565606117249 0.10869565606117249\" stitchTiles=\"stitch\" numOctaves=\"3\" result=\"noise\" seed=\"7339\"/>\n <feColorMatrix in=\"noise\" type=\"luminanceToAlpha\" result=\"alphaNoise\"/>\n <feComponentTransfer in=\"alphaNoise\" result=\"coloredNoise1\">\n <feFuncA type=\"discrete\" tableValues=\"0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 \"/>\n </feComponentTransfer>\n <feComposite operator=\"in\" in2=\"shape\" in=\"coloredNoise1\" result=\"noise1Clipped\"/>\n <feFlood flood-color=\"rgba(0, 0, 0, 0.25)\" result=\"color1Flood\"/>\n <feComposite operator=\"in\" in2=\"noise1Clipped\" in=\"color1Flood\" result=\"color1\"/>\n <feMerge result=\"effect1_noise_1_1753\">\n <feMergeNode in=\"shape\"/>\n <feMergeNode in=\"color1\"/>\n </feMerge>\n </filter>\n <radialGradient id=\"paint0_radial_1_1753\" cx=\"50%\" cy=\"50%\" r=\"50%\" fx=\"50%\" fy=\"50%\">\n <stop offset=\"0%\" stop-opacity=\"0\"/>\n <stop offset=\"100%\" stop-opacity=\"0.25\"/>\n </radialGradient>\n </defs>\n </svg>\n <div #profileImgLeft class=\"profile-img left\" [class.fade-all]=\"fadeAllEdges\">\n <img\n [src]=\"user1Image\"\n alt=\"User 1\"\n [style.transform]=\"user1Transform\"\n [style.top]=\"user1Top\"\n [style.height]=\"user1Height\"\n [style.object-position]=\"user1ObjectPosition\"\n (load)=\"onUserImageLoad(1, $event)\"\n />\n </div>\n <div #profileImgRight class=\"profile-img right\" [class.fade-all]=\"fadeAllEdges\">\n <img\n [src]=\"user2Image\"\n alt=\"User 2\"\n [style.transform]=\"user2Transform\"\n [style.top]=\"user2Top\"\n [style.height]=\"user2Height\"\n [style.object-position]=\"user2ObjectPosition\"\n (load)=\"onUserImageLoad(2, $event)\"\n />\n </div>\n <div class=\"image-shade-overlay\"></div>\n\n <!-- Frosted Glass Overlay Layer -->\n <div class=\"overlay-container\">\n <!-- Top Headers -->\n <div class=\"header-label left\">{{ person1Name }}</div>\n <div class=\"header-label center\">Shared</div>\n <div class=\"header-label right\">{{ person2Name }}</div>\n\n <!-- Chips Columns Grid -->\n <div class=\"chips-grid\">\n <!-- Person 1 Unique Interests -->\n <div class=\"chips-column left\">\n <ng-container *ngFor=\"let interest of getPerson1UniqueInterests(); let i = index\">\n <div class=\"frosted-chip person-a\" [style.margin-right.px]=\"getChipOffset(i, 'left')\" [style.margin-top.px]=\"(i > 0 ? 4 : 0)\">\n <span class=\"chip-label\">{{ interest }}</span>\n </div>\n </ng-container>\n </div>\n\n <!-- Shared Interests (Center Column) -->\n <div class=\"chips-column center\">\n <ng-container *ngFor=\"let interest of centerItem; let i = index\">\n <div *ngIf=\"interest !== 'empty'\" class=\"frosted-chip shared\" [style.margin-left.px]=\"getChipOffset(i, 'center')\" [style.margin-top.px]=\"(i > 0 ? 4 : 0)\">\n <span class=\"chip-label\">{{ interest }}</span>\n </div>\n <div *ngIf=\"interest === 'empty'\" class=\"frosted-chip ghost-chip shared\" [style.margin-left.px]=\"getChipOffset(i, 'center')\" [style.margin-top.px]=\"(i > 0 ? 4 : 0)\">\n <span class=\"chip-label\">&nbsp;</span>\n </div>\n </ng-container>\n </div>\n\n <!-- Person 2 Unique Interests -->\n <div class=\"chips-column right\">\n <ng-container *ngFor=\"let interest of getPerson2UniqueInterests(); let i = index\">\n <div class=\"frosted-chip person-b\" [style.margin-left.px]=\"getChipOffset(i, 'right')\" [style.margin-top.px]=\"(i > 0 ? 4 : 0)\">\n <span class=\"chip-label\">{{ interest }}</span>\n </div>\n </ng-container>\n </div>\n </div>\n\n <!-- Bottom Actions -->\n <div class=\"view-profile left\" (click)=\"onViewProfile('left')\">View Profile</div>\n <div class=\"view-profile right\" (click)=\"onViewProfile('right')\">View Profile</div>\n </div>\n\n <!-- Loading indicator for alignment process -->\n <div *ngIf=\"isAligning\" class=\"loading-indicator\">\n <div class=\"loading-spinner\"></div>\n </div>\n</div>\n\n\n", styles: [":host{display:block;width:100%;max-width:100%;overflow-x:hidden!important;box-sizing:border-box!important}.profile-img.left{position:absolute;top:0;bottom:0;left:0;width:58%;height:100%;margin:0;z-index:1;-webkit-mask-image:linear-gradient(to right,black 0%,black 45%,transparent 92%);mask-image:linear-gradient(to right,black 0%,black 45%,transparent 92%)}.profile-img.right{position:absolute;inset:0 0 0 auto;width:58%;height:100%;margin:0;z-index:2;-webkit-mask-image:linear-gradient(to right,transparent 8%,black 55%,black 100%);mask-image:linear-gradient(to right,transparent 8%,black 55%,black 100%)}@media(min-width:768px){.profile-img.left{width:65%;-webkit-mask-image:linear-gradient(to right,black 0%,black 40%,transparent 90%);mask-image:linear-gradient(to right,black 0%,black 40%,transparent 90%)}.profile-img.right{width:65%;-webkit-mask-image:linear-gradient(to right,transparent 10%,black 60%,black 100%);mask-image:linear-gradient(to right,transparent 10%,black 60%,black 100%)}}.configure-backend-message{padding:1rem;text-align:center;color:#bdc3c7;background:#34495e;border-radius:8px}.backend-error-message{padding:.75rem 1rem;text-align:center;color:#fef3c7;background:#b4530940;border:1px solid rgba(245,158,11,.5);border-radius:8px;margin-bottom:.5rem}.profile-screen{width:100%;max-width:768px;margin:0 auto;overflow:hidden;position:relative;border-radius:12px}.profile-flex{display:flex;width:100%;height:440px;overflow:hidden;background:transparent;gap:0;margin:0;padding:0;position:relative;box-shadow:none;border:none;--overlap-size: 40px}.profile-flex.fade-all{-webkit-mask-image:linear-gradient(to right,transparent 0%,black 18%,black 82%,transparent 100%),linear-gradient(to bottom,transparent 0%,black 15%,black 85%,transparent 100%);-webkit-mask-composite:intersect;mask-image:linear-gradient(to right,transparent 0%,black 18%,black 82%,transparent 100%),linear-gradient(to bottom,transparent 0%,black 15%,black 85%,transparent 100%);mask-composite:intersect}.profile-flex .image-shade-overlay{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:3;background:radial-gradient(ellipse at center,transparent 40%,rgba(41,37,45,.25) 100%)}.profile-flex .image-noise-overlay{position:absolute;inset:0;width:100%;height:100%;max-width:100%;max-height:100%;pointer-events:none;overflow:hidden;z-index:0}.shape-bg1,.shape-bg2{position:absolute;max-width:250px;opacity:1;transition:transform .2s ease-out}.shape-bg1{z-index:1;left:0}.shape-bg2{right:0}.shape-bg.fade-all .shape-bg1{-webkit-mask-image:linear-gradient(to right,transparent 0%,black 20%,black 100%);mask-image:linear-gradient(to right,transparent 0%,black 20%,black 100%)}.shape-bg.fade-all .shape-bg2{-webkit-mask-image:linear-gradient(to left,transparent 0%,black 20%,black 100%);mask-image:linear-gradient(to left,transparent 0%,black 20%,black 100%)}.shape-bg svg{cursor:grab}.shape-bg svg:active{cursor:grabbing}.profile-img{height:100%;position:relative;flex-shrink:0;background:transparent;margin:0;padding:0;left:0;top:0;border:none;box-sizing:border-box;overflow:hidden;box-shadow:none;outline:none}.profile-img img{transition:transform .3s ease;width:100%;height:100%;object-fit:cover;display:block;object-position:center center;opacity:1;transform-origin:center center;border:none;outline:none;box-shadow:none;position:relative;top:0;left:0;filter:contrast(1.2) brightness(1.1) saturate(1.1);image-rendering:auto}.overlay-container{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:10;-webkit-user-select:none;user-select:none;overflow:hidden;background:#29252da6;backdrop-filter:blur(.5px);-webkit-backdrop-filter:blur(.5px)}.header-label{position:absolute;top:20px;font-family:Gilroy,sans-serif;text-shadow:0 1px 8px rgba(0,0,0,.8);pointer-events:none}.header-label.left{left:5%;transform:none;font-size:14px;font-weight:400;color:#fffffff2}.header-label.center{left:50%;transform:translate(-50%);font-size:12px;font-weight:400;color:#fffc;letter-spacing:0}.header-label.right{right:5%;left:auto;transform:none;font-size:14px;font-weight:400;color:#fffffff2}.chips-grid{position:absolute;inset:45px 0;display:flex;padding:0;overflow-y:auto;overscroll-behavior:none;scrollbar-width:none;box-sizing:border-box}.chips-grid::-webkit-scrollbar{display:none}.chips-column{flex:1;display:flex;flex-direction:column;gap:8px;padding:4px 10px;box-sizing:border-box}.chips-column.left{align-items:flex-end}.chips-column.center{align-items:center}.chips-column.right{align-items:flex-start}.frosted-chip{position:relative;cursor:pointer;pointer-events:auto;transition:transform .2s ease,box-shadow .2s ease,background .2s ease;flex-shrink:0;padding:6px 18px;border-radius:999px;display:inline-flex;align-items:center;justify-content:center;border:none;background:#ffffff30;box-shadow:inset 0 1.5px 2px #ffffff59;backdrop-filter:blur(.5px);-webkit-backdrop-filter:blur(.5px)}.frosted-chip:hover{transform:scale(1.04);background:#ffffff29}.frosted-chip .chip-label{position:relative;color:#a8a8a8;text-align:center;font-family:Gilroy,sans-serif;font-size:14px;font-style:normal;font-weight:400;line-height:140%;letter-spacing:-.01em;text-shadow:none;white-space:nowrap;pointer-events:none;z-index:1}.ghost-chip{visibility:hidden!important;pointer-events:none!important}.view-profile{position:absolute;bottom:20px!important;color:#ffffff80;text-shadow:0 2px 4.2px #000;font-family:Calistoga,serif;font-size:18px;font-style:normal;font-weight:400;line-height:normal;cursor:pointer;pointer-events:auto;transition:opacity .2s ease,transform .2s ease}.view-profile:hover{opacity:.85}.view-profile.left{left:5%;transform:none}.view-profile.left:hover{transform:translateY(-2px)}.view-profile.right{right:5%;left:auto;transform:none}.view-profile.right:hover{transform:translateY(-2px)}#drag-preview{position:absolute;pointer-events:none;display:none;background:#000000b3;color:#fff;padding:5px 10px;border-radius:5px;z-index:9999;max-width:200px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.shape-text-container{width:70px;position:relative;overflow:hidden}.draggable{width:70px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:grab;-webkit-user-select:none;user-select:none;position:relative;display:inline-block}.dragging{width:auto;text-overflow:clip;cursor:grabbing;position:absolute;z-index:1000}.p-wrapper{width:70px;overflow-x:hidden}.shape-text-center{display:flex;justify-content:space-between;flex-direction:column;gap:10px;height:100%;margin-top:55px}.loading-indicator{position:absolute;inset:0;width:100%;height:100%;background:#00000040;backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px);color:#fff;z-index:100;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;pointer-events:auto}.loading-spinner{width:44px;height:44px;border:3.5px solid rgba(255,255,255,.25);border-top:3.5px solid #ffffff;border-radius:50%;animation:spin .8s linear infinite;box-shadow:0 0 15px #fff3}@keyframes spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.loading-indicator p{margin:0;font-size:14px;font-weight:500;color:#ffffffd9;letter-spacing:.02em}.api-key-modal-overlay{position:fixed;inset:0;background:#0f172a99;-webkit-backdrop-filter:blur(2px);backdrop-filter:blur(2px);display:flex;align-items:center;justify-content:center;z-index:2000}.api-key-modal{width:min(640px,92vw);background:#0f172a;color:#e2e8f0;border:1px solid #334155;border-radius:12px;box-shadow:0 20px 50px #00000073;padding:16px 18px}.api-key-modal-header{display:flex;align-items:center;justify-content:space-between;gap:12px}.api-key-modal-header h2{margin:0;font-size:20px;font-weight:700;color:#e2e8f0}.warning-icon{color:#fbbf24}.api-key-modal .close-btn{background:#0b1220;border:1px solid #334155;color:#94a3b8;border-radius:8px;padding:6px;cursor:pointer;line-height:0}.api-key-modal-body{margin-top:12px;display:flex;flex-direction:column;gap:10px}.modal-message,.modal-instruction{margin:0;color:#cbd5e1}.modal-message strong{color:#e5e7eb}.api-key-input-group{display:flex;align-items:center;gap:10px}.api-key-input{flex:1 1 auto;background:#0a1020;color:#e2e8f0;border:1px solid #334155;border-radius:8px;padding:10px 12px}.api-key-input:focus{outline:none;border-color:#2563eb;box-shadow:0 0 0 3px #2563eb33}.api-key-load-btn{background:#2563eb;color:#fff;border:none;border-radius:8px;padding:10px 12px;cursor:pointer;font-weight:600}.api-key-load-btn:hover{background:#1d4ed8}.modal-hint{display:flex;align-items:center;gap:8px;color:#94a3b8}.modal-hint a{color:#93c5fd}@media(max-width:1023px){.profile-screen{max-width:640px}.profile-flex{height:400px}.profile-img{height:550px}}@media(max-width:767px){.profile-screen,.profile-flex,.overlay-container{width:100%;max-width:100%;overflow-x:hidden;box-sizing:border-box}.profile-screen{border-radius:0}.profile-flex{height:360px}.profile-img{height:510px}.chips-grid{width:100%;max-width:100%;padding:0 4px;top:50px;bottom:50px;box-sizing:border-box}.chips-column{padding:2px}.chips-column.left{align-items:flex-end}.chips-column.center{align-items:center}.chips-column.right{align-items:flex-start}.frosted-chip{padding:4px 10px;max-width:100%;box-sizing:border-box}.frosted-chip .chip-label{font-size:11px;max-width:100%;overflow:hidden;text-overflow:ellipsis}.view-profile{bottom:14px}}@media(max-width:480px){.profile-flex{height:340px}.profile-img{height:480px}.frosted-chip{padding:3px 8px}.frosted-chip .chip-label{font-size:10px}.header-label{top:14px}.header-label.left{font-size:12px}.header-label.center{font-size:10px}.header-label.right{font-size:12px}}\n"] }]
2581
- }], ctorParameters: () => [{ type: ProfileComparisonBackendService }, { type: i0.Renderer2 }, { type: FileConversionService }, { type: ImageCompressionService }, { type: i0.ChangeDetectorRef }, { type: undefined, decorators: [{
3722
+ args: [{ selector: 'lib-profile-comparison', standalone: false, template: "<div class=\"configure-backend-message\" *ngIf=\"!backendConfigured\">\r\n Configure backend\r\n</div>\r\n\r\n<div class=\"profile-screen\" *ngIf=\"backendConfigured\">\r\n <div #profileFlex class=\"profile-flex\" [class.fade-all]=\"fadeAllEdges\">\r\n <svg class=\"image-noise-overlay\" style=\"position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; overflow: hidden; z-index: 0;\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 320 320\" fill=\"none\" preserveAspectRatio=\"none\">\r\n <g filter=\"url(#filter0_n_1_1753)\">\r\n <rect width=\"100%\" height=\"100%\" fill=\"url(#paint0_radial_1_1753)\" fill-opacity=\"0.33\"/>\r\n </g>\r\n <defs>\r\n <filter id=\"filter0_n_1_1753\" x=\"0%\" y=\"0%\" width=\"100%\" height=\"100%\" filterUnits=\"objectBoundingBox\" color-interpolation-filters=\"sRGB\">\r\n <feFlood flood-opacity=\"0\" result=\"BackgroundImageFix\"/>\r\n <feBlend mode=\"normal\" in=\"SourceGraphic\" in2=\"BackgroundImageFix\" result=\"shape\"/>\r\n <feTurbulence type=\"fractalNoise\" baseFrequency=\"0.10869565606117249 0.10869565606117249\" stitchTiles=\"stitch\" numOctaves=\"3\" result=\"noise\" seed=\"7339\"/>\r\n <feColorMatrix in=\"noise\" type=\"luminanceToAlpha\" result=\"alphaNoise\"/>\r\n <feComponentTransfer in=\"alphaNoise\" result=\"coloredNoise1\">\r\n <feFuncA type=\"discrete\" tableValues=\"0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 \"/>\r\n </feComponentTransfer>\r\n <feComposite operator=\"in\" in2=\"shape\" in=\"coloredNoise1\" result=\"noise1Clipped\"/>\r\n <feFlood flood-color=\"rgba(0, 0, 0, 0.25)\" result=\"color1Flood\"/>\r\n <feComposite operator=\"in\" in2=\"noise1Clipped\" in=\"color1Flood\" result=\"color1\"/>\r\n <feMerge result=\"effect1_noise_1_1753\">\r\n <feMergeNode in=\"shape\"/>\r\n <feMergeNode in=\"color1\"/>\r\n </feMerge>\r\n </filter>\r\n <radialGradient id=\"paint0_radial_1_1753\" cx=\"50%\" cy=\"50%\" r=\"50%\" fx=\"50%\" fy=\"50%\">\r\n <stop offset=\"0%\" stop-opacity=\"0\"/>\r\n <stop offset=\"100%\" stop-opacity=\"0.25\"/>\r\n </radialGradient>\r\n </defs>\r\n </svg>\r\n <div #profileImgLeft class=\"profile-img left\" [class.fade-all]=\"fadeAllEdges\">\r\n <img\r\n [src]=\"user1Image\"\r\n alt=\"User 1\"\r\n [style.transform]=\"user1Transform\"\r\n [style.top]=\"user1Top\"\r\n [style.height]=\"user1Height\"\r\n [style.object-position]=\"user1ObjectPosition\"\r\n (load)=\"onUserImageLoad(1, $event)\"\r\n />\r\n </div>\r\n <div #profileImgRight class=\"profile-img right\" [class.fade-all]=\"fadeAllEdges\">\r\n <img\r\n [src]=\"user2Image\"\r\n alt=\"User 2\"\r\n [style.transform]=\"user2Transform\"\r\n [style.top]=\"user2Top\"\r\n [style.height]=\"user2Height\"\r\n [style.object-position]=\"user2ObjectPosition\"\r\n (load)=\"onUserImageLoad(2, $event)\"\r\n />\r\n </div>\r\n <div class=\"image-shade-overlay\"></div>\r\n\r\n <!-- Frosted Glass Overlay Layer -->\r\n <div class=\"overlay-container\">\r\n <!-- Top Headers \u2014 absolutely positioned overlays, exactly as original -->\r\n <div class=\"header-label left\">{{ person1Name }}</div>\r\n <div class=\"header-label center\">Shared</div>\r\n <div class=\"header-label right\">{{ person2Name }}</div>\r\n\r\n <!-- Force-directed chip canvas (scrollable) -->\r\n <div class=\"chips-grid\" #chipsGrid>\r\n <div class=\"force-canvas\" [style.height.px]=\"forceCanvasHeight\">\r\n <!-- SVG Connections Layer -->\r\n <svg class=\"connections-svg\" [style.height.px]=\"forceCanvasHeight\">\r\n <line\r\n *ngFor=\"let link of sortedForceLinks\"\r\n [attr.x1]=\"getLinkSourceX(link)\"\r\n [attr.y1]=\"getLinkSourceY(link)\"\r\n [attr.x2]=\"getLinkTargetX(link)\"\r\n [attr.y2]=\"getLinkTargetY(link)\"\r\n class=\"connection-line\"\r\n [class.active]=\"isLinkConnectedToActive(link)\"\r\n [class.cross-group]=\"isCrossGroupLink(link)\"\r\n ></line>\r\n </svg>\r\n\r\n <div\r\n *ngFor=\"let node of forceNodes\"\r\n class=\"frosted-chip\"\r\n [class.person-a]=\"node.group === 'p1'\"\r\n [class.shared]=\"node.group === 'shared'\"\r\n [class.person-b]=\"node.group === 'p2'\"\r\n [class.active]=\"activeNode === node\"\r\n [class.dimmed]=\"activeNode && !isNodeConnected(node)\"\r\n [style.left.px]=\"node.x\"\r\n [style.top.px]=\"node.y\"\r\n [style.padding]=\"chipPaddingYPx + 'px ' + chipPaddingXPx + 'px'\"\r\n (mouseenter)=\"onNodeMouseEnter(node)\"\r\n (mouseleave)=\"onNodeMouseLeave(node)\"\r\n (click)=\"onNodeClick(node, $event)\"\r\n (mousedown)=\"onNodeDragStart(node, $event)\"\r\n (touchstart)=\"onNodeDragStart(node, $event)\"\r\n >\r\n <span\r\n class=\"chip-label\"\r\n [style.font-size.px]=\"chipFontSizePx\"\r\n [style.line-height]=\"chipLineHeight\"\r\n >{{ node.label }}</span>\r\n </div>\r\n </div>\r\n </div>\r\n\r\n <!-- Bottom Actions -->\r\n <div class=\"view-profile left\" (click)=\"onViewProfile('left')\">View Profile</div>\r\n <div class=\"view-profile right\" (click)=\"onViewProfile('right')\">View Profile</div>\r\n </div>\r\n\r\n <!-- Loading indicator for alignment process -->\r\n <div *ngIf=\"isAligning\" class=\"loading-indicator\">\r\n <div class=\"loading-spinner\"></div>\r\n </div>\r\n</div>\r\n\r\n\r\n", styles: ["@charset \"UTF-8\";:host{display:block;width:100%;max-width:100%;overflow-x:hidden!important;box-sizing:border-box!important}.profile-img.left{position:absolute;top:0;bottom:0;left:0;width:58%;height:100%;margin:0;z-index:1;-webkit-mask-image:linear-gradient(to right,black 0%,black 45%,transparent 92%);mask-image:linear-gradient(to right,black 0%,black 45%,transparent 92%)}.profile-img.right{position:absolute;inset:0 0 0 auto;width:58%;height:100%;margin:0;z-index:2;-webkit-mask-image:linear-gradient(to right,transparent 8%,black 55%,black 100%);mask-image:linear-gradient(to right,transparent 8%,black 55%,black 100%)}@media(min-width:768px){.profile-img.left{width:65%;-webkit-mask-image:linear-gradient(to right,black 0%,black 40%,transparent 90%);mask-image:linear-gradient(to right,black 0%,black 40%,transparent 90%)}.profile-img.right{width:65%;-webkit-mask-image:linear-gradient(to right,transparent 10%,black 60%,black 100%);mask-image:linear-gradient(to right,transparent 10%,black 60%,black 100%)}}.configure-backend-message{padding:1rem;text-align:center;color:#bdc3c7;background:#34495e;border-radius:8px}.backend-error-message{padding:.75rem 1rem;text-align:center;color:#fef3c7;background:#b4530940;border:1px solid rgba(245,158,11,.5);border-radius:8px;margin-bottom:.5rem}.profile-screen{width:100%;max-width:768px;margin:0 auto;overflow:hidden;position:relative;border-radius:12px}.profile-flex{display:flex;width:100%;height:440px;overflow:hidden;background:transparent;gap:0;margin:0;padding:0;position:relative;box-shadow:none;border:none;--overlap-size: 40px}.profile-flex.fade-all{-webkit-mask-image:linear-gradient(to right,transparent 0%,black 18%,black 82%,transparent 100%),linear-gradient(to bottom,transparent 0%,black 15%,black 85%,transparent 100%);-webkit-mask-composite:intersect;mask-image:linear-gradient(to right,transparent 0%,black 18%,black 82%,transparent 100%),linear-gradient(to bottom,transparent 0%,black 15%,black 85%,transparent 100%);mask-composite:intersect}.profile-flex .image-shade-overlay{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:3;background:radial-gradient(ellipse at center,transparent 40%,rgba(41,37,45,.25) 100%)}.profile-flex .image-noise-overlay{position:absolute;inset:0;width:100%;height:100%;max-width:100%;max-height:100%;pointer-events:none;overflow:hidden;z-index:0}.shape-bg1,.shape-bg2{position:absolute;max-width:250px;opacity:1;transition:transform .2s ease-out}.shape-bg1{z-index:1;left:0}.shape-bg2{right:0}.shape-bg.fade-all .shape-bg1{-webkit-mask-image:linear-gradient(to right,transparent 0%,black 20%,black 100%);mask-image:linear-gradient(to right,transparent 0%,black 20%,black 100%)}.shape-bg.fade-all .shape-bg2{-webkit-mask-image:linear-gradient(to left,transparent 0%,black 20%,black 100%);mask-image:linear-gradient(to left,transparent 0%,black 20%,black 100%)}.shape-bg svg{cursor:grab}.shape-bg svg:active{cursor:grabbing}.profile-img{height:100%;position:relative;flex-shrink:0;background:transparent;margin:0;padding:0;left:0;top:0;border:none;box-sizing:border-box;overflow:hidden;box-shadow:none;outline:none}.profile-img img{transition:transform .3s ease;width:100%;height:100%;object-fit:cover;display:block;object-position:center center;opacity:1;transform-origin:center center;border:none;outline:none;box-shadow:none;position:relative;top:0;left:0;filter:contrast(1.2) brightness(1.1) saturate(1.1);image-rendering:auto}.overlay-container{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:10;-webkit-user-select:none;user-select:none;overflow:hidden;background:#29252da6;backdrop-filter:blur(.5px);-webkit-backdrop-filter:blur(.5px);--name-label-font-size: 14px}.header-label{position:absolute;top:20px;font-family:Gilroy,sans-serif;text-shadow:0 1px 8px rgba(0,0,0,.8);pointer-events:none}.header-label.left{left:25%;transform:translate(-50%);font-size:var(--name-label-font-size);font-weight:400;color:#fffffff2;text-align:center}.header-label.center{left:50%;transform:translate(-50%);font-size:12px;font-weight:400;color:#fffc;letter-spacing:0}.header-label.right{left:75%;transform:translate(-50%);font-size:var(--name-label-font-size);font-weight:400;color:#fffffff2;text-align:center}.chips-grid{position:absolute;inset:45px 0;overflow-y:auto;overscroll-behavior:none;scrollbar-width:none;box-sizing:border-box}.chips-grid::-webkit-scrollbar{display:none}.force-canvas{position:relative;width:100%;box-sizing:border-box;min-height:120px}.connections-svg{position:absolute;top:0;left:0;width:100%;pointer-events:none;z-index:1}@keyframes lineGlow{0%{opacity:0;stroke-dashoffset:8}30%{opacity:.15}to{opacity:.6;stroke-dashoffset:0}}.connection-line{stroke:#fe3c72d1;stroke-width:1.5px;stroke-dasharray:4;opacity:0;pointer-events:none;transition:opacity .3s ease}.connection-line.active{animation:lineGlow .7s cubic-bezier(.4,0,.2,1) forwards}.frosted-chip{position:absolute;cursor:pointer;pointer-events:auto;transition:transform .2s ease,box-shadow .2s ease,background .2s ease,opacity .3s ease;flex-shrink:0;padding:6px 18px;border-radius:999px;display:inline-flex;align-items:center;justify-content:center;transform:translate(-50%,-50%);z-index:2;border:1px solid rgba(255,255,255,.24);background:#ffffff30;box-shadow:inset 0 1.5px 2px #ffffff59,0 0 0 1px #0f172a38;backdrop-filter:blur(.5px);-webkit-backdrop-filter:blur(.5px)}.frosted-chip:hover{transform:translate(-50%,-50%) scale(1.04);background:#ffffff29;cursor:grab}.frosted-chip.active{background:#ffffff4d;box-shadow:0 0 15px #fe3c7261,inset 0 1.5px 2px #ffffff80,0 0 0 1px #ffffff52;transform:translate(-50%,-50%) scale(1.06);z-index:10;cursor:grabbing}.frosted-chip.active .chip-label{color:#fff!important}.frosted-chip.dimmed{opacity:.35}.frosted-chip .chip-label{position:relative;color:#a8a8a8;text-align:center;font-family:Gilroy,sans-serif;font-size:14px;font-style:normal;font-weight:400;line-height:140%;letter-spacing:-.01em;text-shadow:none;white-space:nowrap;pointer-events:none;z-index:1}.ghost-chip{visibility:hidden!important;pointer-events:none!important}.view-profile{position:absolute;bottom:8px;color:#ffffff80;text-shadow:0 2px 4.2px #000;font-family:Calistoga,serif;font-size:var(--name-label-font-size);font-style:normal;font-weight:400;line-height:normal;cursor:pointer;pointer-events:auto;transition:opacity .2s ease,transform .2s ease}.view-profile:hover{opacity:.85}.view-profile.left{left:8%;width:34%;text-align:left;transform:none}.view-profile.left:hover{transform:translateY(-2px)}.view-profile.right{right:8%;width:34%;text-align:right;left:auto;transform:none}.view-profile.right:hover{transform:translateY(-2px)}#drag-preview{position:absolute;pointer-events:none;display:none;background:#000000b3;color:#fff;padding:5px 10px;border-radius:5px;z-index:9999;max-width:200px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.shape-text-container{width:70px;position:relative;overflow:hidden}.draggable{width:70px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:grab;-webkit-user-select:none;user-select:none;position:relative;display:inline-block}.dragging{width:auto;text-overflow:clip;cursor:grabbing;position:absolute;z-index:1000}.p-wrapper{width:70px;overflow-x:hidden}.shape-text-center{display:flex;justify-content:space-between;flex-direction:column;gap:10px;height:100%;margin-top:55px}.loading-indicator{position:absolute;inset:0;width:100%;height:100%;background:#00000040;backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px);color:#fff;z-index:100;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;pointer-events:auto}.loading-spinner{width:44px;height:44px;border:3.5px solid rgba(255,255,255,.25);border-top:3.5px solid #ffffff;border-radius:50%;animation:spin .8s linear infinite;box-shadow:0 0 15px #fff3}@keyframes spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.loading-indicator p{margin:0;font-size:14px;font-weight:500;color:#ffffffd9;letter-spacing:.02em}.api-key-modal-overlay{position:fixed;inset:0;background:#0f172a99;-webkit-backdrop-filter:blur(2px);backdrop-filter:blur(2px);display:flex;align-items:center;justify-content:center;z-index:2000}.api-key-modal{width:min(640px,92vw);background:#0f172a;color:#e2e8f0;border:1px solid #334155;border-radius:12px;box-shadow:0 20px 50px #00000073;padding:16px 18px}.api-key-modal-header{display:flex;align-items:center;justify-content:space-between;gap:12px}.api-key-modal-header h2{margin:0;font-size:20px;font-weight:700;color:#e2e8f0}.warning-icon{color:#fbbf24}.api-key-modal .close-btn{background:#0b1220;border:1px solid #334155;color:#94a3b8;border-radius:8px;padding:6px;cursor:pointer;line-height:0}.api-key-modal-body{margin-top:12px;display:flex;flex-direction:column;gap:10px}.modal-message,.modal-instruction{margin:0;color:#cbd5e1}.modal-message strong{color:#e5e7eb}.api-key-input-group{display:flex;align-items:center;gap:10px}.api-key-input{flex:1 1 auto;background:#0a1020;color:#e2e8f0;border:1px solid #334155;border-radius:8px;padding:10px 12px}.api-key-input:focus{outline:none;border-color:#2563eb;box-shadow:0 0 0 3px #2563eb33}.api-key-load-btn{background:#2563eb;color:#fff;border:none;border-radius:8px;padding:10px 12px;cursor:pointer;font-weight:600}.api-key-load-btn:hover{background:#1d4ed8}.modal-hint{display:flex;align-items:center;gap:8px;color:#94a3b8}.modal-hint a{color:#93c5fd}@media(max-width:1023px){.profile-screen{max-width:640px}.profile-flex{height:400px}.profile-img{height:550px}}@media(max-width:767px){.profile-screen,.profile-flex,.overlay-container{width:100%;max-width:100%;overflow-x:hidden;box-sizing:border-box}.profile-screen{border-radius:0}.profile-flex{height:360px}.profile-img{height:510px}.chips-grid{width:100%;max-width:100%;padding:0;top:45px;bottom:50px;box-sizing:border-box}.frosted-chip{padding:4px 10px;max-width:100%;box-sizing:border-box}.frosted-chip .chip-label{font-size:11px;max-width:100%;overflow:hidden;text-overflow:ellipsis}.view-profile{bottom:10px}}@media(max-width:480px){.overlay-container{--name-label-font-size: 12px}.profile-flex{height:340px}.profile-img{height:480px}.frosted-chip{padding:3px 8px}.frosted-chip .chip-label,.header-label.center{font-size:10px}}\n"] }]
3723
+ }], ctorParameters: () => [{ type: ProfileComparisonBackendService }, { type: i0.Renderer2 }, { type: FileConversionService }, { type: ImageCompressionService }, { type: i0.ChangeDetectorRef }, { type: i0.NgZone }, { type: i0.ElementRef }, { type: undefined, decorators: [{
2582
3724
  type: Optional
2583
3725
  }, {
2584
3726
  type: Inject,
@@ -2643,22 +3785,43 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImpo
2643
3785
  }], shapeTextCenter: [{
2644
3786
  type: ViewChild,
2645
3787
  args: ['shapeTextCenter']
3788
+ }], chipsGrid: [{
3789
+ type: ViewChild,
3790
+ args: ['chipsGrid']
3791
+ }], onWindowResize: [{
3792
+ type: HostListener,
3793
+ args: ['window:resize']
3794
+ }], onDocumentClick: [{
3795
+ type: HostListener,
3796
+ args: ['document:click']
3797
+ }], onDocumentDragMove: [{
3798
+ type: HostListener,
3799
+ args: ['document:mousemove', ['$event']]
3800
+ }, {
3801
+ type: HostListener,
3802
+ args: ['document:touchmove', ['$event']]
3803
+ }], onDocumentDragEnd: [{
3804
+ type: HostListener,
3805
+ args: ['document:mouseup']
3806
+ }, {
3807
+ type: HostListener,
3808
+ args: ['document:touchend']
2646
3809
  }] } });
2647
3810
 
2648
3811
  class ProfileComparisonLibModule {
2649
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: ProfileComparisonLibModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule });
2650
- static ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "20.3.25", ngImport: i0, type: ProfileComparisonLibModule, declarations: [ProfileComparisonLibComponent], imports: [CommonModule,
3812
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.24", ngImport: i0, type: ProfileComparisonLibModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule });
3813
+ static ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "20.3.24", ngImport: i0, type: ProfileComparisonLibModule, declarations: [ProfileComparisonLibComponent], imports: [CommonModule,
2651
3814
  HttpClientModule,
2652
3815
  FormsModule,
2653
3816
  ReactiveFormsModule], exports: [ProfileComparisonLibComponent] });
2654
- static ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: ProfileComparisonLibModule, providers: [
3817
+ static ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "20.3.24", ngImport: i0, type: ProfileComparisonLibModule, providers: [
2655
3818
  ProfileComparisonBackendService
2656
3819
  ], imports: [CommonModule,
2657
3820
  HttpClientModule,
2658
3821
  FormsModule,
2659
3822
  ReactiveFormsModule] });
2660
3823
  }
2661
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: ProfileComparisonLibModule, decorators: [{
3824
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.24", ngImport: i0, type: ProfileComparisonLibModule, decorators: [{
2662
3825
  type: NgModule,
2663
3826
  args: [{
2664
3827
  declarations: [