@naniteninja/profile-comparison-lib 1.0.25 → 1.0.26

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.
@@ -2428,6 +2428,11 @@ class ProfileComparisonLibComponent {
2428
2428
  const idB = this.getLinkNodeId(b);
2429
2429
  return idA < idB ? `${idA}|${idB}` : `${idB}|${idA}`;
2430
2430
  }
2431
+ getRuntimeLinkStrength(link) {
2432
+ return link.kind === 'cross'
2433
+ ? link.strength * 1.7
2434
+ : link.strength * 1.12;
2435
+ }
2431
2436
  selectLinksWithCaps(candidates, maxPerNode, minPerNode = 0) {
2432
2437
  if (!candidates.length || maxPerNode <= 0)
2433
2438
  return [];
@@ -2685,19 +2690,24 @@ class ProfileComparisonLibComponent {
2685
2690
  const dividerLeft = (zoneP1 + zoneShared) / 2;
2686
2691
  const dividerRight = (zoneShared + zoneP2) / 2;
2687
2692
  const laneGap = Math.max(4, Math.min(16, centerGuard * 0.45));
2693
+ const sharedRawMin = Math.max(globalMinX, dividerLeft + laneGap);
2694
+ const sharedRawMax = Math.min(globalMaxX, dividerRight - laneGap);
2695
+ const sharedRawWidth = Math.max(0, sharedRawMax - sharedRawMin);
2696
+ const sharedTighten = Math.max(12, Math.min(34, sharedRawWidth * 0.22));
2697
+ const sharedMinWidth = Math.max(44, Math.min(84, maxHalfShared * 1.05));
2688
2698
  let p1BoundsMinX = globalMinX;
2689
2699
  let p1BoundsMaxX = Math.min(globalMaxX, dividerLeft - laneGap);
2690
- let sharedBoundsMinX = Math.max(globalMinX, dividerLeft + laneGap);
2691
- let sharedBoundsMaxX = Math.min(globalMaxX, dividerRight - laneGap);
2700
+ let sharedBoundsMinX = Math.max(globalMinX, sharedRawMin + sharedTighten);
2701
+ let sharedBoundsMaxX = Math.min(globalMaxX, sharedRawMax - sharedTighten);
2692
2702
  let p2BoundsMinX = Math.max(globalMinX, dividerRight + laneGap);
2693
2703
  let p2BoundsMaxX = globalMaxX;
2694
2704
  if (p1BoundsMaxX < p1BoundsMinX)
2695
2705
  p1BoundsMaxX = p1BoundsMinX;
2696
2706
  if (p2BoundsMinX > p2BoundsMaxX)
2697
2707
  p2BoundsMinX = p2BoundsMaxX;
2698
- if (sharedBoundsMaxX < sharedBoundsMinX) {
2708
+ if (sharedBoundsMaxX - sharedBoundsMinX < sharedMinWidth) {
2699
2709
  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));
2710
+ const fallbackHalfWidth = sharedMinWidth / 2;
2701
2711
  sharedBoundsMinX = Math.max(globalMinX, centerX - fallbackHalfWidth);
2702
2712
  sharedBoundsMaxX = Math.min(globalMaxX, centerX + fallbackHalfWidth);
2703
2713
  if (sharedBoundsMaxX < sharedBoundsMinX) {
@@ -2838,8 +2848,8 @@ class ProfileComparisonLibComponent {
2838
2848
  };
2839
2849
  const crossCandidates = [];
2840
2850
  const intraCandidates = [];
2841
- const MIN_DIST = 62;
2842
- const SPAN = 92;
2851
+ const MIN_DIST = 34;
2852
+ const SPAN = 72;
2843
2853
  for (let i = 0; i < nodes.length; i++) {
2844
2854
  for (let j = i + 1; j < nodes.length; j++) {
2845
2855
  if (!this.canLinkNodes(nodes[i], nodes[j]))
@@ -2873,9 +2883,9 @@ class ProfileComparisonLibComponent {
2873
2883
  kind: 'intra',
2874
2884
  source: groupNodes[i],
2875
2885
  target: groupNodes[j],
2876
- strength: 0.08 + normSim * 0.2,
2886
+ strength: 0.12 + normSim * 0.24,
2877
2887
  similarity,
2878
- distance: 50 + (1 - normSim) * 42,
2888
+ distance: 34 + (1 - normSim) * 46,
2879
2889
  });
2880
2890
  }
2881
2891
  }
@@ -2975,6 +2985,41 @@ class ProfileComparisonLibComponent {
2975
2985
  node.vy = (node.vy ?? 0) + dy * alignStrength * alpha;
2976
2986
  });
2977
2987
  };
2988
+ const linkRecords = links
2989
+ .map(link => {
2990
+ const source = typeof link.source === 'object' ? link.source : nodeById.get(String(link.source));
2991
+ const target = typeof link.target === 'object' ? link.target : nodeById.get(String(link.target));
2992
+ if (!source || !target)
2993
+ return null;
2994
+ return {
2995
+ source,
2996
+ target,
2997
+ similarity: Number(link.similarity ?? link.strength ?? 0.5),
2998
+ kind: link.kind,
2999
+ };
3000
+ })
3001
+ .filter((record) => !!record);
3002
+ const maxLinkSimilarity = Math.max(0.001, ...linkRecords.map(record => Math.max(0, record.similarity)));
3003
+ const MIN_PAIR_DISTANCE = 34;
3004
+ const MAX_PAIR_DISTANCE = 88;
3005
+ const similarityAttractionForce = (alpha) => {
3006
+ linkRecords.forEach(record => {
3007
+ const dx = (record.target.x ?? 0) - (record.source.x ?? 0);
3008
+ const dy = (record.target.y ?? 0) - (record.source.y ?? 0);
3009
+ const dist = Math.sqrt(dx * dx + dy * dy) || 1;
3010
+ const normalizedSimilarity = Math.max(0, Math.min(1, record.similarity / maxLinkSimilarity));
3011
+ const targetDistance = MIN_PAIR_DISTANCE + (MAX_PAIR_DISTANCE - MIN_PAIR_DISTANCE) * (1 - normalizedSimilarity);
3012
+ if (dist <= targetDistance)
3013
+ return;
3014
+ const pullGap = dist - targetDistance;
3015
+ const pullBase = record.kind === 'cross' ? 0.9 : 0.82;
3016
+ const pullFactor = pullBase * alpha * (pullGap / dist);
3017
+ record.source.vx = (record.source.vx ?? 0) + dx * pullFactor;
3018
+ record.source.vy = (record.source.vy ?? 0) + dy * pullFactor;
3019
+ record.target.vx = (record.target.vx ?? 0) - dx * pullFactor;
3020
+ record.target.vy = (record.target.vy ?? 0) - dy * pullFactor;
3021
+ });
3022
+ };
2978
3023
  this.ngZone.runOutsideAngular(() => {
2979
3024
  const sim = forceSimulation(nodes)
2980
3025
  .alphaDecay(0.014)
@@ -2982,7 +3027,7 @@ class ProfileComparisonLibComponent {
2982
3027
  .force('link', forceLink(links)
2983
3028
  .id(d => d.id)
2984
3029
  .distance(d => d.distance)
2985
- .strength(d => d.kind === 'cross' ? d.strength * 1.45 : d.strength * 0.86))
3030
+ .strength(d => this.getRuntimeLinkStrength(d)))
2986
3031
  .force('collide', forceCollide((d) => {
2987
3032
  const baseRadius = getHalfWidth(d.label) * 0.55 + ProfileComparisonLibComponent.CHIP_COLLISION_BORDER_PADDING;
2988
3033
  return Math.min(58, Math.max(ProfileComparisonLibComponent.CHIP_RADIUS + 8, baseRadius));
@@ -2992,7 +3037,8 @@ class ProfileComparisonLibComponent {
2992
3037
  .force('xBound', forceX(d => d.targetX ?? zoneShared)
2993
3038
  .strength(d => d.group === 'shared' ? 1.15 : 0.85))
2994
3039
  .force('yTargetSeed', forceY(d => d.targetY ?? minCenterY).strength(0.1))
2995
- .force('crossAlign', crossAlignForce);
3040
+ .force('crossAlign', crossAlignForce)
3041
+ .force('similarityAttraction', similarityAttractionForce);
2996
3042
  for (let i = 0; i < 420 && sim.alpha() > 0.0035; i++) {
2997
3043
  sim.tick();
2998
3044
  nodes.forEach(n => {
@@ -3008,8 +3054,8 @@ class ProfileComparisonLibComponent {
3008
3054
  sim.force('yTargetSeed', null);
3009
3055
  sim.force('crossAlign', null);
3010
3056
  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));
3057
+ .strength(d => d.group === 'shared' ? 0.12 : 0.045));
3058
+ sim.force('yTarget', forceY(d => d.originalFy ?? d.y ?? 0).strength(0.045));
3013
3059
  this.simulation = sim;
3014
3060
  this.ngZone.run(() => {
3015
3061
  this.forceNodes = [...nodes];
@@ -3439,85 +3485,19 @@ class ProfileComparisonLibComponent {
3439
3485
  }
3440
3486
  }
3441
3487
  /**
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.
3488
+ * Anchor interaction should only affect highlighting/energy.
3489
+ * Similarity attraction is always-on via the base simulation forces.
3448
3490
  */
3449
3491
  updateAttractionForce(anchorNode) {
3450
3492
  if (!this.simulation)
3451
3493
  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);
3494
+ this.simulation.force('attraction', null);
3495
+ if (anchorNode) {
3496
+ this.simulation.alpha(0.24).restart();
3497
+ }
3498
+ else if (!this.isDragging) {
3464
3499
  this.simulation.alphaTarget(0);
3465
- this.simulation.alpha(0.6).restart();
3466
- return;
3467
3500
  }
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
3501
  }
3522
3502
  isNodeConnected(node) {
3523
3503
  if (!this.activeNode)
@@ -3609,21 +3589,21 @@ class ProfileComparisonLibComponent {
3609
3589
  if (this.simulation) {
3610
3590
  const linkForce = this.simulation.force('link');
3611
3591
  if (linkForce)
3612
- linkForce.strength((d) => d.strength * 0.6);
3592
+ linkForce.strength((d) => this.getRuntimeLinkStrength(d));
3613
3593
  const collideForce = this.simulation.force('collide');
3614
3594
  if (collideForce)
3615
3595
  collideForce.strength(1.0);
3616
3596
  const xTarget = this.simulation.force('xTarget');
3617
3597
  if (xTarget)
3618
- xTarget.strength(0.08);
3598
+ xTarget.strength((d) => d.group === 'shared' ? 0.12 : 0.05);
3619
3599
  const yTarget = this.simulation.force('yTarget');
3620
3600
  if (yTarget)
3621
- yTarget.strength(0.08);
3601
+ yTarget.strength(0.05);
3622
3602
  }
3623
3603
  // Pull connected chips toward the dragged node
3624
3604
  this.updateAttractionForce(node);
3625
3605
  // Wake up the simulation target to keep it highly active during drag
3626
- this.simulation.alphaTarget(0.3).restart();
3606
+ this.simulation.alphaTarget(0.28).restart();
3627
3607
  }
3628
3608
  onDocumentDragMove(event) {
3629
3609
  if (!this.isDragging || !this.draggedNode)
@@ -3666,27 +3646,25 @@ class ProfileComparisonLibComponent {
3666
3646
  // Release the node. Clear fx and fy so it is no longer pinned to the mouse
3667
3647
  this.draggedNode.fx = undefined;
3668
3648
  this.draggedNode.fy = undefined;
3669
- // Remove attraction so connected chips return home
3649
+ // Keep baseline similarity attraction and restoring forces active after drag release.
3670
3650
  this.simulation.force('attraction', null);
3671
- // Set other forces to 0 so all chips glide directly to their starting coordinates
3672
3651
  if (this.simulation) {
3673
3652
  const linkForce = this.simulation.force('link');
3674
3653
  if (linkForce)
3675
- linkForce.strength(0);
3654
+ linkForce.strength((d) => this.getRuntimeLinkStrength(d));
3676
3655
  const collideForce = this.simulation.force('collide');
3677
3656
  if (collideForce)
3678
- collideForce.strength(0.92);
3679
- // Pull nodes smoothly and slowly back to their exact starting points
3657
+ collideForce.strength(1.0);
3680
3658
  const xTarget = this.simulation.force('xTarget');
3681
3659
  if (xTarget)
3682
- xTarget.strength(0.13);
3660
+ xTarget.strength((d) => d.group === 'shared' ? 0.12 : 0.045);
3683
3661
  const yTarget = this.simulation.force('yTarget');
3684
3662
  if (yTarget)
3685
- yTarget.strength(0.13);
3663
+ yTarget.strength(0.045);
3686
3664
  }
3687
3665
  // Cool down simulation targets
3688
3666
  this.simulation.alphaTarget(0);
3689
- this.simulation.alpha(0.6).restart();
3667
+ this.simulation.alpha(0.4).restart();
3690
3668
  this.draggedNode = null;
3691
3669
  this.isDragging = false;
3692
3670
  }