@naniteninja/profile-comparison-lib 1.0.26 → 1.0.28

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.
@@ -17,9 +17,23 @@ var BackendMode;
17
17
  /** Use a local mock server at localhost:44101. */
18
18
  BackendMode[BackendMode["Mock"] = 1] = "Mock";
19
19
  })(BackendMode || (BackendMode = {}));
20
+ const PROFILE_COMPARISON_PROD_API_BASE_URL = 'https://api.test.thecyrano.app/api/profile-comparison/api';
21
+ const PROFILE_COMPARISON_LOCAL_API_BASE_URL = 'http://localhost:44101/api';
22
+ function isLocalBrowserRuntime() {
23
+ if (typeof window === 'undefined') {
24
+ return false;
25
+ }
26
+ const hostname = window.location?.hostname?.toLowerCase() ?? '';
27
+ return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '0.0.0.0';
28
+ }
29
+ function resolveDefaultRealBackendUrl() {
30
+ return isLocalBrowserRuntime()
31
+ ? PROFILE_COMPARISON_LOCAL_API_BASE_URL
32
+ : PROFILE_COMPARISON_PROD_API_BASE_URL;
33
+ }
20
34
  const BACKEND_MODE_URLS = {
21
- [BackendMode.Real]: 'https://api.test.thecyrano.app/api/profile-comparison/api',
22
- [BackendMode.Mock]: 'http://localhost:44101/api',
35
+ [BackendMode.Real]: resolveDefaultRealBackendUrl(),
36
+ [BackendMode.Mock]: PROFILE_COMPARISON_LOCAL_API_BASE_URL,
23
37
  };
24
38
  /**
25
39
  * Legacy injection token for the backend base URL.
@@ -203,10 +217,10 @@ class CachePersistenceService {
203
217
  static STORE_OPENAI_EMBEDDINGS = STORE_OPENAI_EMBEDDINGS;
204
218
  static STORE_PROFILE_FACE = STORE_PROFILE_FACE;
205
219
  static STORE_PROFILE_COMPARE = STORE_PROFILE_COMPARE;
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' });
220
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.26", ngImport: i0, type: CachePersistenceService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
221
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.26", ngImport: i0, type: CachePersistenceService, providedIn: 'root' });
208
222
  }
209
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.24", ngImport: i0, type: CachePersistenceService, decorators: [{
223
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.26", ngImport: i0, type: CachePersistenceService, decorators: [{
210
224
  type: Injectable,
211
225
  args: [{ providedIn: 'root' }]
212
226
  }] });
@@ -314,10 +328,10 @@ class FileConversionService {
314
328
  const mime = this.getMimeFromFilename(imageStr);
315
329
  return this.urlToFile(imageStr, fileBaseName, mime);
316
330
  }
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' });
331
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.26", ngImport: i0, type: FileConversionService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
332
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.26", ngImport: i0, type: FileConversionService, providedIn: 'root' });
319
333
  }
320
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.24", ngImport: i0, type: FileConversionService, decorators: [{
334
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.26", ngImport: i0, type: FileConversionService, decorators: [{
321
335
  type: Injectable,
322
336
  args: [{
323
337
  providedIn: 'root'
@@ -433,10 +447,10 @@ class ImageCompressionService {
433
447
  return `${base}.webp`;
434
448
  return `${base}.jpg`;
435
449
  }
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' });
450
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.26", ngImport: i0, type: ImageCompressionService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
451
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.26", ngImport: i0, type: ImageCompressionService, providedIn: 'root' });
438
452
  }
439
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.24", ngImport: i0, type: ImageCompressionService, decorators: [{
453
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.26", ngImport: i0, type: ImageCompressionService, decorators: [{
440
454
  type: Injectable,
441
455
  args: [{
442
456
  providedIn: 'root'
@@ -580,96 +594,96 @@ class OpenAIEmbeddingService {
580
594
  [OpenAIEmbeddingService.HEADER_CONTENT_TYPE]: OpenAIEmbeddingService.CONTENT_TYPE_JSON,
581
595
  [OpenAIEmbeddingService.HEADER_AUTHORIZATION]: `${OpenAIEmbeddingService.BEARER_PREFIX}${apiKey}`
582
596
  });
583
- const prompt = `
584
- You are an expert semantic alignment engine.
585
- Task: Align the items from List A to List B based on semantic similarity.
586
- Context: This is a heuristic, human-semantic alignment (use embeddings later if you want it algorithmically strict).
587
-
588
- List A: ${JSON.stringify(validA)}
589
- List B: ${JSON.stringify(validB)}
590
-
591
- SEMANTIC STRATEGY (3-PASS PROCESS):
592
- 1. **Pass 1: Exact & Strong Matches**: First, lock in all exact synonyms (e.g., "Dog" vs "Puppy") and strong category matches (e.g., "Tennis" vs "Badminton"). These are your anchors.
593
- 2. **Pass 2: Thematic Clustering**: Next, group remaining items by sub-theme (e.g., "Water Sports", "Asian Cuisine"). Align items within these themes even if they aren't perfect pairs.
594
- 3. **Pass 3: Contextual & Loose Matches**: Look for looser associations (e.g., "Reading" vs "Library").
595
- 4. **Isolation**: If an item truly has NO relation to anything remaining in the other list, ISOLATE it using "----". Do NOT force a bad match.
596
-
597
- Rules:
598
- 1. Align items into clusters with variable dash-gaps to show semantic distance.
599
- 2. Use "-" for a small hop (related but distinct).
600
- 3. Use "----" for a hard theme switch (different category).
601
- 4. You MUST reorder items to create the best semantic clusters.
602
- 5. Ensure the output arrays "listA" and "listB" have the SAME length.
603
- 6. If an item has no match, align it with "-" or "----" as appropriate, or just a gap.
604
- 7. Output MUST be a JSON object with "listA" and "listB".
605
- 8. **COMPLETENESS**: You MUST include EVERY item from the input List A and List B in the output. Do not omit any items.
606
- 9. **Handling Leftovers**: If you have items left over at the end that don't match well, still include them in the list, aligned with "-" if necessary, or grouped together if they are somewhat related.
607
- 10. **Spacer Alignment**: You MUST align "----" with "----" in both lists. Do NOT align a hard switch "----" with a word. If you need a switch on one side, put it on the other side too.
608
- 11. **Strict Side Adherence**: Items from Input List A MUST ONLY appear in Output List A. Items from Input List B MUST ONLY appear in Output List B. Do not mix them up.
609
- 12. **Mandatory Spacers**: You MUST insert a spacer ("-" or "----") between clusters of related items. Avoid putting "----" between every single item; use it only when the topic truly shifts.
610
- 13. **NO TRAILING SPACER**: The last item in both lists MUST be a word. Do NOT put a spacer at the very end.
611
- 14. **Length Mismatch Handling**: If one list is shorter than the other, you MUST fill the empty slots in the shorter list with spacers ("-") ONLY. DO NOT invent words or reuse words to fill the space.
612
- 15. **HIGH SIMILARITY CONSOLIDATION (>= 0.8)**: This is CRITICAL for center display:
613
- - When you find a pair with VERY HIGH semantic similarity (>= 0.8), you MUST output the SAME TEXT in both listA and listB at that position.
614
- - **Key principle**: If the items are synonyms, very similar activities, or one is a specific type of the other, consolidate them!
615
- - **STRICT CONSTRAINT**: You can ONLY consolidate if BOTH items actually exist in the input lists and are semantically related.
616
- - **NEVER** invent an item for List B just to make a pair with List A. If List A has "figma" and List B has NOTHING related, output "figma" in List A and "-" in List B. DO NOT put "figma" in List B.
617
- - **STRICT SYMMETRY**: If you use the "×" symbol to consolidate, you MUST output the EXACT SAME string in BOTH "listA" and "listB" at that index.
618
- - This allows the system to automatically move these items to the center display!
619
- - **NEVER** use "×" on one side and a spacer on the other. That is a critical failure.
620
- - **NEVER** include similarity scores or any other numbers in the output strings unless they are part of the original item text.
621
- - **ONLY** use "×" as the separator for consolidated items. Do not use "/", "&", or other symbols for this purpose.
622
- - Example: "Jogging" (List A) + "Running" (List B) -> "Jogging × Running" in BOTH listA and listB.
623
- - **NEVER DROP CONSOLIDATED ITEMS**: If you consolidate "Jogging" and "Running" into "Jogging × Running", you MUST put this string in **BOTH** List A and List B. Do NOT put it in List A and a spacer in List B. That would delete "Running" from the output.
624
- - **CRITICAL ERROR PREVENTION**: Do NOT drop items from List B. If "Running" is in Input List B, it MUST appear in Output List B (either as "Running" or "Jogging × Running"). If you output "Jogging × Running" in List A but "-" in List B, you have FAILED.
625
- - **COMPLETENESS CHECK**: You MUST verify that EVERY item from the input lists is present in the output. If "Running" is in Input List B, it MUST be in Output List B (or consolidated). DO NOT DROP ITEMS.
626
- - **DETERMINISM**: Process the list from top to bottom. Do not change your output between runs.
627
- - This allows the system to automatically move these items to the center display!
628
- 16. Do not insert any new, inferred, or related words. Each pair must be created strictly from the existing list items only.
629
-
630
- NEGATIVE CONSTRAINTS:
631
- - NEVER put an item from List A into List B.
632
- - NEVER put an item from List B into List A.
633
- - NEVER invent new items. Only use items from the input lists.
634
- - **NEVER DUPLICATE ITEMS**. Each item from the input list must appear EXACTLY ONCE in the output.
635
-
636
- VERIFICATION STEP:
637
- Before outputting, verify every single item:
638
- - "Is this item in Input List A?" -> If yes, put it in Output List A.
639
- - "Is this item in Input List B?" -> If yes, put it in Output List B.
640
- - "Is this item in neither?" -> It is a hallucination. DELETE IT.
641
-
642
- CRITICAL: OUTPUT MUST CONTAIN EXACTLY THE SAME ITEMS AS INPUT. NO DELETIONS ALLOWED.
643
-
644
- FINAL SANITY CHECK:
645
- - **HALLUCINATION CHECK**: Go through every word in your Output List A. Is it EXACTLY present in Input List A? If not, DELETE IT. Do the same for List B.
646
- - **DUPLICATION CHECK**: Scan List A and List B. Does ANY word appear more than once? If so, DELETE the extra occurrences immediately. Each item must appear EXACTLY ONCE.
647
- - **COUNT CHECK**: Count the number of non-spacer items in your Output List A. Does it match the number of items in Input List A? If not, you are missing items. FIND THEM AND ADD THEM.
648
- - **COUNT CHECK**: Do the same for List B.
649
- - Look at your generated "listA". Are there any items that belong to Input List B? REMOVE THEM immediately.
650
- - Look at your generated "listB". Are there any items that belong to Input List A? REMOVE THEM immediately.
651
- - **Did you miss any items from Input List A? ADD THEM NOW.**
652
- - **Did you miss any items from Input List B? ADD THEM NOW.**
653
- - If you are unsure where an item belongs, check the Input Lists provided above. Do NOT guess.
654
-
655
- Example of Desired Output Logic:
656
- - "Volunteering at animal shelter" vs "Dog training" -> Related? Yes. Similarity >= 0.8? No (different activities)
657
- - NEXT PAIR: "Trail biking" vs "Rock climbing" -> Is this related to Animals? No. -> INSERT "----"
658
- - "Trail biking" vs "Rock climbing" -> Related? Yes (Outdoors).
659
- - NEXT PAIR: "Jogging" vs "Running" -> Similarity >= 0.8? YES! -> Output "Jogging × Running" in BOTH lists
660
- - "Jogging" vs "Running" -> Related? Yes (Exercise).
661
- - NEXT PAIR: "Beach volleyball" vs "Salsa dancing" -> Is this related to Exercise? Yes. -> INSERT "-" (Small hop)
662
- - "Beach volleyball" vs "Salsa dancing" -> Related? Yes (Hobbies). Similarity >= 0.8? No (different hobbies)
663
- - NEXT PAIR: "Fermenting kimchi" vs "Korean cooking" -> Is this related to Hobbies? No. -> INSERT "----"
664
-
665
- JSON Output Structure:
666
- {
667
- "listA": ["Consolidated Item × Match", "----", "Unique Item A", "-" , "Unique Item A1" , "-" , "Unique Item A2" ,"----" , "Unique Item A3", "-" , "Unique Item A4 × Unique Item B4" , "----" , Unique Item A5, "----" , Uniue Item A6 ],
668
- "listB": ["Consolidated Item × Match", "----", "Unique Item B", "-" , "Unique Item B1" , "-" , "Unique Item B2" ,"----" , "Unique Item B3", "-" , "Unique Item A4 × Unique Item B4" , "----" , Unique Item B5, "----" , - ]
669
- }
670
- Note: In this case, List A has more items, so the last remaining item is displayed as a single card.In other cases, List B may have more words, and in that situation, the extra word from List B is shown as a single card.
671
- Each unique item in List A has a corresponding unique item in List B that is highly similar in meaning or related in terms of concept.
672
- VARIATION & SIMILARITY CONTROL: Maintain strict consistency. If the same semantic relationship appears multiple times, align it identically every time.
597
+ const prompt = `
598
+ You are an expert semantic alignment engine.
599
+ Task: Align the items from List A to List B based on semantic similarity.
600
+ Context: This is a heuristic, human-semantic alignment (use embeddings later if you want it algorithmically strict).
601
+
602
+ List A: ${JSON.stringify(validA)}
603
+ List B: ${JSON.stringify(validB)}
604
+
605
+ SEMANTIC STRATEGY (3-PASS PROCESS):
606
+ 1. **Pass 1: Exact & Strong Matches**: First, lock in all exact synonyms (e.g., "Dog" vs "Puppy") and strong category matches (e.g., "Tennis" vs "Badminton"). These are your anchors.
607
+ 2. **Pass 2: Thematic Clustering**: Next, group remaining items by sub-theme (e.g., "Water Sports", "Asian Cuisine"). Align items within these themes even if they aren't perfect pairs.
608
+ 3. **Pass 3: Contextual & Loose Matches**: Look for looser associations (e.g., "Reading" vs "Library").
609
+ 4. **Isolation**: If an item truly has NO relation to anything remaining in the other list, ISOLATE it using "----". Do NOT force a bad match.
610
+
611
+ Rules:
612
+ 1. Align items into clusters with variable dash-gaps to show semantic distance.
613
+ 2. Use "-" for a small hop (related but distinct).
614
+ 3. Use "----" for a hard theme switch (different category).
615
+ 4. You MUST reorder items to create the best semantic clusters.
616
+ 5. Ensure the output arrays "listA" and "listB" have the SAME length.
617
+ 6. If an item has no match, align it with "-" or "----" as appropriate, or just a gap.
618
+ 7. Output MUST be a JSON object with "listA" and "listB".
619
+ 8. **COMPLETENESS**: You MUST include EVERY item from the input List A and List B in the output. Do not omit any items.
620
+ 9. **Handling Leftovers**: If you have items left over at the end that don't match well, still include them in the list, aligned with "-" if necessary, or grouped together if they are somewhat related.
621
+ 10. **Spacer Alignment**: You MUST align "----" with "----" in both lists. Do NOT align a hard switch "----" with a word. If you need a switch on one side, put it on the other side too.
622
+ 11. **Strict Side Adherence**: Items from Input List A MUST ONLY appear in Output List A. Items from Input List B MUST ONLY appear in Output List B. Do not mix them up.
623
+ 12. **Mandatory Spacers**: You MUST insert a spacer ("-" or "----") between clusters of related items. Avoid putting "----" between every single item; use it only when the topic truly shifts.
624
+ 13. **NO TRAILING SPACER**: The last item in both lists MUST be a word. Do NOT put a spacer at the very end.
625
+ 14. **Length Mismatch Handling**: If one list is shorter than the other, you MUST fill the empty slots in the shorter list with spacers ("-") ONLY. DO NOT invent words or reuse words to fill the space.
626
+ 15. **HIGH SIMILARITY CONSOLIDATION (>= 0.8)**: This is CRITICAL for center display:
627
+ - When you find a pair with VERY HIGH semantic similarity (>= 0.8), you MUST output the SAME TEXT in both listA and listB at that position.
628
+ - **Key principle**: If the items are synonyms, very similar activities, or one is a specific type of the other, consolidate them!
629
+ - **STRICT CONSTRAINT**: You can ONLY consolidate if BOTH items actually exist in the input lists and are semantically related.
630
+ - **NEVER** invent an item for List B just to make a pair with List A. If List A has "figma" and List B has NOTHING related, output "figma" in List A and "-" in List B. DO NOT put "figma" in List B.
631
+ - **STRICT SYMMETRY**: If you use the "×" symbol to consolidate, you MUST output the EXACT SAME string in BOTH "listA" and "listB" at that index.
632
+ - This allows the system to automatically move these items to the center display!
633
+ - **NEVER** use "×" on one side and a spacer on the other. That is a critical failure.
634
+ - **NEVER** include similarity scores or any other numbers in the output strings unless they are part of the original item text.
635
+ - **ONLY** use "×" as the separator for consolidated items. Do not use "/", "&", or other symbols for this purpose.
636
+ - Example: "Jogging" (List A) + "Running" (List B) -> "Jogging × Running" in BOTH listA and listB.
637
+ - **NEVER DROP CONSOLIDATED ITEMS**: If you consolidate "Jogging" and "Running" into "Jogging × Running", you MUST put this string in **BOTH** List A and List B. Do NOT put it in List A and a spacer in List B. That would delete "Running" from the output.
638
+ - **CRITICAL ERROR PREVENTION**: Do NOT drop items from List B. If "Running" is in Input List B, it MUST appear in Output List B (either as "Running" or "Jogging × Running"). If you output "Jogging × Running" in List A but "-" in List B, you have FAILED.
639
+ - **COMPLETENESS CHECK**: You MUST verify that EVERY item from the input lists is present in the output. If "Running" is in Input List B, it MUST be in Output List B (or consolidated). DO NOT DROP ITEMS.
640
+ - **DETERMINISM**: Process the list from top to bottom. Do not change your output between runs.
641
+ - This allows the system to automatically move these items to the center display!
642
+ 16. Do not insert any new, inferred, or related words. Each pair must be created strictly from the existing list items only.
643
+
644
+ NEGATIVE CONSTRAINTS:
645
+ - NEVER put an item from List A into List B.
646
+ - NEVER put an item from List B into List A.
647
+ - NEVER invent new items. Only use items from the input lists.
648
+ - **NEVER DUPLICATE ITEMS**. Each item from the input list must appear EXACTLY ONCE in the output.
649
+
650
+ VERIFICATION STEP:
651
+ Before outputting, verify every single item:
652
+ - "Is this item in Input List A?" -> If yes, put it in Output List A.
653
+ - "Is this item in Input List B?" -> If yes, put it in Output List B.
654
+ - "Is this item in neither?" -> It is a hallucination. DELETE IT.
655
+
656
+ CRITICAL: OUTPUT MUST CONTAIN EXACTLY THE SAME ITEMS AS INPUT. NO DELETIONS ALLOWED.
657
+
658
+ FINAL SANITY CHECK:
659
+ - **HALLUCINATION CHECK**: Go through every word in your Output List A. Is it EXACTLY present in Input List A? If not, DELETE IT. Do the same for List B.
660
+ - **DUPLICATION CHECK**: Scan List A and List B. Does ANY word appear more than once? If so, DELETE the extra occurrences immediately. Each item must appear EXACTLY ONCE.
661
+ - **COUNT CHECK**: Count the number of non-spacer items in your Output List A. Does it match the number of items in Input List A? If not, you are missing items. FIND THEM AND ADD THEM.
662
+ - **COUNT CHECK**: Do the same for List B.
663
+ - Look at your generated "listA". Are there any items that belong to Input List B? REMOVE THEM immediately.
664
+ - Look at your generated "listB". Are there any items that belong to Input List A? REMOVE THEM immediately.
665
+ - **Did you miss any items from Input List A? ADD THEM NOW.**
666
+ - **Did you miss any items from Input List B? ADD THEM NOW.**
667
+ - If you are unsure where an item belongs, check the Input Lists provided above. Do NOT guess.
668
+
669
+ Example of Desired Output Logic:
670
+ - "Volunteering at animal shelter" vs "Dog training" -> Related? Yes. Similarity >= 0.8? No (different activities)
671
+ - NEXT PAIR: "Trail biking" vs "Rock climbing" -> Is this related to Animals? No. -> INSERT "----"
672
+ - "Trail biking" vs "Rock climbing" -> Related? Yes (Outdoors).
673
+ - NEXT PAIR: "Jogging" vs "Running" -> Similarity >= 0.8? YES! -> Output "Jogging × Running" in BOTH lists
674
+ - "Jogging" vs "Running" -> Related? Yes (Exercise).
675
+ - NEXT PAIR: "Beach volleyball" vs "Salsa dancing" -> Is this related to Exercise? Yes. -> INSERT "-" (Small hop)
676
+ - "Beach volleyball" vs "Salsa dancing" -> Related? Yes (Hobbies). Similarity >= 0.8? No (different hobbies)
677
+ - NEXT PAIR: "Fermenting kimchi" vs "Korean cooking" -> Is this related to Hobbies? No. -> INSERT "----"
678
+
679
+ JSON Output Structure:
680
+ {
681
+ "listA": ["Consolidated Item × Match", "----", "Unique Item A", "-" , "Unique Item A1" , "-" , "Unique Item A2" ,"----" , "Unique Item A3", "-" , "Unique Item A4 × Unique Item B4" , "----" , Unique Item A5, "----" , Uniue Item A6 ],
682
+ "listB": ["Consolidated Item × Match", "----", "Unique Item B", "-" , "Unique Item B1" , "-" , "Unique Item B2" ,"----" , "Unique Item B3", "-" , "Unique Item A4 × Unique Item B4" , "----" , Unique Item B5, "----" , - ]
683
+ }
684
+ Note: In this case, List A has more items, so the last remaining item is displayed as a single card.In other cases, List B may have more words, and in that situation, the extra word from List B is shown as a single card.
685
+ Each unique item in List A has a corresponding unique item in List B that is highly similar in meaning or related in terms of concept.
686
+ VARIATION & SIMILARITY CONTROL: Maintain strict consistency. If the same semantic relationship appears multiple times, align it identically every time.
673
687
  `;
674
688
  const body = {
675
689
  model: this.MODEL,
@@ -863,40 +877,40 @@ class OpenAIEmbeddingService {
863
877
  [OpenAIEmbeddingService.HEADER_AUTHORIZATION]: `${OpenAIEmbeddingService.BEARER_PREFIX}${apiKey}`
864
878
  });
865
879
  // Construct the prompt
866
- const prompt = `
867
- You are an expert semantic alignment engine.
868
- Task: Align the items from List A to List B based on semantic similarity.
869
-
870
- List A: ${JSON.stringify(validA)}
871
- List B: ${JSON.stringify(validB)}
872
-
873
- Rules:
874
- 1. Match items that are semantically similar.
875
- 2. Support One-to-One, One-to-Many, and Many-to-Many matches.
876
- 3. **CRITICAL**: Avoid duplicate alignments. If multiple items in List B are highly similar to a single item in List A, group them ALL under that single List A item.
877
- 4. **CONSISTENCY**: The alignment must be symmetric. If "Mutton" matches "Chicken" when "Mutton" is on the left, it MUST also match "Chicken" when "Mutton" is on the right.
878
- 5. **ORDER**: You MUST reorder the items from BOTH lists to create the best semantic clusters. Place related groups next to each other (e.g., put all food items together, all tech items together).
879
- 6. If an item in List A has no match in List B, map it to "-".
880
- 7. If an item in List B is not matched to anything in List A, add a row with "-" on the left.
881
- 8. Output MUST be a JSON object with a single key "alignment" containing an array of objects.
882
- 9. Each object in the array must have:
883
- - "left": string (item from List A or "-")
884
- - "right": string (item from List B or "-")
885
- - "score": number (similarity score between 0 and 1)
886
-
887
- Example Output Format:
888
- {
889
- "alignment": [
890
- { "left": "Seafood", "right": "Fish", "score": 0.9 },
891
- { "left": "Seafood", "right": "Clams", "score": 0.85 },
892
- { "left": "Seafood", "right": "Crab", "score": 0.88 },
893
- { "left": "Chicken", "right": "Meat", "score": 0.8 },
894
- { "left": "Chicken", "right": "Poultry", "score": 0.9 },
895
- { "left": "Chicken", "right": "Mutton", "score": 0.85 },
896
- { "left": "Coffee", "right": "Cafés", "score": 0.9 },
897
- { "left": "Coding", "right": "-", "score": 0 }
898
- ]
899
- }
880
+ const prompt = `
881
+ You are an expert semantic alignment engine.
882
+ Task: Align the items from List A to List B based on semantic similarity.
883
+
884
+ List A: ${JSON.stringify(validA)}
885
+ List B: ${JSON.stringify(validB)}
886
+
887
+ Rules:
888
+ 1. Match items that are semantically similar.
889
+ 2. Support One-to-One, One-to-Many, and Many-to-Many matches.
890
+ 3. **CRITICAL**: Avoid duplicate alignments. If multiple items in List B are highly similar to a single item in List A, group them ALL under that single List A item.
891
+ 4. **CONSISTENCY**: The alignment must be symmetric. If "Mutton" matches "Chicken" when "Mutton" is on the left, it MUST also match "Chicken" when "Mutton" is on the right.
892
+ 5. **ORDER**: You MUST reorder the items from BOTH lists to create the best semantic clusters. Place related groups next to each other (e.g., put all food items together, all tech items together).
893
+ 6. If an item in List A has no match in List B, map it to "-".
894
+ 7. If an item in List B is not matched to anything in List A, add a row with "-" on the left.
895
+ 8. Output MUST be a JSON object with a single key "alignment" containing an array of objects.
896
+ 9. Each object in the array must have:
897
+ - "left": string (item from List A or "-")
898
+ - "right": string (item from List B or "-")
899
+ - "score": number (similarity score between 0 and 1)
900
+
901
+ Example Output Format:
902
+ {
903
+ "alignment": [
904
+ { "left": "Seafood", "right": "Fish", "score": 0.9 },
905
+ { "left": "Seafood", "right": "Clams", "score": 0.85 },
906
+ { "left": "Seafood", "right": "Crab", "score": 0.88 },
907
+ { "left": "Chicken", "right": "Meat", "score": 0.8 },
908
+ { "left": "Chicken", "right": "Poultry", "score": 0.9 },
909
+ { "left": "Chicken", "right": "Mutton", "score": 0.85 },
910
+ { "left": "Coffee", "right": "Cafés", "score": 0.9 },
911
+ { "left": "Coding", "right": "-", "score": 0 }
912
+ ]
913
+ }
900
914
  `;
901
915
  const body = {
902
916
  model: this.MODEL,
@@ -1215,10 +1229,10 @@ class OpenAIEmbeddingService {
1215
1229
  this.persistence.clear(CachePersistenceService.STORE_OPENAI_SIMILARITY);
1216
1230
  this.persistence.clear(CachePersistenceService.STORE_OPENAI_EMBEDDINGS);
1217
1231
  }
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' });
1232
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.26", ngImport: i0, type: OpenAIEmbeddingService, deps: [{ token: i1.HttpClient }, { token: PROFILE_COMPARISON_API_BASE_URL, optional: true }, { token: CachePersistenceService }], target: i0.ɵɵFactoryTarget.Injectable });
1233
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.26", ngImport: i0, type: OpenAIEmbeddingService, providedIn: 'root' });
1220
1234
  }
1221
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.24", ngImport: i0, type: OpenAIEmbeddingService, decorators: [{
1235
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.26", ngImport: i0, type: OpenAIEmbeddingService, decorators: [{
1222
1236
  type: Injectable,
1223
1237
  args: [{
1224
1238
  providedIn: 'root'
@@ -1250,10 +1264,10 @@ class ProfileComparisonLibService {
1250
1264
  getVersion() {
1251
1265
  return '0.0.0';
1252
1266
  }
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' });
1267
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.26", ngImport: i0, type: ProfileComparisonLibService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1268
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.26", ngImport: i0, type: ProfileComparisonLibService, providedIn: 'root' });
1255
1269
  }
1256
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.24", ngImport: i0, type: ProfileComparisonLibService, decorators: [{
1270
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.26", ngImport: i0, type: ProfileComparisonLibService, decorators: [{
1257
1271
  type: Injectable,
1258
1272
  args: [{
1259
1273
  providedIn: 'root'
@@ -1507,10 +1521,10 @@ class ProfileService {
1507
1521
  this.compareInterestsCache.clear();
1508
1522
  this.persistence.clear(CachePersistenceService.STORE_PROFILE_COMPARE);
1509
1523
  }
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' });
1524
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.26", ngImport: i0, type: ProfileService, deps: [{ token: i1.HttpClient }, { token: PROFILE_COMPARISON_API_BASE_URL, optional: true }, { token: CachePersistenceService }], target: i0.ɵɵFactoryTarget.Injectable });
1525
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.26", ngImport: i0, type: ProfileService, providedIn: 'root' });
1512
1526
  }
1513
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.24", ngImport: i0, type: ProfileService, decorators: [{
1527
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.26", ngImport: i0, type: ProfileService, decorators: [{
1514
1528
  type: Injectable,
1515
1529
  args: [{ providedIn: 'root' }]
1516
1530
  }], ctorParameters: () => [{ type: i1.HttpClient }, { type: undefined, decorators: [{
@@ -1566,10 +1580,10 @@ class ProfileComparisonBackendService {
1566
1580
  return throwError(() => err);
1567
1581
  }));
1568
1582
  }
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' });
1583
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.26", ngImport: i0, type: ProfileComparisonBackendService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1584
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.26", ngImport: i0, type: ProfileComparisonBackendService, providedIn: 'root' });
1571
1585
  }
1572
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.24", ngImport: i0, type: ProfileComparisonBackendService, decorators: [{
1586
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.26", ngImport: i0, type: ProfileComparisonBackendService, decorators: [{
1573
1587
  type: Injectable,
1574
1588
  args: [{ providedIn: 'root' }]
1575
1589
  }], ctorParameters: () => [] });
@@ -1718,6 +1732,39 @@ class ProfileComparisonLibComponent {
1718
1732
  static MIN_CROSS_LINKS_PER_NODE = 0;
1719
1733
  static MAX_INTRA_LINKS_PER_NODE = 2;
1720
1734
  static MAX_RELEVANT_INTERESTS_PER_SIDE = 6;
1735
+ static COLUMN_CORE_SEED_PIXELS = 9;
1736
+ static IDLE_ALPHA_TARGET = 0.012;
1737
+ static CLICK_REHEAT_ALPHA = 0.065;
1738
+ static SEMANTIC_STABILITY_ALPHA_FLOOR = 0.012;
1739
+ static RELATIVE_MEDIAN_ALPHA_FLOOR = 0.01;
1740
+ static RELATIVE_MEDIAN_ATTRACT_STRENGTH = 0.44;
1741
+ static RELATIVE_MEDIAN_REPEL_STRENGTH = 0.58;
1742
+ static RELATIVE_MEDIAN_ATTRACT_RADIUS = 72;
1743
+ static RELATIVE_MEDIAN_REPEL_RADIUS = 194;
1744
+ static STRONG_SIMILARITY_BOOST_EXPONENT = 3.2;
1745
+ static STRONG_SIMILARITY_BOOST_SCALE = 0.5;
1746
+ static PRIORITY_PAIR_MIN_SIMILARITY = 0.62;
1747
+ static PRIORITY_PAIR_ATTRACTION_STRENGTH = 1.02;
1748
+ static PRIORITY_PAIR_CORRIDOR_RADIUS = 42;
1749
+ static PRIORITY_PAIR_CORRIDOR_REPEL_STRENGTH = 0.64;
1750
+ static MUTUAL_TOP_PAIR_MIN_SIMILARITY = 0.58;
1751
+ static MUTUAL_TOP_PAIR_MARGIN = 0.12;
1752
+ static MUTUAL_TOP_PAIR_ATTRACTION_STRENGTH = 1.12;
1753
+ static MUTUAL_TOP_PAIR_BARRIER_RADIUS = 56;
1754
+ static MUTUAL_TOP_PAIR_REPEL_STRENGTH = 1.06;
1755
+ static DETERMINISTIC_SEED_PASSES = 6;
1756
+ static DETERMINISTIC_SEED_BLEND = 0.86;
1757
+ static OBJECTIVE_OPTIMIZER_ITERATIONS = 56;
1758
+ static OBJECTIVE_STEP_START = 0.34;
1759
+ static OBJECTIVE_STEP_END = 0.09;
1760
+ static OBJECTIVE_ATTRACTION_WEIGHT = 0.74;
1761
+ static OBJECTIVE_REPULSION_WEIGHT = 0.5;
1762
+ static OBJECTIVE_ANCHOR_WEIGHT = 0.34;
1763
+ static OBJECTIVE_BOUNDARY_WEIGHT = 0.92;
1764
+ static DISSIMILAR_REPEL_THRESHOLD = 0.52;
1765
+ static DISSIMILAR_REPEL_STRENGTH = 0.34;
1766
+ static DISSIMILAR_REPEL_MAX_DISTANCE = 220;
1767
+ static DISSIMILAR_REPEL_DECAY_DISTANCE = 130;
1721
1768
  static CHIP_RADIUS = 18; // half chip height for collision
1722
1769
  static CHIP_COLLISION_BORDER_PADDING = 6;
1723
1770
  static MAX_LINK_DISTANCE = 120;
@@ -1968,11 +2015,15 @@ class ProfileComparisonLibComponent {
1968
2015
  this.log('fetchComparison', 'ignoring stale error', { requestId, current: this.fetchRequestId, status: err?.status });
1969
2016
  return;
1970
2017
  }
1971
- this.log('fetchComparison', 'backend error', err);
1972
- const msg = err?.error?.message ?? err?.message ?? err?.statusText ?? (typeof err?.error === 'string' ? err.error : null) ?? 'Request failed';
1973
- this.backendError = msg;
1974
- console.error('[ProfileComparisonLib] fetchComparison error', err?.status ?? '', msg);
2018
+ this.log('fetchComparison', 'backend unavailable, using raw interests as fallback', err);
1975
2019
  this.isAligning = false;
2020
+ this.backendError = null;
2021
+ this.displayPerson1Interests = [...this.person1Interests];
2022
+ this.displayPerson2Interests = [...this.person2Interests];
2023
+ this.alignedPerson1Interests = [];
2024
+ this.alignedPerson2Interests = [];
2025
+ this.centerItem = this.findNaiveOverlap(this.person1Interests, this.person2Interests);
2026
+ this.buildForceGraph();
1976
2027
  this.cdr.detectChanges();
1977
2028
  }
1978
2029
  });
@@ -2526,6 +2577,11 @@ class ProfileComparisonLibComponent {
2526
2577
  }
2527
2578
  return selected;
2528
2579
  }
2580
+ findNaiveOverlap(list1, list2) {
2581
+ const norm = (s) => s.trim().toLowerCase();
2582
+ const set2 = new Set((list2 ?? []).map(norm));
2583
+ return (list1 ?? []).filter(item => set2.has(norm(item)));
2584
+ }
2529
2585
  filterCenterItemsByThreshold(centerItems, p1Items, p2Items) {
2530
2586
  if (!centerItems?.length)
2531
2587
  return [];
@@ -2731,17 +2787,33 @@ class ProfileComparisonLibComponent {
2731
2787
  if (matrixMax <= springThreshold)
2732
2788
  matrixMax = 1.0;
2733
2789
  const simRange = matrixMax - springThreshold;
2734
- const distributedYForIndex = (index, total) => {
2735
- if (total <= 1)
2736
- return minCenterY + verticalSpan * 0.5;
2737
- const t = index / Math.max(1, total - 1);
2738
- return minCenterY + verticalSpan * t;
2790
+ const columnCoreY = minCenterY + verticalSpan * 0.5;
2791
+ const getMedian = (values) => {
2792
+ if (!values.length)
2793
+ return 0;
2794
+ const sorted = [...values].sort((a, b) => a - b);
2795
+ const mid = Math.floor(sorted.length / 2);
2796
+ if (sorted.length % 2 === 1)
2797
+ return sorted[mid];
2798
+ return (sorted[mid - 1] + sorted[mid]) / 2;
2799
+ };
2800
+ const getTopSixMedian = (values) => {
2801
+ if (!values.length)
2802
+ return 0;
2803
+ const top = [...values].sort((a, b) => b - a).slice(0, Math.min(6, values.length));
2804
+ return getMedian(top);
2805
+ };
2806
+ const hashToken = (value) => {
2807
+ let hash = 0;
2808
+ for (let i = 0; i < value.length; i++) {
2809
+ hash = ((hash << 5) - hash + value.charCodeAt(i)) | 0;
2810
+ }
2811
+ return Math.abs(hash);
2739
2812
  };
2740
2813
  const nodes = [];
2741
2814
  const addNodes = (items, group, zoneX) => {
2742
- const total = items.length;
2743
2815
  items.forEach((label, i) => {
2744
- const targetY = distributedYForIndex(i, total);
2816
+ const targetY = columnCoreY;
2745
2817
  nodes.push({
2746
2818
  id: `${group}-${i}-${label}`,
2747
2819
  label,
@@ -2782,8 +2854,10 @@ class ProfileComparisonLibComponent {
2782
2854
  : zoneP2 + (zoneShared - zoneP2) * centerPull;
2783
2855
  }
2784
2856
  });
2785
- // Vertical placement should be driven by cross-list similarity, not by
2786
- // whichever list happens to have fewer items.
2857
+ // Build a relative attractiveness score (median of top-six similarities)
2858
+ // and seed chips from each column core with only a tiny 9x9 perturbation.
2859
+ // This lets forces "emanate" from a shared origin while preserving
2860
+ // deterministic reset behavior.
2787
2861
  const semanticPool = (node) => {
2788
2862
  if (node.group === 'p1')
2789
2863
  return nodes.filter(n => n.group === 'p2' || n.group === 'shared');
@@ -2791,35 +2865,50 @@ class ProfileComparisonLibComponent {
2791
2865
  return nodes.filter(n => n.group === 'p1' || n.group === 'shared');
2792
2866
  return nodes.filter(n => n.group === 'p1' || n.group === 'p2');
2793
2867
  };
2794
- const ySimilarityFloor = Math.max(0.18, springThreshold - 0.06);
2795
- for (let pass = 0; pass < 6; pass++) {
2796
- const snapshotY = new Map();
2797
- nodes.forEach(node => {
2798
- snapshotY.set(node.id, node.targetY ?? (minCenterY + verticalSpan * 0.5));
2799
- });
2800
- nodes.forEach(node => {
2801
- const pool = semanticPool(node);
2802
- if (!pool.length)
2868
+ const getSemanticAttractiveness = (node) => {
2869
+ const pool = semanticPool(node);
2870
+ if (!pool.length)
2871
+ return 0;
2872
+ const similarities = pool.map(other => Math.max(0, this.getSimilarity(node.label, other.label)));
2873
+ return getTopSixMedian(similarities);
2874
+ };
2875
+ const microSeedSpan = ProfileComparisonLibComponent.COLUMN_CORE_SEED_PIXELS;
2876
+ const microSeedHalf = microSeedSpan / 2;
2877
+ const deterministicPasses = ProfileComparisonLibComponent.DETERMINISTIC_SEED_PASSES;
2878
+ const deterministicBlend = ProfileComparisonLibComponent.DETERMINISTIC_SEED_BLEND;
2879
+ for (let pass = 0; pass < deterministicPasses; pass++) {
2880
+ ['p1', 'shared', 'p2'].forEach(group => {
2881
+ const groupNodes = nodes.filter(node => node.group === group);
2882
+ if (groupNodes.length <= 1)
2803
2883
  return;
2804
- let weightedY = 0;
2805
- let totalWeight = 0;
2806
- pool.forEach(other => {
2807
- const similarity = this.getSimilarity(node.label, other.label);
2808
- if (similarity < ySimilarityFloor)
2809
- return;
2810
- const weight = Math.pow(Math.max(0.001, similarity - ySimilarityFloor + 0.05), 2);
2811
- const otherY = snapshotY.get(other.id) ?? (other.targetY ?? minCenterY);
2812
- weightedY += otherY * weight;
2813
- totalWeight += weight;
2884
+ const ranked = groupNodes
2885
+ .map(node => ({
2886
+ node,
2887
+ attractiveness: getSemanticAttractiveness(node),
2888
+ tie: this.normalizeSimilarityToken(node.label),
2889
+ }))
2890
+ .sort((a, b) => (b.attractiveness - a.attractiveness) || a.tie.localeCompare(b.tie));
2891
+ ranked.forEach((entry, index) => {
2892
+ const rankT = ranked.length <= 1 ? 0.5 : (index / Math.max(1, ranked.length - 1));
2893
+ const yOffset = (rankT - 0.5) * microSeedSpan;
2894
+ const desiredY = Math.max(minCenterY, Math.min(maxY, columnCoreY + yOffset));
2895
+ const currentY = entry.node.targetY ?? columnCoreY;
2896
+ entry.node.targetY = Math.max(minCenterY, Math.min(maxY, currentY + (desiredY - currentY) * deterministicBlend));
2897
+ const xSeedRaw = hashToken(`${entry.node.id}:${pass}`) % (microSeedSpan + 1);
2898
+ const xOffset = xSeedRaw - microSeedHalf;
2899
+ const baseTargetX = entry.node.targetX ?? (entry.node.group === 'p1'
2900
+ ? zoneP1
2901
+ : (entry.node.group === 'p2' ? zoneP2 : zoneShared));
2902
+ entry.node.x = Math.max(globalMinX, Math.min(globalMaxX, baseTargetX + xOffset));
2903
+ entry.node.y = entry.node.targetY;
2814
2904
  });
2815
- if (totalWeight <= 0)
2816
- return;
2817
- const semanticY = weightedY / totalWeight;
2818
- const currentY = snapshotY.get(node.id) ?? semanticY;
2819
- const blend = node.group === 'shared' ? 0.84 : 0.78;
2820
- node.targetY = Math.max(minCenterY, Math.min(maxY, currentY + (semanticY - currentY) * blend));
2821
2905
  });
2822
2906
  }
2907
+ nodes.forEach(node => {
2908
+ const fallbackX = node.targetX ?? (node.group === 'p1' ? zoneP1 : (node.group === 'p2' ? zoneP2 : zoneShared));
2909
+ node.x = Math.max(globalMinX, Math.min(globalMaxX, node.x ?? fallbackX));
2910
+ node.y = Math.max(minCenterY, Math.min(maxY, node.targetY ?? columnCoreY));
2911
+ });
2823
2912
  const buildNodeBounds = (node) => {
2824
2913
  let minX = globalMinX;
2825
2914
  let maxX = globalMaxX;
@@ -2999,10 +3088,96 @@ class ProfileComparisonLibComponent {
2999
3088
  };
3000
3089
  })
3001
3090
  .filter((record) => !!record);
3091
+ const dissimilarThreshold = ProfileComparisonLibComponent.DISSIMILAR_REPEL_THRESHOLD;
3092
+ const dissimilarPairs = [];
3093
+ for (let i = 0; i < nodes.length; i++) {
3094
+ for (let j = i + 1; j < nodes.length; j++) {
3095
+ const source = nodes[i];
3096
+ const target = nodes[j];
3097
+ const similarity = Math.max(0, this.getSimilarity(source.label, target.label));
3098
+ if (similarity >= dissimilarThreshold)
3099
+ continue;
3100
+ const laneFactor = source.group === target.group
3101
+ ? 1
3102
+ : (source.group === 'shared' || target.group === 'shared' ? 0.62 : 0.44);
3103
+ const repulsion = laneFactor * (dissimilarThreshold - similarity) / Math.max(0.001, dissimilarThreshold);
3104
+ dissimilarPairs.push({
3105
+ source,
3106
+ target,
3107
+ repulsion,
3108
+ });
3109
+ }
3110
+ }
3111
+ const relativeMedianByNode = new Map();
3112
+ const relativeMedianPairs = [];
3113
+ ['p1', 'shared', 'p2'].forEach(group => {
3114
+ const groupNodes = nodes.filter(node => node.group === group);
3115
+ if (groupNodes.length <= 1)
3116
+ return;
3117
+ groupNodes.forEach(node => {
3118
+ const similarities = groupNodes
3119
+ .filter(other => other.id !== node.id)
3120
+ .map(other => Math.max(0, this.getSimilarity(node.label, other.label)));
3121
+ relativeMedianByNode.set(node.id, getTopSixMedian(similarities));
3122
+ });
3123
+ for (let i = 0; i < groupNodes.length; i++) {
3124
+ for (let j = i + 1; j < groupNodes.length; j++) {
3125
+ const source = groupNodes[i];
3126
+ const target = groupNodes[j];
3127
+ const similarity = Math.max(0, this.getSimilarity(source.label, target.label));
3128
+ const sourceMedian = relativeMedianByNode.get(source.id) ?? 0;
3129
+ const targetMedian = relativeMedianByNode.get(target.id) ?? 0;
3130
+ const median = (sourceMedian + targetMedian) / 2;
3131
+ relativeMedianPairs.push({ source, target, similarity, median });
3132
+ }
3133
+ }
3134
+ });
3002
3135
  const maxLinkSimilarity = Math.max(0.001, ...linkRecords.map(record => Math.max(0, record.similarity)));
3003
3136
  const MIN_PAIR_DISTANCE = 34;
3004
3137
  const MAX_PAIR_DISTANCE = 88;
3138
+ const semanticAlphaFloor = ProfileComparisonLibComponent.SEMANTIC_STABILITY_ALPHA_FLOOR;
3139
+ const relativeMedianColumnForce = (alpha) => {
3140
+ const effectiveAlpha = Math.max(alpha, ProfileComparisonLibComponent.RELATIVE_MEDIAN_ALPHA_FLOOR);
3141
+ const attractStrength = ProfileComparisonLibComponent.RELATIVE_MEDIAN_ATTRACT_STRENGTH;
3142
+ const repelStrength = ProfileComparisonLibComponent.RELATIVE_MEDIAN_REPEL_STRENGTH;
3143
+ const attractRadius = ProfileComparisonLibComponent.RELATIVE_MEDIAN_ATTRACT_RADIUS;
3144
+ const repelRadius = ProfileComparisonLibComponent.RELATIVE_MEDIAN_REPEL_RADIUS;
3145
+ const deltaDeadband = 0.025;
3146
+ relativeMedianPairs.forEach(pair => {
3147
+ const dx = (pair.target.x ?? 0) - (pair.source.x ?? 0);
3148
+ const dy = (pair.target.y ?? 0) - (pair.source.y ?? 0);
3149
+ const dist = Math.sqrt(dx * dx + dy * dy) || 1;
3150
+ const delta = pair.similarity - pair.median;
3151
+ if (Math.abs(delta) <= deltaDeadband)
3152
+ return;
3153
+ if (delta >= 0) {
3154
+ const closenessBias = Math.max(0.08, Math.min(1, delta + 0.08));
3155
+ const targetDistance = Math.max(24, attractRadius - closenessBias * 46);
3156
+ if (dist <= targetDistance + 2)
3157
+ return;
3158
+ const pull = attractStrength * effectiveAlpha * closenessBias * ((dist - targetDistance) / dist);
3159
+ pair.source.vx = (pair.source.vx ?? 0) + dx * pull;
3160
+ pair.source.vy = (pair.source.vy ?? 0) + dy * pull;
3161
+ pair.target.vx = (pair.target.vx ?? 0) - dx * pull;
3162
+ pair.target.vy = (pair.target.vy ?? 0) - dy * pull;
3163
+ return;
3164
+ }
3165
+ if (dist >= repelRadius || dist <= 28)
3166
+ return;
3167
+ const underMedian = Math.min(1, Math.abs(delta) / Math.max(0.001, pair.median + 0.05));
3168
+ const push = repelStrength * effectiveAlpha * underMedian * (1 - (dist / repelRadius));
3169
+ if (push <= 0)
3170
+ return;
3171
+ const unitX = dx / dist;
3172
+ const unitY = dy / dist;
3173
+ pair.source.vx = (pair.source.vx ?? 0) - unitX * push;
3174
+ pair.source.vy = (pair.source.vy ?? 0) - unitY * push;
3175
+ pair.target.vx = (pair.target.vx ?? 0) + unitX * push;
3176
+ pair.target.vy = (pair.target.vy ?? 0) + unitY * push;
3177
+ });
3178
+ };
3005
3179
  const similarityAttractionForce = (alpha) => {
3180
+ const effectiveAlpha = Math.max(alpha, semanticAlphaFloor);
3006
3181
  linkRecords.forEach(record => {
3007
3182
  const dx = (record.target.x ?? 0) - (record.source.x ?? 0);
3008
3183
  const dy = (record.target.y ?? 0) - (record.source.y ?? 0);
@@ -3012,14 +3187,348 @@ class ProfileComparisonLibComponent {
3012
3187
  if (dist <= targetDistance)
3013
3188
  return;
3014
3189
  const pullGap = dist - targetDistance;
3015
- const pullBase = record.kind === 'cross' ? 0.9 : 0.82;
3016
- const pullFactor = pullBase * alpha * (pullGap / dist);
3190
+ const pullBase = record.kind === 'cross' ? 0.86 : 0.78;
3191
+ const nonlinearBoost = 1
3192
+ + Math.pow(normalizedSimilarity, ProfileComparisonLibComponent.STRONG_SIMILARITY_BOOST_EXPONENT)
3193
+ * ProfileComparisonLibComponent.STRONG_SIMILARITY_BOOST_SCALE;
3194
+ const pullFactor = pullBase * nonlinearBoost * effectiveAlpha * (pullGap / dist);
3017
3195
  record.source.vx = (record.source.vx ?? 0) + dx * pullFactor;
3018
3196
  record.source.vy = (record.source.vy ?? 0) + dy * pullFactor;
3019
3197
  record.target.vx = (record.target.vx ?? 0) - dx * pullFactor;
3020
3198
  record.target.vy = (record.target.vy ?? 0) - dy * pullFactor;
3021
3199
  });
3022
3200
  };
3201
+ const dissimilarRepulsionForce = (alpha) => {
3202
+ const effectiveAlpha = Math.max(alpha, semanticAlphaFloor * 0.9);
3203
+ const maxDistance = ProfileComparisonLibComponent.DISSIMILAR_REPEL_MAX_DISTANCE;
3204
+ const decayDistance = ProfileComparisonLibComponent.DISSIMILAR_REPEL_DECAY_DISTANCE;
3205
+ const baseStrength = ProfileComparisonLibComponent.DISSIMILAR_REPEL_STRENGTH;
3206
+ dissimilarPairs.forEach(record => {
3207
+ const dx = (record.target.x ?? 0) - (record.source.x ?? 0);
3208
+ const dy = (record.target.y ?? 0) - (record.source.y ?? 0);
3209
+ const rawDistance = Math.sqrt(dx * dx + dy * dy) || 1;
3210
+ if (rawDistance >= maxDistance)
3211
+ return;
3212
+ const distance = Math.max(8, rawDistance);
3213
+ const closeness = 1 - Math.min(1, distance / maxDistance);
3214
+ const decay = Math.exp(-Math.pow(distance / Math.max(1, decayDistance), 2));
3215
+ const push = baseStrength * effectiveAlpha * record.repulsion * closeness * decay;
3216
+ if (push <= 0)
3217
+ return;
3218
+ const unitX = dx / distance;
3219
+ const unitY = dy / distance;
3220
+ record.source.vx = (record.source.vx ?? 0) - unitX * push;
3221
+ record.source.vy = (record.source.vy ?? 0) - unitY * push;
3222
+ record.target.vx = (record.target.vx ?? 0) + unitX * push;
3223
+ record.target.vy = (record.target.vy ?? 0) + unitY * push;
3224
+ });
3225
+ };
3226
+ const priorityMinSimilarity = ProfileComparisonLibComponent.PRIORITY_PAIR_MIN_SIMILARITY;
3227
+ const priorityPairCandidates = [...crossCandidates, ...intraCandidates]
3228
+ .filter(link => link.similarity >= priorityMinSimilarity)
3229
+ .sort((a, b) => (b.similarity - a.similarity) || (b.strength - a.strength));
3230
+ const priorityPairKeys = new Set();
3231
+ const endpointClaims = new Map();
3232
+ const maxPairsPerNode = 2;
3233
+ const getClaimCount = (id) => endpointClaims.get(id) ?? 0;
3234
+ const priorityPairs = priorityPairCandidates
3235
+ .filter(candidate => {
3236
+ const sourceId = this.getLinkNodeId(candidate.source);
3237
+ const targetId = this.getLinkNodeId(candidate.target);
3238
+ const key = this.getLinkPairKey(sourceId, targetId);
3239
+ if (priorityPairKeys.has(key))
3240
+ return false;
3241
+ if (getClaimCount(sourceId) >= maxPairsPerNode || getClaimCount(targetId) >= maxPairsPerNode)
3242
+ return false;
3243
+ priorityPairKeys.add(key);
3244
+ endpointClaims.set(sourceId, getClaimCount(sourceId) + 1);
3245
+ endpointClaims.set(targetId, getClaimCount(targetId) + 1);
3246
+ return true;
3247
+ })
3248
+ .map(link => {
3249
+ const source = typeof link.source === 'object' ? link.source : nodeById.get(String(link.source));
3250
+ const target = typeof link.target === 'object' ? link.target : nodeById.get(String(link.target));
3251
+ if (!source || !target)
3252
+ return null;
3253
+ return {
3254
+ source,
3255
+ target,
3256
+ similarity: Math.max(priorityMinSimilarity, Number(link.similarity ?? 0)),
3257
+ };
3258
+ })
3259
+ .filter((pair) => !!pair);
3260
+ const maxPrioritySimilarity = Math.max(priorityMinSimilarity, ...priorityPairs.map(pair => pair.similarity));
3261
+ const priorityPairForce = (alpha) => {
3262
+ const effectiveAlpha = Math.max(alpha, semanticAlphaFloor);
3263
+ const pairStrength = ProfileComparisonLibComponent.PRIORITY_PAIR_ATTRACTION_STRENGTH;
3264
+ const corridorRadius = ProfileComparisonLibComponent.PRIORITY_PAIR_CORRIDOR_RADIUS;
3265
+ const corridorRepel = ProfileComparisonLibComponent.PRIORITY_PAIR_CORRIDOR_REPEL_STRENGTH;
3266
+ priorityPairs.forEach(pair => {
3267
+ const ax = pair.source.x ?? 0;
3268
+ const ay = pair.source.y ?? 0;
3269
+ const bx = pair.target.x ?? 0;
3270
+ const by = pair.target.y ?? 0;
3271
+ const dx = bx - ax;
3272
+ const dy = by - ay;
3273
+ const dist = Math.sqrt(dx * dx + dy * dy) || 1;
3274
+ const similarityNorm = Math.max(0, Math.min(1, pair.similarity / Math.max(0.001, maxPrioritySimilarity)));
3275
+ const targetDistance = 26 + (1 - similarityNorm) * 20;
3276
+ if (dist > targetDistance) {
3277
+ const pull = pairStrength * effectiveAlpha * ((dist - targetDistance) / dist);
3278
+ pair.source.vx = (pair.source.vx ?? 0) + dx * pull;
3279
+ pair.source.vy = (pair.source.vy ?? 0) + dy * pull;
3280
+ pair.target.vx = (pair.target.vx ?? 0) - dx * pull;
3281
+ pair.target.vy = (pair.target.vy ?? 0) - dy * pull;
3282
+ }
3283
+ const segmentLenSq = dx * dx + dy * dy;
3284
+ if (segmentLenSq <= 1)
3285
+ return;
3286
+ nodes.forEach(candidate => {
3287
+ if (candidate.id === pair.source.id || candidate.id === pair.target.id)
3288
+ return;
3289
+ const candidateSimilarity = Math.max(this.getSimilarity(candidate.label, pair.source.label), this.getSimilarity(candidate.label, pair.target.label));
3290
+ if (candidateSimilarity >= pair.similarity * 0.74)
3291
+ return;
3292
+ const px = candidate.x ?? 0;
3293
+ const py = candidate.y ?? 0;
3294
+ const tRaw = ((px - ax) * dx + (py - ay) * dy) / segmentLenSq;
3295
+ const t = Math.max(0, Math.min(1, tRaw));
3296
+ if (t <= 0.12 || t >= 0.88)
3297
+ return;
3298
+ const closestX = ax + dx * t;
3299
+ const closestY = ay + dy * t;
3300
+ const offX = px - closestX;
3301
+ const offY = py - closestY;
3302
+ const offDist = Math.sqrt(offX * offX + offY * offY) || 1;
3303
+ if (offDist >= corridorRadius)
3304
+ return;
3305
+ const proximity = 1 - (offDist / corridorRadius);
3306
+ const mismatch = 1 - Math.max(0, Math.min(1, candidateSimilarity / Math.max(0.001, pair.similarity)));
3307
+ const push = corridorRepel * effectiveAlpha * proximity * mismatch;
3308
+ if (push <= 0)
3309
+ return;
3310
+ const unitOffX = offX / offDist;
3311
+ const unitOffY = offY / offDist;
3312
+ candidate.vx = (candidate.vx ?? 0) + unitOffX * push;
3313
+ candidate.vy = (candidate.vy ?? 0) + unitOffY * push;
3314
+ // Counter-force keeps the pair stable while opening a semantic corridor.
3315
+ const damp = push * 0.24;
3316
+ pair.source.vx = (pair.source.vx ?? 0) - unitOffX * damp;
3317
+ pair.source.vy = (pair.source.vy ?? 0) - unitOffY * damp;
3318
+ pair.target.vx = (pair.target.vx ?? 0) - unitOffX * damp;
3319
+ pair.target.vy = (pair.target.vy ?? 0) - unitOffY * damp;
3320
+ });
3321
+ });
3322
+ };
3323
+ const bestByNode = new Map();
3324
+ nodes.forEach(node => {
3325
+ let bestNode = null;
3326
+ let best = -1;
3327
+ let second = -1;
3328
+ nodes.forEach(other => {
3329
+ if (other.id === node.id)
3330
+ return;
3331
+ const similarity = Math.max(0, this.getSimilarity(node.label, other.label));
3332
+ if (similarity > best) {
3333
+ second = best;
3334
+ best = similarity;
3335
+ bestNode = other;
3336
+ }
3337
+ else if (similarity > second) {
3338
+ second = similarity;
3339
+ }
3340
+ });
3341
+ bestByNode.set(node.id, { node: bestNode, best: Math.max(0, best), second: Math.max(0, second) });
3342
+ });
3343
+ const mutualPairMinSimilarity = ProfileComparisonLibComponent.MUTUAL_TOP_PAIR_MIN_SIMILARITY;
3344
+ const mutualPairMargin = ProfileComparisonLibComponent.MUTUAL_TOP_PAIR_MARGIN;
3345
+ const mutualPairKeys = new Set();
3346
+ const mutualTopPairs = nodes
3347
+ .map(node => {
3348
+ const best = bestByNode.get(node.id);
3349
+ if (!best?.node)
3350
+ return null;
3351
+ if (best.best < mutualPairMinSimilarity)
3352
+ return null;
3353
+ if (best.best - best.second < mutualPairMargin)
3354
+ return null;
3355
+ const reverse = bestByNode.get(best.node.id);
3356
+ if (!reverse?.node || reverse.node.id !== node.id)
3357
+ return null;
3358
+ if (reverse.best < mutualPairMinSimilarity || reverse.best - reverse.second < mutualPairMargin)
3359
+ return null;
3360
+ const key = this.getLinkPairKey(node.id, best.node.id);
3361
+ if (mutualPairKeys.has(key))
3362
+ return null;
3363
+ mutualPairKeys.add(key);
3364
+ return {
3365
+ source: node,
3366
+ target: best.node,
3367
+ similarity: Math.min(best.best, reverse.best),
3368
+ };
3369
+ })
3370
+ .filter((pair) => !!pair);
3371
+ const mutualTopPairForce = (alpha) => {
3372
+ const effectiveAlpha = Math.max(alpha, semanticAlphaFloor * 1.15);
3373
+ const attractionStrength = ProfileComparisonLibComponent.MUTUAL_TOP_PAIR_ATTRACTION_STRENGTH;
3374
+ const baseBarrierRadius = ProfileComparisonLibComponent.MUTUAL_TOP_PAIR_BARRIER_RADIUS;
3375
+ const repelStrength = ProfileComparisonLibComponent.MUTUAL_TOP_PAIR_REPEL_STRENGTH;
3376
+ mutualTopPairs.forEach(pair => {
3377
+ const ax = pair.source.x ?? 0;
3378
+ const ay = pair.source.y ?? 0;
3379
+ const bx = pair.target.x ?? 0;
3380
+ const by = pair.target.y ?? 0;
3381
+ const dx = bx - ax;
3382
+ const dy = by - ay;
3383
+ const dist = Math.sqrt(dx * dx + dy * dy) || 1;
3384
+ const norm = Math.max(0, Math.min(1, pair.similarity));
3385
+ const targetDistance = 24 + (1 - norm) * 26;
3386
+ if (dist > targetDistance) {
3387
+ const pull = attractionStrength * effectiveAlpha * ((dist - targetDistance) / dist);
3388
+ pair.source.vx = (pair.source.vx ?? 0) + dx * pull;
3389
+ pair.source.vy = (pair.source.vy ?? 0) + dy * pull;
3390
+ pair.target.vx = (pair.target.vx ?? 0) - dx * pull;
3391
+ pair.target.vy = (pair.target.vy ?? 0) - dy * pull;
3392
+ }
3393
+ const segmentLenSq = dx * dx + dy * dy;
3394
+ if (segmentLenSq <= 1)
3395
+ return;
3396
+ const barrierRadius = baseBarrierRadius + (1 - norm) * 10;
3397
+ nodes.forEach(candidate => {
3398
+ if (candidate.id === pair.source.id || candidate.id === pair.target.id)
3399
+ return;
3400
+ const candidateSimilarity = Math.max(this.getSimilarity(candidate.label, pair.source.label), this.getSimilarity(candidate.label, pair.target.label));
3401
+ if (candidateSimilarity >= pair.similarity * 0.76)
3402
+ return;
3403
+ const px = candidate.x ?? 0;
3404
+ const py = candidate.y ?? 0;
3405
+ const tRaw = ((px - ax) * dx + (py - ay) * dy) / segmentLenSq;
3406
+ const t = Math.max(0, Math.min(1, tRaw));
3407
+ if (t <= 0.08 || t >= 0.92)
3408
+ return;
3409
+ const closestX = ax + dx * t;
3410
+ const closestY = ay + dy * t;
3411
+ const offX = px - closestX;
3412
+ const offY = py - closestY;
3413
+ const offDist = Math.sqrt(offX * offX + offY * offY) || 1;
3414
+ if (offDist >= barrierRadius)
3415
+ return;
3416
+ const proximity = 1 - (offDist / barrierRadius);
3417
+ const mismatch = 1 - Math.max(0, Math.min(1, candidateSimilarity / Math.max(0.001, pair.similarity)));
3418
+ const push = repelStrength * effectiveAlpha * proximity * mismatch;
3419
+ if (push <= 0)
3420
+ return;
3421
+ const unitOffX = offX / offDist;
3422
+ const unitOffY = offY / offDist;
3423
+ candidate.vx = (candidate.vx ?? 0) + unitOffX * push;
3424
+ candidate.vy = (candidate.vy ?? 0) + unitOffY * push;
3425
+ const damp = push * 0.26;
3426
+ pair.source.vx = (pair.source.vx ?? 0) - unitOffX * damp;
3427
+ pair.source.vy = (pair.source.vy ?? 0) - unitOffY * damp;
3428
+ pair.target.vx = (pair.target.vx ?? 0) - unitOffX * damp;
3429
+ pair.target.vy = (pair.target.vy ?? 0) - unitOffY * damp;
3430
+ });
3431
+ });
3432
+ };
3433
+ const runObjectiveOptimizer = () => {
3434
+ if (!nodes.length)
3435
+ return;
3436
+ const attractionFloor = springThreshold;
3437
+ const dissimilarFloor = dissimilarThreshold;
3438
+ const pairRecords = [];
3439
+ for (let i = 0; i < nodes.length; i++) {
3440
+ for (let j = i + 1; j < nodes.length; j++) {
3441
+ const similarity = Math.max(0, this.getSimilarity(nodes[i].label, nodes[j].label));
3442
+ if (similarity <= dissimilarFloor || similarity >= attractionFloor) {
3443
+ pairRecords.push({ a: nodes[i], b: nodes[j], similarity });
3444
+ }
3445
+ }
3446
+ }
3447
+ const iterations = ProfileComparisonLibComponent.OBJECTIVE_OPTIMIZER_ITERATIONS;
3448
+ const stepStart = ProfileComparisonLibComponent.OBJECTIVE_STEP_START;
3449
+ const stepEnd = ProfileComparisonLibComponent.OBJECTIVE_STEP_END;
3450
+ const attractionWeight = ProfileComparisonLibComponent.OBJECTIVE_ATTRACTION_WEIGHT;
3451
+ const repulsionWeight = ProfileComparisonLibComponent.OBJECTIVE_REPULSION_WEIGHT;
3452
+ const anchorWeight = ProfileComparisonLibComponent.OBJECTIVE_ANCHOR_WEIGHT;
3453
+ const boundaryWeight = ProfileComparisonLibComponent.OBJECTIVE_BOUNDARY_WEIGHT;
3454
+ for (let iteration = 0; iteration < iterations; iteration++) {
3455
+ const progress = iteration / Math.max(1, iterations - 1);
3456
+ const step = stepStart + (stepEnd - stepStart) * progress;
3457
+ const gradient = new Map();
3458
+ nodes.forEach(node => gradient.set(node.id, { x: 0, y: 0 }));
3459
+ pairRecords.forEach(record => {
3460
+ const ax = record.a.x ?? record.a.targetX ?? 0;
3461
+ const ay = record.a.y ?? record.a.targetY ?? 0;
3462
+ const bx = record.b.x ?? record.b.targetX ?? 0;
3463
+ const by = record.b.y ?? record.b.targetY ?? 0;
3464
+ const dx = bx - ax;
3465
+ const dy = by - ay;
3466
+ const dist = Math.sqrt(dx * dx + dy * dy) || 1;
3467
+ const unitX = dx / dist;
3468
+ const unitY = dy / dist;
3469
+ if (record.similarity >= attractionFloor) {
3470
+ const normalized = Math.max(0, Math.min(1, (record.similarity - attractionFloor) / Math.max(0.001, 1 - attractionFloor)));
3471
+ const targetDistance = 24 + (1 - normalized) * 66;
3472
+ if (dist > targetDistance) {
3473
+ const pull = attractionWeight * Math.pow(normalized, 1.4) * ((dist - targetDistance) / dist);
3474
+ const gradA = gradient.get(record.a.id);
3475
+ const gradB = gradient.get(record.b.id);
3476
+ if (gradA && gradB) {
3477
+ gradA.x += dx * pull;
3478
+ gradA.y += dy * pull;
3479
+ gradB.x -= dx * pull;
3480
+ gradB.y -= dy * pull;
3481
+ }
3482
+ }
3483
+ return;
3484
+ }
3485
+ if (record.similarity > dissimilarFloor)
3486
+ return;
3487
+ const normalized = (dissimilarFloor - record.similarity) / Math.max(0.001, dissimilarFloor);
3488
+ const maxDistance = 210;
3489
+ if (dist >= maxDistance)
3490
+ return;
3491
+ const push = repulsionWeight * normalized * (1 - (dist / maxDistance));
3492
+ const gradA = gradient.get(record.a.id);
3493
+ const gradB = gradient.get(record.b.id);
3494
+ if (gradA && gradB) {
3495
+ gradA.x -= unitX * push;
3496
+ gradA.y -= unitY * push;
3497
+ gradB.x += unitX * push;
3498
+ gradB.y += unitY * push;
3499
+ }
3500
+ });
3501
+ nodes.forEach(node => {
3502
+ const grad = gradient.get(node.id);
3503
+ if (!grad)
3504
+ return;
3505
+ const currentX = node.x ?? node.targetX ?? zoneShared;
3506
+ const currentY = node.y ?? node.targetY ?? minCenterY;
3507
+ const targetX = node.targetX ?? currentX;
3508
+ const targetY = node.targetY ?? currentY;
3509
+ grad.x += (targetX - currentX) * anchorWeight;
3510
+ grad.y += (targetY - currentY) * anchorWeight;
3511
+ const bounds = nodeBounds.get(node.id) ?? buildNodeBounds(node);
3512
+ if (currentX < bounds.minX)
3513
+ grad.x += (bounds.minX - currentX) * boundaryWeight;
3514
+ if (currentX > bounds.maxX)
3515
+ grad.x -= (currentX - bounds.maxX) * boundaryWeight;
3516
+ if (currentY < bounds.minY)
3517
+ grad.y += (bounds.minY - currentY) * boundaryWeight;
3518
+ if (currentY > bounds.maxY)
3519
+ grad.y -= (currentY - bounds.maxY) * boundaryWeight;
3520
+ });
3521
+ nodes.forEach(node => {
3522
+ const grad = gradient.get(node.id);
3523
+ if (!grad)
3524
+ return;
3525
+ node.x = (node.x ?? node.targetX ?? zoneShared) + grad.x * step;
3526
+ node.y = (node.y ?? node.targetY ?? minCenterY) + grad.y * step;
3527
+ clampNodeToBounds(node);
3528
+ });
3529
+ }
3530
+ };
3531
+ runObjectiveOptimizer();
3023
3532
  this.ngZone.runOutsideAngular(() => {
3024
3533
  const sim = forceSimulation(nodes)
3025
3534
  .alphaDecay(0.014)
@@ -3038,7 +3547,11 @@ class ProfileComparisonLibComponent {
3038
3547
  .strength(d => d.group === 'shared' ? 1.15 : 0.85))
3039
3548
  .force('yTargetSeed', forceY(d => d.targetY ?? minCenterY).strength(0.1))
3040
3549
  .force('crossAlign', crossAlignForce)
3041
- .force('similarityAttraction', similarityAttractionForce);
3550
+ .force('relativeMedianColumn', relativeMedianColumnForce)
3551
+ .force('similarityAttraction', similarityAttractionForce)
3552
+ .force('dissimilarRepulsion', dissimilarRepulsionForce)
3553
+ .force('priorityPair', priorityPairForce)
3554
+ .force('mutualTopPair', mutualTopPairForce);
3042
3555
  for (let i = 0; i < 420 && sim.alpha() > 0.0035; i++) {
3043
3556
  sim.tick();
3044
3557
  nodes.forEach(n => {
@@ -3054,8 +3567,9 @@ class ProfileComparisonLibComponent {
3054
3567
  sim.force('yTargetSeed', null);
3055
3568
  sim.force('crossAlign', null);
3056
3569
  sim.force('xTarget', forceX(d => d.originalFx ?? d.x ?? 0)
3057
- .strength(d => d.group === 'shared' ? 0.12 : 0.045));
3058
- sim.force('yTarget', forceY(d => d.originalFy ?? d.y ?? 0).strength(0.045));
3570
+ .strength(d => d.group === 'shared' ? 0.085 : 0.03));
3571
+ sim.force('yTarget', forceY(d => d.originalFy ?? d.y ?? 0).strength(0.03));
3572
+ sim.alphaTarget(ProfileComparisonLibComponent.IDLE_ALPHA_TARGET);
3059
3573
  this.simulation = sim;
3060
3574
  this.ngZone.run(() => {
3061
3575
  this.forceNodes = [...nodes];
@@ -3493,10 +4007,14 @@ class ProfileComparisonLibComponent {
3493
4007
  return;
3494
4008
  this.simulation.force('attraction', null);
3495
4009
  if (anchorNode) {
3496
- this.simulation.alpha(0.24).restart();
4010
+ const minClickAlpha = ProfileComparisonLibComponent.CLICK_REHEAT_ALPHA;
4011
+ if (this.simulation.alpha() < minClickAlpha) {
4012
+ this.simulation.alpha(minClickAlpha);
4013
+ }
4014
+ this.simulation.alphaTarget(ProfileComparisonLibComponent.IDLE_ALPHA_TARGET).restart();
3497
4015
  }
3498
4016
  else if (!this.isDragging) {
3499
- this.simulation.alphaTarget(0);
4017
+ this.simulation.alphaTarget(ProfileComparisonLibComponent.IDLE_ALPHA_TARGET);
3500
4018
  }
3501
4019
  }
3502
4020
  isNodeConnected(node) {
@@ -3595,10 +4113,10 @@ class ProfileComparisonLibComponent {
3595
4113
  collideForce.strength(1.0);
3596
4114
  const xTarget = this.simulation.force('xTarget');
3597
4115
  if (xTarget)
3598
- xTarget.strength((d) => d.group === 'shared' ? 0.12 : 0.05);
4116
+ xTarget.strength((d) => d.group === 'shared' ? 0.1 : 0.045);
3599
4117
  const yTarget = this.simulation.force('yTarget');
3600
4118
  if (yTarget)
3601
- yTarget.strength(0.05);
4119
+ yTarget.strength(0.042);
3602
4120
  }
3603
4121
  // Pull connected chips toward the dragged node
3604
4122
  this.updateAttractionForce(node);
@@ -3657,21 +4175,21 @@ class ProfileComparisonLibComponent {
3657
4175
  collideForce.strength(1.0);
3658
4176
  const xTarget = this.simulation.force('xTarget');
3659
4177
  if (xTarget)
3660
- xTarget.strength((d) => d.group === 'shared' ? 0.12 : 0.045);
4178
+ xTarget.strength((d) => d.group === 'shared' ? 0.1 : 0.045);
3661
4179
  const yTarget = this.simulation.force('yTarget');
3662
4180
  if (yTarget)
3663
- yTarget.strength(0.045);
4181
+ yTarget.strength(0.042);
3664
4182
  }
3665
4183
  // Cool down simulation targets
3666
- this.simulation.alphaTarget(0);
3667
- this.simulation.alpha(0.4).restart();
4184
+ this.simulation.alphaTarget(ProfileComparisonLibComponent.IDLE_ALPHA_TARGET);
4185
+ this.simulation.alpha(0.2).restart();
3668
4186
  this.draggedNode = null;
3669
4187
  this.isDragging = false;
3670
4188
  }
3671
- 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 });
3672
- 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"] }] });
4189
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.26", 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 });
4190
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.26", 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"] }] });
3673
4191
  }
3674
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.24", ngImport: i0, type: ProfileComparisonLibComponent, decorators: [{
4192
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.26", ngImport: i0, type: ProfileComparisonLibComponent, decorators: [{
3675
4193
  type: Component,
3676
4194
  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"] }]
3677
4195
  }], ctorParameters: () => [{ type: ProfileComparisonBackendService }, { type: i0.Renderer2 }, { type: FileConversionService }, { type: ImageCompressionService }, { type: i0.ChangeDetectorRef }, { type: i0.NgZone }, { type: i0.ElementRef }, { type: undefined, decorators: [{
@@ -3763,19 +4281,19 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.24", ngImpo
3763
4281
  }] } });
3764
4282
 
3765
4283
  class ProfileComparisonLibModule {
3766
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.24", ngImport: i0, type: ProfileComparisonLibModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule });
3767
- static ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "20.3.24", ngImport: i0, type: ProfileComparisonLibModule, declarations: [ProfileComparisonLibComponent], imports: [CommonModule,
4284
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.26", ngImport: i0, type: ProfileComparisonLibModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule });
4285
+ static ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "20.3.26", ngImport: i0, type: ProfileComparisonLibModule, declarations: [ProfileComparisonLibComponent], imports: [CommonModule,
3768
4286
  HttpClientModule,
3769
4287
  FormsModule,
3770
4288
  ReactiveFormsModule], exports: [ProfileComparisonLibComponent] });
3771
- static ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "20.3.24", ngImport: i0, type: ProfileComparisonLibModule, providers: [
4289
+ static ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "20.3.26", ngImport: i0, type: ProfileComparisonLibModule, providers: [
3772
4290
  ProfileComparisonBackendService
3773
4291
  ], imports: [CommonModule,
3774
4292
  HttpClientModule,
3775
4293
  FormsModule,
3776
4294
  ReactiveFormsModule] });
3777
4295
  }
3778
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.24", ngImport: i0, type: ProfileComparisonLibModule, decorators: [{
4296
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.26", ngImport: i0, type: ProfileComparisonLibModule, decorators: [{
3779
4297
  type: NgModule,
3780
4298
  args: [{
3781
4299
  declarations: [