diff --git a/src/localization/en.json b/src/localization/en.json index 25067161..5f896d9c 100644 --- a/src/localization/en.json +++ b/src/localization/en.json @@ -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": { diff --git a/src/service/database.js b/src/service/database.js index 59fe6ea2..9c8cda3f 100644 --- a/src/service/database.js +++ b/src/service/database.js @@ -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() { diff --git a/src/service/database/mutualGraph.js b/src/service/database/mutualGraph.js new file mode 100644 index 00000000..4e99fa05 --- /dev/null +++ b/src/service/database/mutualGraph.js @@ -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 }; diff --git a/src/shared/utils/index.js b/src/shared/utils/index.js index 44980036..ff94544d 100644 --- a/src/shared/utils/index.js +++ b/src/shared/utils/index.js @@ -17,3 +17,5 @@ export * from './location'; export * from './invite'; export * from './world'; export * from './memos'; +export * from './throttle'; +export * from './retry'; diff --git a/src/shared/utils/retry.js b/src/shared/utils/retry.js new file mode 100644 index 00000000..2736d9ea --- /dev/null +++ b/src/shared/utils/retry.js @@ -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); +} diff --git a/src/shared/utils/throttle.js b/src/shared/utils/throttle.js new file mode 100644 index 00000000..f4c4a676 --- /dev/null +++ b/src/shared/utils/throttle.js @@ -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; + } + }; +} diff --git a/src/stores/charts.js b/src/stores/charts.js new file mode 100644 index 00000000..31e40914 --- /dev/null +++ b/src/stores/charts.js @@ -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 + }; +}); diff --git a/src/stores/friend.js b/src/stores/friend.js index 7b36f814..9e5fd015 100644 --- a/src/stores/friend.js +++ b/src/stores/friend.js @@ -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) { diff --git a/src/stores/index.js b/src/stores/index.js index 6531d12b..6a0d936a 100644 --- a/src/stores/index.js +++ b/src/stores/index.js @@ -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, diff --git a/src/views/Charts/Charts.vue b/src/views/Charts/Charts.vue index 5831b151..dead02e8 100644 --- a/src/views/Charts/Charts.vue +++ b/src/views/Charts/Charts.vue @@ -3,16 +3,37 @@
{{ t('view.charts.header') }}
- + + + + +
+ +
+
+ +
+ + diff --git a/src/views/Charts/components/MutualFriends.vue b/src/views/Charts/components/MutualFriends.vue new file mode 100644 index 00000000..dd49d2fb --- /dev/null +++ b/src/views/Charts/components/MutualFriends.vue @@ -0,0 +1,469 @@ + + + + + diff --git a/src/views/Charts/composables/useMutualGraphChart.js b/src/views/Charts/composables/useMutualGraphChart.js new file mode 100644 index 00000000..34cdc087 --- /dev/null +++ b/src/views/Charts/composables/useMutualGraphChart.js @@ -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 + }; +}