mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-04-19 06:43:51 +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_no_friend_instance": "Show No Friend Instance",
|
||||||
"show_detail": "Show Detail"
|
"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": {
|
"tools": {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { friendLogHistory } from './database/friendLogHistory.js';
|
|||||||
import { gameLog } from './database/gameLog.js';
|
import { gameLog } from './database/gameLog.js';
|
||||||
import { memos } from './database/memos.js';
|
import { memos } from './database/memos.js';
|
||||||
import { moderation } from './database/moderation.js';
|
import { moderation } from './database/moderation.js';
|
||||||
|
import { mutualGraph } from './database/mutualGraph.js';
|
||||||
import { notifications } from './database/notifications.js';
|
import { notifications } from './database/notifications.js';
|
||||||
import { tableAlter } from './database/tableAlter.js';
|
import { tableAlter } from './database/tableAlter.js';
|
||||||
import { tableFixes } from './database/tableFixes.js';
|
import { tableFixes } from './database/tableFixes.js';
|
||||||
@@ -32,6 +33,7 @@ const database = {
|
|||||||
...tableAlter,
|
...tableAlter,
|
||||||
...tableFixes,
|
...tableFixes,
|
||||||
...tableSize,
|
...tableSize,
|
||||||
|
...mutualGraph,
|
||||||
|
|
||||||
setMaxTableSize(limit) {
|
setMaxTableSize(limit) {
|
||||||
dbVars.maxTableSize = limit;
|
dbVars.maxTableSize = limit;
|
||||||
@@ -77,6 +79,12 @@ const database = {
|
|||||||
await sqliteService.executeNonQuery(
|
await sqliteService.executeNonQuery(
|
||||||
`CREATE TABLE IF NOT EXISTS ${dbVars.userPrefix}_notes (user_id TEXT PRIMARY KEY, display_name TEXT, note TEXT, created_at TEXT)`
|
`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() {
|
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 './invite';
|
||||||
export * from './world';
|
export * from './world';
|
||||||
export * from './memos';
|
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 {
|
import {
|
||||||
compareByCreatedAtAscending,
|
compareByCreatedAtAscending,
|
||||||
|
createRateLimiter,
|
||||||
|
executeWithBackoff,
|
||||||
getFriendsSortFunction,
|
getFriendsSortFunction,
|
||||||
getGroupName,
|
getGroupName,
|
||||||
getNameColour,
|
getNameColour,
|
||||||
@@ -690,39 +692,30 @@ export const useFriendStore = defineStore('Friend', () => {
|
|||||||
const MAX_RETRY = 5;
|
const MAX_RETRY = 5;
|
||||||
const RETRY_BASE_DELAY = 1000;
|
const RETRY_BASE_DELAY = 1000;
|
||||||
|
|
||||||
const stamps = [];
|
const rateLimiter = createRateLimiter({
|
||||||
async function throttle() {
|
limitPerInterval: RATE_PER_MINUTE,
|
||||||
const now = Date.now();
|
intervalMs: 60_000
|
||||||
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());
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchPage(offset, retries = MAX_RETRY) {
|
async function fetchPage(offset) {
|
||||||
try {
|
const result = await executeWithBackoff(
|
||||||
const { json } = await friendRequest.getFriends({
|
async () => {
|
||||||
...args,
|
const { json } = await friendRequest.getFriends({
|
||||||
n: PAGE_SIZE,
|
...args,
|
||||||
offset
|
n: PAGE_SIZE,
|
||||||
});
|
offset
|
||||||
return Array.isArray(json) ? json : [];
|
});
|
||||||
} catch (err) {
|
return Array.isArray(json) ? json : [];
|
||||||
const is429 =
|
},
|
||||||
err.status === 429 || (err.message || '').includes('429');
|
{
|
||||||
if (is429 && retries > 0) {
|
maxRetries: MAX_RETRY,
|
||||||
await new Promise((r) =>
|
baseDelay: RETRY_BASE_DELAY,
|
||||||
setTimeout(
|
shouldRetry: (err) =>
|
||||||
r,
|
err?.status === 429 ||
|
||||||
RETRY_BASE_DELAY * Math.pow(2, MAX_RETRY - retries)
|
(err?.message || '').includes('429')
|
||||||
)
|
|
||||||
);
|
|
||||||
return fetchPage(offset, retries - 1);
|
|
||||||
}
|
}
|
||||||
throw err;
|
);
|
||||||
}
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
let nextOffset = 0;
|
let nextOffset = 0;
|
||||||
@@ -742,7 +735,7 @@ export const useFriendStore = defineStore('Friend', () => {
|
|||||||
const offset = getNextOffset();
|
const offset = getNextOffset();
|
||||||
if (offset === null) break;
|
if (offset === null) break;
|
||||||
|
|
||||||
await throttle();
|
await rateLimiter.wait();
|
||||||
|
|
||||||
const page = await fetchPage(offset);
|
const page = await fetchPage(offset);
|
||||||
if (page.length === 0) {
|
if (page.length === 0) {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { createPinia } from 'pinia';
|
import { createPinia } from 'pinia';
|
||||||
import { createSentryPiniaPlugin } from '@sentry/vue';
|
|
||||||
|
|
||||||
import { isSentryEnabled } from '../plugin';
|
import { isSentryEnabled } from '../plugin';
|
||||||
import { useAdvancedSettingsStore } from './settings/advanced';
|
import { useAdvancedSettingsStore } from './settings/advanced';
|
||||||
@@ -7,6 +6,7 @@ import { useAppearanceSettingsStore } from './settings/appearance';
|
|||||||
import { useAuthStore } from './auth';
|
import { useAuthStore } from './auth';
|
||||||
import { useAvatarProviderStore } from './avatarProvider';
|
import { useAvatarProviderStore } from './avatarProvider';
|
||||||
import { useAvatarStore } from './avatar';
|
import { useAvatarStore } from './avatar';
|
||||||
|
import { useChartsStore } from './charts';
|
||||||
import { useDiscordPresenceSettingsStore } from './settings/discordPresence';
|
import { useDiscordPresenceSettingsStore } from './settings/discordPresence';
|
||||||
import { useFavoriteStore } from './favorite';
|
import { useFavoriteStore } from './favorite';
|
||||||
import { useFeedStore } from './feed';
|
import { useFeedStore } from './feed';
|
||||||
@@ -152,7 +152,8 @@ export function createGlobalStores() {
|
|||||||
sharedFeed: useSharedFeedStore(),
|
sharedFeed: useSharedFeedStore(),
|
||||||
updateLoop: useUpdateLoopStore(),
|
updateLoop: useUpdateLoopStore(),
|
||||||
auth: useAuthStore(),
|
auth: useAuthStore(),
|
||||||
vrcStatus: useVrcStatusStore()
|
vrcStatus: useVrcStatusStore(),
|
||||||
|
charts: useChartsStore()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,6 +176,7 @@ export {
|
|||||||
useNotificationStore,
|
useNotificationStore,
|
||||||
usePhotonStore,
|
usePhotonStore,
|
||||||
useSearchStore,
|
useSearchStore,
|
||||||
|
useChartsStore,
|
||||||
useAdvancedSettingsStore,
|
useAdvancedSettingsStore,
|
||||||
useAppearanceSettingsStore,
|
useAppearanceSettingsStore,
|
||||||
useDiscordPresenceSettingsStore,
|
useDiscordPresenceSettingsStore,
|
||||||
|
|||||||
@@ -3,16 +3,37 @@
|
|||||||
<div class="options-container" style="margin-top: 0">
|
<div class="options-container" style="margin-top: 0">
|
||||||
<span class="header">{{ t('view.charts.header') }}</span>
|
<span class="header">{{ t('view.charts.header') }}</span>
|
||||||
</div>
|
</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>
|
<el-backtop target="#chart" :right="30" :bottom="30"></el-backtop>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { defineAsyncComponent } from 'vue';
|
import { defineAsyncComponent } from 'vue';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
import { useChartsStore } from '../../stores';
|
||||||
|
|
||||||
const InstanceActivity = defineAsyncComponent(() => import('./components/InstanceActivity.vue'));
|
const InstanceActivity = defineAsyncComponent(() => import('./components/InstanceActivity.vue'));
|
||||||
|
const MutualFriends = defineAsyncComponent(() => import('./components/MutualFriends.vue'));
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const chartsStore = useChartsStore();
|
||||||
|
const { activeTab } = storeToRefs(chartsStore);
|
||||||
</script>
|
</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