@naniteninja/profile-comparison-lib 1.0.26 → 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;
@@ -2731,17 +2778,33 @@ class ProfileComparisonLibComponent {
2731
2778
  if (matrixMax <= springThreshold)
2732
2779
  matrixMax = 1.0;
2733
2780
  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;
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);
2739
2803
  };
2740
2804
  const nodes = [];
2741
2805
  const addNodes = (items, group, zoneX) => {
2742
- const total = items.length;
2743
2806
  items.forEach((label, i) => {
2744
- const targetY = distributedYForIndex(i, total);
2807
+ const targetY = columnCoreY;
2745
2808
  nodes.push({
2746
2809
  id: `${group}-${i}-${label}`,
2747
2810
  label,
@@ -2782,8 +2845,10 @@ class ProfileComparisonLibComponent {
2782
2845
  : zoneP2 + (zoneShared - zoneP2) * centerPull;
2783
2846
  }
2784
2847
  });
2785
- // Vertical placement should be driven by cross-list similarity, not by
2786
- // 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.
2787
2852
  const semanticPool = (node) => {
2788
2853
  if (node.group === 'p1')
2789
2854
  return nodes.filter(n => n.group === 'p2' || n.group === 'shared');
@@ -2791,35 +2856,50 @@ class ProfileComparisonLibComponent {
2791
2856
  return nodes.filter(n => n.group === 'p1' || n.group === 'shared');
2792
2857
  return nodes.filter(n => n.group === 'p1' || n.group === 'p2');
2793
2858
  };
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)
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)
2803
2874
  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;
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;
2814
2895
  });
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
2896
  });
2822
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
+ });
2823
2903
  const buildNodeBounds = (node) => {
2824
2904
  let minX = globalMinX;
2825
2905
  let maxX = globalMaxX;
@@ -2999,10 +3079,96 @@ class ProfileComparisonLibComponent {
2999
3079
  };
3000
3080
  })
3001
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
+ });
3002
3126
  const maxLinkSimilarity = Math.max(0.001, ...linkRecords.map(record => Math.max(0, record.similarity)));
3003
3127
  const MIN_PAIR_DISTANCE = 34;
3004
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
+ };
3005
3170
  const similarityAttractionForce = (alpha) => {
3171
+ const effectiveAlpha = Math.max(alpha, semanticAlphaFloor);
3006
3172
  linkRecords.forEach(record => {
3007
3173
  const dx = (record.target.x ?? 0) - (record.source.x ?? 0);
3008
3174
  const dy = (record.target.y ?? 0) - (record.source.y ?? 0);
@@ -3012,14 +3178,348 @@ class ProfileComparisonLibComponent {
3012
3178
  if (dist <= targetDistance)
3013
3179
  return;
3014
3180
  const pullGap = dist - targetDistance;
3015
- const pullBase = record.kind === 'cross' ? 0.9 : 0.82;
3016
- const pullFactor = pullBase * alpha * (pullGap / dist);
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);
3017
3186
  record.source.vx = (record.source.vx ?? 0) + dx * pullFactor;
3018
3187
  record.source.vy = (record.source.vy ?? 0) + dy * pullFactor;
3019
3188
  record.target.vx = (record.target.vx ?? 0) - dx * pullFactor;
3020
3189
  record.target.vy = (record.target.vy ?? 0) - dy * pullFactor;
3021
3190
  });
3022
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();
3023
3523
  this.ngZone.runOutsideAngular(() => {
3024
3524
  const sim = forceSimulation(nodes)
3025
3525
  .alphaDecay(0.014)
@@ -3038,7 +3538,11 @@ class ProfileComparisonLibComponent {
3038
3538
  .strength(d => d.group === 'shared' ? 1.15 : 0.85))
3039
3539
  .force('yTargetSeed', forceY(d => d.targetY ?? minCenterY).strength(0.1))
3040
3540
  .force('crossAlign', crossAlignForce)
3041
- .force('similarityAttraction', similarityAttractionForce);
3541
+ .force('relativeMedianColumn', relativeMedianColumnForce)
3542
+ .force('similarityAttraction', similarityAttractionForce)
3543
+ .force('dissimilarRepulsion', dissimilarRepulsionForce)
3544
+ .force('priorityPair', priorityPairForce)
3545
+ .force('mutualTopPair', mutualTopPairForce);
3042
3546
  for (let i = 0; i < 420 && sim.alpha() > 0.0035; i++) {
3043
3547
  sim.tick();
3044
3548
  nodes.forEach(n => {
@@ -3054,8 +3558,9 @@ class ProfileComparisonLibComponent {
3054
3558
  sim.force('yTargetSeed', null);
3055
3559
  sim.force('crossAlign', null);
3056
3560
  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));
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);
3059
3564
  this.simulation = sim;
3060
3565
  this.ngZone.run(() => {
3061
3566
  this.forceNodes = [...nodes];
@@ -3493,10 +3998,14 @@ class ProfileComparisonLibComponent {
3493
3998
  return;
3494
3999
  this.simulation.force('attraction', null);
3495
4000
  if (anchorNode) {
3496
- this.simulation.alpha(0.24).restart();
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();
3497
4006
  }
3498
4007
  else if (!this.isDragging) {
3499
- this.simulation.alphaTarget(0);
4008
+ this.simulation.alphaTarget(ProfileComparisonLibComponent.IDLE_ALPHA_TARGET);
3500
4009
  }
3501
4010
  }
3502
4011
  isNodeConnected(node) {
@@ -3595,10 +4104,10 @@ class ProfileComparisonLibComponent {
3595
4104
  collideForce.strength(1.0);
3596
4105
  const xTarget = this.simulation.force('xTarget');
3597
4106
  if (xTarget)
3598
- xTarget.strength((d) => d.group === 'shared' ? 0.12 : 0.05);
4107
+ xTarget.strength((d) => d.group === 'shared' ? 0.1 : 0.045);
3599
4108
  const yTarget = this.simulation.force('yTarget');
3600
4109
  if (yTarget)
3601
- yTarget.strength(0.05);
4110
+ yTarget.strength(0.042);
3602
4111
  }
3603
4112
  // Pull connected chips toward the dragged node
3604
4113
  this.updateAttractionForce(node);
@@ -3657,14 +4166,14 @@ class ProfileComparisonLibComponent {
3657
4166
  collideForce.strength(1.0);
3658
4167
  const xTarget = this.simulation.force('xTarget');
3659
4168
  if (xTarget)
3660
- xTarget.strength((d) => d.group === 'shared' ? 0.12 : 0.045);
4169
+ xTarget.strength((d) => d.group === 'shared' ? 0.1 : 0.045);
3661
4170
  const yTarget = this.simulation.force('yTarget');
3662
4171
  if (yTarget)
3663
- yTarget.strength(0.045);
4172
+ yTarget.strength(0.042);
3664
4173
  }
3665
4174
  // Cool down simulation targets
3666
- this.simulation.alphaTarget(0);
3667
- this.simulation.alpha(0.4).restart();
4175
+ this.simulation.alphaTarget(ProfileComparisonLibComponent.IDLE_ALPHA_TARGET);
4176
+ this.simulation.alpha(0.2).restart();
3668
4177
  this.draggedNode = null;
3669
4178
  this.isDragging = false;
3670
4179
  }