@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]:
|
|
22
|
-
[BackendMode.Mock]:
|
|
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,
|
|
2691
|
-
let sharedBoundsMaxX = Math.min(globalMaxX,
|
|
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 <
|
|
2755
|
+
if (sharedBoundsMaxX - sharedBoundsMinX < sharedMinWidth) {
|
|
2699
2756
|
const centerX = Math.max(globalMinX, Math.min(globalMaxX, zoneShared));
|
|
2700
|
-
const fallbackHalfWidth =
|
|
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
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
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 =
|
|
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
|
-
//
|
|
2776
|
-
//
|
|
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
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
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
|
-
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
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 =
|
|
2842
|
-
const SPAN =
|
|
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.
|
|
2966
|
+
strength: 0.12 + normSim * 0.24,
|
|
2877
2967
|
similarity,
|
|
2878
|
-
distance:
|
|
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 =>
|
|
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.
|
|
3012
|
-
sim.force('yTarget', forceY(d => d.originalFy ?? d.y ?? 0).strength(0.
|
|
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
|
-
*
|
|
3443
|
-
*
|
|
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
|
-
|
|
3453
|
-
|
|
3454
|
-
|
|
3455
|
-
|
|
3456
|
-
|
|
3457
|
-
|
|
3458
|
-
|
|
3459
|
-
|
|
3460
|
-
|
|
3461
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
//
|
|
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(
|
|
4163
|
+
linkForce.strength((d) => this.getRuntimeLinkStrength(d));
|
|
3676
4164
|
const collideForce = this.simulation.force('collide');
|
|
3677
4165
|
if (collideForce)
|
|
3678
|
-
collideForce.strength(0
|
|
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.
|
|
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.
|
|
4172
|
+
yTarget.strength(0.042);
|
|
3686
4173
|
}
|
|
3687
4174
|
// Cool down simulation targets
|
|
3688
|
-
this.simulation.alphaTarget(
|
|
3689
|
-
this.simulation.alpha(0.
|
|
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
|
}
|