diff --git a/src/localization/en.json b/src/localization/en.json index 5da6de3e..ea1eb8a0 100644 --- a/src/localization/en.json +++ b/src/localization/en.json @@ -433,7 +433,10 @@ "layout_spacing": "Layout spacing", "layout_spacing_help": "How spread out the graph is. Higher means less crowded.", "edge_curvature": "Edge curvature", - "edge_curvature_help": "How curved the lines are. Higher means smoother lines in dense areas." + "edge_curvature_help": "How curved the lines are. Higher means smoother lines in dense areas.", + "community_separation": "Community separation", + "community_separation_help": "How far apart different communities are pushed. Higher means more distinct clusters.", + "reset_defaults": "Reset to defaults" } } }, diff --git a/src/views/Charts/components/MutualFriends.vue b/src/views/Charts/components/MutualFriends.vue index c8df2c92..5d2090c0 100644 --- a/src/views/Charts/components/MutualFriends.vue +++ b/src/views/Charts/components/MutualFriends.vue @@ -133,7 +133,35 @@

+ + + {{ + t('view.charts.mutual_friend.settings.community_separation') + }} + +
+ + + {{ communitySeparationLabel }} + +
+

+ {{ t('view.charts.mutual_friend.settings.community_separation_help') }} +

+
+
+ +
+ +
@@ -204,6 +232,8 @@ import { database } from '../../../service/database'; import { watchState } from '../../../service/watchState'; + import configRepository from '../../../service/config'; + const { t } = useI18n(); const friendStore = useFriendStore(); const userStore = useUserStore(); @@ -255,12 +285,17 @@ const LAYOUT_SPACING_MAX = 240; const EDGE_CURVATURE_MIN = 0; const EDGE_CURVATURE_MAX = 0.2; + const COMMUNITY_SEPARATION_MIN = 0; + const COMMUNITY_SEPARATION_MAX = 3; - const layoutSettings = reactive({ + const LAYOUT_DEFAULTS = { layoutIterations: 800, layoutSpacing: 60, - edgeCurvature: 0.1 - }); + edgeCurvature: 0.1, + communitySeparation: 0 + }; + + const layoutSettings = reactive({ ...LAYOUT_DEFAULTS }); const layoutIterationsModel = computed({ get: () => [layoutSettings.layoutIterations], @@ -298,6 +333,19 @@ const edgeCurvatureLabel = computed(() => layoutSettings.edgeCurvature.toFixed(2)); + const communitySeparationModel = computed({ + get: () => [layoutSettings.communitySeparation], + set: (value) => { + const next = clampNumber( + value?.[0] ?? layoutSettings.communitySeparation, + COMMUNITY_SEPARATION_MIN, + COMMUNITY_SEPARATION_MAX + ); + layoutSettings.communitySeparation = Number(next.toFixed(1)); + } + }); + const communitySeparationLabel = computed(() => layoutSettings.communitySeparation.toFixed(1)); + let lastLayoutSpacing = layoutSettings.layoutSpacing; watch(isDarkMode, () => { @@ -307,14 +355,58 @@ watch( () => [layoutSettings.layoutIterations, layoutSettings.layoutSpacing], - () => scheduleLayoutUpdate({ runLayout: true }) + () => { + scheduleLayoutUpdate({ runLayout: true }); + persistLayoutSettings(); + } ); watch( () => layoutSettings.edgeCurvature, - () => scheduleLayoutUpdate({ runLayout: false }) + () => { + scheduleLayoutUpdate({ runLayout: false }); + persistLayoutSettings(); + } ); + watch( + () => layoutSettings.communitySeparation, + () => { + scheduleLayoutUpdate({ runLayout: true }); + persistLayoutSettings(); + } + ); + + async function loadLayoutSettings() { + const [iterations, spacing, curvature, separation] = await Promise.all([ + configRepository.getInt('VRCX_MutualGraphLayoutIterations', LAYOUT_DEFAULTS.layoutIterations), + configRepository.getInt('VRCX_MutualGraphLayoutSpacing', LAYOUT_DEFAULTS.layoutSpacing), + configRepository.getFloat('VRCX_MutualGraphEdgeCurvature', LAYOUT_DEFAULTS.edgeCurvature), + configRepository.getFloat('VRCX_MutualGraphCommunitySeparation', LAYOUT_DEFAULTS.communitySeparation) + ]); + layoutSettings.layoutIterations = clampNumber(iterations, LAYOUT_ITERATIONS_MIN, LAYOUT_ITERATIONS_MAX); + layoutSettings.layoutSpacing = clampNumber(spacing, LAYOUT_SPACING_MIN, LAYOUT_SPACING_MAX); + layoutSettings.edgeCurvature = clampNumber(curvature, EDGE_CURVATURE_MIN, EDGE_CURVATURE_MAX); + layoutSettings.communitySeparation = clampNumber( + separation, + COMMUNITY_SEPARATION_MIN, + COMMUNITY_SEPARATION_MAX + ); + lastLayoutSpacing = layoutSettings.layoutSpacing; + } + + function persistLayoutSettings() { + configRepository.setInt('VRCX_MutualGraphLayoutIterations', layoutSettings.layoutIterations); + configRepository.setInt('VRCX_MutualGraphLayoutSpacing', layoutSettings.layoutSpacing); + configRepository.setFloat('VRCX_MutualGraphEdgeCurvature', layoutSettings.edgeCurvature); + configRepository.setFloat('VRCX_MutualGraphCommunitySeparation', layoutSettings.communitySeparation); + } + + function resetLayoutSettings() { + Object.assign(layoutSettings, LAYOUT_DEFAULTS); + persistLayoutSettings(); + } + const isFetching = computed({ get: () => status.isFetching, set: (val) => { @@ -390,6 +482,7 @@ } onMounted(() => { + loadLayoutSettings(); nextTick(() => { if (!graphContainerRef.value) return; @@ -473,8 +566,7 @@ function runLayout(graph, { reinitialize } = {}) { if (reinitialize) initPositions(graph); - let iterations = clampNumber(layoutSettings.layoutIterations, LAYOUT_ITERATIONS_MIN, LAYOUT_ITERATIONS_MAX); - iterations = Math.min(iterations, Math.round(Math.sqrt(graph.order) * 20)); + const iterations = clampNumber(layoutSettings.layoutIterations, LAYOUT_ITERATIONS_MIN, LAYOUT_ITERATIONS_MAX); const spacing = clampNumber(layoutSettings.layoutSpacing, LAYOUT_SPACING_MIN, LAYOUT_SPACING_MAX); const t = (spacing - LAYOUT_SPACING_MIN) / (LAYOUT_SPACING_MAX - LAYOUT_SPACING_MIN); const clampedT = clampNumber(t, 0, 1); @@ -514,13 +606,68 @@ }); } + function applyCommunitySeparation(graph) { + const separation = layoutSettings.communitySeparation; + if (separation <= 0) return; + + const communities = new Map(); + graph.forEachNode((node, attrs) => { + const cid = attrs.community; + if (cid === undefined) return; + if (!communities.has(cid)) communities.set(cid, { nodes: [], cx: 0, cy: 0 }); + communities.get(cid).nodes.push({ node, x: attrs.x, y: attrs.y }); + }); + + // compute per-community centroid + for (const [, data] of communities) { + let sx = 0, + sy = 0; + for (const n of data.nodes) { + sx += n.x; + sy += n.y; + } + data.cx = sx / data.nodes.length; + data.cy = sy / data.nodes.length; + } + + // compute global centroid + let gcx = 0, + gcy = 0, + total = 0; + for (const [, data] of communities) { + gcx += data.cx * data.nodes.length; + gcy += data.cy * data.nodes.length; + total += data.nodes.length; + } + gcx /= total; + gcy /= total; + + // push each community away from global centroid + for (const [, data] of communities) { + const dx = data.cx - gcx; + const dy = data.cy - gcy; + const dist = Math.sqrt(dx * dx + dy * dy) || 1; + const pushX = (dx / dist) * separation * 100; + const pushY = (dy / dist) * separation * 100; + for (const n of data.nodes) { + graph.mergeNodeAttributes(n.node, { + x: n.x + pushX, + y: n.y + pushY + }); + } + } + } + function scheduleLayoutUpdate({ runLayout: shouldRunLayout }) { if (!currentGraph) return; if (pendingLayoutUpdate) clearTimeout(pendingLayoutUpdate); pendingLayoutUpdate = setTimeout(() => { pendingLayoutUpdate = null; applyEdgeCurvature(currentGraph); - if (shouldRunLayout) runLayout(currentGraph, { reinitialize: false }); + if (shouldRunLayout) { + runLayout(currentGraph, { reinitialize: false }); + applyCommunitySeparation(currentGraph); + } renderGraph(currentGraph); }, 100); } @@ -595,6 +742,7 @@ if (graph.order > 1) { runLayout(graph, { reinitialize: true }); assignCommunitiesAndColors(graph); + applyCommunitySeparation(graph); applyEdgeCurvature(graph); }