mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-04-25 17:53:48 +02:00
feat: mutual friend graph (#1491)
This commit is contained in:
@@ -3,16 +3,37 @@
|
||||
<div class="options-container" style="margin-top: 0">
|
||||
<span class="header">{{ t('view.charts.header') }}</span>
|
||||
</div>
|
||||
<InstanceActivity />
|
||||
<el-tabs v-model="activeTab" class="charts-tabs">
|
||||
<el-tab-pane :label="t('view.charts.instance_activity.header')" name="instance"></el-tab-pane>
|
||||
<el-tab-pane :label="t('view.charts.mutual_friend.tab_label')" name="mutual"></el-tab-pane>
|
||||
</el-tabs>
|
||||
<div v-show="activeTab === 'instance'">
|
||||
<InstanceActivity />
|
||||
</div>
|
||||
<div v-show="activeTab === 'mutual'">
|
||||
<MutualFriends />
|
||||
</div>
|
||||
<el-backtop target="#chart" :right="30" :bottom="30"></el-backtop>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineAsyncComponent } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { useChartsStore } from '../../stores';
|
||||
|
||||
const InstanceActivity = defineAsyncComponent(() => import('./components/InstanceActivity.vue'));
|
||||
const MutualFriends = defineAsyncComponent(() => import('./components/MutualFriends.vue'));
|
||||
|
||||
const { t } = useI18n();
|
||||
const chartsStore = useChartsStore();
|
||||
const { activeTab } = storeToRefs(chartsStore);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.charts-tabs {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
469
src/views/Charts/components/MutualFriends.vue
Normal file
469
src/views/Charts/components/MutualFriends.vue
Normal file
@@ -0,0 +1,469 @@
|
||||
<template>
|
||||
<div class="mutual-graph">
|
||||
<div class="options-container mutual-graph__toolbar">
|
||||
<div class="mutual-graph__actions">
|
||||
<el-tooltip :content="fetchButtonLabel" placement="top">
|
||||
<el-button type="primary" :disabled="fetchButtonDisabled" :loading="isFetching" @click="startFetch">
|
||||
{{ fetchButtonLabel }}
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
v-if="isFetching"
|
||||
:content="t('view.charts.mutual_friend.actions.stop_fetching')"
|
||||
placement="top">
|
||||
<el-button type="danger" plain :disabled="status.cancelRequested" @click="cancelFetch">
|
||||
{{ t('view.charts.mutual_friend.actions.stop') }}
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isFetching" class="mutual-graph__status">
|
||||
<div class="mutual-graph__status-row">
|
||||
<span>{{ t('view.charts.mutual_friend.progress.friends_processed') }}</span>
|
||||
<strong>{{ fetchState.processedFriends }} / {{ totalFriends }}</strong>
|
||||
</div>
|
||||
|
||||
<el-progress :percentage="progressPercent" :status="progressStatus" :stroke-width="14"> </el-progress>
|
||||
</div>
|
||||
|
||||
<div ref="chartRef" class="mutual-graph__canvas"></div>
|
||||
|
||||
<div v-if="hasFetched && !isFetching && !graphReady" class="mutual-graph__placeholder">
|
||||
<span>{{ t('view.charts.mutual_friend.progress.no_relationships_discovered') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { useAppearanceSettingsStore, useChartsStore, useFriendStore, useUserStore } from '../../../stores';
|
||||
import { createRateLimiter, executeWithBackoff } from '../../../shared/utils';
|
||||
import { database } from '../../../service/database';
|
||||
import { useMutualGraphChart } from '../composables/useMutualGraphChart';
|
||||
import { userRequest } from '../../../api';
|
||||
|
||||
import * as echarts from 'echarts';
|
||||
|
||||
const { t } = useI18n();
|
||||
const friendStore = useFriendStore();
|
||||
const userStore = useUserStore();
|
||||
const chartsStore = useChartsStore();
|
||||
const appearanceStore = useAppearanceSettingsStore();
|
||||
const { friends } = storeToRefs(friendStore);
|
||||
const { currentUser } = storeToRefs(userStore);
|
||||
const { activeTab } = storeToRefs(chartsStore);
|
||||
const { isDarkMode } = storeToRefs(appearanceStore);
|
||||
const cachedUsers = userStore.cachedUsers;
|
||||
const showUserDialog = (userId) => userStore.showUserDialog(userId);
|
||||
|
||||
const graphPayload = chartsStore.mutualGraphPayload;
|
||||
const fetchState = chartsStore.mutualGraphFetchState;
|
||||
const status = chartsStore.mutualGraphStatus;
|
||||
|
||||
const chartTheme = computed(() => (isDarkMode.value ? 'dark' : undefined));
|
||||
|
||||
const { buildGraph, createChartOption } = useMutualGraphChart({
|
||||
cachedUsers,
|
||||
graphPayload
|
||||
});
|
||||
|
||||
const chartRef = ref(null);
|
||||
let chartInstance = null;
|
||||
let resizeObserver = null;
|
||||
let lastRenderablePayload = null;
|
||||
|
||||
const isFetching = computed({
|
||||
get: () => status.isFetching,
|
||||
set: (val) => {
|
||||
status.isFetching = val;
|
||||
}
|
||||
});
|
||||
const hasFetched = computed({
|
||||
get: () => status.hasFetched,
|
||||
set: (val) => {
|
||||
status.hasFetched = val;
|
||||
}
|
||||
});
|
||||
const fetchError = computed({
|
||||
get: () => status.fetchError,
|
||||
set: (val) => {
|
||||
status.fetchError = val;
|
||||
}
|
||||
});
|
||||
|
||||
const totalFriends = computed(() => friends.value.size);
|
||||
const isOptOut = computed(() => Boolean(currentUser.value?.hasSharedConnectionsOptOut));
|
||||
// @ts-ignore
|
||||
const graphReady = computed(() => Array.isArray(graphPayload.value?.nodes) && graphPayload.value.nodes.length > 0);
|
||||
const fetchButtonDisabled = computed(() => isFetching.value || isOptOut.value || totalFriends.value === 0);
|
||||
const fetchButtonLabel = computed(() =>
|
||||
hasFetched.value
|
||||
? t('view.charts.mutual_friend.actions.fetch_again')
|
||||
: t('view.charts.mutual_friend.actions.start_fetch')
|
||||
);
|
||||
const progressPercent = computed(() =>
|
||||
totalFriends.value ? Math.min(100, Math.round((fetchState.processedFriends / totalFriends.value) * 100)) : 0
|
||||
);
|
||||
const progressStatus = computed(() => (isFetching.value ? 'warning' : undefined));
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
if (!chartRef.value) {
|
||||
return;
|
||||
}
|
||||
createChartInstance();
|
||||
resizeObserver = new ResizeObserver(() => chartInstance?.resize());
|
||||
resizeObserver.observe(chartRef.value);
|
||||
});
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect();
|
||||
resizeObserver = null;
|
||||
}
|
||||
if (chartInstance) {
|
||||
chartInstance.dispose();
|
||||
chartInstance = null;
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
chartTheme,
|
||||
() => {
|
||||
if (!chartRef.value) {
|
||||
return;
|
||||
}
|
||||
if (chartInstance) {
|
||||
chartInstance.dispose();
|
||||
chartInstance = null;
|
||||
}
|
||||
nextTick(() => {
|
||||
if (!chartRef.value) {
|
||||
return;
|
||||
}
|
||||
createChartInstance();
|
||||
});
|
||||
},
|
||||
{ immediate: false }
|
||||
);
|
||||
|
||||
watch(
|
||||
activeTab,
|
||||
(tab) => {
|
||||
if (tab === 'mutual') {
|
||||
loadGraphFromDatabase();
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
function showStatusMessage(message, type = 'info') {
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
ElMessage({
|
||||
// @ts-ignore
|
||||
message,
|
||||
type,
|
||||
duration: 4000,
|
||||
grouping: true
|
||||
});
|
||||
}
|
||||
|
||||
function createChartInstance() {
|
||||
if (!chartRef.value) {
|
||||
return;
|
||||
}
|
||||
chartInstance = echarts.init(chartRef.value, chartTheme.value, { useDirtyRect: totalFriends.value > 1000 });
|
||||
if (lastRenderablePayload) {
|
||||
updateChart(lastRenderablePayload);
|
||||
} else if (graphReady.value) {
|
||||
// @ts-ignore
|
||||
updateChart(graphPayload.value);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGraphFromDatabase() {
|
||||
if (hasFetched.value || isFetching.value) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const snapshot = await database.getMutualGraphSnapshot();
|
||||
if (!snapshot || snapshot.size === 0) {
|
||||
await promptInitialFetch();
|
||||
return;
|
||||
}
|
||||
const mutualMap = new Map();
|
||||
snapshot.forEach((mutualIds, friendId) => {
|
||||
if (!friendId) {
|
||||
return;
|
||||
}
|
||||
const friendEntry = friends.value?.get ? friends.value.get(friendId) : undefined;
|
||||
const fallbackRef = friendEntry?.ref || cachedUsers.get(friendId);
|
||||
const normalizedMutuals = Array.isArray(mutualIds) ? mutualIds : [];
|
||||
mutualMap.set(friendId, {
|
||||
friend: friendEntry || (fallbackRef ? { id: friendId, ref: fallbackRef } : { id: friendId }),
|
||||
mutuals: normalizedMutuals.map((id) => ({ id }))
|
||||
});
|
||||
});
|
||||
if (!mutualMap.size) {
|
||||
await promptInitialFetch();
|
||||
return;
|
||||
}
|
||||
buildGraph(mutualMap, updateChart);
|
||||
hasFetched.value = true;
|
||||
fetchState.processedFriends = Math.min(mutualMap.size, totalFriends.value || mutualMap.size);
|
||||
status.friendSignature = totalFriends.value;
|
||||
status.needsRefetch = false;
|
||||
} catch (err) {
|
||||
console.error('[MutualGraph] Failed to load cached mutual graph', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function promptInitialFetch() {
|
||||
if (isFetching.value || hasFetched.value || !totalFriends.value) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
t('view.charts.mutual_friend.prompt.message'),
|
||||
t('view.charts.mutual_friend.prompt.title'),
|
||||
{
|
||||
confirmButtonText: t('view.charts.mutual_friend.prompt.confirm'),
|
||||
cancelButtonText: t('view.charts.mutual_friend.prompt.cancel'),
|
||||
type: 'warning'
|
||||
}
|
||||
);
|
||||
await startFetch();
|
||||
} catch {
|
||||
// cancelled
|
||||
}
|
||||
}
|
||||
|
||||
function cancelFetch() {
|
||||
if (isFetching.value) {
|
||||
status.cancelRequested = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function startFetch() {
|
||||
const rateLimiter = createRateLimiter({
|
||||
limitPerInterval: 5,
|
||||
intervalMs: 1000
|
||||
});
|
||||
|
||||
const fetchMutualFriends = async (userId) => {
|
||||
const collected = [];
|
||||
let offset = 0;
|
||||
while (true) {
|
||||
await rateLimiter.wait();
|
||||
const args = await executeWithBackoff(() => userRequest.getMutualFriends({ userId, offset, n: 100 }), {
|
||||
maxRetries: 4,
|
||||
baseDelay: 500,
|
||||
shouldRetry: (err) => err?.status === 429 || (err?.message || '').includes('429')
|
||||
});
|
||||
collected.push(...args.json);
|
||||
if (args.json.length < 100) {
|
||||
break;
|
||||
}
|
||||
offset += args.json.length;
|
||||
}
|
||||
return collected;
|
||||
};
|
||||
|
||||
if (isFetching.value || isOptOut.value) {
|
||||
return;
|
||||
}
|
||||
if (!totalFriends.value) {
|
||||
showStatusMessage(t('view.charts.mutual_friend.status.no_friends_to_process'), 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
isFetching.value = true;
|
||||
fetchError.value = '';
|
||||
status.completionNotified = false;
|
||||
status.needsRefetch = false;
|
||||
status.cancelRequested = false;
|
||||
hasFetched.value = false;
|
||||
Object.assign(fetchState, {
|
||||
processedFriends: 0
|
||||
});
|
||||
|
||||
const friendSnapshot = Array.from(friends.value.values());
|
||||
const mutualMap = new Map();
|
||||
|
||||
let cancelled = false;
|
||||
try {
|
||||
for (let index = 0; index < friendSnapshot.length; index += 1) {
|
||||
const friend = friendSnapshot[index];
|
||||
if (!friend?.id) {
|
||||
continue;
|
||||
}
|
||||
const mutuals = await fetchMutualFriends(friend.id);
|
||||
mutualMap.set(friend.id, { friend, mutuals });
|
||||
fetchState.processedFriends = index + 1;
|
||||
if (status.cancelRequested) {
|
||||
cancelled = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (cancelled) {
|
||||
hasFetched.value = false;
|
||||
showStatusMessage(t('view.charts.mutual_friend.messages.fetch_cancelled_graph_not_updated'), 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
buildGraph(mutualMap, updateChart);
|
||||
status.friendSignature = totalFriends.value;
|
||||
status.needsRefetch = false;
|
||||
|
||||
try {
|
||||
await persistMutualGraph(mutualMap);
|
||||
} catch (persistErr) {
|
||||
console.error('[MutualGraph] Failed to cache data', persistErr);
|
||||
}
|
||||
hasFetched.value = true;
|
||||
} catch (err) {
|
||||
console.error('[MutualGraph] fetch aborted', err);
|
||||
} finally {
|
||||
isFetching.value = false;
|
||||
status.cancelRequested = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function persistMutualGraph(mutualMap) {
|
||||
const snapshot = new Map();
|
||||
mutualMap.forEach((value, friendId) => {
|
||||
if (!friendId) {
|
||||
return;
|
||||
}
|
||||
const normalizedFriendId = String(friendId);
|
||||
const collection = Array.isArray(value?.mutuals) ? value.mutuals : [];
|
||||
const ids = [];
|
||||
for (const entry of collection) {
|
||||
const identifier =
|
||||
typeof entry?.id === 'string'
|
||||
? entry.id
|
||||
: entry?.id !== undefined && entry?.id !== null
|
||||
? String(entry.id)
|
||||
: '';
|
||||
if (identifier) {
|
||||
ids.push(identifier);
|
||||
}
|
||||
}
|
||||
snapshot.set(normalizedFriendId, ids);
|
||||
});
|
||||
await database.saveMutualGraphSnapshot(snapshot);
|
||||
}
|
||||
|
||||
function updateChart(payload) {
|
||||
const nodes = payload?.nodes ?? [];
|
||||
if (!nodes.length) {
|
||||
if (chartInstance) {
|
||||
chartInstance.clear();
|
||||
}
|
||||
return;
|
||||
}
|
||||
lastRenderablePayload = payload;
|
||||
if (!chartInstance) {
|
||||
return;
|
||||
}
|
||||
chartInstance.setOption(createChartOption(payload), true);
|
||||
registerChartEvents();
|
||||
nextTick(() => chartInstance?.resize());
|
||||
}
|
||||
|
||||
function registerChartEvents() {
|
||||
if (!chartInstance) {
|
||||
return;
|
||||
}
|
||||
chartInstance.off('click', handleChartNodeClick);
|
||||
chartInstance.on('click', handleChartNodeClick);
|
||||
}
|
||||
|
||||
function handleChartNodeClick(params) {
|
||||
if (params?.dataType !== 'node') {
|
||||
return;
|
||||
}
|
||||
const nodeId = params.data?.id;
|
||||
if (nodeId) {
|
||||
showUserDialog(nodeId);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mutual-graph {
|
||||
margin-top: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: calc(100vh - 140px);
|
||||
}
|
||||
|
||||
.mutual-graph__toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
margin-top: 0;
|
||||
margin-bottom: 8px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
padding: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.mutual-graph__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mutual-graph__status {
|
||||
margin-top: 12px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--el-border-color);
|
||||
background: transparent;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 8px 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mutual-graph__status-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.mutual-graph__status-row strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mutual-graph__canvas {
|
||||
margin-top: 12px;
|
||||
width: 100%;
|
||||
flex: 1 1 auto;
|
||||
height: calc(100vh - 260px);
|
||||
min-height: 520px;
|
||||
}
|
||||
|
||||
.mutual-graph__placeholder {
|
||||
margin-top: 12px;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
border: 1px dashed var(--el-border-color);
|
||||
border-radius: 8px;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
244
src/views/Charts/composables/useMutualGraphChart.js
Normal file
244
src/views/Charts/composables/useMutualGraphChart.js
Normal file
@@ -0,0 +1,244 @@
|
||||
import { i18n } from '../../../plugin/i18n';
|
||||
|
||||
const COLORS_PALETTE = [
|
||||
'#5470c6',
|
||||
'#91cc75',
|
||||
'#fac858',
|
||||
'#ee6666',
|
||||
'#73c0de',
|
||||
'#3ba272',
|
||||
'#fc8452',
|
||||
'#9a60b4',
|
||||
'#ea7ccc'
|
||||
];
|
||||
|
||||
const MAX_LABEL_NAME_LENGTH = 22;
|
||||
|
||||
function truncateLabelText(text) {
|
||||
if (!text) {
|
||||
return 'Unknown';
|
||||
}
|
||||
return text.length > MAX_LABEL_NAME_LENGTH
|
||||
? `${text.slice(0, MAX_LABEL_NAME_LENGTH)}…`
|
||||
: text;
|
||||
}
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function computeForceOptions(nodes, links) {
|
||||
const nodeCount = nodes.length || 1;
|
||||
const degreeSum = nodes.reduce((sum, node) => sum + (node.degree || 0), 0);
|
||||
const maxSymbol = nodes.reduce(
|
||||
(max, node) => Math.max(max, node.symbolSize || 0),
|
||||
0
|
||||
);
|
||||
const avgDegree = degreeSum / nodeCount || 0;
|
||||
const density = links.length ? links.length / nodeCount : 0;
|
||||
|
||||
const repulsionBase = 140 + maxSymbol * 4 + avgDegree * 6;
|
||||
const repulsion = clamp(repulsionBase, 180, 720);
|
||||
|
||||
const minEdge = clamp(34 + avgDegree * 1.2, 34, 70);
|
||||
const maxEdge = clamp(
|
||||
minEdge + 60 + Math.max(0, 140 - density * 18),
|
||||
90,
|
||||
200
|
||||
);
|
||||
|
||||
return {
|
||||
repulsion,
|
||||
edgeLength: [minEdge, maxEdge],
|
||||
gravity: 0.08,
|
||||
layoutAnimation: true
|
||||
};
|
||||
}
|
||||
|
||||
const t = i18n.global.t;
|
||||
|
||||
export function useMutualGraphChart({ cachedUsers, graphPayload }) {
|
||||
function buildGraph(mutualMap, updateChart) {
|
||||
const nodes = new Map();
|
||||
const links = [];
|
||||
const linkKeys = new Set();
|
||||
|
||||
function ensureNode(id, name, rawUser) {
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
const existing = nodes.get(id);
|
||||
if (existing) {
|
||||
if (!existing.rawUser && rawUser) {
|
||||
existing.rawUser = rawUser;
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
const node = {
|
||||
id,
|
||||
name: name || id,
|
||||
value: name || id
|
||||
};
|
||||
nodes.set(id, node);
|
||||
return node;
|
||||
}
|
||||
|
||||
function incrementDegree(nodeId) {
|
||||
const node = nodes.get(nodeId);
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
node.degree = (node.degree || 0) + 1;
|
||||
}
|
||||
|
||||
function addLink(source, target) {
|
||||
if (!source || !target || source === target) {
|
||||
return;
|
||||
}
|
||||
const key = [source, target].sort().join('__');
|
||||
if (linkKeys.has(key)) {
|
||||
return;
|
||||
}
|
||||
linkKeys.add(key);
|
||||
links.push({ source, target });
|
||||
incrementDegree(source);
|
||||
incrementDegree(target);
|
||||
}
|
||||
|
||||
for (const [friendId, { friend, mutuals }] of mutualMap.entries()) {
|
||||
const friendRef = friend?.ref || cachedUsers.get(friendId);
|
||||
const friendName = friendRef?.displayName;
|
||||
ensureNode(friendId, friendName, friendRef);
|
||||
|
||||
for (const mutual of mutuals) {
|
||||
if (!mutual?.id) {
|
||||
continue;
|
||||
}
|
||||
const cached = cachedUsers.get(mutual.id);
|
||||
const label =
|
||||
cached?.displayName || mutual.displayName || mutual.id;
|
||||
ensureNode(mutual.id, label);
|
||||
addLink(friendId, mutual.id);
|
||||
}
|
||||
}
|
||||
|
||||
const nodeList = Array.from(nodes.values());
|
||||
const maxDegree = nodeList.reduce(
|
||||
(acc, node) => Math.max(acc, node.degree || 0),
|
||||
0
|
||||
);
|
||||
|
||||
nodeList.forEach((node, index) => {
|
||||
const normalized = maxDegree ? (node.degree || 0) / maxDegree : 0;
|
||||
const size = Math.round(26 + normalized * 52);
|
||||
const color = COLORS_PALETTE[index % COLORS_PALETTE.length];
|
||||
const displayName = truncateLabelText(node.name || node.id);
|
||||
|
||||
node.symbolSize = size;
|
||||
node.label = {
|
||||
show: true,
|
||||
formatter: `${displayName}`
|
||||
};
|
||||
node.itemStyle = {
|
||||
...(node.itemStyle || {}),
|
||||
color
|
||||
};
|
||||
});
|
||||
|
||||
graphPayload.value = {
|
||||
nodes: nodeList,
|
||||
links
|
||||
};
|
||||
|
||||
updateChart?.(graphPayload.value);
|
||||
}
|
||||
|
||||
function createChartOption(payload) {
|
||||
const nodes = payload?.nodes ?? [];
|
||||
const links = payload?.links ?? [];
|
||||
const force = computeForceOptions(nodes, links);
|
||||
const labelMap = Object.create(null);
|
||||
nodes.forEach((node) => {
|
||||
if (node?.id) {
|
||||
labelMap[node.id] = node.name || node.id;
|
||||
}
|
||||
});
|
||||
return {
|
||||
color: COLORS_PALETTE,
|
||||
backgroundColor: 'transparent',
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: (params) => {
|
||||
if (params.dataType === 'node') {
|
||||
const name =
|
||||
params.data?.name || params.data?.id || 'Unknown';
|
||||
const mutualCount = Number.isFinite(params.data?.degree)
|
||||
? params.data.degree
|
||||
: 0;
|
||||
const mutualLabel = t(
|
||||
'view.charts.mutual_friend.tooltip.mutual_friends_count',
|
||||
{
|
||||
count: mutualCount
|
||||
}
|
||||
);
|
||||
return `${name}\n${mutualLabel}`;
|
||||
}
|
||||
if (params.dataType === 'edge') {
|
||||
const sourceLabel =
|
||||
labelMap[params.data.source] || params.data.source;
|
||||
const targetLabel =
|
||||
labelMap[params.data.target] || params.data.target;
|
||||
return t('view.charts.mutual_friend.tooltip.edge', {
|
||||
source: sourceLabel,
|
||||
target: targetLabel
|
||||
});
|
||||
}
|
||||
return '';
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'graph',
|
||||
layout: 'force',
|
||||
legendHoverLink: false,
|
||||
roam: true,
|
||||
roamTrigger: 'global',
|
||||
data: nodes,
|
||||
links,
|
||||
label: {
|
||||
position: 'right',
|
||||
formatter: '{b}'
|
||||
},
|
||||
symbol: 'circle',
|
||||
emphasis: {
|
||||
focus: 'adjacency',
|
||||
lineStyle: {
|
||||
width: 5,
|
||||
opacity: 0.5
|
||||
}
|
||||
},
|
||||
force,
|
||||
itemStyle: {
|
||||
borderColor: '#ffffff',
|
||||
borderWidth: 1,
|
||||
shadowBlur: 16,
|
||||
shadowColor: 'rgba(0,0,0,0.35)'
|
||||
},
|
||||
lineStyle: {
|
||||
curveness: 0.18,
|
||||
width: 0.5,
|
||||
opacity: 0.4
|
||||
},
|
||||
labelLayout: {
|
||||
hideOverlap: true
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
buildGraph,
|
||||
createChartOption
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user