mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-04-19 06:43:51 +02:00
feat: add mutual friends graph layout settings (#1532)
This commit is contained in:
@@ -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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
<div class="mutual-graph">
|
||||
<div class="options-container mutual-graph__toolbar">
|
||||
<div class="mutual-graph__actions">
|
||||
<el-tooltip :content="t('view.charts.mutual_friend.force_dialog.open_label')" placement="top">
|
||||
<el-button circle :icon="Setting" :disabled="!graphReady" @click="openForceDialog"></el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip :content="fetchButtonLabel" placement="top">
|
||||
<el-button type="primary" :disabled="fetchButtonDisabled" :loading="isFetching" @click="startFetch">
|
||||
{{ fetchButtonLabel }}
|
||||
@@ -32,20 +35,85 @@
|
||||
<div v-if="hasFetched && !isFetching && !graphReady" class="mutual-graph__placeholder">
|
||||
<span>{{ t('view.charts.mutual_friend.progress.no_relationships_discovered') }}</span>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="isForceDialogVisible"
|
||||
:title="t('view.charts.mutual_friend.force_dialog.title')"
|
||||
width="440px">
|
||||
<p class="mutual-graph__force-description">
|
||||
{{ t('view.charts.mutual_friend.force_dialog.description') }}
|
||||
</p>
|
||||
<el-form label-position="top" size="small" class="mutual-graph__force-form">
|
||||
<el-form-item :label="t('view.charts.mutual_friend.force_dialog.repulsion')">
|
||||
<el-input-number
|
||||
v-model="forceForm.repulsion"
|
||||
:precision="0"
|
||||
:controls="false"
|
||||
class="mutual-graph__number-input" />
|
||||
<div class="mutual-graph__helper">
|
||||
{{ t('view.charts.mutual_friend.force_dialog.repulsion_help') }}
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('view.charts.mutual_friend.force_dialog.edge_length_min')">
|
||||
<el-input-number
|
||||
v-model="forceForm.edgeLengthMin"
|
||||
:precision="0"
|
||||
:controls="false"
|
||||
class="mutual-graph__number-input" />
|
||||
<div class="mutual-graph__helper">
|
||||
{{ t('view.charts.mutual_friend.force_dialog.edge_length_min_help') }}
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('view.charts.mutual_friend.force_dialog.edge_length_max')">
|
||||
<el-input-number
|
||||
v-model="forceForm.edgeLengthMax"
|
||||
:precision="0"
|
||||
:controls="false"
|
||||
class="mutual-graph__number-input" />
|
||||
<div class="mutual-graph__helper">
|
||||
{{ t('view.charts.mutual_friend.force_dialog.edge_length_max_help') }}
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('view.charts.mutual_friend.force_dialog.gravity')">
|
||||
<el-input-number
|
||||
v-model="forceForm.gravity"
|
||||
:max="1"
|
||||
:step="0.1"
|
||||
:precision="1"
|
||||
:controls="false"
|
||||
class="mutual-graph__number-input" />
|
||||
<div class="mutual-graph__helper">
|
||||
{{ t('view.charts.mutual_friend.force_dialog.gravity_help') }}
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<div class="mutual-graph__dialog-footer">
|
||||
<el-button @click="resetForceSettings">{{
|
||||
t('view.charts.mutual_friend.force_dialog.reset')
|
||||
}}</el-button>
|
||||
<el-button type="primary" :disabled="!graphReady" @click="applyForceSettings">
|
||||
{{ t('view.charts.mutual_friend.force_dialog.apply') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { Setting } from '@element-plus/icons-vue';
|
||||
import { onBeforeRouteLeave } from 'vue-router';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { useAppearanceSettingsStore, useChartsStore, useFriendStore, useUserStore } from '../../../stores';
|
||||
import { computeForceOptions, useMutualGraphChart } from '../composables/useMutualGraphChart';
|
||||
import { createRateLimiter, executeWithBackoff } from '../../../shared/utils';
|
||||
import { database } from '../../../service/database';
|
||||
import { useMutualGraphChart } from '../composables/useMutualGraphChart';
|
||||
import { userRequest } from '../../../api';
|
||||
|
||||
import * as echarts from 'echarts';
|
||||
@@ -57,12 +125,12 @@
|
||||
const appearanceStore = useAppearanceSettingsStore();
|
||||
const { friends } = storeToRefs(friendStore);
|
||||
const { currentUser } = storeToRefs(userStore);
|
||||
const { activeTab } = storeToRefs(chartsStore);
|
||||
const { activeTab, mutualGraphPayload } = storeToRefs(chartsStore);
|
||||
const { isDarkMode } = storeToRefs(appearanceStore);
|
||||
const cachedUsers = userStore.cachedUsers;
|
||||
const showUserDialog = (userId) => userStore.showUserDialog(userId);
|
||||
|
||||
const graphPayload = chartsStore.mutualGraphPayload;
|
||||
const graphPayload = mutualGraphPayload;
|
||||
const fetchState = chartsStore.mutualGraphFetchState;
|
||||
const status = chartsStore.mutualGraphStatus;
|
||||
|
||||
@@ -104,6 +172,36 @@
|
||||
totalFriends.value ? Math.min(100, Math.round((fetchState.processedFriends / totalFriends.value) * 100)) : 0
|
||||
);
|
||||
const progressStatus = computed(() => (isFetching.value ? 'warning' : undefined));
|
||||
const forceDefaults = computed(() =>
|
||||
computeForceOptions(graphPayload.value?.nodes ?? [], graphPayload.value?.links ?? [])
|
||||
);
|
||||
const hasGraphData = computed(() => graphReady.value && Boolean(graphPayload.value?.nodes?.length));
|
||||
|
||||
const isForceDialogVisible = ref(false);
|
||||
const forceOverrides = ref(null);
|
||||
const forceForm = reactive({
|
||||
repulsion: null,
|
||||
edgeLengthMin: null,
|
||||
edgeLengthMax: null,
|
||||
gravity: null
|
||||
});
|
||||
|
||||
const parseForceField = (value, { min = 0, max = Infinity, decimals = 0 } = {}) => {
|
||||
if (value === '' || value === null || value === undefined) {
|
||||
return { value: null, invalid: false };
|
||||
}
|
||||
const num = Number(value);
|
||||
if (Number.isNaN(num) || num < min || num > max) {
|
||||
return { value: null, invalid: true };
|
||||
}
|
||||
const factor = decimals ? 10 ** decimals : 1;
|
||||
return { value: Math.round(num * factor) / factor, invalid: false };
|
||||
};
|
||||
|
||||
const coerceForceField = (value, options) => {
|
||||
const parsed = parseForceField(value, options);
|
||||
return parsed.invalid ? null : parsed.value;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
@@ -432,7 +530,7 @@
|
||||
if (!chartInstance) {
|
||||
return;
|
||||
}
|
||||
chartInstance.setOption(createChartOption(payload));
|
||||
chartInstance.setOption(createChartOption(payload, forceOverrides.value));
|
||||
nextTick(() => chartInstance?.resize());
|
||||
}
|
||||
|
||||
@@ -449,6 +547,60 @@
|
||||
onBeforeRouteLeave(() => {
|
||||
chartsStore.resetMutualGraphState();
|
||||
});
|
||||
|
||||
function syncForceForm(source) {
|
||||
const base = source || forceDefaults.value || {};
|
||||
const edgeLength = Array.isArray(base.edgeLength) ? base.edgeLength : [];
|
||||
forceForm.repulsion = coerceForceField(base.repulsion, { min: 0 });
|
||||
forceForm.edgeLengthMin = coerceForceField(edgeLength[0], { min: 0 });
|
||||
forceForm.edgeLengthMax = coerceForceField(edgeLength[1], { min: 0 });
|
||||
forceForm.gravity = coerceForceField(base.gravity, { min: 0, max: 1, decimals: 1 });
|
||||
}
|
||||
|
||||
function openForceDialog() {
|
||||
syncForceForm(forceOverrides.value);
|
||||
isForceDialogVisible.value = true;
|
||||
}
|
||||
|
||||
function applyForceSettings() {
|
||||
if (!hasGraphData.value) {
|
||||
isForceDialogVisible.value = false;
|
||||
return;
|
||||
}
|
||||
const defaults = forceDefaults.value;
|
||||
const defaultEdge = Array.isArray(defaults.edgeLength) ? defaults.edgeLength : [null, null];
|
||||
const repulsion = parseForceField(forceForm.repulsion, { min: 0 });
|
||||
const minEdge = parseForceField(forceForm.edgeLengthMin, { min: 0 });
|
||||
const maxEdge = parseForceField(forceForm.edgeLengthMax, { min: 0 });
|
||||
const gravity = parseForceField(forceForm.gravity, { min: 0, max: 1, decimals: 1 });
|
||||
|
||||
const hasInvalid = [repulsion, minEdge, maxEdge, gravity].some((entry) => entry.invalid);
|
||||
if (hasInvalid) {
|
||||
ElMessage.error(t('view.charts.mutual_friend.force_dialog.invalid_input'));
|
||||
return;
|
||||
}
|
||||
|
||||
const edgeLength = [minEdge.value ?? defaultEdge[0] ?? 0, maxEdge.value ?? defaultEdge[1] ?? 0];
|
||||
edgeLength[0] = Math.max(0, edgeLength[0]);
|
||||
edgeLength[1] = Math.max(edgeLength[0], edgeLength[1]);
|
||||
|
||||
forceOverrides.value = {
|
||||
repulsion: repulsion.value === null ? defaults.repulsion : repulsion.value,
|
||||
edgeLength,
|
||||
gravity: gravity.value === null ? defaults.gravity : gravity.value,
|
||||
layoutAnimation: defaults.layoutAnimation
|
||||
};
|
||||
updateChart(graphPayload.value);
|
||||
isForceDialogVisible.value = false;
|
||||
}
|
||||
|
||||
function resetForceSettings() {
|
||||
forceOverrides.value = null;
|
||||
syncForceForm(forceDefaults.value);
|
||||
if (hasGraphData.value) {
|
||||
updateChart(graphPayload.value);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -518,4 +670,33 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.mutual-graph__force-description {
|
||||
margin: 0 0 12px 0;
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.mutual-graph__force-form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 8px 16px;
|
||||
}
|
||||
|
||||
.mutual-graph__number-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mutual-graph__dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mutual-graph__helper {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user