mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-05-07 06:56:04 +02:00
use worker
This commit is contained in:
@@ -0,0 +1,173 @@
|
|||||||
|
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
import { createPinia, setActivePinia } from 'pinia';
|
||||||
|
import { nextTick, reactive } from 'vue';
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
workerInstances: [],
|
||||||
|
friendStore: null,
|
||||||
|
favoriteStore: null,
|
||||||
|
avatarStore: null,
|
||||||
|
worldStore: null,
|
||||||
|
groupStore: null,
|
||||||
|
userStore: null
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../searchWorker.js?worker', () => ({
|
||||||
|
default: class MockSearchWorker {
|
||||||
|
constructor() {
|
||||||
|
this.onmessage = null;
|
||||||
|
this.postMessage = vi.fn();
|
||||||
|
mocks.workerInstances.push(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(data) {
|
||||||
|
this.onmessage?.({ data });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../friend', () => ({
|
||||||
|
useFriendStore: () => mocks.friendStore
|
||||||
|
}));
|
||||||
|
vi.mock('../favorite', () => ({
|
||||||
|
useFavoriteStore: () => mocks.favoriteStore
|
||||||
|
}));
|
||||||
|
vi.mock('../avatar', () => ({
|
||||||
|
useAvatarStore: () => mocks.avatarStore
|
||||||
|
}));
|
||||||
|
vi.mock('../world', () => ({
|
||||||
|
useWorldStore: () => mocks.worldStore
|
||||||
|
}));
|
||||||
|
vi.mock('../group', () => ({
|
||||||
|
useGroupStore: () => mocks.groupStore
|
||||||
|
}));
|
||||||
|
vi.mock('../user', () => ({
|
||||||
|
useUserStore: () => mocks.userStore
|
||||||
|
}));
|
||||||
|
|
||||||
|
const showUserDialog = vi.fn();
|
||||||
|
const showAvatarDialog = vi.fn();
|
||||||
|
const showWorldDialog = vi.fn();
|
||||||
|
const showGroupDialog = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('../../coordinators/userCoordinator', () => ({ showUserDialog: (...args) => showUserDialog(...args) }));
|
||||||
|
vi.mock('../../coordinators/avatarCoordinator', () => ({ showAvatarDialog: (...args) => showAvatarDialog(...args) }));
|
||||||
|
vi.mock('../../coordinators/worldCoordinator', () => ({ showWorldDialog: (...args) => showWorldDialog(...args) }));
|
||||||
|
vi.mock('../../coordinators/groupCoordinator', () => ({ showGroupDialog: (...args) => showGroupDialog(...args) }));
|
||||||
|
|
||||||
|
import { useGlobalSearchStore } from '../globalSearch';
|
||||||
|
|
||||||
|
function setupStores() {
|
||||||
|
mocks.friendStore = reactive({ friends: new Map() });
|
||||||
|
mocks.favoriteStore = reactive({ favoriteAvatars: [], favoriteWorlds: [] });
|
||||||
|
mocks.avatarStore = reactive({ cachedAvatars: new Map() });
|
||||||
|
mocks.worldStore = reactive({ cachedWorlds: new Map() });
|
||||||
|
mocks.groupStore = reactive({ currentUserGroups: new Map() });
|
||||||
|
mocks.userStore = reactive({ currentUser: { id: 'usr_me' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useGlobalSearchStore', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mocks.workerInstances.length = 0;
|
||||||
|
setActivePinia(createPinia());
|
||||||
|
setupStores();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('discards stale worker results and applies the latest seq only', async () => {
|
||||||
|
const store = useGlobalSearchStore();
|
||||||
|
store.setQuery('ab');
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
const worker = mocks.workerInstances[0];
|
||||||
|
expect(worker).toBeTruthy();
|
||||||
|
const firstMsg = worker.postMessage.mock.calls.at(-1)[0];
|
||||||
|
expect(firstMsg.type).toBe('search');
|
||||||
|
|
||||||
|
store.setQuery('abc');
|
||||||
|
await nextTick();
|
||||||
|
const secondMsg = worker.postMessage.mock.calls.at(-1)[0];
|
||||||
|
expect(secondMsg.payload.seq).toBeGreaterThan(firstMsg.payload.seq);
|
||||||
|
|
||||||
|
worker.emit({
|
||||||
|
type: 'searchResult',
|
||||||
|
payload: {
|
||||||
|
seq: firstMsg.payload.seq,
|
||||||
|
friends: [{ id: 'usr_old', name: 'Old' }],
|
||||||
|
ownAvatars: [],
|
||||||
|
favAvatars: [],
|
||||||
|
ownWorlds: [],
|
||||||
|
favWorlds: [],
|
||||||
|
ownGroups: [],
|
||||||
|
joinedGroups: []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(store.friendResults).toEqual([]);
|
||||||
|
|
||||||
|
mocks.friendStore.friends.set('usr_new', { id: 'usr_new', ref: { id: 'usr_new' } });
|
||||||
|
worker.emit({
|
||||||
|
type: 'searchResult',
|
||||||
|
payload: {
|
||||||
|
seq: secondMsg.payload.seq,
|
||||||
|
friends: [{ id: 'usr_new', name: 'New' }],
|
||||||
|
ownAvatars: [],
|
||||||
|
favAvatars: [],
|
||||||
|
ownWorlds: [],
|
||||||
|
favWorlds: [],
|
||||||
|
ownGroups: [],
|
||||||
|
joinedGroups: []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(store.friendResults).toHaveLength(1);
|
||||||
|
expect(store.friendResults[0].id).toBe('usr_new');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('short query clears results and blocks stale refill', async () => {
|
||||||
|
const store = useGlobalSearchStore();
|
||||||
|
store.setQuery('ab');
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
const worker = mocks.workerInstances[0];
|
||||||
|
const firstSeq = worker.postMessage.mock.calls.at(-1)[0].payload.seq;
|
||||||
|
|
||||||
|
store.setQuery('');
|
||||||
|
await nextTick();
|
||||||
|
expect(store.friendResults).toEqual([]);
|
||||||
|
|
||||||
|
worker.emit({
|
||||||
|
type: 'searchResult',
|
||||||
|
payload: {
|
||||||
|
seq: firstSeq,
|
||||||
|
friends: [{ id: 'usr_old', name: 'Old' }],
|
||||||
|
ownAvatars: [],
|
||||||
|
favAvatars: [],
|
||||||
|
ownWorlds: [],
|
||||||
|
favWorlds: [],
|
||||||
|
ownGroups: [],
|
||||||
|
joinedGroups: []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(store.friendResults).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('re-dispatches search when currentUserId changes and query is active', async () => {
|
||||||
|
const store = useGlobalSearchStore();
|
||||||
|
store.setQuery('ab');
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
const worker = mocks.workerInstances[0];
|
||||||
|
const callsBefore = worker.postMessage.mock.calls.length;
|
||||||
|
|
||||||
|
mocks.userStore.currentUser.id = 'usr_other';
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
const callsAfter = worker.postMessage.mock.calls.length;
|
||||||
|
expect(callsAfter).toBeGreaterThan(callsBefore);
|
||||||
|
|
||||||
|
const lastMessage = worker.postMessage.mock.calls.at(-1)[0];
|
||||||
|
expect(lastMessage.type).toBe('search');
|
||||||
|
expect(lastMessage.payload.currentUserId).toBe('usr_other');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
function setupWorkerHarness() {
|
||||||
|
const sent = [];
|
||||||
|
let handler = null;
|
||||||
|
|
||||||
|
globalThis.self = {
|
||||||
|
addEventListener: vi.fn((event, cb) => {
|
||||||
|
if (event === 'message') handler = cb;
|
||||||
|
}),
|
||||||
|
postMessage: vi.fn((payload) => {
|
||||||
|
sent.push(payload);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
sent,
|
||||||
|
dispatch: (data) => handler?.({ data })
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('searchWorker message protocol', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns empty search result for short query', async () => {
|
||||||
|
const harness = setupWorkerHarness();
|
||||||
|
await import('../searchWorker.js');
|
||||||
|
|
||||||
|
harness.dispatch({
|
||||||
|
type: 'search',
|
||||||
|
payload: { seq: 7, query: 'a', currentUserId: 'usr_me', language: 'en-US' }
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(harness.sent).toHaveLength(1);
|
||||||
|
expect(harness.sent[0]).toEqual({
|
||||||
|
type: 'searchResult',
|
||||||
|
payload: {
|
||||||
|
seq: 7,
|
||||||
|
friends: [],
|
||||||
|
ownAvatars: [],
|
||||||
|
favAvatars: [],
|
||||||
|
ownWorlds: [],
|
||||||
|
favWorlds: [],
|
||||||
|
ownGroups: [],
|
||||||
|
joinedGroups: []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deduplicates favorites and joined groups against own results', async () => {
|
||||||
|
const harness = setupWorkerHarness();
|
||||||
|
await import('../searchWorker.js');
|
||||||
|
|
||||||
|
harness.dispatch({
|
||||||
|
type: 'updateIndex',
|
||||||
|
payload: {
|
||||||
|
friends: [],
|
||||||
|
avatars: [{ id: 'avtr_1', name: 'Alpha Avatar', authorId: 'usr_me', imageUrl: '' }],
|
||||||
|
worlds: [{ id: 'wrld_1', name: 'Alpha World', authorId: 'usr_me', imageUrl: '' }],
|
||||||
|
groups: [{ id: 'grp_1', name: 'Alpha Group', ownerId: 'usr_me', imageUrl: '' }],
|
||||||
|
favAvatars: [{ id: 'avtr_1', name: 'Alpha Avatar', imageUrl: '' }],
|
||||||
|
favWorlds: [{ id: 'wrld_1', name: 'Alpha World', imageUrl: '' }]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
harness.dispatch({
|
||||||
|
type: 'search',
|
||||||
|
payload: { seq: 8, query: 'Alpha', currentUserId: 'usr_me', language: 'en-US' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = harness.sent.at(-1);
|
||||||
|
expect(result.type).toBe('searchResult');
|
||||||
|
expect(result.payload.seq).toBe(8);
|
||||||
|
expect(result.payload.ownAvatars).toHaveLength(1);
|
||||||
|
expect(result.payload.favAvatars).toHaveLength(0);
|
||||||
|
expect(result.payload.ownWorlds).toHaveLength(1);
|
||||||
|
expect(result.payload.favWorlds).toHaveLength(0);
|
||||||
|
expect(result.payload.ownGroups).toHaveLength(1);
|
||||||
|
expect(result.payload.joinedGroups).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
+209
-111
@@ -1,24 +1,17 @@
|
|||||||
import { computed, ref, watch } from 'vue';
|
import { ref, watch, computed } from 'vue';
|
||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
|
|
||||||
import {
|
|
||||||
searchAvatars,
|
|
||||||
searchFavoriteAvatars,
|
|
||||||
searchFavoriteWorlds,
|
|
||||||
searchFriends,
|
|
||||||
searchGroups,
|
|
||||||
searchWorlds
|
|
||||||
} from '../shared/utils/globalSearchUtils';
|
|
||||||
import { useAvatarStore } from './avatar';
|
import { useAvatarStore } from './avatar';
|
||||||
import { useFavoriteStore } from './favorite';
|
import { useFavoriteStore } from './favorite';
|
||||||
import { useFriendStore } from './friend';
|
import { useFriendStore } from './friend';
|
||||||
import { useGroupStore } from './group';
|
import { useGroupStore } from './group';
|
||||||
|
import { useUserStore } from './user';
|
||||||
|
import { useWorldStore } from './world';
|
||||||
import { showGroupDialog } from '../coordinators/groupCoordinator';
|
import { showGroupDialog } from '../coordinators/groupCoordinator';
|
||||||
import { showWorldDialog } from '../coordinators/worldCoordinator';
|
import { showWorldDialog } from '../coordinators/worldCoordinator';
|
||||||
import { showAvatarDialog } from '../coordinators/avatarCoordinator';
|
import { showAvatarDialog } from '../coordinators/avatarCoordinator';
|
||||||
import { showUserDialog } from '../coordinators/userCoordinator';
|
import { showUserDialog } from '../coordinators/userCoordinator';
|
||||||
import { useUserStore } from './user';
|
|
||||||
import { useWorldStore } from './world';
|
import SearchWorker from './searchWorker.js?worker';
|
||||||
|
|
||||||
export const useGlobalSearchStore = defineStore('GlobalSearch', () => {
|
export const useGlobalSearchStore = defineStore('GlobalSearch', () => {
|
||||||
const friendStore = useFriendStore();
|
const friendStore = useFriendStore();
|
||||||
@@ -31,102 +24,26 @@ export const useGlobalSearchStore = defineStore('GlobalSearch', () => {
|
|||||||
const isOpen = ref(false);
|
const isOpen = ref(false);
|
||||||
const query = ref('');
|
const query = ref('');
|
||||||
|
|
||||||
const stringComparer = computed(
|
// Worker instance (lazy)
|
||||||
() =>
|
let worker = null;
|
||||||
new Intl.Collator(undefined, {
|
let indexUpdateTimer = null;
|
||||||
usage: 'search',
|
|
||||||
sensitivity: 'base'
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Reset query when dialog closes
|
function getWorker() {
|
||||||
watch(isOpen, (open) => {
|
if (!worker) {
|
||||||
if (!open) {
|
worker = new SearchWorker();
|
||||||
query.value = '';
|
worker.onmessage = handleWorkerMessage;
|
||||||
}
|
}
|
||||||
});
|
return worker;
|
||||||
|
}
|
||||||
|
|
||||||
const currentUserId = computed(() => userStore.currentUser?.id);
|
// Search results (updated from worker messages)
|
||||||
|
const friendResults = ref([]);
|
||||||
const friendResults = computed(() => {
|
const ownAvatarResults = ref([]);
|
||||||
if (!query.value || query.value.length < 2) return [];
|
const favoriteAvatarResults = ref([]);
|
||||||
return searchFriends(
|
const ownWorldResults = ref([]);
|
||||||
query.value,
|
const favoriteWorldResults = ref([]);
|
||||||
friendStore.friends,
|
const ownGroupResults = ref([]);
|
||||||
stringComparer.value
|
const joinedGroupResults = ref([]);
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Own avatars (filter cachedAvatars by authorId)
|
|
||||||
const ownAvatarResults = computed(() => {
|
|
||||||
if (!query.value || query.value.length < 2) return [];
|
|
||||||
return searchAvatars(
|
|
||||||
query.value,
|
|
||||||
avatarStore.cachedAvatars,
|
|
||||||
stringComparer.value,
|
|
||||||
currentUserId.value
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Favorite avatars (from favoriteStore, deduplicated against own)
|
|
||||||
const favoriteAvatarResults = computed(() => {
|
|
||||||
if (!query.value || query.value.length < 2) return [];
|
|
||||||
const favResults = searchFavoriteAvatars(
|
|
||||||
query.value,
|
|
||||||
favoriteStore.favoriteAvatars,
|
|
||||||
stringComparer.value
|
|
||||||
);
|
|
||||||
// Deduplicate: remove items already in ownAvatarResults
|
|
||||||
const ownIds = new Set(ownAvatarResults.value.map((r) => r.id));
|
|
||||||
return favResults.filter((r) => !ownIds.has(r.id));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Own worlds (filter cachedWorlds by authorId)
|
|
||||||
const ownWorldResults = computed(() => {
|
|
||||||
if (!query.value || query.value.length < 2) return [];
|
|
||||||
return searchWorlds(
|
|
||||||
query.value,
|
|
||||||
worldStore.cachedWorlds,
|
|
||||||
stringComparer.value,
|
|
||||||
currentUserId.value
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Favorite worlds (from favoriteStore, deduplicated against own)
|
|
||||||
const favoriteWorldResults = computed(() => {
|
|
||||||
if (!query.value || query.value.length < 2) return [];
|
|
||||||
const favResults = searchFavoriteWorlds(
|
|
||||||
query.value,
|
|
||||||
favoriteStore.favoriteWorlds,
|
|
||||||
stringComparer.value
|
|
||||||
);
|
|
||||||
// Deduplicate: remove items already in ownWorldResults
|
|
||||||
const ownIds = new Set(ownWorldResults.value.map((r) => r.id));
|
|
||||||
return favResults.filter((r) => !ownIds.has(r.id));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Own groups (filter by ownerId === currentUser)
|
|
||||||
const ownGroupResults = computed(() => {
|
|
||||||
if (!query.value || query.value.length < 2) return [];
|
|
||||||
return searchGroups(
|
|
||||||
query.value,
|
|
||||||
groupStore.currentUserGroups,
|
|
||||||
stringComparer.value,
|
|
||||||
currentUserId.value
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Joined groups (all matching groups, deduplicated against own)
|
|
||||||
const joinedGroupResults = computed(() => {
|
|
||||||
if (!query.value || query.value.length < 2) return [];
|
|
||||||
const allResults = searchGroups(
|
|
||||||
query.value,
|
|
||||||
groupStore.currentUserGroups,
|
|
||||||
stringComparer.value
|
|
||||||
);
|
|
||||||
const ownIds = new Set(ownGroupResults.value.map((r) => r.id));
|
|
||||||
return allResults.filter((r) => !ownIds.has(r.id));
|
|
||||||
});
|
|
||||||
|
|
||||||
const hasResults = computed(
|
const hasResults = computed(
|
||||||
() =>
|
() =>
|
||||||
@@ -139,16 +56,197 @@ export const useGlobalSearchStore = defineStore('GlobalSearch', () => {
|
|||||||
joinedGroupResults.value.length > 0
|
joinedGroupResults.value.length > 0
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
const currentUserId = computed(() => userStore.currentUser?.id);
|
||||||
*
|
|
||||||
*/
|
watch(isOpen, (open) => {
|
||||||
|
if (!open) {
|
||||||
|
query.value = '';
|
||||||
|
clearResults();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send index update to worker when data changes
|
||||||
|
function scheduleIndexUpdate() {
|
||||||
|
if (indexUpdateTimer) clearTimeout(indexUpdateTimer);
|
||||||
|
indexUpdateTimer = setTimeout(() => {
|
||||||
|
indexUpdateTimer = null;
|
||||||
|
sendIndexUpdate();
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendIndexUpdate() {
|
||||||
|
const w = getWorker();
|
||||||
|
|
||||||
|
const friends = [];
|
||||||
|
for (const ctx of friendStore.friends.values()) {
|
||||||
|
if (typeof ctx.ref === 'undefined') continue;
|
||||||
|
friends.push({
|
||||||
|
id: ctx.id,
|
||||||
|
name: ctx.name,
|
||||||
|
memo: ctx.memo || '',
|
||||||
|
note: ctx.ref.note || '',
|
||||||
|
imageUrl: ctx.ref.currentAvatarThumbnailImageUrl
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const avatars = [];
|
||||||
|
for (const ref of avatarStore.cachedAvatars.values()) {
|
||||||
|
if (!ref || !ref.name) continue;
|
||||||
|
avatars.push({
|
||||||
|
id: ref.id,
|
||||||
|
name: ref.name,
|
||||||
|
authorId: ref.authorId,
|
||||||
|
imageUrl: ref.thumbnailImageUrl || ref.imageUrl
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const worlds = [];
|
||||||
|
for (const ref of worldStore.cachedWorlds.values()) {
|
||||||
|
if (!ref || !ref.name) continue;
|
||||||
|
worlds.push({
|
||||||
|
id: ref.id,
|
||||||
|
name: ref.name,
|
||||||
|
authorId: ref.authorId,
|
||||||
|
imageUrl: ref.thumbnailImageUrl || ref.imageUrl
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = [];
|
||||||
|
for (const ref of groupStore.currentUserGroups.values()) {
|
||||||
|
if (!ref || !ref.name) continue;
|
||||||
|
groups.push({
|
||||||
|
id: ref.id,
|
||||||
|
name: ref.name,
|
||||||
|
ownerId: ref.ownerId,
|
||||||
|
imageUrl: ref.iconUrl || ref.bannerUrl
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const favAvatars = [];
|
||||||
|
for (const ctx of favoriteStore.favoriteAvatars) {
|
||||||
|
if (!ctx?.ref?.name) continue;
|
||||||
|
favAvatars.push({
|
||||||
|
id: ctx.ref.id,
|
||||||
|
name: ctx.ref.name,
|
||||||
|
imageUrl: ctx.ref.thumbnailImageUrl || ctx.ref.imageUrl
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const favWorlds = [];
|
||||||
|
for (const ctx of favoriteStore.favoriteWorlds) {
|
||||||
|
if (!ctx?.ref?.name) continue;
|
||||||
|
favWorlds.push({
|
||||||
|
id: ctx.ref.id,
|
||||||
|
name: ctx.ref.name,
|
||||||
|
imageUrl: ctx.ref.thumbnailImageUrl || ctx.ref.imageUrl
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
w.postMessage({
|
||||||
|
type: 'updateIndex',
|
||||||
|
payload: { friends, avatars, worlds, groups, favAvatars, favWorlds }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => friendStore.friends,
|
||||||
|
() => scheduleIndexUpdate(),
|
||||||
|
{ deep: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => avatarStore.cachedAvatars,
|
||||||
|
() => scheduleIndexUpdate(),
|
||||||
|
{ deep: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => worldStore.cachedWorlds,
|
||||||
|
() => scheduleIndexUpdate(),
|
||||||
|
{ deep: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => groupStore.currentUserGroups,
|
||||||
|
() => scheduleIndexUpdate(),
|
||||||
|
{ deep: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => favoriteStore.favoriteAvatars,
|
||||||
|
() => scheduleIndexUpdate(),
|
||||||
|
{ deep: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => favoriteStore.favoriteWorlds,
|
||||||
|
() => scheduleIndexUpdate(),
|
||||||
|
{ deep: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
let searchSeq = 0;
|
||||||
|
|
||||||
|
function dispatchSearch() {
|
||||||
|
const q = query.value;
|
||||||
|
if (!q || q.length < 2) {
|
||||||
|
++searchSeq;
|
||||||
|
clearResults();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const seq = ++searchSeq;
|
||||||
|
const w = getWorker();
|
||||||
|
w.postMessage({
|
||||||
|
type: 'search',
|
||||||
|
payload: {
|
||||||
|
seq,
|
||||||
|
query: q,
|
||||||
|
currentUserId: currentUserId.value,
|
||||||
|
language: navigator.language
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(query, dispatchSearch);
|
||||||
|
watch(currentUserId, () => {
|
||||||
|
if (query.value && query.value.length >= 2) dispatchSearch();
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleWorkerMessage(event) {
|
||||||
|
const { type, payload } = event.data;
|
||||||
|
if (type === 'searchResult') {
|
||||||
|
if (payload.seq !== searchSeq) return;
|
||||||
|
|
||||||
|
// Enrich friend results with reactive ref from store
|
||||||
|
// (Worker can't serialize Vue reactive objects)
|
||||||
|
friendResults.value = payload.friends.map((item) => {
|
||||||
|
const friendEntry = friendStore.friends.get(item.id);
|
||||||
|
return { ...item, ref: friendEntry?.ref };
|
||||||
|
});
|
||||||
|
ownAvatarResults.value = payload.ownAvatars;
|
||||||
|
favoriteAvatarResults.value = payload.favAvatars;
|
||||||
|
ownWorldResults.value = payload.ownWorlds;
|
||||||
|
favoriteWorldResults.value = payload.favWorlds;
|
||||||
|
ownGroupResults.value = payload.ownGroups;
|
||||||
|
joinedGroupResults.value = payload.joinedGroups;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearResults() {
|
||||||
|
friendResults.value = [];
|
||||||
|
ownAvatarResults.value = [];
|
||||||
|
favoriteAvatarResults.value = [];
|
||||||
|
ownWorldResults.value = [];
|
||||||
|
favoriteWorldResults.value = [];
|
||||||
|
ownGroupResults.value = [];
|
||||||
|
joinedGroupResults.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function open() {
|
function open() {
|
||||||
|
sendIndexUpdate();
|
||||||
isOpen.value = true;
|
isOpen.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
function close() {
|
function close() {
|
||||||
isOpen.value = false;
|
isOpen.value = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,280 @@
|
|||||||
|
/**
|
||||||
|
* Web Worker for search operations.
|
||||||
|
*
|
||||||
|
* Offloads CPU-heavy confusable-character normalization and
|
||||||
|
* locale-aware string search from the main thread.
|
||||||
|
*
|
||||||
|
* Protocol — Main → Worker:
|
||||||
|
* { type: 'updateIndex', payload: { friends, avatars, worlds, groups, favAvatars, favWorlds } }
|
||||||
|
* { type: 'search', payload: { seq, query, currentUserId, language } }
|
||||||
|
*
|
||||||
|
* Protocol — Worker → Main:
|
||||||
|
* { type: 'searchResult', payload: { seq, friends, ownAvatars, favAvatars, ownWorlds, favWorlds, ownGroups, joinedGroups } }
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── Confusables (inlined for Worker isolation) ──────────────────────
|
||||||
|
// We inline the minimal confusables logic to avoid importing from a
|
||||||
|
// module that might rely on non-Worker-safe globals.
|
||||||
|
|
||||||
|
const charToConfusables = new Map([
|
||||||
|
[' ', ' '],
|
||||||
|
['0', '⓿'],
|
||||||
|
['1', '11⓵➊⑴¹𝟏𝟙1𝟷𝟣⒈𝟭1➀₁①❶⥠'],
|
||||||
|
['2', '⓶⒉⑵➋ƻ²ᒿ𝟚2𝟮𝟤ᒾ𝟸Ƨ𝟐②ᴤ₂➁❷ᘝƨ'],
|
||||||
|
['3', '³ȝჳⳌꞫ𝟑ℨ𝟛𝟯𝟥Ꝫ➌ЗȜ⓷ӠƷ3𝟹⑶⒊ʒʓǯǮƺ𝕴ᶾзᦡ➂③₃ᶚᴣᴟ❸ҘҙӬӡӭӟӞ'],
|
||||||
|
['4', '𝟰𝟺𝟦𝟒➍ҶᏎ𝟜ҷ⓸ҸҹӴӵᶣ4чㄩ⁴➃₄④❹Ӌ⑷⒋'],
|
||||||
|
['5', '𝟱⓹➎Ƽ𝟓𝟻𝟝𝟧5➄₅⑤⁵❺ƽ⑸⒌'],
|
||||||
|
['6', 'Ⳓ🄇𝟼Ꮾ𝟲𝟞𝟨𝟔➏⓺Ϭϭ⁶б6ᧈ⑥➅₆❻⑹⒍'],
|
||||||
|
['7', '𝟕𝟟𝟩𝟳𝟽🄈⓻𐓒➐7⁷⑦₇❼➆⑺⒎'],
|
||||||
|
['8', '𐌚🄉➑⓼8𝟠𝟪৪⁸₈𝟴➇⑧❽𝟾𝟖⑻⒏'],
|
||||||
|
['9', '൭Ꝯ𝝑𝞋𝟅🄊𝟡𝟵Ⳋ⓽➒੧৭୨9𝟫𝟿𝟗⁹₉Գ➈⑨❾⑼⒐'],
|
||||||
|
['a', '∂⍺ⓐ'],
|
||||||
|
['b', 'ꮟᏏ'],
|
||||||
|
['c', '🝌'],
|
||||||
|
['d', 'Ꮷ'],
|
||||||
|
['e', 'əәⅇꬲ'],
|
||||||
|
['f', 'ꬵꞙẝ'],
|
||||||
|
['g', 'ᶃᶢⓖ'],
|
||||||
|
['h', 'ꞕ৸'],
|
||||||
|
['i', '⍳ℹⅈ'],
|
||||||
|
['j', 'ꭻⅉⓙ'],
|
||||||
|
['k', 'ⓚꝁ'],
|
||||||
|
['l', 'ⓛ'],
|
||||||
|
['m', '₥ᵯ'],
|
||||||
|
['n', 'ոռח'],
|
||||||
|
['o', 'ంಂംං'],
|
||||||
|
['p', 'ⲣҏ℗ⓟ'],
|
||||||
|
['q', 'ꝗ'],
|
||||||
|
['r', 'ⓡ'],
|
||||||
|
['s', 'ᣵⓢꜱ'],
|
||||||
|
['t', 'ⓣ'],
|
||||||
|
['u', 'ⓤ'],
|
||||||
|
['v', '∨⌄⋁ⅴ'],
|
||||||
|
['w', 'ⓦ'],
|
||||||
|
['x', '᙮ⅹ'],
|
||||||
|
['y', 'ʏỿꭚ'],
|
||||||
|
['z', 'ⓩꮓ']
|
||||||
|
]);
|
||||||
|
|
||||||
|
const confusablesToChar = new Map();
|
||||||
|
for (const [char_, confusables] of charToConfusables) {
|
||||||
|
for (const confusable of confusables) {
|
||||||
|
confusablesToChar.set(confusable, char_);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nonConfusables = /^[!-~]*$/;
|
||||||
|
const regexLineBreakCombiningMarks =
|
||||||
|
/[\0-\x08\x0E-\x1F\x7F-\x84\x86-\x9F\u0300-\u034E\u0350-\u035B\u0363-\u036F\u0483-\u0489\u0591-\u05BD\u05BF\u05C1\u05C2\u05C4\u05C5\u05C7\u0610-\u061A\u061C\u064B-\u065F\u0670\u06D6-\u06DC\u06DF-\u06E4\u06E7\u06E8\u06EA-\u06ED\u0711\u0730-\u074A\u07A6-\u07B0\u07EB-\u07F3\u0816-\u0819\u081B-\u0823\u0825-\u0827\u0829-\u082D\u0859-\u085B\u08D4-\u08E1\u08E3-\u0903\u093A-\u093C\u093E-\u094F\u0951-\u0957\u0962\u0963\u0981-\u0983\u09BC\u09BE-\u09C4\u09C7\u09C8\u09CB-\u09CD\u09D7\u09E2\u09E3\u0A01-\u0A03\u0A3C\u0A3E-\u0A42\u0A47\u0A48\u0A4B-\u0A4D\u0A51\u0A70\u0A71\u0A75\u0A81-\u0A83\u0ABC\u0ABE-\u0AC5\u0AC7-\u0AC9\u0ACB-\u0ACD\u0AE2\u0AE3\u0B01-\u0B03\u0B3C\u0B3E-\u0B44\u0B47\u0B48\u0B4B-\u0B4D\u0B56\u0B57\u0B62\u0B63\u0B82\u0BBE-\u0BC2\u0BC6-\u0BC8\u0BCA-\u0BCD\u0BD7\u0C00-\u0C03\u0C3E-\u0C44\u0C46-\u0C48\u0C4A-\u0C4D\u0C55\u0C56\u0C62\u0C63\u0C81-\u0C83\u0CBC\u0CBE-\u0CC4\u0CC6-\u0CC8\u0CCA-\u0CCD\u0CD5\u0CD6\u0CE2\u0CE3\u0D01-\u0D03\u0D3E-\u0D44\u0D46-\u0D48\u0D4A-\u0D4D\u0D57\u0D62\u0D63\u0D82\u0D83\u0DCA\u0DCF-\u0DD4\u0DD6\u0DD8-\u0DDF\u0DF2\u0DF3\u0F18\u0F19\u0F35\u0F37\u0F39\u0F3E\u0F3F\u0F71-\u0F7E\u0F80-\u0F84\u0F86\u0F87\u0F8D-\u0F97\u0F99-\u0FBC\u0FC6\u135D-\u135F\u1712-\u1714\u1732-\u1734\u1752\u1753\u1772\u1773\u180B-\u180D\u1885\u1886\u18A9\u1920-\u192B\u1930-\u193B\u1A17-\u1A1B\u1A7F\u1AB0-\u1ABE\u1B00-\u1B04\u1B34-\u1B44\u1B6B-\u1B73\u1B80-\u1B82\u1BA1-\u1BAD\u1BE6-\u1BF3\u1C24-\u1C37\u1CD0-\u1CD2\u1CD4-\u1CE8\u1CED\u1CF2-\u1CF4\u1CF8\u1CF9\u1DC0-\u1DF5\u1DFB-\u1DFF\u200C\u200E\u200F\u202A-\u202E\u2066-\u206F\u20D0-\u20F0\u2CEF-\u2CF1\u2D7F\u2DE0-\u2DFF\u302A-\u302F\u3035\u3099\u309A\uA66F-\uA672\uA674-\uA67D\uA69E\uA69F\uA6F0\uA6F1\uA802\uA806\uA80B\uA823-\uA827\uA880\uA881\uA8B4-\uA8C5\uA8E0-\uA8F1\uA926-\uA92D\uA947-\uA953\uA980-\uA983\uA9B3-\uA9C0\uAA29-\uAA36\uAA43\uAA4C\uAA4D\uAAEB-\uAAEF\uAAF5\uAAF6\uABE3-\uABEA\uABEC\uABED\uFB1E\uFE00-\uFE0F\uFE20-\uFE2F\uFFF9-\uFFFB]/g;
|
||||||
|
const regexSymbolWithCombiningMarks =
|
||||||
|
/([\0-\u02FF\u0370-\u1AAF\u1B00-\u1DBF\u1E00-\u20CF\u2100-\uD7FF\uE000-\uFE1F\uFE30-\uFFFF]|[\uD800-\uDBFF][\uDC00-\uDFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:[^\uD800-\uDBFF]|^)[\uDC00-\uDFFF])([\u0300-\u036F\u1AB0-\u1AFF\u1DC0-\u1DFF\u20D0-\u20FF\uFE20-\uFE2F]+)/g;
|
||||||
|
|
||||||
|
function removeConfusables(a) {
|
||||||
|
if (nonConfusables.test(a)) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
let ret = '';
|
||||||
|
for (const char_ of a
|
||||||
|
.normalize()
|
||||||
|
.replace(regexLineBreakCombiningMarks, '')
|
||||||
|
.replace(regexSymbolWithCombiningMarks, '$1')
|
||||||
|
.replace(/\s/g, '')) {
|
||||||
|
ret += confusablesToChar.get(char_) || char_;
|
||||||
|
}
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeWhitespace(a) {
|
||||||
|
return a.replace(/\s/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Locale-aware string search ──────────────────────────────────────
|
||||||
|
|
||||||
|
function localeIncludes(str, search, comparer) {
|
||||||
|
if (search === '') return true;
|
||||||
|
if (!str || !search) return false;
|
||||||
|
const strObj = String(str);
|
||||||
|
const searchObj = String(search);
|
||||||
|
if (strObj.length === 0) return false;
|
||||||
|
if (searchObj.length > strObj.length) return false;
|
||||||
|
for (let i = 0; i < str.length - searchObj.length + 1; i++) {
|
||||||
|
const substr = strObj.substring(i, i + searchObj.length);
|
||||||
|
if (comparer.compare(substr, searchObj) === 0) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchName(name, query, comparer) {
|
||||||
|
if (!name || !query) return false;
|
||||||
|
const cleanQuery = removeWhitespace(query);
|
||||||
|
if (!cleanQuery) return false;
|
||||||
|
const cleanName = removeConfusables(name);
|
||||||
|
if (localeIncludes(cleanName, cleanQuery, comparer)) return true;
|
||||||
|
return localeIncludes(name, cleanQuery, comparer);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPrefixMatch(name, query, comparer) {
|
||||||
|
if (!name || !query) return false;
|
||||||
|
const cleanQuery = removeWhitespace(query);
|
||||||
|
if (!cleanQuery) return false;
|
||||||
|
return (
|
||||||
|
comparer.compare(name.substring(0, cleanQuery.length), cleanQuery) === 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Index data (updated from main thread) ───────────────────────────
|
||||||
|
|
||||||
|
let indexedFriends = []; // { id, name, memo, note, imageUrl }
|
||||||
|
let indexedAvatars = []; // { id, name, authorId, imageUrl }
|
||||||
|
let indexedWorlds = []; // { id, name, authorId, imageUrl }
|
||||||
|
let indexedGroups = []; // { id, name, ownerId, imageUrl }
|
||||||
|
let indexedFavAvatars = []; // { id, name, imageUrl }
|
||||||
|
let indexedFavWorlds = []; // { id, name, imageUrl }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the search index with fresh data snapshots.
|
||||||
|
*/
|
||||||
|
function updateIndex(payload) {
|
||||||
|
if (payload.friends) indexedFriends = payload.friends;
|
||||||
|
if (payload.avatars) indexedAvatars = payload.avatars;
|
||||||
|
if (payload.worlds) indexedWorlds = payload.worlds;
|
||||||
|
if (payload.groups) indexedGroups = payload.groups;
|
||||||
|
if (payload.favAvatars) indexedFavAvatars = payload.favAvatars;
|
||||||
|
if (payload.favWorlds) indexedFavWorlds = payload.favWorlds;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Search functions ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function searchFriends(query, comparer, limit = 10) {
|
||||||
|
const results = [];
|
||||||
|
for (const ctx of indexedFriends) {
|
||||||
|
let match = matchName(ctx.name, query, comparer);
|
||||||
|
let matchedField = match ? 'name' : null;
|
||||||
|
if (!match && ctx.memo) {
|
||||||
|
match = localeIncludes(ctx.memo, query, comparer);
|
||||||
|
if (match) matchedField = 'memo';
|
||||||
|
}
|
||||||
|
if (!match && ctx.note) {
|
||||||
|
match = localeIncludes(ctx.note, query, comparer);
|
||||||
|
if (match) matchedField = 'note';
|
||||||
|
}
|
||||||
|
if (match) {
|
||||||
|
results.push({
|
||||||
|
id: ctx.id,
|
||||||
|
name: ctx.name,
|
||||||
|
type: 'friend',
|
||||||
|
imageUrl: ctx.imageUrl,
|
||||||
|
memo: ctx.memo || '',
|
||||||
|
note: ctx.note || '',
|
||||||
|
matchedField
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
results.sort((a, b) => {
|
||||||
|
const aPrefix = isPrefixMatch(a.name, query, comparer);
|
||||||
|
const bPrefix = isPrefixMatch(b.name, query, comparer);
|
||||||
|
if (aPrefix && !bPrefix) return -1;
|
||||||
|
if (bPrefix && !aPrefix) return 1;
|
||||||
|
return comparer.compare(a.name, b.name);
|
||||||
|
});
|
||||||
|
if (results.length > limit) results.length = limit;
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
function searchItems(query, items, type, comparer, ownerKey, ownerId, limit = 10) {
|
||||||
|
const results = [];
|
||||||
|
for (const ref of items) {
|
||||||
|
if (!ref || !ref.name) continue;
|
||||||
|
if (ownerId && ref[ownerKey] !== ownerId) continue;
|
||||||
|
if (matchName(ref.name, query, comparer)) {
|
||||||
|
results.push({
|
||||||
|
id: ref.id,
|
||||||
|
name: ref.name,
|
||||||
|
type,
|
||||||
|
imageUrl: ref.imageUrl
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
results.sort((a, b) => {
|
||||||
|
const aPrefix = isPrefixMatch(a.name, query, comparer);
|
||||||
|
const bPrefix = isPrefixMatch(b.name, query, comparer);
|
||||||
|
if (aPrefix && !bPrefix) return -1;
|
||||||
|
if (bPrefix && !aPrefix) return 1;
|
||||||
|
return comparer.compare(a.name, b.name);
|
||||||
|
});
|
||||||
|
if (results.length > limit) results.length = limit;
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearch(payload) {
|
||||||
|
const { seq, query, currentUserId, language } = payload;
|
||||||
|
|
||||||
|
if (!query || query.length < 2) {
|
||||||
|
self.postMessage({
|
||||||
|
type: 'searchResult',
|
||||||
|
payload: {
|
||||||
|
seq,
|
||||||
|
friends: [],
|
||||||
|
ownAvatars: [],
|
||||||
|
favAvatars: [],
|
||||||
|
ownWorlds: [],
|
||||||
|
favWorlds: [],
|
||||||
|
ownGroups: [],
|
||||||
|
joinedGroups: []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const comparer = new Intl.Collator(
|
||||||
|
(language || 'en').replace('_', '-'),
|
||||||
|
{ usage: 'search', sensitivity: 'base' }
|
||||||
|
);
|
||||||
|
|
||||||
|
const friends = searchFriends(query, comparer);
|
||||||
|
const ownAvatars = searchItems(query, indexedAvatars, 'avatar', comparer, 'authorId', currentUserId);
|
||||||
|
const favAvatars = searchItems(query, indexedFavAvatars, 'avatar', comparer, null, null);
|
||||||
|
const ownWorlds = searchItems(query, indexedWorlds, 'world', comparer, 'authorId', currentUserId);
|
||||||
|
const favWorlds = searchItems(query, indexedFavWorlds, 'world', comparer, null, null);
|
||||||
|
const ownGroups = searchItems(query, indexedGroups, 'group', comparer, 'ownerId', currentUserId);
|
||||||
|
const joinedGroups = searchItems(query, indexedGroups, 'group', comparer, null, null);
|
||||||
|
|
||||||
|
// Deduplicate favorites against own
|
||||||
|
const ownAvatarIds = new Set(ownAvatars.map((r) => r.id));
|
||||||
|
const dedupedFavAvatars = favAvatars.filter((r) => !ownAvatarIds.has(r.id));
|
||||||
|
const ownWorldIds = new Set(ownWorlds.map((r) => r.id));
|
||||||
|
const dedupedFavWorlds = favWorlds.filter((r) => !ownWorldIds.has(r.id));
|
||||||
|
const ownGroupIds = new Set(ownGroups.map((r) => r.id));
|
||||||
|
const dedupedJoinedGroups = joinedGroups.filter((r) => !ownGroupIds.has(r.id));
|
||||||
|
|
||||||
|
self.postMessage({
|
||||||
|
type: 'searchResult',
|
||||||
|
payload: {
|
||||||
|
seq,
|
||||||
|
friends,
|
||||||
|
ownAvatars,
|
||||||
|
favAvatars: dedupedFavAvatars,
|
||||||
|
ownWorlds,
|
||||||
|
favWorlds: dedupedFavWorlds,
|
||||||
|
ownGroups,
|
||||||
|
joinedGroups: dedupedJoinedGroups
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Message handler ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
self.addEventListener('message', (event) => {
|
||||||
|
const { type, payload } = event.data;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'updateIndex':
|
||||||
|
updateIndex(payload);
|
||||||
|
break;
|
||||||
|
case 'search':
|
||||||
|
handleSearch(payload);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.warn('[SearchWorker] Unknown message type:', type);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
class FakeGraph {
|
||||||
|
constructor() {
|
||||||
|
this._nodes = new Map();
|
||||||
|
this._edges = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
addNode(id, attrs = {}) {
|
||||||
|
this._nodes.set(id, { ...attrs });
|
||||||
|
}
|
||||||
|
|
||||||
|
addEdgeWithKey(key, source, target, attrs = {}) {
|
||||||
|
if (!this._nodes.has(source) || !this._nodes.has(target)) {
|
||||||
|
throw new Error('missing node');
|
||||||
|
}
|
||||||
|
this._edges.push({ key, source, target, attrs: { ...attrs } });
|
||||||
|
}
|
||||||
|
|
||||||
|
get order() {
|
||||||
|
return this._nodes.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
forEachNode(cb) {
|
||||||
|
for (const [id, attrs] of this._nodes.entries()) cb(id, attrs);
|
||||||
|
}
|
||||||
|
|
||||||
|
mergeNodeAttributes(node, attrs) {
|
||||||
|
this._nodes.set(node, { ...this._nodes.get(node), ...attrs });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock('graphology', () => ({ default: FakeGraph }));
|
||||||
|
vi.mock('graphology-layout-forceatlas2', () => ({
|
||||||
|
default: {
|
||||||
|
inferSettings: vi.fn(() => ({ gravity: 1 })),
|
||||||
|
assign: vi.fn((graph) => {
|
||||||
|
graph.forEachNode((id, attrs) => {
|
||||||
|
graph.mergeNodeAttributes(id, {
|
||||||
|
x: Number.isFinite(attrs.x) ? attrs.x + 1 : 1,
|
||||||
|
y: Number.isFinite(attrs.y) ? attrs.y + 1 : 1
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
vi.mock('graphology-layout-noverlap', () => ({
|
||||||
|
default: {
|
||||||
|
assign: vi.fn()
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
function setupWorkerHarness() {
|
||||||
|
const sent = [];
|
||||||
|
let handler = null;
|
||||||
|
|
||||||
|
globalThis.self = {
|
||||||
|
addEventListener: vi.fn((event, cb) => {
|
||||||
|
if (event === 'message') handler = cb;
|
||||||
|
}),
|
||||||
|
postMessage: vi.fn((payload) => {
|
||||||
|
sent.push(payload);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
sent,
|
||||||
|
dispatch: (data) => handler?.({ data })
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('graphLayoutWorker message protocol', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns positions with the same requestId on success', async () => {
|
||||||
|
const harness = setupWorkerHarness();
|
||||||
|
await import('../graphLayoutWorker.js');
|
||||||
|
|
||||||
|
harness.dispatch({
|
||||||
|
requestId: 11,
|
||||||
|
nodes: [{ id: 'n1', attributes: { x: 0, y: 0 } }, { id: 'n2', attributes: { x: 2, y: 2 } }],
|
||||||
|
edges: [{ key: 'n1__n2', source: 'n1', target: 'n2', attributes: {} }],
|
||||||
|
settings: { layoutIterations: 300, layoutSpacing: 60, deltaSpacing: 0, reinitialize: false }
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(harness.sent).toHaveLength(1);
|
||||||
|
expect(harness.sent[0].requestId).toBe(11);
|
||||||
|
expect(harness.sent[0].positions.n1).toBeTruthy();
|
||||||
|
expect(harness.sent[0].positions.n2).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns error with requestId when layout throws', async () => {
|
||||||
|
const harness = setupWorkerHarness();
|
||||||
|
await import('../graphLayoutWorker.js');
|
||||||
|
|
||||||
|
harness.dispatch({
|
||||||
|
requestId: 12,
|
||||||
|
nodes: [{ id: 'n1', attributes: { x: 0, y: 0 } }],
|
||||||
|
edges: [{ key: 'n1__n2', source: 'n1', target: 'n2', attributes: {} }],
|
||||||
|
settings: { layoutIterations: 300, layoutSpacing: 60, deltaSpacing: 0, reinitialize: false }
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(harness.sent).toHaveLength(1);
|
||||||
|
expect(harness.sent[0].requestId).toBe(12);
|
||||||
|
expect(harness.sent[0].error).toContain('missing node');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -263,9 +263,9 @@
|
|||||||
import EdgeCurveProgram from '@sigma/edge-curve';
|
import EdgeCurveProgram from '@sigma/edge-curve';
|
||||||
import Graph from 'graphology';
|
import Graph from 'graphology';
|
||||||
import Sigma from 'sigma';
|
import Sigma from 'sigma';
|
||||||
import forceAtlas2 from 'graphology-layout-forceatlas2';
|
|
||||||
import louvain from 'graphology-communities-louvain';
|
import louvain from 'graphology-communities-louvain';
|
||||||
import noverlap from 'graphology-layout-noverlap';
|
|
||||||
|
import GraphLayoutWorker from '../graphLayoutWorker.js?worker';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
useAppearanceSettingsStore,
|
useAppearanceSettingsStore,
|
||||||
@@ -326,6 +326,39 @@
|
|||||||
let pendingRender = null;
|
let pendingRender = null;
|
||||||
let pendingLayoutUpdate = null;
|
let pendingLayoutUpdate = null;
|
||||||
let lastMutualMap = null;
|
let lastMutualMap = null;
|
||||||
|
let layoutWorker = null;
|
||||||
|
let layoutRequestId = 0;
|
||||||
|
const layoutResolvers = new Map();
|
||||||
|
let layoutQueue = Promise.resolve();
|
||||||
|
|
||||||
|
function getLayoutWorker() {
|
||||||
|
if (!layoutWorker) {
|
||||||
|
layoutWorker = new GraphLayoutWorker();
|
||||||
|
layoutWorker.addEventListener('message', handleLayoutMessage);
|
||||||
|
layoutWorker.addEventListener('error', handleLayoutError);
|
||||||
|
}
|
||||||
|
return layoutWorker;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLayoutMessage(event) {
|
||||||
|
const { requestId, positions, error } = event.data;
|
||||||
|
const resolver = layoutResolvers.get(requestId);
|
||||||
|
if (!resolver) return;
|
||||||
|
layoutResolvers.delete(requestId);
|
||||||
|
if (error) {
|
||||||
|
resolver.reject(new Error(error));
|
||||||
|
} else {
|
||||||
|
resolver.resolve(positions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLayoutError(err) {
|
||||||
|
// Reject all pending requests on worker-level error
|
||||||
|
for (const [id, resolver] of layoutResolvers) {
|
||||||
|
resolver.reject(err);
|
||||||
|
layoutResolvers.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const LAYOUT_ITERATIONS_MIN = 300;
|
const LAYOUT_ITERATIONS_MIN = 300;
|
||||||
const LAYOUT_ITERATIONS_MAX = 1500;
|
const LAYOUT_ITERATIONS_MAX = 1500;
|
||||||
@@ -507,9 +540,15 @@
|
|||||||
localStorage.setItem(EXCLUDED_FRIENDS_KEY, JSON.stringify(excludedFriendIds.value));
|
localStorage.setItem(EXCLUDED_FRIENDS_KEY, JSON.stringify(excludedFriendIds.value));
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(excludedFriendIds, () => {
|
watch(excludedFriendIds, async () => {
|
||||||
saveExcludedFriends();
|
saveExcludedFriends();
|
||||||
if (lastMutualMap) applyGraph(lastMutualMap);
|
if (lastMutualMap) {
|
||||||
|
try {
|
||||||
|
await applyGraph(lastMutualMap);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[MutualNetworkGraph] Failed to apply graph after exclude change', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const excludePickerGroups = computed(() => {
|
const excludePickerGroups = computed(() => {
|
||||||
@@ -602,6 +641,14 @@
|
|||||||
sigmaInstance = null;
|
sigmaInstance = null;
|
||||||
}
|
}
|
||||||
currentGraph = null;
|
currentGraph = null;
|
||||||
|
if (layoutWorker) {
|
||||||
|
for (const [id, resolver] of layoutResolvers) {
|
||||||
|
resolver.reject(new Error('Component unmounted'));
|
||||||
|
layoutResolvers.delete(id);
|
||||||
|
}
|
||||||
|
layoutWorker.terminate();
|
||||||
|
layoutWorker = null;
|
||||||
|
}
|
||||||
if (mutualGraphResizeObserver) mutualGraphResizeObserver.disconnect();
|
if (mutualGraphResizeObserver) mutualGraphResizeObserver.disconnect();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -624,71 +671,72 @@
|
|||||||
return text.length > MAX_LABEL_NAME_LENGTH ? `${text.slice(0, MAX_LABEL_NAME_LENGTH)}…` : text;
|
return text.length > MAX_LABEL_NAME_LENGTH ? `${text.slice(0, MAX_LABEL_NAME_LENGTH)}…` : text;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 clampNumber(value, min, max) {
|
function clampNumber(value, min, max) {
|
||||||
const normalized = Number.isFinite(value) ? value : min;
|
const normalized = Number.isFinite(value) ? value : min;
|
||||||
return Math.min(max, Math.max(min, normalized));
|
return Math.min(max, Math.max(min, normalized));
|
||||||
}
|
}
|
||||||
|
|
||||||
function lerp(a, b, t) {
|
/**
|
||||||
return a + (b - a) * t;
|
* @param {Graph} graph
|
||||||
}
|
* @returns {{ nodes: Array, edges: Array }}
|
||||||
|
*/
|
||||||
function jitterPositions(graph, magnitude) {
|
function serializeGraph(graph) {
|
||||||
graph.forEachNode((node, attrs) => {
|
const nodes = [];
|
||||||
if (!Number.isFinite(attrs.x) || !Number.isFinite(attrs.y)) return;
|
graph.forEachNode((id, attributes) => {
|
||||||
graph.mergeNodeAttributes(node, {
|
nodes.push({ id, attributes: { ...attributes } });
|
||||||
x: attrs.x + (Math.random() - 0.5) * magnitude,
|
|
||||||
y: attrs.y + (Math.random() - 0.5) * magnitude
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
const edges = [];
|
||||||
|
graph.forEachEdge((key, attributes, source, target) => {
|
||||||
|
edges.push({ key, source, target, attributes: { ...attributes } });
|
||||||
|
});
|
||||||
|
return { nodes, edges };
|
||||||
}
|
}
|
||||||
|
|
||||||
// @ts-ignore
|
/**
|
||||||
function runLayout(graph, { reinitialize } = {}) {
|
* Run ForceAtlas2 + Noverlap layout in a Web Worker.
|
||||||
if (reinitialize) initPositions(graph);
|
* Requests are serialized: a new call waits for the previous one to finish,
|
||||||
|
* preventing concurrent callbacks from stepping on each other.
|
||||||
const iterations = clampNumber(layoutSettings.layoutIterations, LAYOUT_ITERATIONS_MIN, LAYOUT_ITERATIONS_MAX);
|
* @param {Graph} graph
|
||||||
|
* @param {object} options
|
||||||
|
* @param {boolean} [options.reinitialize]
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async function runLayout(graph, { reinitialize } = {}) {
|
||||||
const spacing = clampNumber(layoutSettings.layoutSpacing, LAYOUT_SPACING_MIN, LAYOUT_SPACING_MAX);
|
const spacing = clampNumber(layoutSettings.layoutSpacing, LAYOUT_SPACING_MIN, LAYOUT_SPACING_MAX);
|
||||||
const t = (spacing - LAYOUT_SPACING_MIN) / (LAYOUT_SPACING_MAX - LAYOUT_SPACING_MIN);
|
|
||||||
const clampedT = clampNumber(t, 0, 1);
|
|
||||||
const deltaSpacing = spacing - lastLayoutSpacing;
|
const deltaSpacing = spacing - lastLayoutSpacing;
|
||||||
lastLayoutSpacing = spacing;
|
lastLayoutSpacing = spacing;
|
||||||
|
|
||||||
const inferred = forceAtlas2.inferSettings ? forceAtlas2.inferSettings(graph) : {};
|
const { nodes, edges } = serializeGraph(graph);
|
||||||
const settings = {
|
const worker = getLayoutWorker();
|
||||||
...inferred,
|
const id = ++layoutRequestId;
|
||||||
barnesHutOptimize: true,
|
|
||||||
barnesHutTheta: 0.8,
|
|
||||||
strongGravityMode: true,
|
|
||||||
gravity: lerp(1.6, 0.6, clampedT),
|
|
||||||
scalingRatio: spacing,
|
|
||||||
slowDown: 2
|
|
||||||
};
|
|
||||||
|
|
||||||
if (Math.abs(deltaSpacing) >= 8) jitterPositions(graph, lerp(0.5, 2.0, clampedT));
|
// Serialize: wait for any in-flight layout to finish first
|
||||||
|
const task = layoutQueue.then(async () => {
|
||||||
|
const positions = await new Promise((resolve, reject) => {
|
||||||
|
layoutResolvers.set(id, { resolve, reject });
|
||||||
|
worker.postMessage({
|
||||||
|
requestId: id,
|
||||||
|
nodes,
|
||||||
|
edges,
|
||||||
|
settings: {
|
||||||
|
layoutIterations: layoutSettings.layoutIterations,
|
||||||
|
layoutSpacing: spacing,
|
||||||
|
deltaSpacing,
|
||||||
|
reinitialize: reinitialize ?? false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
forceAtlas2.assign(graph, { iterations, settings });
|
for (const [nodeId, pos] of Object.entries(positions)) {
|
||||||
const noverlapIterations = clampNumber(Math.round(Math.sqrt(graph.order) * 6), 200, 600);
|
if (graph.hasNode(nodeId)) {
|
||||||
noverlap.assign(graph, {
|
graph.mergeNodeAttributes(nodeId, { x: pos.x, y: pos.y });
|
||||||
maxIterations: noverlapIterations,
|
}
|
||||||
settings: {
|
|
||||||
ratio: lerp(1.05, 1.35, clampedT),
|
|
||||||
margin: lerp(1, 8, clampedT)
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Keep the queue going even if this request fails
|
||||||
|
layoutQueue = task.catch(() => {});
|
||||||
|
return task;
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyEdgeCurvature(graph) {
|
function applyEdgeCurvature(graph) {
|
||||||
@@ -755,14 +803,18 @@
|
|||||||
function scheduleLayoutUpdate({ runLayout: shouldRunLayout }) {
|
function scheduleLayoutUpdate({ runLayout: shouldRunLayout }) {
|
||||||
if (!currentGraph) return;
|
if (!currentGraph) return;
|
||||||
if (pendingLayoutUpdate) clearTimeout(pendingLayoutUpdate);
|
if (pendingLayoutUpdate) clearTimeout(pendingLayoutUpdate);
|
||||||
pendingLayoutUpdate = setTimeout(() => {
|
pendingLayoutUpdate = setTimeout(async () => {
|
||||||
pendingLayoutUpdate = null;
|
pendingLayoutUpdate = null;
|
||||||
applyEdgeCurvature(currentGraph);
|
try {
|
||||||
if (shouldRunLayout) {
|
applyEdgeCurvature(currentGraph);
|
||||||
runLayout(currentGraph, { reinitialize: false });
|
if (shouldRunLayout) {
|
||||||
applyCommunitySeparation(currentGraph);
|
await runLayout(currentGraph, { reinitialize: false });
|
||||||
|
applyCommunitySeparation(currentGraph);
|
||||||
|
}
|
||||||
|
renderGraph(currentGraph);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[MutualNetworkGraph] Layout update failed', err);
|
||||||
}
|
}
|
||||||
renderGraph(currentGraph);
|
|
||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -780,7 +832,7 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildGraphFromMutualMap(mutualMap) {
|
async function buildGraphFromMutualMap(mutualMap) {
|
||||||
const graph = new Graph({
|
const graph = new Graph({
|
||||||
type: 'undirected',
|
type: 'undirected',
|
||||||
multi: false,
|
multi: false,
|
||||||
@@ -836,7 +888,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (graph.order > 1) {
|
if (graph.order > 1) {
|
||||||
runLayout(graph, { reinitialize: true });
|
await runLayout(graph, { reinitialize: true });
|
||||||
assignCommunitiesAndColors(graph);
|
assignCommunitiesAndColors(graph);
|
||||||
applyCommunitySeparation(graph);
|
applyCommunitySeparation(graph);
|
||||||
applyEdgeCurvature(graph);
|
applyEdgeCurvature(graph);
|
||||||
@@ -1023,9 +1075,9 @@
|
|||||||
sigmaInstance.refresh();
|
sigmaInstance.refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyGraph(mutualMap) {
|
async function applyGraph(mutualMap) {
|
||||||
lastMutualMap = mutualMap;
|
lastMutualMap = mutualMap;
|
||||||
const graph = buildGraphFromMutualMap(mutualMap);
|
const graph = await buildGraphFromMutualMap(mutualMap);
|
||||||
currentGraph = graph;
|
currentGraph = graph;
|
||||||
renderGraph(graph);
|
renderGraph(graph);
|
||||||
}
|
}
|
||||||
@@ -1074,7 +1126,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
applyGraph(mutualMap);
|
await applyGraph(mutualMap);
|
||||||
chartsStore.markMutualGraphLoaded({ notify: false });
|
chartsStore.markMutualGraphLoaded({ notify: false });
|
||||||
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;
|
||||||
@@ -1122,7 +1174,11 @@
|
|||||||
if (isFetching.value || isOptOut.value) return;
|
if (isFetching.value || isOptOut.value) return;
|
||||||
const mutualMap = await chartsStore.fetchMutualGraph();
|
const mutualMap = await chartsStore.fetchMutualGraph();
|
||||||
if (!mutualMap) return;
|
if (!mutualMap) return;
|
||||||
applyGraph(mutualMap);
|
try {
|
||||||
|
await applyGraph(mutualMap);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[MutualNetworkGraph] Failed to apply graph after fetch', err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelFetch() {
|
function cancelFetch() {
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
/**
|
||||||
|
* Web Worker for graph layout computation.
|
||||||
|
*
|
||||||
|
* Runs ForceAtlas2 and Noverlap layout algorithms off the main thread
|
||||||
|
* to prevent UI freezing during heavy graph layout calculations.
|
||||||
|
*
|
||||||
|
* Protocol:
|
||||||
|
* Main → Worker: { requestId, nodes, edges, settings }
|
||||||
|
* Worker → Main: { requestId, positions } | { requestId, error }
|
||||||
|
*/
|
||||||
|
import forceAtlas2 from 'graphology-layout-forceatlas2';
|
||||||
|
import noverlap from 'graphology-layout-noverlap';
|
||||||
|
import Graph from 'graphology';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clamp a number between min and max.
|
||||||
|
* @param {number} value
|
||||||
|
* @param {number} min
|
||||||
|
* @param {number} max
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
function clampNumber(value, min, max) {
|
||||||
|
const normalized = Number.isFinite(value) ? value : min;
|
||||||
|
return Math.min(max, Math.max(min, normalized));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Linear interpolation.
|
||||||
|
* @param {number} a
|
||||||
|
* @param {number} b
|
||||||
|
* @param {number} t
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
function lerp(a, b, t) {
|
||||||
|
return a + (b - a) * t;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add small random offsets to node positions.
|
||||||
|
* @param {Graph} graph
|
||||||
|
* @param {number} magnitude
|
||||||
|
*/
|
||||||
|
function jitterPositions(graph, magnitude) {
|
||||||
|
graph.forEachNode((node, attrs) => {
|
||||||
|
if (!Number.isFinite(attrs.x) || !Number.isFinite(attrs.y)) return;
|
||||||
|
graph.mergeNodeAttributes(node, {
|
||||||
|
x: attrs.x + (Math.random() - 0.5) * magnitude,
|
||||||
|
y: attrs.y + (Math.random() - 0.5) * magnitude
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assign random initial positions to graph nodes.
|
||||||
|
* @param {Graph} graph
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const LAYOUT_SPACING_MIN = 8;
|
||||||
|
const LAYOUT_SPACING_MAX = 240;
|
||||||
|
const LAYOUT_ITERATIONS_MIN = 300;
|
||||||
|
const LAYOUT_ITERATIONS_MAX = 1500;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run ForceAtlas2 + Noverlap layout on a serialized graph.
|
||||||
|
* @param {object} data - Message data from main thread
|
||||||
|
*/
|
||||||
|
function runLayout(data) {
|
||||||
|
const { nodes, edges, settings } = data;
|
||||||
|
|
||||||
|
// Reconstruct graph in worker
|
||||||
|
const graph = new Graph({
|
||||||
|
type: 'undirected',
|
||||||
|
multi: false,
|
||||||
|
allowSelfLoops: false
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const node of nodes) {
|
||||||
|
graph.addNode(node.id, node.attributes);
|
||||||
|
}
|
||||||
|
for (const edge of edges) {
|
||||||
|
graph.addEdgeWithKey(edge.key, edge.source, edge.target, edge.attributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reinitialize = settings.reinitialize ?? false;
|
||||||
|
if (reinitialize) {
|
||||||
|
initPositions(graph);
|
||||||
|
}
|
||||||
|
|
||||||
|
const iterations = clampNumber(
|
||||||
|
settings.layoutIterations,
|
||||||
|
LAYOUT_ITERATIONS_MIN,
|
||||||
|
LAYOUT_ITERATIONS_MAX
|
||||||
|
);
|
||||||
|
const spacing = clampNumber(
|
||||||
|
settings.layoutSpacing,
|
||||||
|
LAYOUT_SPACING_MIN,
|
||||||
|
LAYOUT_SPACING_MAX
|
||||||
|
);
|
||||||
|
const t = (spacing - LAYOUT_SPACING_MIN) / (LAYOUT_SPACING_MAX - LAYOUT_SPACING_MIN);
|
||||||
|
const clampedT = clampNumber(t, 0, 1);
|
||||||
|
const deltaSpacing = settings.deltaSpacing ?? 0;
|
||||||
|
|
||||||
|
// ForceAtlas2
|
||||||
|
const inferred = forceAtlas2.inferSettings
|
||||||
|
? forceAtlas2.inferSettings(graph)
|
||||||
|
: {};
|
||||||
|
const fa2Settings = {
|
||||||
|
...inferred,
|
||||||
|
barnesHutOptimize: true,
|
||||||
|
barnesHutTheta: 0.8,
|
||||||
|
strongGravityMode: true,
|
||||||
|
gravity: lerp(1.6, 0.6, clampedT),
|
||||||
|
scalingRatio: spacing,
|
||||||
|
slowDown: 2
|
||||||
|
};
|
||||||
|
|
||||||
|
if (Math.abs(deltaSpacing) >= 8) {
|
||||||
|
jitterPositions(graph, lerp(0.5, 2.0, clampedT));
|
||||||
|
}
|
||||||
|
|
||||||
|
forceAtlas2.assign(graph, { iterations, settings: fa2Settings });
|
||||||
|
|
||||||
|
// Noverlap
|
||||||
|
const noverlapIterations = clampNumber(
|
||||||
|
Math.round(Math.sqrt(graph.order) * 6),
|
||||||
|
200,
|
||||||
|
600
|
||||||
|
);
|
||||||
|
noverlap.assign(graph, {
|
||||||
|
maxIterations: noverlapIterations,
|
||||||
|
settings: {
|
||||||
|
ratio: lerp(1.05, 1.35, clampedT),
|
||||||
|
margin: lerp(1, 8, clampedT)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Extract positions
|
||||||
|
const positions = {};
|
||||||
|
graph.forEachNode((node, attrs) => {
|
||||||
|
positions[node] = { x: attrs.x, y: attrs.y };
|
||||||
|
});
|
||||||
|
|
||||||
|
return positions;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.addEventListener('message', (event) => {
|
||||||
|
const { requestId } = event.data;
|
||||||
|
try {
|
||||||
|
const positions = runLayout(event.data);
|
||||||
|
self.postMessage({ requestId, positions });
|
||||||
|
} catch (err) {
|
||||||
|
self.postMessage({ requestId, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user