mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-04-17 13:53:52 +02:00
rewrite mutual friends graph
This commit is contained in:
84
package-lock.json
generated
84
package-lock.json
generated
@@ -22,6 +22,7 @@
|
||||
"@kamiya4047/eslint-plugin-pretty-import": "^0.1.6",
|
||||
"@sentry/vite-plugin": "^4.8.0",
|
||||
"@sentry/vue": "^10.38.0",
|
||||
"@sigma/node-border": "^3.0.0",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tanstack/vue-table": "^8.21.3",
|
||||
"@tanstack/vue-virtual": "^3.13.18",
|
||||
@@ -49,7 +50,9 @@
|
||||
"eslint-plugin-vue": "^9.33.0",
|
||||
"globals": "^17.2.0",
|
||||
"graphology": "^0.26.0",
|
||||
"graphology-communities-louvain": "^2.0.2",
|
||||
"graphology-layout-forceatlas2": "^0.10.1",
|
||||
"graphology-layout-noverlap": "^0.4.2",
|
||||
"jest": "^30.2.0",
|
||||
"lightningcss": "^1.31.1",
|
||||
"lucide-vue-next": "^0.562.0",
|
||||
@@ -4704,6 +4707,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@sigma/node-border": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@sigma/node-border/-/node-border-3.0.0.tgz",
|
||||
"integrity": "sha512-mE3zUfjvJVuAMhSjiP/zdlkqe0OVTETxd04XHUwof01YqdzTk0OB4ACJIhWrwgsBXl7tTd9lPuKoroafLh8MtQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"sigma": ">=3.0.0-beta.17"
|
||||
}
|
||||
},
|
||||
"node_modules/@sinclair/typebox": {
|
||||
"version": "0.34.41",
|
||||
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz",
|
||||
@@ -10228,6 +10241,36 @@
|
||||
"graphology-types": ">=0.24.0"
|
||||
}
|
||||
},
|
||||
"node_modules/graphology-communities-louvain": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/graphology-communities-louvain/-/graphology-communities-louvain-2.0.2.tgz",
|
||||
"integrity": "sha512-zt+2hHVPYxjEquyecxWXoUoIuN/UvYzsvI7boDdMNz0rRvpESQ7+e+Ejv6wK7AThycbZXuQ6DkG8NPMCq6XwoA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graphology-indices": "^0.17.0",
|
||||
"graphology-utils": "^2.4.4",
|
||||
"mnemonist": "^0.39.0",
|
||||
"pandemonium": "^2.4.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"graphology-types": ">=0.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/graphology-indices": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/graphology-indices/-/graphology-indices-0.17.0.tgz",
|
||||
"integrity": "sha512-A7RXuKQvdqSWOpn7ZVQo4S33O0vCfPBnUSf7FwE0zNCasqwZVUaCXePuWo5HBpWw68KJcwObZDHpFk6HKH6MYQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graphology-utils": "^2.4.2",
|
||||
"mnemonist": "^0.39.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"graphology-types": ">=0.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/graphology-layout-forceatlas2": {
|
||||
"version": "0.10.1",
|
||||
"resolved": "https://registry.npmjs.org/graphology-layout-forceatlas2/-/graphology-layout-forceatlas2-0.10.1.tgz",
|
||||
@@ -10241,6 +10284,19 @@
|
||||
"graphology-types": ">=0.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/graphology-layout-noverlap": {
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmjs.org/graphology-layout-noverlap/-/graphology-layout-noverlap-0.4.2.tgz",
|
||||
"integrity": "sha512-13WwZSx96zim6l1dfZONcqLh3oqyRcjIBsqz2c2iJ3ohgs3605IDWjldH41Gnhh462xGB1j6VGmuGhZ2FKISXA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graphology-utils": "^2.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"graphology-types": ">=0.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/graphology-types": {
|
||||
"version": "0.24.8",
|
||||
"resolved": "https://registry.npmjs.org/graphology-types/-/graphology-types-0.24.8.tgz",
|
||||
@@ -14082,6 +14138,16 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/mnemonist": {
|
||||
"version": "0.39.8",
|
||||
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.8.tgz",
|
||||
"integrity": "sha512-vyWo2K3fjrUw8YeeZ1zF0fy6Mu59RHokURlld8ymdUPjMlD9EC9ov1/YPqTgqRvUN9nTr3Gqfz29LYAmu0PHPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"obliterator": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -14496,6 +14562,13 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/obliterator": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz",
|
||||
"integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ohash": {
|
||||
"version": "2.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz",
|
||||
@@ -14676,6 +14749,16 @@
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0"
|
||||
},
|
||||
"node_modules/pandemonium": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/pandemonium/-/pandemonium-2.4.1.tgz",
|
||||
"integrity": "sha512-wRqjisUyiUfXowgm7MFH2rwJzKIr20rca5FsHXCMNm1W5YPP1hCtrZfgmQ62kP7OZ7Xt+cR858aB28lu5NX55g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mnemonist": "^0.39.2"
|
||||
}
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
@@ -16490,6 +16573,7 @@
|
||||
"integrity": "sha512-/BUbeOwPGruiBOm0YQQ6ZMcLIZ6tf/W+Jcm7dxZyAX0tK3WP9/sq7/NAWBxPIxVahdGjCJoGwej0Gdrv0DxlQQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"events": "^3.3.0",
|
||||
"graphology-utils": "^2.5.2"
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
"@kamiya4047/eslint-plugin-pretty-import": "^0.1.6",
|
||||
"@sentry/vite-plugin": "^4.8.0",
|
||||
"@sentry/vue": "^10.38.0",
|
||||
"@sigma/node-border": "^3.0.0",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tanstack/vue-table": "^8.21.3",
|
||||
"@tanstack/vue-virtual": "^3.13.18",
|
||||
@@ -70,7 +71,9 @@
|
||||
"eslint-plugin-vue": "^9.33.0",
|
||||
"globals": "^17.2.0",
|
||||
"graphology": "^0.26.0",
|
||||
"graphology-communities-louvain": "^2.0.2",
|
||||
"graphology-layout-forceatlas2": "^0.10.1",
|
||||
"graphology-layout-noverlap": "^0.4.2",
|
||||
"jest": "^30.2.0",
|
||||
"lightningcss": "^1.31.1",
|
||||
"lucide-vue-next": "^0.562.0",
|
||||
|
||||
@@ -1,46 +1,60 @@
|
||||
<template>
|
||||
<div class="mutual-graph pt-12" ref="mutualGraphRef">
|
||||
<div class="options-container mutual-graph__toolbar">
|
||||
<div class="mutual-graph__actions">
|
||||
<TooltipWrapper :content="fetchButtonLabel" side="top">
|
||||
<Button :disabled="fetchButtonDisabled" @click="startFetch">
|
||||
<Spinner v-if="isFetching" />
|
||||
{{ fetchButtonLabel }}
|
||||
</Button>
|
||||
</TooltipWrapper>
|
||||
<TooltipWrapper
|
||||
v-if="isFetching"
|
||||
:content="t('view.charts.mutual_friend.actions.stop_fetching')"
|
||||
side="top">
|
||||
<Button variant="destructive" :disabled="status.cancelRequested" @click="cancelFetch">
|
||||
{{ t('view.charts.mutual_friend.actions.stop') }}
|
||||
</Button>
|
||||
</TooltipWrapper>
|
||||
<div class="mt-0 flex min-h-[calc(100vh-140px)] flex-col items-center justify-betweenpt-12" ref="mutualGraphRef">
|
||||
<div class="flex items-center w-full">
|
||||
<div
|
||||
class="options-container mt-2 mb-0 flex flex-wrap items-center gap-3 bg-transparent px-0 pb-2 shadow-none">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<TooltipWrapper :content="fetchButtonLabel" side="top">
|
||||
<Button :disabled="fetchButtonDisabled" @click="startFetch">
|
||||
<Spinner v-if="isFetching" />
|
||||
{{ fetchButtonLabel }}
|
||||
</Button>
|
||||
</TooltipWrapper>
|
||||
|
||||
<TooltipWrapper
|
||||
v-if="isFetching"
|
||||
:content="t('view.charts.mutual_friend.actions.stop_fetching')"
|
||||
side="top">
|
||||
<Button variant="destructive" :disabled="status.cancelRequested" @click="cancelFetch">
|
||||
{{ t('view.charts.mutual_friend.actions.stop') }}
|
||||
</Button>
|
||||
</TooltipWrapper>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="isFetching"
|
||||
class="mt-3 grid grid-cols-[repeat(auto-fit,minmax(150px,1fr))] items-center gap-x-3 gap-y-2 rounded-md bg-transparent p-3 ml-auto">
|
||||
<div class="flex justify-between text-[13px]">
|
||||
<span>{{ t('view.charts.mutual_friend.progress.friends_processed') }}</span>
|
||||
<strong>{{ fetchState.processedFriends }} / {{ totalFriends }}</strong>
|
||||
</div>
|
||||
<Progress :model-value="progressPercent" class="h-3" />
|
||||
</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>
|
||||
<div
|
||||
v-show="!(hasFetched && !isFetching && !graphReady)"
|
||||
ref="graphContainerRef"
|
||||
class="mt-3 h-[calc(100vh-260px)] min-h-[520px] w-full flex-1 rounded-lg bg-transparent"
|
||||
:style="{ backgroundColor: canvasBackground }"></div>
|
||||
|
||||
<Progress :model-value="progressPercent" class="h-3" />
|
||||
</div>
|
||||
|
||||
<div ref="graphContainerRef" class="mutual-graph__canvas" :style="{ backgroundColor: canvasBackground }"></div>
|
||||
|
||||
<div v-if="hasFetched && !isFetching && !graphReady" class="mutual-graph__placeholder">
|
||||
<span>{{ t('view.charts.mutual_friend.progress.no_relationships_discovered') }}</span>
|
||||
</div>
|
||||
<Empty v-if="hasFetched && !isFetching && !graphReady" class="mt-3 w-full flex-1">
|
||||
<EmptyHeader>
|
||||
<EmptyDescription>
|
||||
{{ t('view.charts.mutual_friend.progress.no_relationships_discovered') }}
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { Empty, EmptyDescription, EmptyHeader } from '@/components/ui/empty';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { createNodeBorderProgram } from '@sigma/node-border';
|
||||
import { onBeforeRouteLeave } from 'vue-router';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { toast } from 'vue-sonner';
|
||||
@@ -49,6 +63,8 @@
|
||||
import Graph from 'graphology';
|
||||
import Sigma from 'sigma';
|
||||
import forceAtlas2 from 'graphology-layout-forceatlas2';
|
||||
import louvain from 'graphology-communities-louvain';
|
||||
import noverlap from 'graphology-layout-noverlap';
|
||||
|
||||
import {
|
||||
useAppearanceSettingsStore,
|
||||
@@ -58,7 +74,9 @@
|
||||
useUserStore
|
||||
} from '../../../stores';
|
||||
import { createRateLimiter, executeWithBackoff } from '../../../shared/utils';
|
||||
import { database } from '../../../service/database';
|
||||
import { userRequest } from '../../../api';
|
||||
import { watchState } from '../../../service/watchState';
|
||||
|
||||
const { t } = useI18n();
|
||||
const friendStore = useFriendStore();
|
||||
@@ -76,7 +94,8 @@
|
||||
const fetchState = chartsStore.mutualGraphFetchState;
|
||||
const status = chartsStore.mutualGraphStatus;
|
||||
|
||||
const LOCAL_STORAGE_KEY = 'VRCX_MutualGraphSnapshot';
|
||||
const MAX_LABEL_NAME_LENGTH = 20;
|
||||
|
||||
const COLORS_PALETTE = [
|
||||
'#5470c6',
|
||||
'#91cc75',
|
||||
@@ -88,14 +107,26 @@
|
||||
'#9a60b4',
|
||||
'#ea7ccc'
|
||||
];
|
||||
const MAX_LABEL_NAME_LENGTH = 22;
|
||||
|
||||
const NodeBorderProgram = createNodeBorderProgram({
|
||||
borders: [
|
||||
{ size: { value: 0.1 }, color: { value: '#f2f2f2' } },
|
||||
{ size: { fill: true }, color: { attribute: 'color' } }
|
||||
]
|
||||
});
|
||||
|
||||
const graphContainerRef = ref(null);
|
||||
const mutualGraphRef = ref(null);
|
||||
|
||||
let sigmaInstance = null;
|
||||
let currentGraph = null;
|
||||
let resizeObserver = null;
|
||||
|
||||
watch(isDarkMode, () => {
|
||||
if (!currentGraph) return;
|
||||
renderGraph(currentGraph, true);
|
||||
});
|
||||
|
||||
const isFetching = computed({
|
||||
get: () => status.isFetching,
|
||||
set: (val) => {
|
||||
@@ -126,8 +157,8 @@
|
||||
const progressPercent = computed(() =>
|
||||
totalFriends.value ? Math.min(100, Math.round((fetchState.processedFriends / totalFriends.value) * 100)) : 0
|
||||
);
|
||||
const canvasBackground = computed(() => (isDarkMode.value ? 'rgba(15, 23, 42, 0.35)' : 'rgba(15, 23, 42, 0.02)'));
|
||||
const edgeColor = computed(() => (isDarkMode.value ? 'rgba(226,232,240,0.2)' : 'rgba(15,23,42,0.2)'));
|
||||
|
||||
const canvasBackground = computed(() => 'transparent');
|
||||
|
||||
const mutualGraphResizeObserver = new ResizeObserver(() => {
|
||||
setMutualGraphHeight();
|
||||
@@ -143,21 +174,17 @@
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
if (!graphContainerRef.value) {
|
||||
return;
|
||||
}
|
||||
if (!graphContainerRef.value) return;
|
||||
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
if (sigmaInstance?.refresh) {
|
||||
sigmaInstance.refresh();
|
||||
}
|
||||
if (sigmaInstance?.refresh) sigmaInstance.refresh();
|
||||
});
|
||||
resizeObserver.observe(graphContainerRef.value);
|
||||
|
||||
mutualGraphResizeObserver.observe(mutualGraphRef.value);
|
||||
setMutualGraphHeight();
|
||||
|
||||
if (currentGraph) {
|
||||
renderGraph(currentGraph);
|
||||
}
|
||||
if (currentGraph) renderGraph(currentGraph);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -171,56 +198,81 @@
|
||||
sigmaInstance = null;
|
||||
}
|
||||
currentGraph = null;
|
||||
if (mutualGraphResizeObserver) {
|
||||
mutualGraphResizeObserver.disconnect();
|
||||
}
|
||||
if (mutualGraphResizeObserver) mutualGraphResizeObserver.disconnect();
|
||||
});
|
||||
|
||||
watch(
|
||||
activeTab,
|
||||
(tab) => {
|
||||
if (tab === 'mutual') {
|
||||
loadGraphFromLocalStorage();
|
||||
}
|
||||
if (tab === 'mutual') loadGraphFromDatabase();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(isDarkMode, () => {
|
||||
if (!currentGraph) {
|
||||
return;
|
||||
watch(
|
||||
() => watchState.isFriendsLoaded,
|
||||
(isFriendsLoaded) => {
|
||||
if (isFriendsLoaded && activeTab.value === 'mutual') {
|
||||
loadGraphFromDatabase();
|
||||
}
|
||||
}
|
||||
applyThemeToGraph(currentGraph);
|
||||
renderGraph(currentGraph);
|
||||
});
|
||||
);
|
||||
|
||||
function showStatusMessage(message, type = 'info') {
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
if (!message) return;
|
||||
const toastFn = toast[type] ?? toast;
|
||||
toastFn(message, { duration: 4000 });
|
||||
}
|
||||
|
||||
function truncateLabelText(text) {
|
||||
if (!text) {
|
||||
return 'Unknown';
|
||||
}
|
||||
if (!text) return 'Unknown';
|
||||
return text.length > MAX_LABEL_NAME_LENGTH ? `${text.slice(0, MAX_LABEL_NAME_LENGTH)}…` : text;
|
||||
}
|
||||
|
||||
function hashToUnit(value) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < value.length; i += 1) {
|
||||
hash = (hash * 31 + value.charCodeAt(i)) % 1000;
|
||||
}
|
||||
return hash / 1000 - 0.5;
|
||||
function initPositions(graph) {
|
||||
const n = graph.order;
|
||||
const radius = Math.max(50, Math.sqrt(n) * 30);
|
||||
graph.forEachNode((node) => {
|
||||
const a = Math.random() * Math.PI * 2;
|
||||
const r = Math.sqrt(Math.random()) * radius;
|
||||
graph.mergeNodeAttributes(node, {
|
||||
x: Math.cos(a) * r,
|
||||
y: Math.sin(a) * r
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function applyThemeToGraph(graph) {
|
||||
const color = edgeColor.value;
|
||||
graph.forEachEdge((edge) => {
|
||||
graph.setEdgeAttribute(edge, 'color', color);
|
||||
function runLayout(graph) {
|
||||
initPositions(graph);
|
||||
|
||||
const iterations = Math.min(1200, Math.max(400, Math.round(Math.sqrt(graph.order) * 18)));
|
||||
|
||||
const inferred = forceAtlas2.inferSettings ? forceAtlas2.inferSettings(graph) : {};
|
||||
const settings = {
|
||||
...inferred,
|
||||
barnesHutOptimize: true,
|
||||
barnesHutTheta: 0.8,
|
||||
strongGravityMode: true,
|
||||
gravity: 1.2,
|
||||
scalingRatio: 20,
|
||||
slowDown: 2
|
||||
};
|
||||
|
||||
forceAtlas2.assign(graph, { iterations, settings });
|
||||
noverlap.assign(graph, { maxIterations: 200, settings: { ratio: 1.1, margin: 2 } });
|
||||
}
|
||||
|
||||
function assignCommunitiesAndColors(graph) {
|
||||
const communities = louvain(graph);
|
||||
const ids = Array.from(new Set(Object.values(communities)));
|
||||
ids.sort((a, b) => String(a).localeCompare(String(b)));
|
||||
const idToIndex = new Map(ids.map((id, i) => [id, i]));
|
||||
|
||||
graph.forEachNode((node) => {
|
||||
const communityId = communities[node];
|
||||
const idx = idToIndex.get(communityId) ?? 0;
|
||||
graph.setNodeAttribute(node, 'community', communityId);
|
||||
graph.setNodeAttribute(node, 'color', COLORS_PALETTE[idx % COLORS_PALETTE.length]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -230,32 +282,25 @@
|
||||
multi: false,
|
||||
allowSelfLoops: false
|
||||
});
|
||||
|
||||
const nodeDegree = new Map();
|
||||
const nodeNames = new Map();
|
||||
|
||||
function ensureNode(id, name) {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
if (!id) return;
|
||||
if (!graph.hasNode(id)) {
|
||||
graph.addNode(id);
|
||||
nodeDegree.set(id, 0);
|
||||
}
|
||||
if (name && !nodeNames.get(id)) {
|
||||
nodeNames.set(id, name);
|
||||
}
|
||||
if (name && !nodeNames.get(id)) nodeNames.set(id, name);
|
||||
}
|
||||
|
||||
function addEdge(source, target) {
|
||||
if (!source || !target || source === target) {
|
||||
return;
|
||||
}
|
||||
if (!source || !target || source === target) return;
|
||||
const [a, b] = [source, target].sort();
|
||||
const key = `${a}__${b}`;
|
||||
if (graph.hasEdge(key)) {
|
||||
return;
|
||||
}
|
||||
graph.addEdgeWithKey(key, a, b, { color: edgeColor.value });
|
||||
if (graph.hasEdge(key)) return;
|
||||
graph.addEdgeWithKey(key, a, b, { size: 0.75 });
|
||||
nodeDegree.set(a, (nodeDegree.get(a) || 0) + 1);
|
||||
nodeDegree.set(b, (nodeDegree.get(b) || 0) + 1);
|
||||
}
|
||||
@@ -266,9 +311,7 @@
|
||||
ensureNode(friendId, friendName || friendId);
|
||||
|
||||
for (const mutual of mutuals) {
|
||||
if (!mutual?.id) {
|
||||
continue;
|
||||
}
|
||||
if (!mutual?.id) continue;
|
||||
const cached = cachedUsers.get(mutual.id);
|
||||
const label = cached?.displayName || mutual.displayName || mutual.id;
|
||||
ensureNode(mutual.id, label);
|
||||
@@ -278,69 +321,187 @@
|
||||
|
||||
const nodeIds = graph.nodes();
|
||||
const maxDegree = nodeIds.reduce((max, id) => Math.max(max, nodeDegree.get(id) || 0), 0);
|
||||
const radius = Math.max(80, Math.sqrt(Math.max(nodeIds.length, 1)) * 60);
|
||||
|
||||
nodeIds.forEach((id, index) => {
|
||||
const baseX = hashToUnit(id) * radius;
|
||||
const baseY = hashToUnit(`${id}-y`) * radius;
|
||||
const jitterX = hashToUnit(id + id) * radius * 0.15;
|
||||
const jitterY = hashToUnit(id + 'z') * radius * 0.15;
|
||||
nodeIds.forEach((id) => {
|
||||
const degree = nodeDegree.get(id) || 0;
|
||||
const size = 6 + (maxDegree ? (degree / maxDegree) * 16 : 0);
|
||||
const size = 4 + (maxDegree ? (degree / maxDegree) * 18 : 0);
|
||||
const label = truncateLabelText(nodeNames.get(id) || id);
|
||||
const color = COLORS_PALETTE[index % COLORS_PALETTE.length];
|
||||
|
||||
graph.mergeNodeAttributes(id, {
|
||||
label,
|
||||
size,
|
||||
color,
|
||||
x: baseX + jitterX,
|
||||
y: baseY + jitterY
|
||||
});
|
||||
graph.mergeNodeAttributes(id, { label, size, type: 'border' });
|
||||
});
|
||||
|
||||
if (graph.order > 1) {
|
||||
const iterations = Math.min(200, Math.max(90, Math.round(Math.sqrt(graph.order)) * 12));
|
||||
const inferred = forceAtlas2.inferSettings ? forceAtlas2.inferSettings(graph) : {};
|
||||
const settings = {
|
||||
...inferred,
|
||||
gravity: 0.7,
|
||||
scalingRatio: 14,
|
||||
slowDown: 1,
|
||||
barnesHutOptimize: true,
|
||||
strongGravityMode: false
|
||||
};
|
||||
forceAtlas2.assign(graph, {
|
||||
iterations,
|
||||
settings
|
||||
});
|
||||
runLayout(graph);
|
||||
assignCommunitiesAndColors(graph);
|
||||
}
|
||||
|
||||
applyThemeToGraph(graph);
|
||||
|
||||
graphNodeCount.value = graph.order;
|
||||
return graph;
|
||||
}
|
||||
|
||||
function renderGraph(graph) {
|
||||
if (!graphContainerRef.value) {
|
||||
return;
|
||||
}
|
||||
if (sigmaInstance) {
|
||||
function renderGraph(graph, forceRecreate = false) {
|
||||
if (!graphContainerRef.value) return;
|
||||
|
||||
const DEFAULT_LABEL_THRESHOLD = 10;
|
||||
|
||||
const labelColor = isDarkMode.value ? '#e2e8f0' : '#111827';
|
||||
const EDGE_BASE = isDarkMode.value ? '#334155' : '#94a3b8';
|
||||
const EDGE_ACTIVE = isDarkMode.value ? '#bac1c9' : '#0f172a';
|
||||
|
||||
let cameraState = null;
|
||||
|
||||
if (sigmaInstance && forceRecreate) {
|
||||
try {
|
||||
const cam = sigmaInstance.getCamera?.();
|
||||
cameraState = cam?.getState?.() || null;
|
||||
} catch (e) {}
|
||||
sigmaInstance.kill();
|
||||
sigmaInstance = null;
|
||||
}
|
||||
const labelColor = isDarkMode.value ? '#e2e8f0' : '#111827';
|
||||
sigmaInstance = new Sigma(graph, graphContainerRef.value, {
|
||||
renderLabels: true,
|
||||
labelRenderedSizeThreshold: 8,
|
||||
labelColor: { color: labelColor }
|
||||
});
|
||||
sigmaInstance.on('clickNode', ({ node }) => {
|
||||
if (node) {
|
||||
showUserDialog(node);
|
||||
|
||||
if (!sigmaInstance) {
|
||||
sigmaInstance = new Sigma(graph, graphContainerRef.value, {
|
||||
renderLabels: true,
|
||||
labelRenderedSizeThreshold: DEFAULT_LABEL_THRESHOLD,
|
||||
labelColor: { color: labelColor },
|
||||
defaultEdgeColor: EDGE_BASE,
|
||||
zIndex: true,
|
||||
defaultNodeType: 'border',
|
||||
nodeProgramClasses: { border: NodeBorderProgram },
|
||||
defaultDrawNodeHover: (ctx, data, settings) => {
|
||||
if (!data.label) return;
|
||||
|
||||
const fontSize = settings.labelSize ?? 12;
|
||||
const font = settings.labelFont ?? 'sans-serif';
|
||||
|
||||
ctx.font = `${fontSize}px ${font}`;
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
const paddingX = 6;
|
||||
const paddingY = 4;
|
||||
|
||||
const textWidth = ctx.measureText(data.label).width;
|
||||
const w = textWidth + paddingX * 2;
|
||||
const h = fontSize + paddingY * 2;
|
||||
|
||||
const x = data.x + data.size - 5;
|
||||
const y = data.y - h / 2;
|
||||
|
||||
ctx.shadowColor = 'rgba(0, 0, 0, 0.25)';
|
||||
ctx.shadowBlur = 6;
|
||||
ctx.shadowOffsetX = 0;
|
||||
ctx.shadowOffsetY = 2;
|
||||
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 1)';
|
||||
ctx.fillRect(x, y, w, h);
|
||||
|
||||
ctx.fillStyle = '#111827';
|
||||
ctx.fillText(data.label, x + paddingX, y + h / 2);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
sigmaInstance.setGraph(graph);
|
||||
sigmaInstance.setSetting('labelRenderedSizeThreshold', DEFAULT_LABEL_THRESHOLD);
|
||||
sigmaInstance.setSetting('labelColor', { color: labelColor });
|
||||
sigmaInstance.setSetting('defaultEdgeColor', EDGE_BASE);
|
||||
sigmaInstance.setSetting('zIndex', true);
|
||||
}
|
||||
|
||||
if (cameraState) {
|
||||
try {
|
||||
const cam = sigmaInstance.getCamera?.();
|
||||
cam?.setState?.(cameraState);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
let hovered = null;
|
||||
let neighbors = new Set();
|
||||
|
||||
const rebuildNeighbors = (node) => {
|
||||
neighbors = node ? new Set(graph.neighbors(node)) : new Set();
|
||||
};
|
||||
|
||||
sigmaInstance.setSetting('nodeReducer', (node, data) => {
|
||||
const res = { ...data };
|
||||
|
||||
if (!hovered) {
|
||||
res.color = data.color;
|
||||
res.zIndex = 1;
|
||||
return res;
|
||||
}
|
||||
|
||||
const isHover = node === hovered;
|
||||
const isNeighbor = neighbors.has(node);
|
||||
|
||||
if (isHover) {
|
||||
res.color = '#facc15';
|
||||
res.size = (data.size || 4) * 1.6;
|
||||
res.label = data.label;
|
||||
res.labelColor = '#111827';
|
||||
res.zIndex = 3;
|
||||
return res;
|
||||
}
|
||||
|
||||
if (isNeighbor) {
|
||||
res.color = data.color;
|
||||
res.size = (data.size || 4) * 1.2;
|
||||
res.label = data.label;
|
||||
res.labelColor = '#111827';
|
||||
res.zIndex = 2;
|
||||
return res;
|
||||
}
|
||||
|
||||
res.color = isDarkMode.value ? 'rgba(148,163,184,0.04)' : 'rgba(100,116,139,0.06)';
|
||||
res.size = 0.7;
|
||||
res.label = '';
|
||||
res.zIndex = 0;
|
||||
return res;
|
||||
});
|
||||
|
||||
sigmaInstance.setSetting('edgeReducer', (edge, data) => {
|
||||
const res = { ...data };
|
||||
|
||||
if (!hovered) {
|
||||
res.hidden = false;
|
||||
res.color = EDGE_BASE;
|
||||
res.size = data.size || 1;
|
||||
return res;
|
||||
}
|
||||
|
||||
const [s, t] = graph.extremities(edge);
|
||||
const active = s === hovered || t === hovered;
|
||||
|
||||
if (active) {
|
||||
res.hidden = false;
|
||||
res.color = EDGE_ACTIVE;
|
||||
res.size = data.size || 1;
|
||||
return res;
|
||||
}
|
||||
|
||||
res.hidden = true;
|
||||
return res;
|
||||
});
|
||||
|
||||
sigmaInstance.removeAllListeners?.();
|
||||
|
||||
sigmaInstance.on('enterNode', ({ node }) => {
|
||||
hovered = node;
|
||||
rebuildNeighbors(node);
|
||||
sigmaInstance.setSetting('labelRenderedSizeThreshold', 0);
|
||||
sigmaInstance.refresh();
|
||||
});
|
||||
|
||||
sigmaInstance.on('leaveNode', () => {
|
||||
hovered = null;
|
||||
rebuildNeighbors(null);
|
||||
sigmaInstance.setSetting('labelRenderedSizeThreshold', DEFAULT_LABEL_THRESHOLD);
|
||||
sigmaInstance.refresh();
|
||||
});
|
||||
|
||||
sigmaInstance.on('clickNode', ({ node }) => {
|
||||
if (node) showUserDialog(node);
|
||||
});
|
||||
|
||||
sigmaInstance.refresh();
|
||||
}
|
||||
|
||||
function applyGraph(mutualMap) {
|
||||
@@ -349,15 +510,22 @@
|
||||
renderGraph(graph);
|
||||
}
|
||||
|
||||
async function loadGraphFromLocalStorage() {
|
||||
if (hasFetched.value || isFetching.value || isLoadingSnapshot.value) {
|
||||
return;
|
||||
}
|
||||
async function loadGraphFromDatabase() {
|
||||
if (!watchState.isLoggedIn || !currentUser.value?.id) return;
|
||||
if (!watchState.isFriendsLoaded) return;
|
||||
if (isFetching.value || isLoadingSnapshot.value) return;
|
||||
if (hasFetched.value && !status.needsRefetch) return;
|
||||
|
||||
isLoadingSnapshot.value = true;
|
||||
loadingToastId.value = toast.loading(t('view.charts.mutual_friend.status.loading_cache'));
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem(LOCAL_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
const snapshot = await database.getMutualGraphSnapshot();
|
||||
if (!snapshot || snapshot.size === 0) {
|
||||
if (totalFriends.value === 0) {
|
||||
showStatusMessage(t('view.charts.mutual_friend.status.no_friends_to_process'), 'info');
|
||||
return;
|
||||
}
|
||||
if (isOptOut.value) {
|
||||
promptEnableMutualFriendsSharing();
|
||||
return;
|
||||
@@ -366,22 +534,15 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw);
|
||||
const snapshot = parsed?.data;
|
||||
if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot)) {
|
||||
await promptInitialFetch();
|
||||
return;
|
||||
}
|
||||
|
||||
const mutualMap = new Map();
|
||||
Object.entries(snapshot).forEach(([friendId, mutualIds]) => {
|
||||
if (!friendId) {
|
||||
return;
|
||||
}
|
||||
snapshot.forEach((mutualIds, friendId) => {
|
||||
if (!friendId) return;
|
||||
const friendEntry = friends.value?.get ? friends.value.get(friendId) : undefined;
|
||||
const fallbackRef = friendEntry?.ref || cachedUsers.get(friendId);
|
||||
|
||||
let normalizedMutuals = Array.isArray(mutualIds) ? mutualIds : [];
|
||||
normalizedMutuals = normalizedMutuals.filter((id) => id != 'usr_00000000-0000-0000-0000-000000000000');
|
||||
|
||||
mutualMap.set(friendId, {
|
||||
friend: friendEntry || (fallbackRef ? { id: friendId, ref: fallbackRef } : { id: friendId }),
|
||||
mutuals: normalizedMutuals.map((id) => ({ id }))
|
||||
@@ -410,9 +571,7 @@
|
||||
}
|
||||
|
||||
async function promptInitialFetch() {
|
||||
if (isFetching.value || hasFetched.value || !totalFriends.value) {
|
||||
return;
|
||||
}
|
||||
if (isFetching.value || hasFetched.value || !totalFriends.value) return;
|
||||
|
||||
modalStore
|
||||
.confirm({
|
||||
@@ -423,7 +582,6 @@
|
||||
})
|
||||
.then(async ({ ok }) => {
|
||||
if (!ok) return;
|
||||
|
||||
await startFetch();
|
||||
});
|
||||
}
|
||||
@@ -445,35 +603,26 @@
|
||||
}
|
||||
|
||||
function cancelFetch() {
|
||||
if (isFetching.value) {
|
||||
status.cancelRequested = true;
|
||||
}
|
||||
if (isFetching.value) status.cancelRequested = true;
|
||||
}
|
||||
|
||||
const isCancelled = () => status.cancelRequested === true;
|
||||
|
||||
async function startFetch() {
|
||||
const rateLimiter = createRateLimiter({
|
||||
limitPerInterval: 5,
|
||||
intervalMs: 1000
|
||||
});
|
||||
const rateLimiter = createRateLimiter({ limitPerInterval: 5, intervalMs: 1000 });
|
||||
|
||||
const fetchMutualFriends = async (userId) => {
|
||||
const collected = [];
|
||||
let offset = 0;
|
||||
|
||||
while (true) {
|
||||
if (isCancelled()) {
|
||||
break;
|
||||
}
|
||||
if (isCancelled()) break;
|
||||
await rateLimiter.wait();
|
||||
if (isCancelled()) {
|
||||
break;
|
||||
}
|
||||
if (isCancelled()) break;
|
||||
|
||||
const args = await executeWithBackoff(
|
||||
() => {
|
||||
if (isCancelled()) {
|
||||
throw new Error('cancelled');
|
||||
}
|
||||
if (isCancelled()) throw new Error('cancelled');
|
||||
return userRequest.getMutualFriends({ userId, offset, n: 100 });
|
||||
},
|
||||
{
|
||||
@@ -482,26 +631,23 @@
|
||||
shouldRetry: (err) => err?.status === 429 || (err?.message || '').includes('429')
|
||||
}
|
||||
).catch((err) => {
|
||||
if ((err?.message || '') === 'cancelled') {
|
||||
return null;
|
||||
}
|
||||
if ((err?.message || '') === 'cancelled') return null;
|
||||
throw err;
|
||||
});
|
||||
if (!args || isCancelled()) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (!args || isCancelled()) break;
|
||||
|
||||
collected.push(...args.json);
|
||||
if (args.json.length < 100) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (args.json.length < 100) break;
|
||||
offset += args.json.length;
|
||||
}
|
||||
|
||||
return collected;
|
||||
};
|
||||
|
||||
if (isFetching.value || isOptOut.value) {
|
||||
return;
|
||||
}
|
||||
if (isFetching.value || isOptOut.value) return;
|
||||
|
||||
if (!totalFriends.value) {
|
||||
showStatusMessage(t('view.charts.mutual_friend.status.no_friends_to_process'), 'info');
|
||||
return;
|
||||
@@ -512,24 +658,23 @@
|
||||
status.needsRefetch = false;
|
||||
status.cancelRequested = false;
|
||||
hasFetched.value = false;
|
||||
Object.assign(fetchState, {
|
||||
processedFriends: 0
|
||||
});
|
||||
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;
|
||||
}
|
||||
if (!friend?.id) continue;
|
||||
|
||||
if (isCancelled()) {
|
||||
cancelled = true;
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
const mutuals = await fetchMutualFriends(friend.id);
|
||||
if (isCancelled()) {
|
||||
@@ -545,6 +690,7 @@
|
||||
console.warn('[MutualNetworkGraph] Skipping friend due to fetch error', friend.id, err);
|
||||
continue;
|
||||
}
|
||||
|
||||
fetchState.processedFriends = index + 1;
|
||||
if (status.cancelRequested) {
|
||||
cancelled = true;
|
||||
@@ -563,10 +709,31 @@
|
||||
status.needsRefetch = false;
|
||||
|
||||
try {
|
||||
persistMutualGraphToLocalStorage(mutualMap);
|
||||
const entries = 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 && identifier !== 'usr_00000000-0000-0000-0000-000000000000')
|
||||
ids.push(identifier);
|
||||
}
|
||||
|
||||
entries.set(normalizedFriendId, ids);
|
||||
});
|
||||
await database.saveMutualGraphSnapshot(entries);
|
||||
} catch (persistErr) {
|
||||
console.error('[MutualNetworkGraph] Failed to cache data', persistErr);
|
||||
}
|
||||
|
||||
hasFetched.value = true;
|
||||
} catch (err) {
|
||||
console.error('[MutualNetworkGraph] fetch aborted', err);
|
||||
@@ -576,114 +743,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
function persistMutualGraphToLocalStorage(mutualMap) {
|
||||
const snapshot = {};
|
||||
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[normalizedFriendId] = ids;
|
||||
});
|
||||
|
||||
const payload = {
|
||||
version: 1,
|
||||
savedAt: Date.now(),
|
||||
data: snapshot
|
||||
};
|
||||
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(payload));
|
||||
}
|
||||
|
||||
onBeforeRouteLeave(() => {
|
||||
chartsStore.resetMutualGraphState();
|
||||
});
|
||||
</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: space-between;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
padding: 0 0 8px 0;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.mutual-graph__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.mutual-graph__docs-button {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.mutual-graph__status {
|
||||
margin-top: 12px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 6px;
|
||||
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;
|
||||
border-radius: 8px;
|
||||
background: rgba(15, 23, 42, 0.02);
|
||||
}
|
||||
|
||||
.mutual-graph__placeholder {
|
||||
margin-top: 12px;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
border-radius: 8px;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user