feat: add mutual friends graph layout settings (#1532)

This commit is contained in:
pa
2025-12-12 02:57:46 +09:00
committed by Natsumi
parent 19a7b7a9d0
commit b830311772
3 changed files with 240 additions and 8 deletions

View File

@@ -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."
}
}
},

View File

@@ -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>

View File

@@ -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) {