@naniteninja/profile-comparison-lib 1.0.25 → 1.0.27

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.
@@ -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;
@@ -2428,6 +2475,11 @@ class ProfileComparisonLibComponent {
2428
2475
  const idB = this.getLinkNodeId(b);
2429
2476
  return idA < idB ? `${idA}|${idB}` : `${idB}|${idA}`;
2430
2477
  }
2478
+ getRuntimeLinkStrength(link) {
2479
+ return link.kind === 'cross'
2480
+ ? link.strength * 1.7
2481
+ : link.strength * 1.12;
2482
+ }
2431
2483
  selectLinksWithCaps(candidates, maxPerNode, minPerNode = 0) {
2432
2484
  if (!candidates.length || maxPerNode <= 0)
2433
2485
  return [];
@@ -2685,19 +2737,24 @@ class ProfileComparisonLibComponent {
2685
2737
  const dividerLeft = (zoneP1 + zoneShared) / 2;
2686
2738
  const dividerRight = (zoneShared + zoneP2) / 2;
2687
2739
  const laneGap = Math.max(4, Math.min(16, centerGuard * 0.45));
2740
+ const sharedRawMin = Math.max(globalMinX, dividerLeft + laneGap);
2741
+ const sharedRawMax = Math.min(globalMaxX, dividerRight - laneGap);
2742
+ const sharedRawWidth = Math.max(0, sharedRawMax - sharedRawMin);
2743
+ const sharedTighten = Math.max(12, Math.min(34, sharedRawWidth * 0.22));
2744
+ const sharedMinWidth = Math.max(44, Math.min(84, maxHalfShared * 1.05));
2688
2745
  let p1BoundsMinX = globalMinX;
2689
2746
  let p1BoundsMaxX = Math.min(globalMaxX, dividerLeft - laneGap);
2690
- let sharedBoundsMinX = Math.max(globalMinX, dividerLeft + laneGap);
2691
- let sharedBoundsMaxX = Math.min(globalMaxX, dividerRight - laneGap);
2747
+ let sharedBoundsMinX = Math.max(globalMinX, sharedRawMin + sharedTighten);
2748
+ let sharedBoundsMaxX = Math.min(globalMaxX, sharedRawMax - sharedTighten);
2692
2749
  let p2BoundsMinX = Math.max(globalMinX, dividerRight + laneGap);
2693
2750
  let p2BoundsMaxX = globalMaxX;
2694
2751
  if (p1BoundsMaxX < p1BoundsMinX)
2695
2752
  p1BoundsMaxX = p1BoundsMinX;
2696
2753
  if (p2BoundsMinX > p2BoundsMaxX)
2697
2754
  p2BoundsMinX = p2BoundsMaxX;
2698
- if (sharedBoundsMaxX < sharedBoundsMinX) {
2755
+ if (sharedBoundsMaxX - sharedBoundsMinX < sharedMinWidth) {
2699
2756
  const centerX = Math.max(globalMinX, Math.min(globalMaxX, zoneShared));
2700
- const fallbackHalfWidth = Math.max(6, Math.min(20, Math.max(0, p2BoundsMinX - p1BoundsMaxX) * 0.25));
2757
+ const fallbackHalfWidth = sharedMinWidth / 2;
2701
2758
  sharedBoundsMinX = Math.max(globalMinX, centerX - fallbackHalfWidth);
2702
2759
  sharedBoundsMaxX = Math.min(globalMaxX, centerX + fallbackHalfWidth);
2703
2760
  if (sharedBoundsMaxX < sharedBoundsMinX) {
@@ -2721,17 +2778,33 @@ class ProfileComparisonLibComponent {
2721
2778
  if (matrixMax <= springThreshold)
2722
2779
  matrixMax = 1.0;
2723
2780
  const simRange = matrixMax - springThreshold;
2724
- const distributedYForIndex = (index, total) => {
2725
- if (total <= 1)
2726
- return minCenterY + verticalSpan * 0.5;
2727
- const t = index / Math.max(1, total - 1);
2728
- return minCenterY + verticalSpan * t;
2781
+ const columnCoreY = minCenterY + verticalSpan * 0.5;
2782
+ const getMedian = (values) => {
2783
+ if (!values.length)
2784
+ return 0;
2785
+ const sorted = [...values].sort((a, b) => a - b);
2786
+ const mid = Math.floor(sorted.length / 2);
2787
+ if (sorted.length % 2 === 1)
2788
+ return sorted[mid];
2789
+ return (sorted[mid - 1] + sorted[mid]) / 2;
2790
+ };
2791
+ const getTopSixMedian = (values) => {
2792
+ if (!values.length)
2793
+ return 0;
2794
+ const top = [...values].sort((a, b) => b - a).slice(0, Math.min(6, values.length));
2795
+ return getMedian(top);
2796
+ };
2797
+ const hashToken = (value) => {
2798
+ let hash = 0;
2799
+ for (let i = 0; i < value.length; i++) {
2800
+ hash = ((hash << 5) - hash + value.charCodeAt(i)) | 0;
2801
+ }
2802
+ return Math.abs(hash);
2729
2803
  };
2730
2804
  const nodes = [];
2731
2805
  const addNodes = (items, group, zoneX) => {
2732
- const total = items.length;
2733
2806
  items.forEach((label, i) => {
2734
- const targetY = distributedYForIndex(i, total);
2807
+ const targetY = columnCoreY;
2735
2808
  nodes.push({
2736
2809
  id: `${group}-${i}-${label}`,
2737
2810
  label,
@@ -2772,8 +2845,10 @@ class ProfileComparisonLibComponent {
2772
2845
  : zoneP2 + (zoneShared - zoneP2) * centerPull;
2773
2846
  }
2774
2847
  });
2775
- // Vertical placement should be driven by cross-list similarity, not by
2776
- // whichever list happens to have fewer items.
2848
+ // Build a relative attractiveness score (median of top-six similarities)
2849
+ // and seed chips from each column core with only a tiny 9x9 perturbation.
2850
+ // This lets forces "emanate" from a shared origin while preserving
2851
+ // deterministic reset behavior.
2777
2852
  const semanticPool = (node) => {
2778
2853
  if (node.group === 'p1')
2779
2854
  return nodes.filter(n => n.group === 'p2' || n.group === 'shared');
@@ -2781,35 +2856,50 @@ class ProfileComparisonLibComponent {
2781
2856
  return nodes.filter(n => n.group === 'p1' || n.group === 'shared');
2782
2857
  return nodes.filter(n => n.group === 'p1' || n.group === 'p2');
2783
2858
  };
2784
- const ySimilarityFloor = Math.max(0.18, springThreshold - 0.06);
2785
- for (let pass = 0; pass < 6; pass++) {
2786
- const snapshotY = new Map();
2787
- nodes.forEach(node => {
2788
- snapshotY.set(node.id, node.targetY ?? (minCenterY + verticalSpan * 0.5));
2789
- });
2790
- nodes.forEach(node => {
2791
- const pool = semanticPool(node);
2792
- if (!pool.length)
2859
+ const getSemanticAttractiveness = (node) => {
2860
+ const pool = semanticPool(node);
2861
+ if (!pool.length)
2862
+ return 0;
2863
+ const similarities = pool.map(other => Math.max(0, this.getSimilarity(node.label, other.label)));
2864
+ return getTopSixMedian(similarities);
2865
+ };
2866
+ const microSeedSpan = ProfileComparisonLibComponent.COLUMN_CORE_SEED_PIXELS;
2867
+ const microSeedHalf = microSeedSpan / 2;
2868
+ const deterministicPasses = ProfileComparisonLibComponent.DETERMINISTIC_SEED_PASSES;
2869
+ const deterministicBlend = ProfileComparisonLibComponent.DETERMINISTIC_SEED_BLEND;
2870
+ for (let pass = 0; pass < deterministicPasses; pass++) {
2871
+ ['p1', 'shared', 'p2'].forEach(group => {
2872
+ const groupNodes = nodes.filter(node => node.group === group);
2873
+ if (groupNodes.length <= 1)
2793
2874
  return;
2794
- let weightedY = 0;
2795
- let totalWeight = 0;
2796
- pool.forEach(other => {
2797
- const similarity = this.getSimilarity(node.label, other.label);
2798
- if (similarity < ySimilarityFloor)
2799
- return;
2800
- const weight = Math.pow(Math.max(0.001, similarity - ySimilarityFloor + 0.05), 2);
2801
- const otherY = snapshotY.get(other.id) ?? (other.targetY ?? minCenterY);
2802
- weightedY += otherY * weight;
2803
- totalWeight += weight;
2875
+ const ranked = groupNodes
2876
+ .map(node => ({
2877
+ node,
2878
+ attractiveness: getSemanticAttractiveness(node),
2879
+ tie: this.normalizeSimilarityToken(node.label),
2880
+ }))
2881
+ .sort((a, b) => (b.attractiveness - a.attractiveness) || a.tie.localeCompare(b.tie));
2882
+ ranked.forEach((entry, index) => {
2883
+ const rankT = ranked.length <= 1 ? 0.5 : (index / Math.max(1, ranked.length - 1));
2884
+ const yOffset = (rankT - 0.5) * microSeedSpan;
2885
+ const desiredY = Math.max(minCenterY, Math.min(maxY, columnCoreY + yOffset));
2886
+ const currentY = entry.node.targetY ?? columnCoreY;
2887
+ entry.node.targetY = Math.max(minCenterY, Math.min(maxY, currentY + (desiredY - currentY) * deterministicBlend));
2888
+ const xSeedRaw = hashToken(`${entry.node.id}:${pass}`) % (microSeedSpan + 1);
2889
+ const xOffset = xSeedRaw - microSeedHalf;
2890
+ const baseTargetX = entry.node.targetX ?? (entry.node.group === 'p1'
2891
+ ? zoneP1
2892
+ : (entry.node.group === 'p2' ? zoneP2 : zoneShared));
2893
+ entry.node.x = Math.max(globalMinX, Math.min(globalMaxX, baseTargetX + xOffset));
2894
+ entry.node.y = entry.node.targetY;
2804
2895
  });
2805
- if (totalWeight <= 0)
2806
- return;
2807
- const semanticY = weightedY / totalWeight;
2808
- const currentY = snapshotY.get(node.id) ?? semanticY;
2809
- const blend = node.group === 'shared' ? 0.84 : 0.78;
2810
- node.targetY = Math.max(minCenterY, Math.min(maxY, currentY + (semanticY - currentY) * blend));
2811
2896
  });
2812
2897
  }
2898
+ nodes.forEach(node => {
2899
+ const fallbackX = node.targetX ?? (node.group === 'p1' ? zoneP1 : (node.group === 'p2' ? zoneP2 : zoneShared));
2900
+ node.x = Math.max(globalMinX, Math.min(globalMaxX, node.x ?? fallbackX));
2901
+ node.y = Math.max(minCenterY, Math.min(maxY, node.targetY ?? columnCoreY));
2902
+ });
2813
2903
  const buildNodeBounds = (node) => {
2814
2904
  let minX = globalMinX;
2815
2905
  let maxX = globalMaxX;
@@ -2838,8 +2928,8 @@ class ProfileComparisonLibComponent {
2838
2928
  };
2839
2929
  const crossCandidates = [];
2840
2930
  const intraCandidates = [];
2841
- const MIN_DIST = 62;
2842
- const SPAN = 92;
2931
+ const MIN_DIST = 34;
2932
+ const SPAN = 72;
2843
2933
  for (let i = 0; i < nodes.length; i++) {
2844
2934
  for (let j = i + 1; j < nodes.length; j++) {
2845
2935
  if (!this.canLinkNodes(nodes[i], nodes[j]))
@@ -2873,9 +2963,9 @@ class ProfileComparisonLibComponent {
2873
2963
  kind: 'intra',
2874
2964
  source: groupNodes[i],
2875
2965
  target: groupNodes[j],
2876
- strength: 0.08 + normSim * 0.2,
2966
+ strength: 0.12 + normSim * 0.24,
2877
2967
  similarity,
2878
- distance: 50 + (1 - normSim) * 42,
2968
+ distance: 34 + (1 - normSim) * 46,
2879
2969
  });
2880
2970
  }
2881
2971
  }
@@ -2975,6 +3065,461 @@ class ProfileComparisonLibComponent {
2975
3065
  node.vy = (node.vy ?? 0) + dy * alignStrength * alpha;
2976
3066
  });
2977
3067
  };
3068
+ const linkRecords = links
3069
+ .map(link => {
3070
+ const source = typeof link.source === 'object' ? link.source : nodeById.get(String(link.source));
3071
+ const target = typeof link.target === 'object' ? link.target : nodeById.get(String(link.target));
3072
+ if (!source || !target)
3073
+ return null;
3074
+ return {
3075
+ source,
3076
+ target,
3077
+ similarity: Number(link.similarity ?? link.strength ?? 0.5),
3078
+ kind: link.kind,
3079
+ };
3080
+ })
3081
+ .filter((record) => !!record);
3082
+ const dissimilarThreshold = ProfileComparisonLibComponent.DISSIMILAR_REPEL_THRESHOLD;
3083
+ const dissimilarPairs = [];
3084
+ for (let i = 0; i < nodes.length; i++) {
3085
+ for (let j = i + 1; j < nodes.length; j++) {
3086
+ const source = nodes[i];
3087
+ const target = nodes[j];
3088
+ const similarity = Math.max(0, this.getSimilarity(source.label, target.label));
3089
+ if (similarity >= dissimilarThreshold)
3090
+ continue;
3091
+ const laneFactor = source.group === target.group
3092
+ ? 1
3093
+ : (source.group === 'shared' || target.group === 'shared' ? 0.62 : 0.44);
3094
+ const repulsion = laneFactor * (dissimilarThreshold - similarity) / Math.max(0.001, dissimilarThreshold);
3095
+ dissimilarPairs.push({
3096
+ source,
3097
+ target,
3098
+ repulsion,
3099
+ });
3100
+ }
3101
+ }
3102
+ const relativeMedianByNode = new Map();
3103
+ const relativeMedianPairs = [];
3104
+ ['p1', 'shared', 'p2'].forEach(group => {
3105
+ const groupNodes = nodes.filter(node => node.group === group);
3106
+ if (groupNodes.length <= 1)
3107
+ return;
3108
+ groupNodes.forEach(node => {
3109
+ const similarities = groupNodes
3110
+ .filter(other => other.id !== node.id)
3111
+ .map(other => Math.max(0, this.getSimilarity(node.label, other.label)));
3112
+ relativeMedianByNode.set(node.id, getTopSixMedian(similarities));
3113
+ });
3114
+ for (let i = 0; i < groupNodes.length; i++) {
3115
+ for (let j = i + 1; j < groupNodes.length; j++) {
3116
+ const source = groupNodes[i];
3117
+ const target = groupNodes[j];
3118
+ const similarity = Math.max(0, this.getSimilarity(source.label, target.label));
3119
+ const sourceMedian = relativeMedianByNode.get(source.id) ?? 0;
3120
+ const targetMedian = relativeMedianByNode.get(target.id) ?? 0;
3121
+ const median = (sourceMedian + targetMedian) / 2;
3122
+ relativeMedianPairs.push({ source, target, similarity, median });
3123
+ }
3124
+ }
3125
+ });
3126
+ const maxLinkSimilarity = Math.max(0.001, ...linkRecords.map(record => Math.max(0, record.similarity)));
3127
+ const MIN_PAIR_DISTANCE = 34;
3128
+ const MAX_PAIR_DISTANCE = 88;
3129
+ const semanticAlphaFloor = ProfileComparisonLibComponent.SEMANTIC_STABILITY_ALPHA_FLOOR;
3130
+ const relativeMedianColumnForce = (alpha) => {
3131
+ const effectiveAlpha = Math.max(alpha, ProfileComparisonLibComponent.RELATIVE_MEDIAN_ALPHA_FLOOR);
3132
+ const attractStrength = ProfileComparisonLibComponent.RELATIVE_MEDIAN_ATTRACT_STRENGTH;
3133
+ const repelStrength = ProfileComparisonLibComponent.RELATIVE_MEDIAN_REPEL_STRENGTH;
3134
+ const attractRadius = ProfileComparisonLibComponent.RELATIVE_MEDIAN_ATTRACT_RADIUS;
3135
+ const repelRadius = ProfileComparisonLibComponent.RELATIVE_MEDIAN_REPEL_RADIUS;
3136
+ const deltaDeadband = 0.025;
3137
+ relativeMedianPairs.forEach(pair => {
3138
+ const dx = (pair.target.x ?? 0) - (pair.source.x ?? 0);
3139
+ const dy = (pair.target.y ?? 0) - (pair.source.y ?? 0);
3140
+ const dist = Math.sqrt(dx * dx + dy * dy) || 1;
3141
+ const delta = pair.similarity - pair.median;
3142
+ if (Math.abs(delta) <= deltaDeadband)
3143
+ return;
3144
+ if (delta >= 0) {
3145
+ const closenessBias = Math.max(0.08, Math.min(1, delta + 0.08));
3146
+ const targetDistance = Math.max(24, attractRadius - closenessBias * 46);
3147
+ if (dist <= targetDistance + 2)
3148
+ return;
3149
+ const pull = attractStrength * effectiveAlpha * closenessBias * ((dist - targetDistance) / dist);
3150
+ pair.source.vx = (pair.source.vx ?? 0) + dx * pull;
3151
+ pair.source.vy = (pair.source.vy ?? 0) + dy * pull;
3152
+ pair.target.vx = (pair.target.vx ?? 0) - dx * pull;
3153
+ pair.target.vy = (pair.target.vy ?? 0) - dy * pull;
3154
+ return;
3155
+ }
3156
+ if (dist >= repelRadius || dist <= 28)
3157
+ return;
3158
+ const underMedian = Math.min(1, Math.abs(delta) / Math.max(0.001, pair.median + 0.05));
3159
+ const push = repelStrength * effectiveAlpha * underMedian * (1 - (dist / repelRadius));
3160
+ if (push <= 0)
3161
+ return;
3162
+ const unitX = dx / dist;
3163
+ const unitY = dy / dist;
3164
+ pair.source.vx = (pair.source.vx ?? 0) - unitX * push;
3165
+ pair.source.vy = (pair.source.vy ?? 0) - unitY * push;
3166
+ pair.target.vx = (pair.target.vx ?? 0) + unitX * push;
3167
+ pair.target.vy = (pair.target.vy ?? 0) + unitY * push;
3168
+ });
3169
+ };
3170
+ const similarityAttractionForce = (alpha) => {
3171
+ const effectiveAlpha = Math.max(alpha, semanticAlphaFloor);
3172
+ linkRecords.forEach(record => {
3173
+ const dx = (record.target.x ?? 0) - (record.source.x ?? 0);
3174
+ const dy = (record.target.y ?? 0) - (record.source.y ?? 0);
3175
+ const dist = Math.sqrt(dx * dx + dy * dy) || 1;
3176
+ const normalizedSimilarity = Math.max(0, Math.min(1, record.similarity / maxLinkSimilarity));
3177
+ const targetDistance = MIN_PAIR_DISTANCE + (MAX_PAIR_DISTANCE - MIN_PAIR_DISTANCE) * (1 - normalizedSimilarity);
3178
+ if (dist <= targetDistance)
3179
+ return;
3180
+ const pullGap = dist - targetDistance;
3181
+ const pullBase = record.kind === 'cross' ? 0.86 : 0.78;
3182
+ const nonlinearBoost = 1
3183
+ + Math.pow(normalizedSimilarity, ProfileComparisonLibComponent.STRONG_SIMILARITY_BOOST_EXPONENT)
3184
+ * ProfileComparisonLibComponent.STRONG_SIMILARITY_BOOST_SCALE;
3185
+ const pullFactor = pullBase * nonlinearBoost * effectiveAlpha * (pullGap / dist);
3186
+ record.source.vx = (record.source.vx ?? 0) + dx * pullFactor;
3187
+ record.source.vy = (record.source.vy ?? 0) + dy * pullFactor;
3188
+ record.target.vx = (record.target.vx ?? 0) - dx * pullFactor;
3189
+ record.target.vy = (record.target.vy ?? 0) - dy * pullFactor;
3190
+ });
3191
+ };
3192
+ const dissimilarRepulsionForce = (alpha) => {
3193
+ const effectiveAlpha = Math.max(alpha, semanticAlphaFloor * 0.9);
3194
+ const maxDistance = ProfileComparisonLibComponent.DISSIMILAR_REPEL_MAX_DISTANCE;
3195
+ const decayDistance = ProfileComparisonLibComponent.DISSIMILAR_REPEL_DECAY_DISTANCE;
3196
+ const baseStrength = ProfileComparisonLibComponent.DISSIMILAR_REPEL_STRENGTH;
3197
+ dissimilarPairs.forEach(record => {
3198
+ const dx = (record.target.x ?? 0) - (record.source.x ?? 0);
3199
+ const dy = (record.target.y ?? 0) - (record.source.y ?? 0);
3200
+ const rawDistance = Math.sqrt(dx * dx + dy * dy) || 1;
3201
+ if (rawDistance >= maxDistance)
3202
+ return;
3203
+ const distance = Math.max(8, rawDistance);
3204
+ const closeness = 1 - Math.min(1, distance / maxDistance);
3205
+ const decay = Math.exp(-Math.pow(distance / Math.max(1, decayDistance), 2));
3206
+ const push = baseStrength * effectiveAlpha * record.repulsion * closeness * decay;
3207
+ if (push <= 0)
3208
+ return;
3209
+ const unitX = dx / distance;
3210
+ const unitY = dy / distance;
3211
+ record.source.vx = (record.source.vx ?? 0) - unitX * push;
3212
+ record.source.vy = (record.source.vy ?? 0) - unitY * push;
3213
+ record.target.vx = (record.target.vx ?? 0) + unitX * push;
3214
+ record.target.vy = (record.target.vy ?? 0) + unitY * push;
3215
+ });
3216
+ };
3217
+ const priorityMinSimilarity = ProfileComparisonLibComponent.PRIORITY_PAIR_MIN_SIMILARITY;
3218
+ const priorityPairCandidates = [...crossCandidates, ...intraCandidates]
3219
+ .filter(link => link.similarity >= priorityMinSimilarity)
3220
+ .sort((a, b) => (b.similarity - a.similarity) || (b.strength - a.strength));
3221
+ const priorityPairKeys = new Set();
3222
+ const endpointClaims = new Map();
3223
+ const maxPairsPerNode = 2;
3224
+ const getClaimCount = (id) => endpointClaims.get(id) ?? 0;
3225
+ const priorityPairs = priorityPairCandidates
3226
+ .filter(candidate => {
3227
+ const sourceId = this.getLinkNodeId(candidate.source);
3228
+ const targetId = this.getLinkNodeId(candidate.target);
3229
+ const key = this.getLinkPairKey(sourceId, targetId);
3230
+ if (priorityPairKeys.has(key))
3231
+ return false;
3232
+ if (getClaimCount(sourceId) >= maxPairsPerNode || getClaimCount(targetId) >= maxPairsPerNode)
3233
+ return false;
3234
+ priorityPairKeys.add(key);
3235
+ endpointClaims.set(sourceId, getClaimCount(sourceId) + 1);
3236
+ endpointClaims.set(targetId, getClaimCount(targetId) + 1);
3237
+ return true;
3238
+ })
3239
+ .map(link => {
3240
+ const source = typeof link.source === 'object' ? link.source : nodeById.get(String(link.source));
3241
+ const target = typeof link.target === 'object' ? link.target : nodeById.get(String(link.target));
3242
+ if (!source || !target)
3243
+ return null;
3244
+ return {
3245
+ source,
3246
+ target,
3247
+ similarity: Math.max(priorityMinSimilarity, Number(link.similarity ?? 0)),
3248
+ };
3249
+ })
3250
+ .filter((pair) => !!pair);
3251
+ const maxPrioritySimilarity = Math.max(priorityMinSimilarity, ...priorityPairs.map(pair => pair.similarity));
3252
+ const priorityPairForce = (alpha) => {
3253
+ const effectiveAlpha = Math.max(alpha, semanticAlphaFloor);
3254
+ const pairStrength = ProfileComparisonLibComponent.PRIORITY_PAIR_ATTRACTION_STRENGTH;
3255
+ const corridorRadius = ProfileComparisonLibComponent.PRIORITY_PAIR_CORRIDOR_RADIUS;
3256
+ const corridorRepel = ProfileComparisonLibComponent.PRIORITY_PAIR_CORRIDOR_REPEL_STRENGTH;
3257
+ priorityPairs.forEach(pair => {
3258
+ const ax = pair.source.x ?? 0;
3259
+ const ay = pair.source.y ?? 0;
3260
+ const bx = pair.target.x ?? 0;
3261
+ const by = pair.target.y ?? 0;
3262
+ const dx = bx - ax;
3263
+ const dy = by - ay;
3264
+ const dist = Math.sqrt(dx * dx + dy * dy) || 1;
3265
+ const similarityNorm = Math.max(0, Math.min(1, pair.similarity / Math.max(0.001, maxPrioritySimilarity)));
3266
+ const targetDistance = 26 + (1 - similarityNorm) * 20;
3267
+ if (dist > targetDistance) {
3268
+ const pull = pairStrength * effectiveAlpha * ((dist - targetDistance) / dist);
3269
+ pair.source.vx = (pair.source.vx ?? 0) + dx * pull;
3270
+ pair.source.vy = (pair.source.vy ?? 0) + dy * pull;
3271
+ pair.target.vx = (pair.target.vx ?? 0) - dx * pull;
3272
+ pair.target.vy = (pair.target.vy ?? 0) - dy * pull;
3273
+ }
3274
+ const segmentLenSq = dx * dx + dy * dy;
3275
+ if (segmentLenSq <= 1)
3276
+ return;
3277
+ nodes.forEach(candidate => {
3278
+ if (candidate.id === pair.source.id || candidate.id === pair.target.id)
3279
+ return;
3280
+ const candidateSimilarity = Math.max(this.getSimilarity(candidate.label, pair.source.label), this.getSimilarity(candidate.label, pair.target.label));
3281
+ if (candidateSimilarity >= pair.similarity * 0.74)
3282
+ return;
3283
+ const px = candidate.x ?? 0;
3284
+ const py = candidate.y ?? 0;
3285
+ const tRaw = ((px - ax) * dx + (py - ay) * dy) / segmentLenSq;
3286
+ const t = Math.max(0, Math.min(1, tRaw));
3287
+ if (t <= 0.12 || t >= 0.88)
3288
+ return;
3289
+ const closestX = ax + dx * t;
3290
+ const closestY = ay + dy * t;
3291
+ const offX = px - closestX;
3292
+ const offY = py - closestY;
3293
+ const offDist = Math.sqrt(offX * offX + offY * offY) || 1;
3294
+ if (offDist >= corridorRadius)
3295
+ return;
3296
+ const proximity = 1 - (offDist / corridorRadius);
3297
+ const mismatch = 1 - Math.max(0, Math.min(1, candidateSimilarity / Math.max(0.001, pair.similarity)));
3298
+ const push = corridorRepel * effectiveAlpha * proximity * mismatch;
3299
+ if (push <= 0)
3300
+ return;
3301
+ const unitOffX = offX / offDist;
3302
+ const unitOffY = offY / offDist;
3303
+ candidate.vx = (candidate.vx ?? 0) + unitOffX * push;
3304
+ candidate.vy = (candidate.vy ?? 0) + unitOffY * push;
3305
+ // Counter-force keeps the pair stable while opening a semantic corridor.
3306
+ const damp = push * 0.24;
3307
+ pair.source.vx = (pair.source.vx ?? 0) - unitOffX * damp;
3308
+ pair.source.vy = (pair.source.vy ?? 0) - unitOffY * damp;
3309
+ pair.target.vx = (pair.target.vx ?? 0) - unitOffX * damp;
3310
+ pair.target.vy = (pair.target.vy ?? 0) - unitOffY * damp;
3311
+ });
3312
+ });
3313
+ };
3314
+ const bestByNode = new Map();
3315
+ nodes.forEach(node => {
3316
+ let bestNode = null;
3317
+ let best = -1;
3318
+ let second = -1;
3319
+ nodes.forEach(other => {
3320
+ if (other.id === node.id)
3321
+ return;
3322
+ const similarity = Math.max(0, this.getSimilarity(node.label, other.label));
3323
+ if (similarity > best) {
3324
+ second = best;
3325
+ best = similarity;
3326
+ bestNode = other;
3327
+ }
3328
+ else if (similarity > second) {
3329
+ second = similarity;
3330
+ }
3331
+ });
3332
+ bestByNode.set(node.id, { node: bestNode, best: Math.max(0, best), second: Math.max(0, second) });
3333
+ });
3334
+ const mutualPairMinSimilarity = ProfileComparisonLibComponent.MUTUAL_TOP_PAIR_MIN_SIMILARITY;
3335
+ const mutualPairMargin = ProfileComparisonLibComponent.MUTUAL_TOP_PAIR_MARGIN;
3336
+ const mutualPairKeys = new Set();
3337
+ const mutualTopPairs = nodes
3338
+ .map(node => {
3339
+ const best = bestByNode.get(node.id);
3340
+ if (!best?.node)
3341
+ return null;
3342
+ if (best.best < mutualPairMinSimilarity)
3343
+ return null;
3344
+ if (best.best - best.second < mutualPairMargin)
3345
+ return null;
3346
+ const reverse = bestByNode.get(best.node.id);
3347
+ if (!reverse?.node || reverse.node.id !== node.id)
3348
+ return null;
3349
+ if (reverse.best < mutualPairMinSimilarity || reverse.best - reverse.second < mutualPairMargin)
3350
+ return null;
3351
+ const key = this.getLinkPairKey(node.id, best.node.id);
3352
+ if (mutualPairKeys.has(key))
3353
+ return null;
3354
+ mutualPairKeys.add(key);
3355
+ return {
3356
+ source: node,
3357
+ target: best.node,
3358
+ similarity: Math.min(best.best, reverse.best),
3359
+ };
3360
+ })
3361
+ .filter((pair) => !!pair);
3362
+ const mutualTopPairForce = (alpha) => {
3363
+ const effectiveAlpha = Math.max(alpha, semanticAlphaFloor * 1.15);
3364
+ const attractionStrength = ProfileComparisonLibComponent.MUTUAL_TOP_PAIR_ATTRACTION_STRENGTH;
3365
+ const baseBarrierRadius = ProfileComparisonLibComponent.MUTUAL_TOP_PAIR_BARRIER_RADIUS;
3366
+ const repelStrength = ProfileComparisonLibComponent.MUTUAL_TOP_PAIR_REPEL_STRENGTH;
3367
+ mutualTopPairs.forEach(pair => {
3368
+ const ax = pair.source.x ?? 0;
3369
+ const ay = pair.source.y ?? 0;
3370
+ const bx = pair.target.x ?? 0;
3371
+ const by = pair.target.y ?? 0;
3372
+ const dx = bx - ax;
3373
+ const dy = by - ay;
3374
+ const dist = Math.sqrt(dx * dx + dy * dy) || 1;
3375
+ const norm = Math.max(0, Math.min(1, pair.similarity));
3376
+ const targetDistance = 24 + (1 - norm) * 26;
3377
+ if (dist > targetDistance) {
3378
+ const pull = attractionStrength * effectiveAlpha * ((dist - targetDistance) / dist);
3379
+ pair.source.vx = (pair.source.vx ?? 0) + dx * pull;
3380
+ pair.source.vy = (pair.source.vy ?? 0) + dy * pull;
3381
+ pair.target.vx = (pair.target.vx ?? 0) - dx * pull;
3382
+ pair.target.vy = (pair.target.vy ?? 0) - dy * pull;
3383
+ }
3384
+ const segmentLenSq = dx * dx + dy * dy;
3385
+ if (segmentLenSq <= 1)
3386
+ return;
3387
+ const barrierRadius = baseBarrierRadius + (1 - norm) * 10;
3388
+ nodes.forEach(candidate => {
3389
+ if (candidate.id === pair.source.id || candidate.id === pair.target.id)
3390
+ return;
3391
+ const candidateSimilarity = Math.max(this.getSimilarity(candidate.label, pair.source.label), this.getSimilarity(candidate.label, pair.target.label));
3392
+ if (candidateSimilarity >= pair.similarity * 0.76)
3393
+ return;
3394
+ const px = candidate.x ?? 0;
3395
+ const py = candidate.y ?? 0;
3396
+ const tRaw = ((px - ax) * dx + (py - ay) * dy) / segmentLenSq;
3397
+ const t = Math.max(0, Math.min(1, tRaw));
3398
+ if (t <= 0.08 || t >= 0.92)
3399
+ return;
3400
+ const closestX = ax + dx * t;
3401
+ const closestY = ay + dy * t;
3402
+ const offX = px - closestX;
3403
+ const offY = py - closestY;
3404
+ const offDist = Math.sqrt(offX * offX + offY * offY) || 1;
3405
+ if (offDist >= barrierRadius)
3406
+ return;
3407
+ const proximity = 1 - (offDist / barrierRadius);
3408
+ const mismatch = 1 - Math.max(0, Math.min(1, candidateSimilarity / Math.max(0.001, pair.similarity)));
3409
+ const push = repelStrength * effectiveAlpha * proximity * mismatch;
3410
+ if (push <= 0)
3411
+ return;
3412
+ const unitOffX = offX / offDist;
3413
+ const unitOffY = offY / offDist;
3414
+ candidate.vx = (candidate.vx ?? 0) + unitOffX * push;
3415
+ candidate.vy = (candidate.vy ?? 0) + unitOffY * push;
3416
+ const damp = push * 0.26;
3417
+ pair.source.vx = (pair.source.vx ?? 0) - unitOffX * damp;
3418
+ pair.source.vy = (pair.source.vy ?? 0) - unitOffY * damp;
3419
+ pair.target.vx = (pair.target.vx ?? 0) - unitOffX * damp;
3420
+ pair.target.vy = (pair.target.vy ?? 0) - unitOffY * damp;
3421
+ });
3422
+ });
3423
+ };
3424
+ const runObjectiveOptimizer = () => {
3425
+ if (!nodes.length)
3426
+ return;
3427
+ const attractionFloor = springThreshold;
3428
+ const dissimilarFloor = dissimilarThreshold;
3429
+ const pairRecords = [];
3430
+ for (let i = 0; i < nodes.length; i++) {
3431
+ for (let j = i + 1; j < nodes.length; j++) {
3432
+ const similarity = Math.max(0, this.getSimilarity(nodes[i].label, nodes[j].label));
3433
+ if (similarity <= dissimilarFloor || similarity >= attractionFloor) {
3434
+ pairRecords.push({ a: nodes[i], b: nodes[j], similarity });
3435
+ }
3436
+ }
3437
+ }
3438
+ const iterations = ProfileComparisonLibComponent.OBJECTIVE_OPTIMIZER_ITERATIONS;
3439
+ const stepStart = ProfileComparisonLibComponent.OBJECTIVE_STEP_START;
3440
+ const stepEnd = ProfileComparisonLibComponent.OBJECTIVE_STEP_END;
3441
+ const attractionWeight = ProfileComparisonLibComponent.OBJECTIVE_ATTRACTION_WEIGHT;
3442
+ const repulsionWeight = ProfileComparisonLibComponent.OBJECTIVE_REPULSION_WEIGHT;
3443
+ const anchorWeight = ProfileComparisonLibComponent.OBJECTIVE_ANCHOR_WEIGHT;
3444
+ const boundaryWeight = ProfileComparisonLibComponent.OBJECTIVE_BOUNDARY_WEIGHT;
3445
+ for (let iteration = 0; iteration < iterations; iteration++) {
3446
+ const progress = iteration / Math.max(1, iterations - 1);
3447
+ const step = stepStart + (stepEnd - stepStart) * progress;
3448
+ const gradient = new Map();
3449
+ nodes.forEach(node => gradient.set(node.id, { x: 0, y: 0 }));
3450
+ pairRecords.forEach(record => {
3451
+ const ax = record.a.x ?? record.a.targetX ?? 0;
3452
+ const ay = record.a.y ?? record.a.targetY ?? 0;
3453
+ const bx = record.b.x ?? record.b.targetX ?? 0;
3454
+ const by = record.b.y ?? record.b.targetY ?? 0;
3455
+ const dx = bx - ax;
3456
+ const dy = by - ay;
3457
+ const dist = Math.sqrt(dx * dx + dy * dy) || 1;
3458
+ const unitX = dx / dist;
3459
+ const unitY = dy / dist;
3460
+ if (record.similarity >= attractionFloor) {
3461
+ const normalized = Math.max(0, Math.min(1, (record.similarity - attractionFloor) / Math.max(0.001, 1 - attractionFloor)));
3462
+ const targetDistance = 24 + (1 - normalized) * 66;
3463
+ if (dist > targetDistance) {
3464
+ const pull = attractionWeight * Math.pow(normalized, 1.4) * ((dist - targetDistance) / dist);
3465
+ const gradA = gradient.get(record.a.id);
3466
+ const gradB = gradient.get(record.b.id);
3467
+ if (gradA && gradB) {
3468
+ gradA.x += dx * pull;
3469
+ gradA.y += dy * pull;
3470
+ gradB.x -= dx * pull;
3471
+ gradB.y -= dy * pull;
3472
+ }
3473
+ }
3474
+ return;
3475
+ }
3476
+ if (record.similarity > dissimilarFloor)
3477
+ return;
3478
+ const normalized = (dissimilarFloor - record.similarity) / Math.max(0.001, dissimilarFloor);
3479
+ const maxDistance = 210;
3480
+ if (dist >= maxDistance)
3481
+ return;
3482
+ const push = repulsionWeight * normalized * (1 - (dist / maxDistance));
3483
+ const gradA = gradient.get(record.a.id);
3484
+ const gradB = gradient.get(record.b.id);
3485
+ if (gradA && gradB) {
3486
+ gradA.x -= unitX * push;
3487
+ gradA.y -= unitY * push;
3488
+ gradB.x += unitX * push;
3489
+ gradB.y += unitY * push;
3490
+ }
3491
+ });
3492
+ nodes.forEach(node => {
3493
+ const grad = gradient.get(node.id);
3494
+ if (!grad)
3495
+ return;
3496
+ const currentX = node.x ?? node.targetX ?? zoneShared;
3497
+ const currentY = node.y ?? node.targetY ?? minCenterY;
3498
+ const targetX = node.targetX ?? currentX;
3499
+ const targetY = node.targetY ?? currentY;
3500
+ grad.x += (targetX - currentX) * anchorWeight;
3501
+ grad.y += (targetY - currentY) * anchorWeight;
3502
+ const bounds = nodeBounds.get(node.id) ?? buildNodeBounds(node);
3503
+ if (currentX < bounds.minX)
3504
+ grad.x += (bounds.minX - currentX) * boundaryWeight;
3505
+ if (currentX > bounds.maxX)
3506
+ grad.x -= (currentX - bounds.maxX) * boundaryWeight;
3507
+ if (currentY < bounds.minY)
3508
+ grad.y += (bounds.minY - currentY) * boundaryWeight;
3509
+ if (currentY > bounds.maxY)
3510
+ grad.y -= (currentY - bounds.maxY) * boundaryWeight;
3511
+ });
3512
+ nodes.forEach(node => {
3513
+ const grad = gradient.get(node.id);
3514
+ if (!grad)
3515
+ return;
3516
+ node.x = (node.x ?? node.targetX ?? zoneShared) + grad.x * step;
3517
+ node.y = (node.y ?? node.targetY ?? minCenterY) + grad.y * step;
3518
+ clampNodeToBounds(node);
3519
+ });
3520
+ }
3521
+ };
3522
+ runObjectiveOptimizer();
2978
3523
  this.ngZone.runOutsideAngular(() => {
2979
3524
  const sim = forceSimulation(nodes)
2980
3525
  .alphaDecay(0.014)
@@ -2982,7 +3527,7 @@ class ProfileComparisonLibComponent {
2982
3527
  .force('link', forceLink(links)
2983
3528
  .id(d => d.id)
2984
3529
  .distance(d => d.distance)
2985
- .strength(d => d.kind === 'cross' ? d.strength * 1.45 : d.strength * 0.86))
3530
+ .strength(d => this.getRuntimeLinkStrength(d)))
2986
3531
  .force('collide', forceCollide((d) => {
2987
3532
  const baseRadius = getHalfWidth(d.label) * 0.55 + ProfileComparisonLibComponent.CHIP_COLLISION_BORDER_PADDING;
2988
3533
  return Math.min(58, Math.max(ProfileComparisonLibComponent.CHIP_RADIUS + 8, baseRadius));
@@ -2992,7 +3537,12 @@ class ProfileComparisonLibComponent {
2992
3537
  .force('xBound', forceX(d => d.targetX ?? zoneShared)
2993
3538
  .strength(d => d.group === 'shared' ? 1.15 : 0.85))
2994
3539
  .force('yTargetSeed', forceY(d => d.targetY ?? minCenterY).strength(0.1))
2995
- .force('crossAlign', crossAlignForce);
3540
+ .force('crossAlign', crossAlignForce)
3541
+ .force('relativeMedianColumn', relativeMedianColumnForce)
3542
+ .force('similarityAttraction', similarityAttractionForce)
3543
+ .force('dissimilarRepulsion', dissimilarRepulsionForce)
3544
+ .force('priorityPair', priorityPairForce)
3545
+ .force('mutualTopPair', mutualTopPairForce);
2996
3546
  for (let i = 0; i < 420 && sim.alpha() > 0.0035; i++) {
2997
3547
  sim.tick();
2998
3548
  nodes.forEach(n => {
@@ -3008,8 +3558,9 @@ class ProfileComparisonLibComponent {
3008
3558
  sim.force('yTargetSeed', null);
3009
3559
  sim.force('crossAlign', null);
3010
3560
  sim.force('xTarget', forceX(d => d.originalFx ?? d.x ?? 0)
3011
- .strength(d => d.group === 'shared' ? 0.16 : 0.075));
3012
- sim.force('yTarget', forceY(d => d.originalFy ?? d.y ?? 0).strength(0.085));
3561
+ .strength(d => d.group === 'shared' ? 0.085 : 0.03));
3562
+ sim.force('yTarget', forceY(d => d.originalFy ?? d.y ?? 0).strength(0.03));
3563
+ sim.alphaTarget(ProfileComparisonLibComponent.IDLE_ALPHA_TARGET);
3013
3564
  this.simulation = sim;
3014
3565
  this.ngZone.run(() => {
3015
3566
  this.forceNodes = [...nodes];
@@ -3439,85 +3990,23 @@ class ProfileComparisonLibComponent {
3439
3990
  }
3440
3991
  }
3441
3992
  /**
3442
- * Installs (or removes) a custom d3 force that pulls every linked node toward
3443
- * the anchor, stopping at an orbit radius scaled by similarity:
3444
- * highest-sim → MIN_ORBIT (touches anchor), lowest-sim → MAX_ORBIT.
3445
- *
3446
- * While active, the xTarget/yTarget restoring forces are muted so they don't
3447
- * fight the attraction. They are restored the moment attraction is cleared.
3993
+ * Anchor interaction should only affect highlighting/energy.
3994
+ * Similarity attraction is always-on via the base simulation forces.
3448
3995
  */
3449
3996
  updateAttractionForce(anchorNode) {
3450
3997
  if (!this.simulation)
3451
3998
  return;
3452
- if (!anchorNode) {
3453
- this.simulation.force('attraction', null);
3454
- // Restore restoring forces so nodes spring back home
3455
- const xT = this.simulation.force('xTarget');
3456
- if (xT)
3457
- xT.strength(0.1);
3458
- const yT = this.simulation.force('yTarget');
3459
- if (yT)
3460
- yT.strength(0.1);
3461
- const col = this.simulation.force('collide');
3462
- if (col)
3463
- col.strength(1.0);
3464
- this.simulation.alphaTarget(0);
3465
- this.simulation.alpha(0.6).restart();
3466
- return;
3999
+ this.simulation.force('attraction', null);
4000
+ if (anchorNode) {
4001
+ const minClickAlpha = ProfileComparisonLibComponent.CLICK_REHEAT_ALPHA;
4002
+ if (this.simulation.alpha() < minClickAlpha) {
4003
+ this.simulation.alpha(minClickAlpha);
4004
+ }
4005
+ this.simulation.alphaTarget(ProfileComparisonLibComponent.IDLE_ALPHA_TARGET).restart();
4006
+ }
4007
+ else if (!this.isDragging) {
4008
+ this.simulation.alphaTarget(ProfileComparisonLibComponent.IDLE_ALPHA_TARGET);
3467
4009
  }
3468
- // Collect connected node IDs and their similarity values
3469
- const linkSim = new Map();
3470
- this.forceLinks.forEach(link => {
3471
- const srcId = typeof link.source === 'object' ? link.source.id : link.source;
3472
- const tgtId = typeof link.target === 'object' ? link.target.id : link.target;
3473
- const sim = Number(link.similarity ?? link.strength ?? 0.5);
3474
- if (srcId === anchorNode.id)
3475
- linkSim.set(String(tgtId), sim);
3476
- if (tgtId === anchorNode.id)
3477
- linkSim.set(String(srcId), sim);
3478
- });
3479
- const simValues = Array.from(linkSim.values());
3480
- const maxSim = simValues.length ? Math.max(...simValues) : 1;
3481
- // Closest chip (highest sim) touches the anchor (36px ≈ one chip height).
3482
- // Others are spread tightly between 36–80px.
3483
- const MIN_ORBIT = 36; // px — highest-sim chip: touching
3484
- const MAX_ORBIT = 80; // px — lowest-sim chip: still very close
3485
- const PULL = 0.5; // strong pull — overcomes any residual forces
3486
- // Mute the forces that would fight the pull while attraction is active
3487
- const xT = this.simulation.force('xTarget');
3488
- if (xT)
3489
- xT.strength(0);
3490
- const yT = this.simulation.force('yTarget');
3491
- if (yT)
3492
- yT.strength(0);
3493
- const col = this.simulation.force('collide');
3494
- if (col)
3495
- col.strength(0.35); // keep slight chip separation while still allowing orbiting
3496
- const attractionForce = (alpha) => {
3497
- const ax = anchorNode.x ?? 0;
3498
- const ay = anchorNode.y ?? 0;
3499
- this.forceNodes.forEach(n => {
3500
- if (n.id === anchorNode.id)
3501
- return;
3502
- const sim = linkSim.get(n.id);
3503
- if (sim === undefined)
3504
- return; // not connected — leave untouched
3505
- const normalizedSim = maxSim > 0 ? sim / maxSim : 0;
3506
- const orbitDist = MIN_ORBIT + (MAX_ORBIT - MIN_ORBIT) * (1 - normalizedSim);
3507
- const dx = ax - (n.x ?? 0);
3508
- const dy = ay - (n.y ?? 0);
3509
- const dist = Math.sqrt(dx * dx + dy * dy) || 1;
3510
- if (dist > orbitDist) {
3511
- // Pull toward orbit boundary — proportional to gap remaining
3512
- const factor = PULL * alpha * (dist - orbitDist) / dist;
3513
- n.vx = (n.vx ?? 0) + dx * factor;
3514
- n.vy = (n.vy ?? 0) + dy * factor;
3515
- }
3516
- });
3517
- };
3518
- this.simulation.force('attraction', attractionForce);
3519
- // alphaTarget 0.3 keeps alpha high so attraction stays strong indefinitely
3520
- this.simulation.alphaTarget(0.3).restart();
3521
4010
  }
3522
4011
  isNodeConnected(node) {
3523
4012
  if (!this.activeNode)
@@ -3609,21 +4098,21 @@ class ProfileComparisonLibComponent {
3609
4098
  if (this.simulation) {
3610
4099
  const linkForce = this.simulation.force('link');
3611
4100
  if (linkForce)
3612
- linkForce.strength((d) => d.strength * 0.6);
4101
+ linkForce.strength((d) => this.getRuntimeLinkStrength(d));
3613
4102
  const collideForce = this.simulation.force('collide');
3614
4103
  if (collideForce)
3615
4104
  collideForce.strength(1.0);
3616
4105
  const xTarget = this.simulation.force('xTarget');
3617
4106
  if (xTarget)
3618
- xTarget.strength(0.08);
4107
+ xTarget.strength((d) => d.group === 'shared' ? 0.1 : 0.045);
3619
4108
  const yTarget = this.simulation.force('yTarget');
3620
4109
  if (yTarget)
3621
- yTarget.strength(0.08);
4110
+ yTarget.strength(0.042);
3622
4111
  }
3623
4112
  // Pull connected chips toward the dragged node
3624
4113
  this.updateAttractionForce(node);
3625
4114
  // Wake up the simulation target to keep it highly active during drag
3626
- this.simulation.alphaTarget(0.3).restart();
4115
+ this.simulation.alphaTarget(0.28).restart();
3627
4116
  }
3628
4117
  onDocumentDragMove(event) {
3629
4118
  if (!this.isDragging || !this.draggedNode)
@@ -3666,27 +4155,25 @@ class ProfileComparisonLibComponent {
3666
4155
  // Release the node. Clear fx and fy so it is no longer pinned to the mouse
3667
4156
  this.draggedNode.fx = undefined;
3668
4157
  this.draggedNode.fy = undefined;
3669
- // Remove attraction so connected chips return home
4158
+ // Keep baseline similarity attraction and restoring forces active after drag release.
3670
4159
  this.simulation.force('attraction', null);
3671
- // Set other forces to 0 so all chips glide directly to their starting coordinates
3672
4160
  if (this.simulation) {
3673
4161
  const linkForce = this.simulation.force('link');
3674
4162
  if (linkForce)
3675
- linkForce.strength(0);
4163
+ linkForce.strength((d) => this.getRuntimeLinkStrength(d));
3676
4164
  const collideForce = this.simulation.force('collide');
3677
4165
  if (collideForce)
3678
- collideForce.strength(0.92);
3679
- // Pull nodes smoothly and slowly back to their exact starting points
4166
+ collideForce.strength(1.0);
3680
4167
  const xTarget = this.simulation.force('xTarget');
3681
4168
  if (xTarget)
3682
- xTarget.strength(0.13);
4169
+ xTarget.strength((d) => d.group === 'shared' ? 0.1 : 0.045);
3683
4170
  const yTarget = this.simulation.force('yTarget');
3684
4171
  if (yTarget)
3685
- yTarget.strength(0.13);
4172
+ yTarget.strength(0.042);
3686
4173
  }
3687
4174
  // Cool down simulation targets
3688
- this.simulation.alphaTarget(0);
3689
- this.simulation.alpha(0.6).restart();
4175
+ this.simulation.alphaTarget(ProfileComparisonLibComponent.IDLE_ALPHA_TARGET);
4176
+ this.simulation.alpha(0.2).restart();
3690
4177
  this.draggedNode = null;
3691
4178
  this.isDragging = false;
3692
4179
  }