mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-04-10 10:23:52 +02:00
feat: mutual friend graph (#1491)
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
92
src/service/database/mutualGraph.js
Normal file
92
src/service/database/mutualGraph.js
Normal 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 };
|
||||
@@ -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
24
src/shared/utils/retry.js
Normal 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);
|
||||
}
|
||||
28
src/shared/utils/throttle.js
Normal file
28
src/shared/utils/throttle.js
Normal 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
120
src/stores/charts.js
Normal 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
|
||||
};
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
469
src/views/Charts/components/MutualFriends.vue
Normal file
469
src/views/Charts/components/MutualFriends.vue
Normal 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>
|
||||
244
src/views/Charts/composables/useMutualGraphChart.js
Normal file
244
src/views/Charts/composables/useMutualGraphChart.js
Normal 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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user