feat: mutual friend graph (#1491)

This commit is contained in:
pa
2025-11-18 23:30:00 +09:00
committed by Natsumi
parent 0bc9980cae
commit 424edb04e0
12 changed files with 1073 additions and 35 deletions

View File

@@ -297,6 +297,41 @@
"show_no_friend_instance": "Show No Friend Instance",
"show_detail": "Show Detail"
}
},
"mutual_friend": {
"tab_label": "Mutual Friend",
"actions": {
"start_fetch": "Start Fetch",
"fetch_again": "Fetch Again",
"stop": "Stop",
"stop_fetching": "Stop fetching"
},
"status": {
"no_friends_to_process": "You have no friends to process"
},
"progress": {
"friends_processed": "Friends processed",
"no_relationships_discovered": "No relationships discovered"
},
"prompt": {
"title": "Mutual Friend Graph",
"message": "No cached mutual friend graph data was found. Start fetching now?\\nThis may take a while, we will notify you when it is finishes",
"confirm": "Start Fetch",
"cancel": "Maybe Later"
},
"messages": {
"fetch_cancelled_graph_not_updated": "Fetch cancelled"
},
"notifications": {
"start_fetching": "Start fetching",
"mutual_friend_graph_ready_title": "Mutual Friend Graph",
"mutual_friend_graph_ready_message": "Mutual friend graph is ready",
"friend_list_changed_fetch_again": "Friend list changed. Please fetch the mutual graph again."
},
"tooltip": {
"mutual_friends_count": "Mutual friends: {count}",
"edge": "{source} ↔ {target}"
}
}
},
"tools": {

View File

@@ -5,6 +5,7 @@ import { friendLogHistory } from './database/friendLogHistory.js';
import { gameLog } from './database/gameLog.js';
import { memos } from './database/memos.js';
import { moderation } from './database/moderation.js';
import { mutualGraph } from './database/mutualGraph.js';
import { notifications } from './database/notifications.js';
import { tableAlter } from './database/tableAlter.js';
import { tableFixes } from './database/tableFixes.js';
@@ -32,6 +33,7 @@ const database = {
...tableAlter,
...tableFixes,
...tableSize,
...mutualGraph,
setMaxTableSize(limit) {
dbVars.maxTableSize = limit;
@@ -77,6 +79,12 @@ const database = {
await sqliteService.executeNonQuery(
`CREATE TABLE IF NOT EXISTS ${dbVars.userPrefix}_notes (user_id TEXT PRIMARY KEY, display_name TEXT, note TEXT, created_at TEXT)`
);
await sqliteService.executeNonQuery(
`CREATE TABLE IF NOT EXISTS ${dbVars.userPrefix}_mutual_graph_friends (friend_id TEXT PRIMARY KEY)`
);
await sqliteService.executeNonQuery(
`CREATE TABLE IF NOT EXISTS ${dbVars.userPrefix}_mutual_graph_links (friend_id TEXT NOT NULL, mutual_id TEXT NOT NULL, PRIMARY KEY(friend_id, mutual_id))`
);
},
async initTables() {

View File

@@ -0,0 +1,92 @@
import { dbVars } from '../database';
import sqliteService from '../sqlite.js';
const mutualGraph = {
async getMutualGraphSnapshot() {
const snapshot = new Map();
if (!dbVars.userPrefix) {
return snapshot;
}
const friendTable = `${dbVars.userPrefix}_mutual_graph_friends`;
const linkTable = `${dbVars.userPrefix}_mutual_graph_links`;
await sqliteService.execute((dbRow) => {
const friendId = dbRow[0];
if (friendId && !snapshot.has(friendId)) {
snapshot.set(friendId, []);
}
}, `SELECT friend_id FROM ${friendTable}`);
await sqliteService.execute((dbRow) => {
const friendId = dbRow[0];
const mutualId = dbRow[1];
if (!friendId || !mutualId) {
return;
}
let list = snapshot.get(friendId);
if (!list) {
list = [];
snapshot.set(friendId, list);
}
list.push(mutualId);
}, `SELECT friend_id, mutual_id FROM ${linkTable}`);
return snapshot;
},
async saveMutualGraphSnapshot(entries) {
if (!dbVars.userPrefix) {
return;
}
const friendTable = `${dbVars.userPrefix}_mutual_graph_friends`;
const linkTable = `${dbVars.userPrefix}_mutual_graph_links`;
const pairs = entries instanceof Map ? entries : new Map();
await sqliteService.executeNonQuery('BEGIN');
try {
await sqliteService.executeNonQuery(`DELETE FROM ${friendTable}`);
await sqliteService.executeNonQuery(`DELETE FROM ${linkTable}`);
if (pairs.size === 0) {
await sqliteService.executeNonQuery('COMMIT');
return;
}
let friendValues = '';
let edgeValues = '';
pairs.forEach((mutualIds, friendId) => {
if (!friendId) {
return;
}
const safeFriendId = friendId.replace(/'/g, "''");
friendValues += `('${safeFriendId}'),`;
let collection = [];
if (Array.isArray(mutualIds)) {
collection = mutualIds;
} else if (mutualIds instanceof Set) {
collection = Array.from(mutualIds);
}
for (const mutual of collection) {
if (!mutual) {
continue;
}
const safeMutualId = String(mutual).replace(/'/g, "''");
edgeValues += `('${safeFriendId}', '${safeMutualId}'),`;
}
});
if (friendValues) {
friendValues = friendValues.slice(0, -1);
await sqliteService.executeNonQuery(
`INSERT OR REPLACE INTO ${friendTable} (friend_id) VALUES ${friendValues}`
);
}
if (edgeValues) {
edgeValues = edgeValues.slice(0, -1);
await sqliteService.executeNonQuery(
`INSERT OR REPLACE INTO ${linkTable} (friend_id, mutual_id) VALUES ${edgeValues}`
);
}
await sqliteService.executeNonQuery('COMMIT');
} catch (err) {
await sqliteService.executeNonQuery('ROLLBACK');
throw err;
}
}
};
export { mutualGraph };

View File

@@ -17,3 +17,5 @@ export * from './location';
export * from './invite';
export * from './world';
export * from './memos';
export * from './throttle';
export * from './retry';

24
src/shared/utils/retry.js Normal file
View File

@@ -0,0 +1,24 @@
export async function executeWithBackoff(fn, options = {}) {
const {
maxRetries = 5,
baseDelay = 1000,
shouldRetry = () => true
} = options;
async function attempt(remaining) {
try {
return await fn();
} catch (err) {
if (remaining <= 0 || !shouldRetry(err)) {
throw err;
}
const delay =
baseDelay *
Math.pow(2, (options.maxRetries || maxRetries) - remaining);
await new Promise((resolve) => setTimeout(resolve, delay));
return attempt(remaining - 1);
}
}
return attempt(maxRetries);
}

View File

@@ -0,0 +1,28 @@
export function createRateLimiter({ limitPerInterval, intervalMs }) {
const stamps = [];
async function throttle() {
const now = Date.now();
while (stamps.length && now - stamps[0] > intervalMs) {
stamps.shift();
}
if (stamps.length >= limitPerInterval) {
const wait = intervalMs - (now - stamps[0]);
await new Promise((resolve) => setTimeout(resolve, wait));
}
stamps.push(Date.now());
}
return {
async schedule(fn) {
await throttle();
return fn();
},
async wait() {
await throttle();
},
clear() {
stamps.length = 0;
}
};
}

120
src/stores/charts.js Normal file
View File

@@ -0,0 +1,120 @@
import { computed, reactive, ref, watch } from 'vue';
import { ElMessage, ElNotification } from 'element-plus';
import { defineStore } from 'pinia';
import { useI18n } from 'vue-i18n';
import { useFriendStore } from './friend';
function createDefaultFetchState() {
return {
processedFriends: 0
};
}
function createDefaultPayload() {
return {
nodes: [],
links: []
};
}
export const useChartsStore = defineStore('Charts', () => {
const friendStore = useFriendStore();
const { t } = useI18n();
const activeTab = ref('instance');
const mutualGraphPayload = ref(createDefaultPayload());
const mutualGraphFetchState = reactive(createDefaultFetchState());
const mutualGraphStatus = reactive({
isFetching: false,
hasFetched: false,
fetchError: '',
completionNotified: false,
friendSignature: 0,
needsRefetch: false,
cancelRequested: false
});
const friendCount = computed(() => friendStore.friends.size || 0);
function showInfoMessage(message, type) {
ElMessage({
message,
type,
duration: 4000,
grouping: true
});
}
watch(
() => mutualGraphStatus.isFetching,
(isFetching, wasFetching) => {
if (isFetching) {
showInfoMessage(
t('view.charts.mutual_friend.notifications.start_fetching'),
'info'
);
mutualGraphStatus.completionNotified = false;
} else if (
wasFetching &&
mutualGraphStatus.hasFetched &&
!mutualGraphStatus.completionNotified
) {
mutualGraphStatus.completionNotified = true;
ElNotification({
title: t(
'view.charts.mutual_friend.notifications.mutual_friend_graph_ready_title'
),
message: t(
'view.charts.mutual_friend.notifications.mutual_friend_graph_ready_message'
),
type: 'success',
position: 'top-right',
duration: 5000,
showClose: true
});
}
}
);
watch(friendCount, (count) => {
if (
!mutualGraphStatus.hasFetched ||
mutualGraphStatus.isFetching ||
!mutualGraphStatus.friendSignature ||
mutualGraphStatus.needsRefetch
) {
return;
}
if (count !== mutualGraphStatus.friendSignature) {
mutualGraphStatus.needsRefetch = true;
showInfoMessage(
t(
'view.charts.mutual_friend.notifications.friend_list_changed_fetch_again'
),
'warning'
);
}
});
function resetMutualGraphState() {
mutualGraphPayload.value = createDefaultPayload();
Object.assign(mutualGraphFetchState, createDefaultFetchState());
mutualGraphStatus.isFetching = false;
mutualGraphStatus.hasFetched = false;
mutualGraphStatus.fetchError = '';
mutualGraphStatus.completionNotified = false;
mutualGraphStatus.friendSignature = 0;
mutualGraphStatus.needsRefetch = false;
mutualGraphStatus.cancelRequested = false;
}
return {
activeTab,
mutualGraphPayload,
mutualGraphFetchState,
mutualGraphStatus,
resetMutualGraphState
};
});

View File

@@ -5,6 +5,8 @@ import { useI18n } from 'vue-i18n';
import {
compareByCreatedAtAscending,
createRateLimiter,
executeWithBackoff,
getFriendsSortFunction,
getGroupName,
getNameColour,
@@ -690,39 +692,30 @@ export const useFriendStore = defineStore('Friend', () => {
const MAX_RETRY = 5;
const RETRY_BASE_DELAY = 1000;
const stamps = [];
async function throttle() {
const now = Date.now();
while (stamps.length && now - stamps[0] > 60_000) stamps.shift();
if (stamps.length >= RATE_PER_MINUTE) {
const wait = 60_000 - (now - stamps[0]);
await new Promise((r) => setTimeout(r, wait));
}
stamps.push(Date.now());
}
const rateLimiter = createRateLimiter({
limitPerInterval: RATE_PER_MINUTE,
intervalMs: 60_000
});
async function fetchPage(offset, retries = MAX_RETRY) {
try {
const { json } = await friendRequest.getFriends({
...args,
n: PAGE_SIZE,
offset
});
return Array.isArray(json) ? json : [];
} catch (err) {
const is429 =
err.status === 429 || (err.message || '').includes('429');
if (is429 && retries > 0) {
await new Promise((r) =>
setTimeout(
r,
RETRY_BASE_DELAY * Math.pow(2, MAX_RETRY - retries)
)
);
return fetchPage(offset, retries - 1);
async function fetchPage(offset) {
const result = await executeWithBackoff(
async () => {
const { json } = await friendRequest.getFriends({
...args,
n: PAGE_SIZE,
offset
});
return Array.isArray(json) ? json : [];
},
{
maxRetries: MAX_RETRY,
baseDelay: RETRY_BASE_DELAY,
shouldRetry: (err) =>
err?.status === 429 ||
(err?.message || '').includes('429')
}
throw err;
}
);
return result;
}
let nextOffset = 0;
@@ -742,7 +735,7 @@ export const useFriendStore = defineStore('Friend', () => {
const offset = getNextOffset();
if (offset === null) break;
await throttle();
await rateLimiter.wait();
const page = await fetchPage(offset);
if (page.length === 0) {

View File

@@ -1,5 +1,4 @@
import { createPinia } from 'pinia';
import { createSentryPiniaPlugin } from '@sentry/vue';
import { isSentryEnabled } from '../plugin';
import { useAdvancedSettingsStore } from './settings/advanced';
@@ -7,6 +6,7 @@ import { useAppearanceSettingsStore } from './settings/appearance';
import { useAuthStore } from './auth';
import { useAvatarProviderStore } from './avatarProvider';
import { useAvatarStore } from './avatar';
import { useChartsStore } from './charts';
import { useDiscordPresenceSettingsStore } from './settings/discordPresence';
import { useFavoriteStore } from './favorite';
import { useFeedStore } from './feed';
@@ -152,7 +152,8 @@ export function createGlobalStores() {
sharedFeed: useSharedFeedStore(),
updateLoop: useUpdateLoopStore(),
auth: useAuthStore(),
vrcStatus: useVrcStatusStore()
vrcStatus: useVrcStatusStore(),
charts: useChartsStore()
};
}
@@ -175,6 +176,7 @@ export {
useNotificationStore,
usePhotonStore,
useSearchStore,
useChartsStore,
useAdvancedSettingsStore,
useAppearanceSettingsStore,
useDiscordPresenceSettingsStore,

View File

@@ -3,16 +3,37 @@
<div class="options-container" style="margin-top: 0">
<span class="header">{{ t('view.charts.header') }}</span>
</div>
<InstanceActivity />
<el-tabs v-model="activeTab" class="charts-tabs">
<el-tab-pane :label="t('view.charts.instance_activity.header')" name="instance"></el-tab-pane>
<el-tab-pane :label="t('view.charts.mutual_friend.tab_label')" name="mutual"></el-tab-pane>
</el-tabs>
<div v-show="activeTab === 'instance'">
<InstanceActivity />
</div>
<div v-show="activeTab === 'mutual'">
<MutualFriends />
</div>
<el-backtop target="#chart" :right="30" :bottom="30"></el-backtop>
</div>
</template>
<script setup>
import { defineAsyncComponent } from 'vue';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n';
import { useChartsStore } from '../../stores';
const InstanceActivity = defineAsyncComponent(() => import('./components/InstanceActivity.vue'));
const MutualFriends = defineAsyncComponent(() => import('./components/MutualFriends.vue'));
const { t } = useI18n();
const chartsStore = useChartsStore();
const { activeTab } = storeToRefs(chartsStore);
</script>
<style scoped>
.charts-tabs {
margin-bottom: 12px;
}
</style>

View File

@@ -0,0 +1,469 @@
<template>
<div class="mutual-graph">
<div class="options-container mutual-graph__toolbar">
<div class="mutual-graph__actions">
<el-tooltip :content="fetchButtonLabel" placement="top">
<el-button type="primary" :disabled="fetchButtonDisabled" :loading="isFetching" @click="startFetch">
{{ fetchButtonLabel }}
</el-button>
</el-tooltip>
<el-tooltip
v-if="isFetching"
:content="t('view.charts.mutual_friend.actions.stop_fetching')"
placement="top">
<el-button type="danger" plain :disabled="status.cancelRequested" @click="cancelFetch">
{{ t('view.charts.mutual_friend.actions.stop') }}
</el-button>
</el-tooltip>
</div>
</div>
<div v-if="isFetching" class="mutual-graph__status">
<div class="mutual-graph__status-row">
<span>{{ t('view.charts.mutual_friend.progress.friends_processed') }}</span>
<strong>{{ fetchState.processedFriends }} / {{ totalFriends }}</strong>
</div>
<el-progress :percentage="progressPercent" :status="progressStatus" :stroke-width="14"> </el-progress>
</div>
<div ref="chartRef" class="mutual-graph__canvas"></div>
<div v-if="hasFetched && !isFetching && !graphReady" class="mutual-graph__placeholder">
<span>{{ t('view.charts.mutual_friend.progress.no_relationships_discovered') }}</span>
</div>
</div>
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n';
import { useAppearanceSettingsStore, useChartsStore, useFriendStore, useUserStore } from '../../../stores';
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';
const { t } = useI18n();
const friendStore = useFriendStore();
const userStore = useUserStore();
const chartsStore = useChartsStore();
const appearanceStore = useAppearanceSettingsStore();
const { friends } = storeToRefs(friendStore);
const { currentUser } = storeToRefs(userStore);
const { activeTab } = storeToRefs(chartsStore);
const { isDarkMode } = storeToRefs(appearanceStore);
const cachedUsers = userStore.cachedUsers;
const showUserDialog = (userId) => userStore.showUserDialog(userId);
const graphPayload = chartsStore.mutualGraphPayload;
const fetchState = chartsStore.mutualGraphFetchState;
const status = chartsStore.mutualGraphStatus;
const chartTheme = computed(() => (isDarkMode.value ? 'dark' : undefined));
const { buildGraph, createChartOption } = useMutualGraphChart({
cachedUsers,
graphPayload
});
const chartRef = ref(null);
let chartInstance = null;
let resizeObserver = null;
let lastRenderablePayload = null;
const isFetching = computed({
get: () => status.isFetching,
set: (val) => {
status.isFetching = val;
}
});
const hasFetched = computed({
get: () => status.hasFetched,
set: (val) => {
status.hasFetched = val;
}
});
const fetchError = computed({
get: () => status.fetchError,
set: (val) => {
status.fetchError = val;
}
});
const totalFriends = computed(() => friends.value.size);
const isOptOut = computed(() => Boolean(currentUser.value?.hasSharedConnectionsOptOut));
// @ts-ignore
const graphReady = computed(() => Array.isArray(graphPayload.value?.nodes) && graphPayload.value.nodes.length > 0);
const fetchButtonDisabled = computed(() => isFetching.value || isOptOut.value || totalFriends.value === 0);
const fetchButtonLabel = computed(() =>
hasFetched.value
? t('view.charts.mutual_friend.actions.fetch_again')
: t('view.charts.mutual_friend.actions.start_fetch')
);
const progressPercent = computed(() =>
totalFriends.value ? Math.min(100, Math.round((fetchState.processedFriends / totalFriends.value) * 100)) : 0
);
const progressStatus = computed(() => (isFetching.value ? 'warning' : undefined));
onMounted(() => {
nextTick(() => {
if (!chartRef.value) {
return;
}
createChartInstance();
resizeObserver = new ResizeObserver(() => chartInstance?.resize());
resizeObserver.observe(chartRef.value);
});
});
onBeforeUnmount(() => {
if (resizeObserver) {
resizeObserver.disconnect();
resizeObserver = null;
}
if (chartInstance) {
chartInstance.dispose();
chartInstance = null;
}
});
watch(
chartTheme,
() => {
if (!chartRef.value) {
return;
}
if (chartInstance) {
chartInstance.dispose();
chartInstance = null;
}
nextTick(() => {
if (!chartRef.value) {
return;
}
createChartInstance();
});
},
{ immediate: false }
);
watch(
activeTab,
(tab) => {
if (tab === 'mutual') {
loadGraphFromDatabase();
}
},
{ immediate: true }
);
function showStatusMessage(message, type = 'info') {
if (!message) {
return;
}
ElMessage({
// @ts-ignore
message,
type,
duration: 4000,
grouping: true
});
}
function createChartInstance() {
if (!chartRef.value) {
return;
}
chartInstance = echarts.init(chartRef.value, chartTheme.value, { useDirtyRect: totalFriends.value > 1000 });
if (lastRenderablePayload) {
updateChart(lastRenderablePayload);
} else if (graphReady.value) {
// @ts-ignore
updateChart(graphPayload.value);
}
}
async function loadGraphFromDatabase() {
if (hasFetched.value || isFetching.value) {
return;
}
try {
const snapshot = await database.getMutualGraphSnapshot();
if (!snapshot || snapshot.size === 0) {
await promptInitialFetch();
return;
}
const mutualMap = new Map();
snapshot.forEach((mutualIds, friendId) => {
if (!friendId) {
return;
}
const friendEntry = friends.value?.get ? friends.value.get(friendId) : undefined;
const fallbackRef = friendEntry?.ref || cachedUsers.get(friendId);
const normalizedMutuals = Array.isArray(mutualIds) ? mutualIds : [];
mutualMap.set(friendId, {
friend: friendEntry || (fallbackRef ? { id: friendId, ref: fallbackRef } : { id: friendId }),
mutuals: normalizedMutuals.map((id) => ({ id }))
});
});
if (!mutualMap.size) {
await promptInitialFetch();
return;
}
buildGraph(mutualMap, updateChart);
hasFetched.value = true;
fetchState.processedFriends = Math.min(mutualMap.size, totalFriends.value || mutualMap.size);
status.friendSignature = totalFriends.value;
status.needsRefetch = false;
} catch (err) {
console.error('[MutualGraph] Failed to load cached mutual graph', err);
}
}
async function promptInitialFetch() {
if (isFetching.value || hasFetched.value || !totalFriends.value) {
return;
}
try {
await ElMessageBox.confirm(
t('view.charts.mutual_friend.prompt.message'),
t('view.charts.mutual_friend.prompt.title'),
{
confirmButtonText: t('view.charts.mutual_friend.prompt.confirm'),
cancelButtonText: t('view.charts.mutual_friend.prompt.cancel'),
type: 'warning'
}
);
await startFetch();
} catch {
// cancelled
}
}
function cancelFetch() {
if (isFetching.value) {
status.cancelRequested = true;
}
}
async function startFetch() {
const rateLimiter = createRateLimiter({
limitPerInterval: 5,
intervalMs: 1000
});
const fetchMutualFriends = async (userId) => {
const collected = [];
let offset = 0;
while (true) {
await rateLimiter.wait();
const args = await executeWithBackoff(() => userRequest.getMutualFriends({ userId, offset, n: 100 }), {
maxRetries: 4,
baseDelay: 500,
shouldRetry: (err) => err?.status === 429 || (err?.message || '').includes('429')
});
collected.push(...args.json);
if (args.json.length < 100) {
break;
}
offset += args.json.length;
}
return collected;
};
if (isFetching.value || isOptOut.value) {
return;
}
if (!totalFriends.value) {
showStatusMessage(t('view.charts.mutual_friend.status.no_friends_to_process'), 'info');
return;
}
isFetching.value = true;
fetchError.value = '';
status.completionNotified = false;
status.needsRefetch = false;
status.cancelRequested = false;
hasFetched.value = false;
Object.assign(fetchState, {
processedFriends: 0
});
const friendSnapshot = Array.from(friends.value.values());
const mutualMap = new Map();
let cancelled = false;
try {
for (let index = 0; index < friendSnapshot.length; index += 1) {
const friend = friendSnapshot[index];
if (!friend?.id) {
continue;
}
const mutuals = await fetchMutualFriends(friend.id);
mutualMap.set(friend.id, { friend, mutuals });
fetchState.processedFriends = index + 1;
if (status.cancelRequested) {
cancelled = true;
break;
}
}
if (cancelled) {
hasFetched.value = false;
showStatusMessage(t('view.charts.mutual_friend.messages.fetch_cancelled_graph_not_updated'), 'warning');
return;
}
buildGraph(mutualMap, updateChart);
status.friendSignature = totalFriends.value;
status.needsRefetch = false;
try {
await persistMutualGraph(mutualMap);
} catch (persistErr) {
console.error('[MutualGraph] Failed to cache data', persistErr);
}
hasFetched.value = true;
} catch (err) {
console.error('[MutualGraph] fetch aborted', err);
} finally {
isFetching.value = false;
status.cancelRequested = false;
}
}
async function persistMutualGraph(mutualMap) {
const snapshot = new Map();
mutualMap.forEach((value, friendId) => {
if (!friendId) {
return;
}
const normalizedFriendId = String(friendId);
const collection = Array.isArray(value?.mutuals) ? value.mutuals : [];
const ids = [];
for (const entry of collection) {
const identifier =
typeof entry?.id === 'string'
? entry.id
: entry?.id !== undefined && entry?.id !== null
? String(entry.id)
: '';
if (identifier) {
ids.push(identifier);
}
}
snapshot.set(normalizedFriendId, ids);
});
await database.saveMutualGraphSnapshot(snapshot);
}
function updateChart(payload) {
const nodes = payload?.nodes ?? [];
if (!nodes.length) {
if (chartInstance) {
chartInstance.clear();
}
return;
}
lastRenderablePayload = payload;
if (!chartInstance) {
return;
}
chartInstance.setOption(createChartOption(payload), true);
registerChartEvents();
nextTick(() => chartInstance?.resize());
}
function registerChartEvents() {
if (!chartInstance) {
return;
}
chartInstance.off('click', handleChartNodeClick);
chartInstance.on('click', handleChartNodeClick);
}
function handleChartNodeClick(params) {
if (params?.dataType !== 'node') {
return;
}
const nodeId = params.data?.id;
if (nodeId) {
showUserDialog(nodeId);
}
}
</script>
<style scoped>
.mutual-graph {
margin-top: 0;
display: flex;
flex-direction: column;
min-height: calc(100vh - 140px);
}
.mutual-graph__toolbar {
display: flex;
justify-content: flex-end;
align-items: center;
margin-top: 0;
margin-bottom: 8px;
background: transparent;
border: none;
box-shadow: none;
padding: 0 0 8px 0;
}
.mutual-graph__actions {
display: flex;
gap: 8px;
align-items: center;
}
.mutual-graph__status {
margin-top: 12px;
padding: 12px 16px;
border-radius: 6px;
border: 1px solid var(--el-border-color);
background: transparent;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 8px 12px;
align-items: center;
}
.mutual-graph__status-row {
display: flex;
justify-content: space-between;
font-size: 13px;
}
.mutual-graph__status-row strong {
font-weight: 600;
}
.mutual-graph__canvas {
margin-top: 12px;
width: 100%;
flex: 1 1 auto;
height: calc(100vh - 260px);
min-height: 520px;
}
.mutual-graph__placeholder {
margin-top: 12px;
padding: 40px;
text-align: center;
border: 1px dashed var(--el-border-color);
border-radius: 8px;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
</style>

View File

@@ -0,0 +1,244 @@
import { i18n } from '../../../plugin/i18n';
const COLORS_PALETTE = [
'#5470c6',
'#91cc75',
'#fac858',
'#ee6666',
'#73c0de',
'#3ba272',
'#fc8452',
'#9a60b4',
'#ea7ccc'
];
const MAX_LABEL_NAME_LENGTH = 22;
function truncateLabelText(text) {
if (!text) {
return 'Unknown';
}
return text.length > MAX_LABEL_NAME_LENGTH
? `${text.slice(0, MAX_LABEL_NAME_LENGTH)}`
: text;
}
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function computeForceOptions(nodes, links) {
const nodeCount = nodes.length || 1;
const degreeSum = nodes.reduce((sum, node) => sum + (node.degree || 0), 0);
const maxSymbol = nodes.reduce(
(max, node) => Math.max(max, node.symbolSize || 0),
0
);
const avgDegree = degreeSum / nodeCount || 0;
const density = links.length ? links.length / nodeCount : 0;
const repulsionBase = 140 + maxSymbol * 4 + avgDegree * 6;
const repulsion = clamp(repulsionBase, 180, 720);
const minEdge = clamp(34 + avgDegree * 1.2, 34, 70);
const maxEdge = clamp(
minEdge + 60 + Math.max(0, 140 - density * 18),
90,
200
);
return {
repulsion,
edgeLength: [minEdge, maxEdge],
gravity: 0.08,
layoutAnimation: true
};
}
const t = i18n.global.t;
export function useMutualGraphChart({ cachedUsers, graphPayload }) {
function buildGraph(mutualMap, updateChart) {
const nodes = new Map();
const links = [];
const linkKeys = new Set();
function ensureNode(id, name, rawUser) {
if (!id) {
return null;
}
const existing = nodes.get(id);
if (existing) {
if (!existing.rawUser && rawUser) {
existing.rawUser = rawUser;
}
return existing;
}
const node = {
id,
name: name || id,
value: name || id
};
nodes.set(id, node);
return node;
}
function incrementDegree(nodeId) {
const node = nodes.get(nodeId);
if (!node) {
return;
}
node.degree = (node.degree || 0) + 1;
}
function addLink(source, target) {
if (!source || !target || source === target) {
return;
}
const key = [source, target].sort().join('__');
if (linkKeys.has(key)) {
return;
}
linkKeys.add(key);
links.push({ source, target });
incrementDegree(source);
incrementDegree(target);
}
for (const [friendId, { friend, mutuals }] of mutualMap.entries()) {
const friendRef = friend?.ref || cachedUsers.get(friendId);
const friendName = friendRef?.displayName;
ensureNode(friendId, friendName, friendRef);
for (const mutual of mutuals) {
if (!mutual?.id) {
continue;
}
const cached = cachedUsers.get(mutual.id);
const label =
cached?.displayName || mutual.displayName || mutual.id;
ensureNode(mutual.id, label);
addLink(friendId, mutual.id);
}
}
const nodeList = Array.from(nodes.values());
const maxDegree = nodeList.reduce(
(acc, node) => Math.max(acc, node.degree || 0),
0
);
nodeList.forEach((node, index) => {
const normalized = maxDegree ? (node.degree || 0) / maxDegree : 0;
const size = Math.round(26 + normalized * 52);
const color = COLORS_PALETTE[index % COLORS_PALETTE.length];
const displayName = truncateLabelText(node.name || node.id);
node.symbolSize = size;
node.label = {
show: true,
formatter: `${displayName}`
};
node.itemStyle = {
...(node.itemStyle || {}),
color
};
});
graphPayload.value = {
nodes: nodeList,
links
};
updateChart?.(graphPayload.value);
}
function createChartOption(payload) {
const nodes = payload?.nodes ?? [];
const links = payload?.links ?? [];
const force = computeForceOptions(nodes, links);
const labelMap = Object.create(null);
nodes.forEach((node) => {
if (node?.id) {
labelMap[node.id] = node.name || node.id;
}
});
return {
color: COLORS_PALETTE,
backgroundColor: 'transparent',
tooltip: {
trigger: 'item',
formatter: (params) => {
if (params.dataType === 'node') {
const name =
params.data?.name || params.data?.id || 'Unknown';
const mutualCount = Number.isFinite(params.data?.degree)
? params.data.degree
: 0;
const mutualLabel = t(
'view.charts.mutual_friend.tooltip.mutual_friends_count',
{
count: mutualCount
}
);
return `${name}\n${mutualLabel}`;
}
if (params.dataType === 'edge') {
const sourceLabel =
labelMap[params.data.source] || params.data.source;
const targetLabel =
labelMap[params.data.target] || params.data.target;
return t('view.charts.mutual_friend.tooltip.edge', {
source: sourceLabel,
target: targetLabel
});
}
return '';
}
},
series: [
{
type: 'graph',
layout: 'force',
legendHoverLink: false,
roam: true,
roamTrigger: 'global',
data: nodes,
links,
label: {
position: 'right',
formatter: '{b}'
},
symbol: 'circle',
emphasis: {
focus: 'adjacency',
lineStyle: {
width: 5,
opacity: 0.5
}
},
force,
itemStyle: {
borderColor: '#ffffff',
borderWidth: 1,
shadowBlur: 16,
shadowColor: 'rgba(0,0,0,0.35)'
},
lineStyle: {
curveness: 0.18,
width: 0.5,
opacity: 0.4
},
labelLayout: {
hideOverlap: true
}
}
]
};
}
return {
buildGraph,
createChartOption
};
}