mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-04-19 06:43:51 +02:00
init sigma.js
This commit is contained in:
68
package-lock.json
generated
68
package-lock.json
generated
@@ -48,6 +48,8 @@
|
|||||||
"eslint-plugin-prettier": "^5.5.5",
|
"eslint-plugin-prettier": "^5.5.5",
|
||||||
"eslint-plugin-vue": "^9.33.0",
|
"eslint-plugin-vue": "^9.33.0",
|
||||||
"globals": "^17.2.0",
|
"globals": "^17.2.0",
|
||||||
|
"graphology": "^0.26.0",
|
||||||
|
"graphology-layout-forceatlas2": "^0.10.1",
|
||||||
"jest": "^30.2.0",
|
"jest": "^30.2.0",
|
||||||
"lightningcss": "^1.31.1",
|
"lightningcss": "^1.31.1",
|
||||||
"lucide-vue-next": "^0.562.0",
|
"lucide-vue-next": "^0.562.0",
|
||||||
@@ -57,6 +59,7 @@
|
|||||||
"reka-ui": "^2.8.0",
|
"reka-ui": "^2.8.0",
|
||||||
"remixicon": "^4.9.1",
|
"remixicon": "^4.9.1",
|
||||||
"sass-embedded": "^1.97.3",
|
"sass-embedded": "^1.97.3",
|
||||||
|
"sigma": "^3.0.2",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
@@ -9321,6 +9324,16 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/events": {
|
||||||
|
"version": "3.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||||
|
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/exec-sh": {
|
"node_modules/exec-sh": {
|
||||||
"version": "0.3.6",
|
"version": "0.3.6",
|
||||||
"resolved": "https://registry.npmjs.org/exec-sh/-/exec-sh-0.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/exec-sh/-/exec-sh-0.3.6.tgz",
|
||||||
@@ -10202,6 +10215,50 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/graphology": {
|
||||||
|
"version": "0.26.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/graphology/-/graphology-0.26.0.tgz",
|
||||||
|
"integrity": "sha512-8SSImzgUUYC89Z042s+0r/vMibY7GX/Emz4LDO5e7jYXhuoWfHISPFJYjpRLUSJGq6UQ6xlenvX1p/hJdfXuXg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"events": "^3.3.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"graphology-types": ">=0.24.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",
|
||||||
|
"integrity": "sha512-ogzBeF1FvWzjkikrIFwxhlZXvD2+wlY54lqhsrWprcdPjopM2J9HoMweUmIgwaTvY4bUYVimpSsOdvDv1gPRFQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"graphology-utils": "^2.1.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",
|
||||||
|
"integrity": "sha512-hDRKYXa8TsoZHjgEaysSRyPdT6uB78Ci8WnjgbStlQysz7xR52PInxNsmnB7IBOM1BhikxkNyCVEFgmPKnpx3Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
|
"node_modules/graphology-utils": {
|
||||||
|
"version": "2.5.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/graphology-utils/-/graphology-utils-2.5.2.tgz",
|
||||||
|
"integrity": "sha512-ckHg8MXrXJkOARk56ZaSCM1g1Wihe2d6iTmz1enGOz4W/l831MBCKSayeFQfowgF8wd+PQ4rlch/56Vs/VZLDQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"graphology-types": ">=0.23.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/has-flag": {
|
"node_modules/has-flag": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||||
@@ -16427,6 +16484,17 @@
|
|||||||
"node": "^12.20.0 || >=14"
|
"node": "^12.20.0 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/sigma": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/sigma/-/sigma-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-/BUbeOwPGruiBOm0YQQ6ZMcLIZ6tf/W+Jcm7dxZyAX0tK3WP9/sq7/NAWBxPIxVahdGjCJoGwej0Gdrv0DxlQQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"events": "^3.3.0",
|
||||||
|
"graphology-utils": "^2.5.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/signal-exit": {
|
"node_modules/signal-exit": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||||
|
|||||||
@@ -69,6 +69,8 @@
|
|||||||
"eslint-plugin-prettier": "^5.5.5",
|
"eslint-plugin-prettier": "^5.5.5",
|
||||||
"eslint-plugin-vue": "^9.33.0",
|
"eslint-plugin-vue": "^9.33.0",
|
||||||
"globals": "^17.2.0",
|
"globals": "^17.2.0",
|
||||||
|
"graphology": "^0.26.0",
|
||||||
|
"graphology-layout-forceatlas2": "^0.10.1",
|
||||||
"jest": "^30.2.0",
|
"jest": "^30.2.0",
|
||||||
"lightningcss": "^1.31.1",
|
"lightningcss": "^1.31.1",
|
||||||
"lucide-vue-next": "^0.562.0",
|
"lucide-vue-next": "^0.562.0",
|
||||||
@@ -78,6 +80,7 @@
|
|||||||
"reka-ui": "^2.8.0",
|
"reka-ui": "^2.8.0",
|
||||||
"remixicon": "^4.9.1",
|
"remixicon": "^4.9.1",
|
||||||
"sass-embedded": "^1.97.3",
|
"sass-embedded": "^1.97.3",
|
||||||
|
"sigma": "^3.0.2",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
@@ -181,4 +184,4 @@
|
|||||||
"hazardous": "^0.3.0",
|
"hazardous": "^0.3.0",
|
||||||
"node-api-dotnet": "^0.9.19"
|
"node-api-dotnet": "^0.9.19"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,20 +11,12 @@ function createDefaultFetchState() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createDefaultPayload() {
|
|
||||||
return {
|
|
||||||
nodes: [],
|
|
||||||
links: []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useChartsStore = defineStore('Charts', () => {
|
export const useChartsStore = defineStore('Charts', () => {
|
||||||
const friendStore = useFriendStore();
|
const friendStore = useFriendStore();
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const activeTab = ref('instance');
|
const activeTab = ref('instance');
|
||||||
const mutualGraphPayload = ref(createDefaultPayload());
|
|
||||||
const mutualGraphFetchState = reactive(createDefaultFetchState());
|
const mutualGraphFetchState = reactive(createDefaultFetchState());
|
||||||
const mutualGraphStatus = reactive({
|
const mutualGraphStatus = reactive({
|
||||||
isFetching: false,
|
isFetching: false,
|
||||||
@@ -92,7 +84,6 @@ export const useChartsStore = defineStore('Charts', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function resetMutualGraphState() {
|
function resetMutualGraphState() {
|
||||||
mutualGraphPayload.value = createDefaultPayload();
|
|
||||||
Object.assign(mutualGraphFetchState, createDefaultFetchState());
|
Object.assign(mutualGraphFetchState, createDefaultFetchState());
|
||||||
mutualGraphStatus.isFetching = false;
|
mutualGraphStatus.isFetching = false;
|
||||||
mutualGraphStatus.hasFetched = false;
|
mutualGraphStatus.hasFetched = false;
|
||||||
@@ -104,7 +95,6 @@ export const useChartsStore = defineStore('Charts', () => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
activeTab,
|
activeTab,
|
||||||
mutualGraphPayload,
|
|
||||||
mutualGraphFetchState,
|
mutualGraphFetchState,
|
||||||
mutualGraphStatus,
|
mutualGraphStatus,
|
||||||
resetMutualGraphState
|
resetMutualGraphState
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, nextTick, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from 'vue';
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
import { DataTableEmpty } from '@/components/ui/data-table';
|
import { DataTableEmpty } from '@/components/ui/data-table';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
|
|
||||||
|
|||||||
@@ -2,16 +2,6 @@
|
|||||||
<div class="mutual-graph pt-12" ref="mutualGraphRef">
|
<div class="mutual-graph pt-12" ref="mutualGraphRef">
|
||||||
<div class="options-container mutual-graph__toolbar">
|
<div class="options-container mutual-graph__toolbar">
|
||||||
<div class="mutual-graph__actions">
|
<div class="mutual-graph__actions">
|
||||||
<TooltipWrapper :content="t('view.charts.mutual_friend.force_dialog.open_label')" side="top">
|
|
||||||
<Button
|
|
||||||
class="rounded-full"
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
:disabled="!graphReady"
|
|
||||||
@click="openForceDialog">
|
|
||||||
<Settings />
|
|
||||||
</Button>
|
|
||||||
</TooltipWrapper>
|
|
||||||
<TooltipWrapper :content="fetchButtonLabel" side="top">
|
<TooltipWrapper :content="fetchButtonLabel" side="top">
|
||||||
<Button :disabled="fetchButtonDisabled" @click="startFetch">
|
<Button :disabled="fetchButtonDisabled" @click="startFetch">
|
||||||
<Spinner v-if="isFetching" />
|
<Spinner v-if="isFetching" />
|
||||||
@@ -38,122 +28,28 @@
|
|||||||
<Progress :model-value="progressPercent" class="h-3" />
|
<Progress :model-value="progressPercent" class="h-3" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div ref="chartRef" class="mutual-graph__canvas"></div>
|
<div ref="graphContainerRef" class="mutual-graph__canvas" :style="{ backgroundColor: canvasBackground }"></div>
|
||||||
|
|
||||||
<div v-if="hasFetched && !isFetching && !graphReady" class="mutual-graph__placeholder">
|
<div v-if="hasFetched && !isFetching && !graphReady" class="mutual-graph__placeholder">
|
||||||
<span>{{ t('view.charts.mutual_friend.progress.no_relationships_discovered') }}</span>
|
<span>{{ t('view.charts.mutual_friend.progress.no_relationships_discovered') }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog v-model:open="isForceDialogVisible">
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{{ t('view.charts.mutual_friend.force_dialog.title') }}</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<p class="mutual-graph__force-description">
|
|
||||||
{{ t('view.charts.mutual_friend.force_dialog.description') }}
|
|
||||||
</p>
|
|
||||||
<FieldGroup class="mutual-graph__force-form">
|
|
||||||
<Field>
|
|
||||||
<FieldLabel>{{ t('view.charts.mutual_friend.force_dialog.repulsion') }}</FieldLabel>
|
|
||||||
<FieldContent>
|
|
||||||
<NumberField
|
|
||||||
v-model="forceForm.repulsion"
|
|
||||||
:step="1"
|
|
||||||
:format-options="{ maximumFractionDigits: 0 }"
|
|
||||||
class="mutual-graph__number-input">
|
|
||||||
<NumberFieldContent>
|
|
||||||
<NumberFieldInput />
|
|
||||||
</NumberFieldContent>
|
|
||||||
</NumberField>
|
|
||||||
<FieldDescription class="mutual-graph__helper">
|
|
||||||
{{ t('view.charts.mutual_friend.force_dialog.repulsion_help') }}
|
|
||||||
</FieldDescription>
|
|
||||||
</FieldContent>
|
|
||||||
</Field>
|
|
||||||
<Field>
|
|
||||||
<FieldLabel>{{ t('view.charts.mutual_friend.force_dialog.edge_length_min') }}</FieldLabel>
|
|
||||||
<FieldContent>
|
|
||||||
<NumberField
|
|
||||||
v-model="forceForm.edgeLengthMin"
|
|
||||||
:step="1"
|
|
||||||
:format-options="{ maximumFractionDigits: 0 }"
|
|
||||||
class="mutual-graph__number-input">
|
|
||||||
<NumberFieldContent>
|
|
||||||
<NumberFieldInput />
|
|
||||||
</NumberFieldContent>
|
|
||||||
</NumberField>
|
|
||||||
<FieldDescription class="mutual-graph__helper">
|
|
||||||
{{ t('view.charts.mutual_friend.force_dialog.edge_length_min_help') }}
|
|
||||||
</FieldDescription>
|
|
||||||
</FieldContent>
|
|
||||||
</Field>
|
|
||||||
<Field>
|
|
||||||
<FieldLabel>{{ t('view.charts.mutual_friend.force_dialog.edge_length_max') }}</FieldLabel>
|
|
||||||
<FieldContent>
|
|
||||||
<NumberField
|
|
||||||
v-model="forceForm.edgeLengthMax"
|
|
||||||
:step="1"
|
|
||||||
:format-options="{ maximumFractionDigits: 0 }"
|
|
||||||
class="mutual-graph__number-input">
|
|
||||||
<NumberFieldContent>
|
|
||||||
<NumberFieldInput />
|
|
||||||
</NumberFieldContent>
|
|
||||||
</NumberField>
|
|
||||||
<FieldDescription class="mutual-graph__helper">
|
|
||||||
{{ t('view.charts.mutual_friend.force_dialog.edge_length_max_help') }}
|
|
||||||
</FieldDescription>
|
|
||||||
</FieldContent>
|
|
||||||
</Field>
|
|
||||||
<Field>
|
|
||||||
<FieldLabel>{{ t('view.charts.mutual_friend.force_dialog.gravity') }}</FieldLabel>
|
|
||||||
<FieldContent>
|
|
||||||
<NumberField
|
|
||||||
v-model="forceForm.gravity"
|
|
||||||
:max="1"
|
|
||||||
:step="0.1"
|
|
||||||
:format-options="{ maximumFractionDigits: 1 }"
|
|
||||||
class="mutual-graph__number-input">
|
|
||||||
<NumberFieldContent>
|
|
||||||
<NumberFieldInput />
|
|
||||||
</NumberFieldContent>
|
|
||||||
</NumberField>
|
|
||||||
<FieldDescription class="mutual-graph__helper">
|
|
||||||
{{ t('view.charts.mutual_friend.force_dialog.gravity_help') }}
|
|
||||||
</FieldDescription>
|
|
||||||
</FieldContent>
|
|
||||||
</Field>
|
|
||||||
</FieldGroup>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<div class="mutual-graph__dialog-footer">
|
|
||||||
<Button variant="secondary" class="mr-2" @click="resetForceSettings">{{
|
|
||||||
t('view.charts.mutual_friend.force_dialog.reset')
|
|
||||||
}}</Button>
|
|
||||||
<Button :disabled="!graphReady" @click="applyForceSettings">
|
|
||||||
{{ t('view.charts.mutual_friend.force_dialog.apply') }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
|
||||||
import { Field, FieldContent, FieldDescription, FieldGroup, FieldLabel } from '@/components/ui/field';
|
|
||||||
import { NumberField, NumberFieldContent, NumberFieldInput } from '@/components/ui/number-field';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Progress } from '@/components/ui/progress';
|
import { Progress } from '@/components/ui/progress';
|
||||||
import { Settings } from 'lucide-vue-next';
|
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { onBeforeRouteLeave } from 'vue-router';
|
import { onBeforeRouteLeave } from 'vue-router';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { toast } from 'vue-sonner';
|
import { toast } from 'vue-sonner';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
import Graph from 'graphology';
|
||||||
|
import Sigma from 'sigma';
|
||||||
|
import forceAtlas2 from 'graphology-layout-forceatlas2';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
useAppearanceSettingsStore,
|
useAppearanceSettingsStore,
|
||||||
useChartsStore,
|
useChartsStore,
|
||||||
@@ -161,15 +57,9 @@
|
|||||||
useModalStore,
|
useModalStore,
|
||||||
useUserStore
|
useUserStore
|
||||||
} from '../../../stores';
|
} from '../../../stores';
|
||||||
import { applyForceOverrides, computeForceOptions, useMutualGraphChart } from '../composables/useMutualGraphChart';
|
|
||||||
import { createRateLimiter, executeWithBackoff } from '../../../shared/utils';
|
import { createRateLimiter, executeWithBackoff } from '../../../shared/utils';
|
||||||
import { database } from '../../../service/database';
|
|
||||||
import { userRequest } from '../../../api';
|
import { userRequest } from '../../../api';
|
||||||
|
|
||||||
import configRepository from '../../../service/config';
|
|
||||||
|
|
||||||
import * as echarts from 'echarts';
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const friendStore = useFriendStore();
|
const friendStore = useFriendStore();
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
@@ -178,24 +68,32 @@
|
|||||||
const appearanceStore = useAppearanceSettingsStore();
|
const appearanceStore = useAppearanceSettingsStore();
|
||||||
const { friends } = storeToRefs(friendStore);
|
const { friends } = storeToRefs(friendStore);
|
||||||
const { currentUser } = storeToRefs(userStore);
|
const { currentUser } = storeToRefs(userStore);
|
||||||
const { activeTab, mutualGraphPayload } = storeToRefs(chartsStore);
|
const { activeTab } = storeToRefs(chartsStore);
|
||||||
const { isDarkMode } = storeToRefs(appearanceStore);
|
const { isDarkMode } = storeToRefs(appearanceStore);
|
||||||
const cachedUsers = userStore.cachedUsers;
|
const cachedUsers = userStore.cachedUsers;
|
||||||
const showUserDialog = (userId) => userStore.showUserDialog(userId);
|
const showUserDialog = (userId) => userStore.showUserDialog(userId);
|
||||||
|
|
||||||
const graphPayload = mutualGraphPayload;
|
|
||||||
const fetchState = chartsStore.mutualGraphFetchState;
|
const fetchState = chartsStore.mutualGraphFetchState;
|
||||||
const status = chartsStore.mutualGraphStatus;
|
const status = chartsStore.mutualGraphStatus;
|
||||||
|
|
||||||
const chartTheme = computed(() => (isDarkMode.value ? 'dark' : undefined));
|
const LOCAL_STORAGE_KEY = 'VRCX_MutualGraphSnapshot';
|
||||||
|
const COLORS_PALETTE = [
|
||||||
|
'#5470c6',
|
||||||
|
'#91cc75',
|
||||||
|
'#fac858',
|
||||||
|
'#ee6666',
|
||||||
|
'#73c0de',
|
||||||
|
'#3ba272',
|
||||||
|
'#fc8452',
|
||||||
|
'#9a60b4',
|
||||||
|
'#ea7ccc'
|
||||||
|
];
|
||||||
|
const MAX_LABEL_NAME_LENGTH = 22;
|
||||||
|
|
||||||
const { buildGraph, createChartOption } = useMutualGraphChart({
|
const graphContainerRef = ref(null);
|
||||||
cachedUsers,
|
const mutualGraphRef = ref(null);
|
||||||
graphPayload
|
let sigmaInstance = null;
|
||||||
});
|
let currentGraph = null;
|
||||||
|
|
||||||
const chartRef = ref(null);
|
|
||||||
let chartInstance = null;
|
|
||||||
let resizeObserver = null;
|
let resizeObserver = null;
|
||||||
|
|
||||||
const isFetching = computed({
|
const isFetching = computed({
|
||||||
@@ -211,12 +109,12 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const totalFriends = computed(() => friends.value.size);
|
const graphNodeCount = ref(0);
|
||||||
const isOptOut = computed(() => Boolean(currentUser.value?.hasSharedConnectionsOptOut));
|
|
||||||
// @ts-ignore
|
|
||||||
const graphReady = computed(() => Array.isArray(graphPayload.value?.nodes) && graphPayload.value.nodes.length > 0);
|
|
||||||
const isLoadingSnapshot = ref(false);
|
const isLoadingSnapshot = ref(false);
|
||||||
const loadingToastId = ref(null);
|
const loadingToastId = ref(null);
|
||||||
|
const totalFriends = computed(() => friends.value.size);
|
||||||
|
const isOptOut = computed(() => Boolean(currentUser.value?.hasSharedConnectionsOptOut));
|
||||||
|
const graphReady = computed(() => graphNodeCount.value > 0);
|
||||||
const fetchButtonDisabled = computed(
|
const fetchButtonDisabled = computed(
|
||||||
() => isFetching.value || isOptOut.value || totalFriends.value === 0 || isLoadingSnapshot.value
|
() => isFetching.value || isOptOut.value || totalFriends.value === 0 || isLoadingSnapshot.value
|
||||||
);
|
);
|
||||||
@@ -228,40 +126,8 @@
|
|||||||
const progressPercent = computed(() =>
|
const progressPercent = computed(() =>
|
||||||
totalFriends.value ? Math.min(100, Math.round((fetchState.processedFriends / totalFriends.value) * 100)) : 0
|
totalFriends.value ? Math.min(100, Math.round((fetchState.processedFriends / totalFriends.value) * 100)) : 0
|
||||||
);
|
);
|
||||||
const forceDefaults = computed(() =>
|
const canvasBackground = computed(() => (isDarkMode.value ? 'rgba(15, 23, 42, 0.35)' : 'rgba(15, 23, 42, 0.02)'));
|
||||||
computeForceOptions(graphPayload.value?.nodes ?? [], graphPayload.value?.links ?? [])
|
const edgeColor = computed(() => (isDarkMode.value ? 'rgba(226,232,240,0.2)' : 'rgba(15,23,42,0.2)'));
|
||||||
);
|
|
||||||
const hasGraphData = computed(() => graphReady.value && Boolean(graphPayload.value?.nodes?.length));
|
|
||||||
|
|
||||||
const isForceDialogVisible = ref(false);
|
|
||||||
const forceOverrides = ref(null);
|
|
||||||
const persistedForce = ref(null);
|
|
||||||
const forceForm = reactive({
|
|
||||||
repulsion: null,
|
|
||||||
edgeLengthMin: null,
|
|
||||||
edgeLengthMax: null,
|
|
||||||
gravity: null
|
|
||||||
});
|
|
||||||
const forceConfigKey = 'VRCX_MutualGraphForce';
|
|
||||||
|
|
||||||
const parseForceField = (value, { min = 0, max = Infinity, decimals = 0 } = {}) => {
|
|
||||||
if (value === '' || value === null || value === undefined) {
|
|
||||||
return { value: null, invalid: false };
|
|
||||||
}
|
|
||||||
const num = Number(value);
|
|
||||||
if (Number.isNaN(num) || num < min || num > max) {
|
|
||||||
return { value: null, invalid: true };
|
|
||||||
}
|
|
||||||
const factor = decimals ? 10 ** decimals : 1;
|
|
||||||
return { value: Math.round(num * factor) / factor, invalid: false };
|
|
||||||
};
|
|
||||||
|
|
||||||
const coerceForceField = (value, options) => {
|
|
||||||
const parsed = parseForceField(value, options);
|
|
||||||
return parsed.invalid ? null : parsed.value;
|
|
||||||
};
|
|
||||||
|
|
||||||
const mutualGraphRef = ref(null);
|
|
||||||
|
|
||||||
const mutualGraphResizeObserver = new ResizeObserver(() => {
|
const mutualGraphResizeObserver = new ResizeObserver(() => {
|
||||||
setMutualGraphHeight();
|
setMutualGraphHeight();
|
||||||
@@ -277,14 +143,21 @@
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
if (!chartRef.value) {
|
if (!graphContainerRef.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
createChartInstance();
|
resizeObserver = new ResizeObserver(() => {
|
||||||
resizeObserver = new ResizeObserver(() => chartInstance?.resize());
|
if (sigmaInstance?.refresh) {
|
||||||
resizeObserver.observe(chartRef.value);
|
sigmaInstance.refresh();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
resizeObserver.observe(graphContainerRef.value);
|
||||||
mutualGraphResizeObserver.observe(mutualGraphRef.value);
|
mutualGraphResizeObserver.observe(mutualGraphRef.value);
|
||||||
setMutualGraphHeight();
|
setMutualGraphHeight();
|
||||||
|
|
||||||
|
if (currentGraph) {
|
||||||
|
renderGraph(currentGraph);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -293,55 +166,33 @@
|
|||||||
resizeObserver.disconnect();
|
resizeObserver.disconnect();
|
||||||
resizeObserver = null;
|
resizeObserver = null;
|
||||||
}
|
}
|
||||||
if (chartInstance) {
|
if (sigmaInstance) {
|
||||||
chartInstance.dispose();
|
sigmaInstance.kill();
|
||||||
chartInstance = null;
|
sigmaInstance = null;
|
||||||
}
|
}
|
||||||
|
currentGraph = null;
|
||||||
if (mutualGraphResizeObserver) {
|
if (mutualGraphResizeObserver) {
|
||||||
mutualGraphResizeObserver.disconnect();
|
mutualGraphResizeObserver.disconnect();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(
|
|
||||||
chartTheme,
|
|
||||||
() => {
|
|
||||||
if (!chartRef.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (chartInstance) {
|
|
||||||
chartInstance.dispose();
|
|
||||||
chartInstance = null;
|
|
||||||
}
|
|
||||||
nextTick(() => {
|
|
||||||
if (!chartRef.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
createChartInstance();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
{ immediate: false }
|
|
||||||
);
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
activeTab,
|
activeTab,
|
||||||
(tab) => {
|
(tab) => {
|
||||||
if (tab === 'mutual') {
|
if (tab === 'mutual') {
|
||||||
loadGraphFromDatabase();
|
loadGraphFromLocalStorage();
|
||||||
loadForceOverridesFromConfig();
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
watch(
|
watch(isDarkMode, () => {
|
||||||
graphReady,
|
if (!currentGraph) {
|
||||||
(ready) => {
|
return;
|
||||||
if (ready && forceOverrides.value) {
|
}
|
||||||
updateChart(graphPayload.value);
|
applyThemeToGraph(currentGraph);
|
||||||
}
|
renderGraph(currentGraph);
|
||||||
},
|
});
|
||||||
{ immediate: false }
|
|
||||||
);
|
|
||||||
|
|
||||||
function showStatusMessage(message, type = 'info') {
|
function showStatusMessage(message, type = 'info') {
|
||||||
if (!message) {
|
if (!message) {
|
||||||
@@ -351,28 +202,162 @@
|
|||||||
toastFn(message, { duration: 4000 });
|
toastFn(message, { duration: 4000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
function createChartInstance() {
|
function truncateLabelText(text) {
|
||||||
if (!chartRef.value) {
|
if (!text) {
|
||||||
return;
|
return 'Unknown';
|
||||||
}
|
|
||||||
chartInstance = echarts.init(chartRef.value, chartTheme.value, { renderer: 'svg' });
|
|
||||||
chartInstance.on('click', handleChartNodeClick);
|
|
||||||
|
|
||||||
if (graphReady.value) {
|
|
||||||
// @ts-ignore
|
|
||||||
updateChart(graphPayload.value);
|
|
||||||
}
|
}
|
||||||
|
return text.length > MAX_LABEL_NAME_LENGTH ? `${text.slice(0, MAX_LABEL_NAME_LENGTH)}…` : text;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadGraphFromDatabase() {
|
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 applyThemeToGraph(graph) {
|
||||||
|
const color = edgeColor.value;
|
||||||
|
graph.forEachEdge((edge) => {
|
||||||
|
graph.setEdgeAttribute(edge, 'color', color);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildGraphFromMutualMap(mutualMap) {
|
||||||
|
const graph = new Graph({
|
||||||
|
type: 'undirected',
|
||||||
|
multi: false,
|
||||||
|
allowSelfLoops: false
|
||||||
|
});
|
||||||
|
const nodeDegree = new Map();
|
||||||
|
const nodeNames = new Map();
|
||||||
|
|
||||||
|
function ensureNode(id, name) {
|
||||||
|
if (!id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!graph.hasNode(id)) {
|
||||||
|
graph.addNode(id);
|
||||||
|
nodeDegree.set(id, 0);
|
||||||
|
}
|
||||||
|
if (name && !nodeNames.get(id)) {
|
||||||
|
nodeNames.set(id, name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addEdge(source, target) {
|
||||||
|
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 });
|
||||||
|
nodeDegree.set(a, (nodeDegree.get(a) || 0) + 1);
|
||||||
|
nodeDegree.set(b, (nodeDegree.get(b) || 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [friendId, { friend, mutuals }] of mutualMap.entries()) {
|
||||||
|
const friendRef = friend?.ref || cachedUsers.get(friendId);
|
||||||
|
const friendName = friendRef?.displayName;
|
||||||
|
ensureNode(friendId, friendName || friendId);
|
||||||
|
|
||||||
|
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);
|
||||||
|
addEdge(friendId, mutual.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
const degree = nodeDegree.get(id) || 0;
|
||||||
|
const size = 6 + (maxDegree ? (degree / maxDegree) * 16 : 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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
applyThemeToGraph(graph);
|
||||||
|
|
||||||
|
graphNodeCount.value = graph.order;
|
||||||
|
return graph;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGraph(graph) {
|
||||||
|
if (!graphContainerRef.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sigmaInstance) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyGraph(mutualMap) {
|
||||||
|
const graph = buildGraphFromMutualMap(mutualMap);
|
||||||
|
currentGraph = graph;
|
||||||
|
renderGraph(graph);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadGraphFromLocalStorage() {
|
||||||
if (hasFetched.value || isFetching.value || isLoadingSnapshot.value) {
|
if (hasFetched.value || isFetching.value || isLoadingSnapshot.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
isLoadingSnapshot.value = true;
|
isLoadingSnapshot.value = true;
|
||||||
loadingToastId.value = toast.loading(t('view.charts.mutual_friend.status.loading_cache'));
|
loadingToastId.value = toast.loading(t('view.charts.mutual_friend.status.loading_cache'));
|
||||||
try {
|
try {
|
||||||
const snapshot = await database.getMutualGraphSnapshot();
|
const raw = localStorage.getItem(LOCAL_STORAGE_KEY);
|
||||||
if (!snapshot || snapshot.size === 0) {
|
if (!raw) {
|
||||||
if (isOptOut.value) {
|
if (isOptOut.value) {
|
||||||
promptEnableMutualFriendsSharing();
|
promptEnableMutualFriendsSharing();
|
||||||
return;
|
return;
|
||||||
@@ -380,8 +365,16 @@
|
|||||||
await promptInitialFetch();
|
await promptInitialFetch();
|
||||||
return;
|
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();
|
const mutualMap = new Map();
|
||||||
snapshot.forEach((mutualIds, friendId) => {
|
Object.entries(snapshot).forEach(([friendId, mutualIds]) => {
|
||||||
if (!friendId) {
|
if (!friendId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -394,11 +387,13 @@
|
|||||||
mutuals: normalizedMutuals.map((id) => ({ id }))
|
mutuals: normalizedMutuals.map((id) => ({ id }))
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!mutualMap.size) {
|
if (!mutualMap.size) {
|
||||||
await promptInitialFetch();
|
await promptInitialFetch();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
buildGraph(mutualMap, updateChart);
|
|
||||||
|
applyGraph(mutualMap);
|
||||||
hasFetched.value = true;
|
hasFetched.value = true;
|
||||||
fetchState.processedFriends = Math.min(mutualMap.size, totalFriends.value || mutualMap.size);
|
fetchState.processedFriends = Math.min(mutualMap.size, totalFriends.value || mutualMap.size);
|
||||||
status.friendSignature = totalFriends.value;
|
status.friendSignature = totalFriends.value;
|
||||||
@@ -563,12 +558,12 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
buildGraph(mutualMap, updateChart);
|
applyGraph(mutualMap);
|
||||||
status.friendSignature = totalFriends.value;
|
status.friendSignature = totalFriends.value;
|
||||||
status.needsRefetch = false;
|
status.needsRefetch = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await persistMutualGraph(mutualMap);
|
persistMutualGraphToLocalStorage(mutualMap);
|
||||||
} catch (persistErr) {
|
} catch (persistErr) {
|
||||||
console.error('[MutualNetworkGraph] Failed to cache data', persistErr);
|
console.error('[MutualNetworkGraph] Failed to cache data', persistErr);
|
||||||
}
|
}
|
||||||
@@ -581,8 +576,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function persistMutualGraph(mutualMap) {
|
function persistMutualGraphToLocalStorage(mutualMap) {
|
||||||
const snapshot = new Map();
|
const snapshot = {};
|
||||||
mutualMap.forEach((value, friendId) => {
|
mutualMap.forEach((value, friendId) => {
|
||||||
if (!friendId) {
|
if (!friendId) {
|
||||||
return;
|
return;
|
||||||
@@ -601,139 +596,20 @@
|
|||||||
ids.push(identifier);
|
ids.push(identifier);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
snapshot.set(normalizedFriendId, ids);
|
snapshot[normalizedFriendId] = ids;
|
||||||
});
|
});
|
||||||
await database.saveMutualGraphSnapshot(snapshot);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateChart(payload) {
|
const payload = {
|
||||||
const nodes = payload?.nodes ?? [];
|
version: 1,
|
||||||
if (!nodes.length) {
|
savedAt: Date.now(),
|
||||||
if (chartInstance) {
|
data: snapshot
|
||||||
chartInstance.clear();
|
};
|
||||||
}
|
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(payload));
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!chartInstance) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const forceOption =
|
|
||||||
persistedForce.value ||
|
|
||||||
applyForceOverrides(computeForceOptions(nodes, payload?.links ?? []), forceOverrides.value);
|
|
||||||
chartInstance.setOption(createChartOption(payload, forceOption));
|
|
||||||
nextTick(() => chartInstance?.resize());
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleChartNodeClick(params) {
|
|
||||||
if (params?.dataType !== 'node') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const nodeId = params.data?.id;
|
|
||||||
if (nodeId) {
|
|
||||||
showUserDialog(nodeId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onBeforeRouteLeave(() => {
|
onBeforeRouteLeave(() => {
|
||||||
chartsStore.resetMutualGraphState();
|
chartsStore.resetMutualGraphState();
|
||||||
});
|
});
|
||||||
|
|
||||||
function syncForceForm(source) {
|
|
||||||
const base = source || forceDefaults.value || {};
|
|
||||||
const edgeLength = Array.isArray(base.edgeLength) ? base.edgeLength : [];
|
|
||||||
forceForm.repulsion = coerceForceField(base.repulsion, { min: 0 });
|
|
||||||
forceForm.edgeLengthMin = coerceForceField(edgeLength[0], { min: 0 });
|
|
||||||
forceForm.edgeLengthMax = coerceForceField(edgeLength[1], { min: 0 });
|
|
||||||
forceForm.gravity = coerceForceField(base.gravity, { min: 0, max: 1, decimals: 1 });
|
|
||||||
}
|
|
||||||
|
|
||||||
function openForceDialog() {
|
|
||||||
syncForceForm(forceOverrides.value);
|
|
||||||
isForceDialogVisible.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyForceSettings() {
|
|
||||||
if (!hasGraphData.value) {
|
|
||||||
isForceDialogVisible.value = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const defaults = forceDefaults.value;
|
|
||||||
const defaultEdge = Array.isArray(defaults.edgeLength) ? defaults.edgeLength : [null, null];
|
|
||||||
const repulsion = parseForceField(forceForm.repulsion, { min: 0 });
|
|
||||||
const minEdge = parseForceField(forceForm.edgeLengthMin, { min: 0 });
|
|
||||||
const maxEdge = parseForceField(forceForm.edgeLengthMax, { min: 0 });
|
|
||||||
const gravity = parseForceField(forceForm.gravity, { min: 0, max: 1, decimals: 1 });
|
|
||||||
|
|
||||||
const hasInvalid = [repulsion, minEdge, maxEdge, gravity].some((entry) => entry.invalid);
|
|
||||||
if (hasInvalid) {
|
|
||||||
toast.error(t('view.charts.mutual_friend.force_dialog.invalid_input'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const edgeLength = [minEdge.value ?? defaultEdge[0] ?? 0, maxEdge.value ?? defaultEdge[1] ?? 0];
|
|
||||||
edgeLength[0] = Math.max(0, edgeLength[0]);
|
|
||||||
edgeLength[1] = Math.max(edgeLength[0], edgeLength[1]);
|
|
||||||
|
|
||||||
forceOverrides.value = {
|
|
||||||
repulsion: repulsion.value === null ? defaults.repulsion : repulsion.value,
|
|
||||||
edgeLength,
|
|
||||||
gravity: gravity.value === null ? defaults.gravity : gravity.value,
|
|
||||||
layoutAnimation: defaults.layoutAnimation
|
|
||||||
};
|
|
||||||
persistedForce.value = applyForceOverrides(defaults, forceOverrides.value);
|
|
||||||
persistForceOverrides();
|
|
||||||
updateChart(graphPayload.value);
|
|
||||||
isForceDialogVisible.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetForceSettings() {
|
|
||||||
forceOverrides.value = null;
|
|
||||||
persistedForce.value = null;
|
|
||||||
syncForceForm(forceDefaults.value);
|
|
||||||
if (hasGraphData.value) {
|
|
||||||
updateChart(graphPayload.value);
|
|
||||||
}
|
|
||||||
clearForceOverrides();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadForceOverridesFromConfig() {
|
|
||||||
try {
|
|
||||||
const saved = await configRepository.getObject(forceConfigKey, null);
|
|
||||||
if (!saved || typeof saved !== 'object') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
forceOverrides.value = saved.overrides || null;
|
|
||||||
persistedForce.value = saved.force || null;
|
|
||||||
if (forceOverrides.value) {
|
|
||||||
syncForceForm(forceOverrides.value);
|
|
||||||
}
|
|
||||||
if (graphReady.value) {
|
|
||||||
updateChart(graphPayload.value);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('[MutualNetworkGraph] Failed to load force settings', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function persistForceOverrides() {
|
|
||||||
if (!forceOverrides.value) {
|
|
||||||
clearForceOverrides();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const payload = {
|
|
||||||
overrides: forceOverrides.value,
|
|
||||||
force: persistedForce.value
|
|
||||||
};
|
|
||||||
configRepository.setObject(forceConfigKey, payload).catch((err) => {
|
|
||||||
console.warn('[MutualNetworkGraph] Failed to save force settings', err);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearForceOverrides() {
|
|
||||||
configRepository.remove(forceConfigKey).catch((err) => {
|
|
||||||
console.warn('[MutualNetworkGraph] Failed to clear force settings', err);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -746,7 +622,7 @@
|
|||||||
|
|
||||||
.mutual-graph__toolbar {
|
.mutual-graph__toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
@@ -754,12 +630,19 @@
|
|||||||
border: none;
|
border: none;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
padding: 0 0 8px 0;
|
padding: 0 0 8px 0;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mutual-graph__actions {
|
.mutual-graph__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mutual-graph__docs-button {
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mutual-graph__status {
|
.mutual-graph__status {
|
||||||
@@ -789,6 +672,8 @@
|
|||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
height: calc(100vh - 260px);
|
height: calc(100vh - 260px);
|
||||||
min-height: 520px;
|
min-height: 520px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(15, 23, 42, 0.02);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mutual-graph__placeholder {
|
.mutual-graph__placeholder {
|
||||||
@@ -801,31 +686,4 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mutual-graph__force-description {
|
|
||||||
margin: 0 0 12px 0;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mutual-graph__force-form {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
|
||||||
gap: 8px 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mutual-graph__number-input {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mutual-graph__dialog-footer {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mutual-graph__helper {
|
|
||||||
margin-top: 4px;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,267 +0,0 @@
|
|||||||
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));
|
|
||||||
}
|
|
||||||
|
|
||||||
export 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(48 + avgDegree * 1.2, 48, 90);
|
|
||||||
const maxEdge = clamp(
|
|
||||||
minEdge + 60 + Math.max(0, 140 - density * 18),
|
|
||||||
90,
|
|
||||||
200
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
repulsion,
|
|
||||||
edgeLength: [minEdge, maxEdge],
|
|
||||||
gravity: 0.3,
|
|
||||||
layoutAnimation: nodes.length < 1000
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applyForceOverrides(force, forceOverrides) {
|
|
||||||
if (!forceOverrides) {
|
|
||||||
return force;
|
|
||||||
}
|
|
||||||
const merged = { ...force };
|
|
||||||
if (typeof forceOverrides.repulsion === 'number') {
|
|
||||||
merged.repulsion = Math.max(0, forceOverrides.repulsion);
|
|
||||||
}
|
|
||||||
if (Array.isArray(forceOverrides.edgeLength)) {
|
|
||||||
const [
|
|
||||||
minRaw = merged.edgeLength?.[0],
|
|
||||||
maxRaw = merged.edgeLength?.[1]
|
|
||||||
] = forceOverrides.edgeLength;
|
|
||||||
const min =
|
|
||||||
typeof minRaw === 'number' ? minRaw : merged.edgeLength?.[0];
|
|
||||||
const max =
|
|
||||||
typeof maxRaw === 'number' ? maxRaw : merged.edgeLength?.[1];
|
|
||||||
const hasBoth = typeof min === 'number' && typeof max === 'number';
|
|
||||||
if (hasBoth) {
|
|
||||||
const normalizedMin = Math.max(0, min);
|
|
||||||
merged.edgeLength = [normalizedMin, Math.max(normalizedMin, max)];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (typeof forceOverrides.gravity === 'number') {
|
|
||||||
merged.gravity = clamp(forceOverrides.gravity, 0, 1);
|
|
||||||
}
|
|
||||||
if (typeof forceOverrides.layoutAnimation === 'boolean') {
|
|
||||||
merged.layoutAnimation = forceOverrides.layoutAnimation;
|
|
||||||
}
|
|
||||||
return merged;
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
|
||||||
if (!id) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const existing = nodes.get(id);
|
|
||||||
if (existing) {
|
|
||||||
return existing;
|
|
||||||
}
|
|
||||||
const node = {
|
|
||||||
id,
|
|
||||||
name: 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);
|
|
||||||
|
|
||||||
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, force) {
|
|
||||||
const nodes = payload?.nodes ?? [];
|
|
||||||
const links = payload?.links ?? [];
|
|
||||||
const labelMap = Object.create(null);
|
|
||||||
nodes.forEach((node) => {
|
|
||||||
if (node?.id) {
|
|
||||||
labelMap[node.id] = node.name || node.id;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const resolvedForce = {
|
|
||||||
...(force || {}),
|
|
||||||
layoutAnimation: false
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
color: COLORS_PALETTE,
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
animation: false,
|
|
||||||
animationDuration: 0,
|
|
||||||
animationDurationUpdate: 0,
|
|
||||||
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}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
series: [
|
|
||||||
{
|
|
||||||
type: 'graph',
|
|
||||||
layout: 'force',
|
|
||||||
legendHoverLink: false,
|
|
||||||
roam: true,
|
|
||||||
roamTrigger: 'global',
|
|
||||||
animation: false,
|
|
||||||
animationDuration: 0,
|
|
||||||
animationDurationUpdate: 0,
|
|
||||||
data: nodes,
|
|
||||||
links,
|
|
||||||
label: {
|
|
||||||
position: 'right'
|
|
||||||
},
|
|
||||||
symbol: 'circle',
|
|
||||||
emphasis: {
|
|
||||||
focus: 'adjacency',
|
|
||||||
itemStyle: {
|
|
||||||
borderWidth: 3,
|
|
||||||
opacity: 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
force: resolvedForce,
|
|
||||||
itemStyle: {
|
|
||||||
borderColor: '#ffffff',
|
|
||||||
borderWidth: 1,
|
|
||||||
shadowBlur: 16,
|
|
||||||
shadowColor: 'rgba(0,0,0,0.35)'
|
|
||||||
},
|
|
||||||
lineStyle: {
|
|
||||||
curveness: 0.18,
|
|
||||||
width: 0.5,
|
|
||||||
opacity: 0.4
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
buildGraph,
|
|
||||||
createChartOption
|
|
||||||
};
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user