diff --git a/src/localization/en.json b/src/localization/en.json index b70d2ef4..0b5d401b 100644 --- a/src/localization/en.json +++ b/src/localization/en.json @@ -358,6 +358,22 @@ "tooltip": { "mutual_friends_count": "Mutual friends: {count}", "edge": "{source} ↔ {target}" + }, + "force_dialog": { + "open_label": "Adjust graph layout settings", + "title": "Graph Layout Settings", + "description": "Fine-tune the force-directed layout used to draw the mutual friend graph.", + "repulsion": "Repulsion", + "edge_length_min": "Edge length (min)", + "edge_length_max": "Edge length (max)", + "gravity": "Gravity", + "apply": "Apply", + "reset": "Reset", + "repulsion_help": "Repulsion between nodes.", + "edge_length_min_help": "Minimum distance between connected nodes.", + "edge_length_max_help": "Maximum distance between connected nodes.", + "gravity_help": "Pull strength toward the graph center.", + "invalid_input": "Please enter non-negative numbers." } } }, diff --git a/src/views/Charts/components/MutualFriends.vue b/src/views/Charts/components/MutualFriends.vue index 6110238e..8598ea67 100644 --- a/src/views/Charts/components/MutualFriends.vue +++ b/src/views/Charts/components/MutualFriends.vue @@ -2,6 +2,9 @@
+ + + {{ fetchButtonLabel }} @@ -32,20 +35,85 @@
{{ t('view.charts.mutual_friend.progress.no_relationships_discovered') }}
+ + +

+ {{ t('view.charts.mutual_friend.force_dialog.description') }} +

+ + + +
+ {{ t('view.charts.mutual_friend.force_dialog.repulsion_help') }} +
+
+ + +
+ {{ t('view.charts.mutual_friend.force_dialog.edge_length_min_help') }} +
+
+ + +
+ {{ t('view.charts.mutual_friend.force_dialog.edge_length_max_help') }} +
+
+ + +
+ {{ t('view.charts.mutual_friend.force_dialog.gravity_help') }} +
+
+
+ + +
diff --git a/src/views/Charts/composables/useMutualGraphChart.js b/src/views/Charts/composables/useMutualGraphChart.js index f5c2b0de..96dd7c90 100644 --- a/src/views/Charts/composables/useMutualGraphChart.js +++ b/src/views/Charts/composables/useMutualGraphChart.js @@ -27,7 +27,7 @@ function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); } -function computeForceOptions(nodes, links) { +export function computeForceOptions(nodes, links) { const nodeCount = nodes.length || 1; const degreeSum = nodes.reduce((sum, node) => sum + (node.degree || 0), 0); const maxSymbol = nodes.reduce( @@ -55,6 +55,38 @@ function computeForceOptions(nodes, links) { }; } +function applyForceOverrides(force, forceOverrides) { + if (!forceOverrides) { + return force; + } + const merged = { ...force }; + if (typeof forceOverrides.repulsion === 'number') { + merged.repulsion = Math.max(0, forceOverrides.repulsion); + } + if (Array.isArray(forceOverrides.edgeLength)) { + const [ + minRaw = merged.edgeLength?.[0], + maxRaw = merged.edgeLength?.[1] + ] = forceOverrides.edgeLength; + const min = + typeof minRaw === 'number' ? minRaw : merged.edgeLength?.[0]; + const max = + typeof maxRaw === 'number' ? maxRaw : merged.edgeLength?.[1]; + const hasBoth = typeof min === 'number' && typeof max === 'number'; + if (hasBoth) { + const normalizedMin = Math.max(0, min); + merged.edgeLength = [normalizedMin, Math.max(normalizedMin, max)]; + } + } + if (typeof forceOverrides.gravity === 'number') { + merged.gravity = clamp(forceOverrides.gravity, 0, 1); + } + if (typeof forceOverrides.layoutAnimation === 'boolean') { + merged.layoutAnimation = forceOverrides.layoutAnimation; + } + return merged; +} + const t = i18n.global.t; export function useMutualGraphChart({ cachedUsers, graphPayload }) { @@ -149,10 +181,13 @@ export function useMutualGraphChart({ cachedUsers, graphPayload }) { updateChart?.(graphPayload.value); } - function createChartOption(payload) { + function createChartOption(payload, forceOverrides) { const nodes = payload?.nodes ?? []; const links = payload?.links ?? []; - const force = computeForceOptions(nodes, links); + const force = applyForceOverrides( + computeForceOptions(nodes, links), + forceOverrides + ); const labelMap = Object.create(null); nodes.forEach((node) => { if (node?.id) {