diff --git a/src/stores/__tests__/globalSearch.test.js b/src/stores/__tests__/globalSearch.test.js new file mode 100644 index 00000000..23867fab --- /dev/null +++ b/src/stores/__tests__/globalSearch.test.js @@ -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'); + }); +}); diff --git a/src/stores/__tests__/searchWorker.test.js b/src/stores/__tests__/searchWorker.test.js new file mode 100644 index 00000000..76a08f3a --- /dev/null +++ b/src/stores/__tests__/searchWorker.test.js @@ -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); + }); +}); diff --git a/src/stores/globalSearch.js b/src/stores/globalSearch.js index 09a26309..f8edf588 100644 --- a/src/stores/globalSearch.js +++ b/src/stores/globalSearch.js @@ -1,24 +1,17 @@ -import { computed, ref, watch } from 'vue'; +import { ref, watch, computed } from 'vue'; import { defineStore } from 'pinia'; - -import { - searchAvatars, - searchFavoriteAvatars, - searchFavoriteWorlds, - searchFriends, - searchGroups, - searchWorlds -} from '../shared/utils/globalSearchUtils'; import { useAvatarStore } from './avatar'; import { useFavoriteStore } from './favorite'; import { useFriendStore } from './friend'; import { useGroupStore } from './group'; +import { useUserStore } from './user'; +import { useWorldStore } from './world'; import { showGroupDialog } from '../coordinators/groupCoordinator'; import { showWorldDialog } from '../coordinators/worldCoordinator'; import { showAvatarDialog } from '../coordinators/avatarCoordinator'; import { showUserDialog } from '../coordinators/userCoordinator'; -import { useUserStore } from './user'; -import { useWorldStore } from './world'; + +import SearchWorker from './searchWorker.js?worker'; export const useGlobalSearchStore = defineStore('GlobalSearch', () => { const friendStore = useFriendStore(); @@ -31,102 +24,26 @@ export const useGlobalSearchStore = defineStore('GlobalSearch', () => { const isOpen = ref(false); const query = ref(''); - const stringComparer = computed( - () => - new Intl.Collator(undefined, { - usage: 'search', - sensitivity: 'base' - }) - ); + // Worker instance (lazy) + let worker = null; + let indexUpdateTimer = null; - // Reset query when dialog closes - watch(isOpen, (open) => { - if (!open) { - query.value = ''; + function getWorker() { + if (!worker) { + worker = new SearchWorker(); + worker.onmessage = handleWorkerMessage; } - }); + return worker; + } - const currentUserId = computed(() => userStore.currentUser?.id); - - const friendResults = computed(() => { - if (!query.value || query.value.length < 2) return []; - return searchFriends( - query.value, - friendStore.friends, - stringComparer.value - ); - }); - - // 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)); - }); + // Search results (updated from worker messages) + const friendResults = ref([]); + const ownAvatarResults = ref([]); + const favoriteAvatarResults = ref([]); + const ownWorldResults = ref([]); + const favoriteWorldResults = ref([]); + const ownGroupResults = ref([]); + const joinedGroupResults = ref([]); const hasResults = computed( () => @@ -139,16 +56,197 @@ export const useGlobalSearchStore = defineStore('GlobalSearch', () => { 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() { + sendIndexUpdate(); isOpen.value = true; } - /** - * - */ function close() { isOpen.value = false; } diff --git a/src/stores/searchWorker.js b/src/stores/searchWorker.js new file mode 100644 index 00000000..650ae845 --- /dev/null +++ b/src/stores/searchWorker.js @@ -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); + } +}); diff --git a/src/views/Charts/__tests__/graphLayoutWorker.test.js b/src/views/Charts/__tests__/graphLayoutWorker.test.js new file mode 100644 index 00000000..f967b11c --- /dev/null +++ b/src/views/Charts/__tests__/graphLayoutWorker.test.js @@ -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'); + }); +}); diff --git a/src/views/Charts/components/MutualFriends.vue b/src/views/Charts/components/MutualFriends.vue index cdaa5ccd..4689cc4c 100644 --- a/src/views/Charts/components/MutualFriends.vue +++ b/src/views/Charts/components/MutualFriends.vue @@ -263,9 +263,9 @@ import EdgeCurveProgram from '@sigma/edge-curve'; import Graph from 'graphology'; import Sigma from 'sigma'; - import forceAtlas2 from 'graphology-layout-forceatlas2'; import louvain from 'graphology-communities-louvain'; - import noverlap from 'graphology-layout-noverlap'; + + import GraphLayoutWorker from '../graphLayoutWorker.js?worker'; import { useAppearanceSettingsStore, @@ -326,6 +326,39 @@ let pendingRender = null; let pendingLayoutUpdate = 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_MAX = 1500; @@ -507,9 +540,15 @@ localStorage.setItem(EXCLUDED_FRIENDS_KEY, JSON.stringify(excludedFriendIds.value)); } - watch(excludedFriendIds, () => { + watch(excludedFriendIds, async () => { 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(() => { @@ -602,6 +641,14 @@ sigmaInstance = 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(); }); @@ -624,71 +671,72 @@ 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) { const normalized = Number.isFinite(value) ? value : min; return Math.min(max, Math.max(min, normalized)); } - function lerp(a, b, t) { - return a + (b - a) * t; - } - - 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 - }); + /** + * @param {Graph} graph + * @returns {{ nodes: Array, edges: Array }} + */ + function serializeGraph(graph) { + const nodes = []; + graph.forEachNode((id, attributes) => { + nodes.push({ id, attributes: { ...attributes } }); }); + const edges = []; + graph.forEachEdge((key, attributes, source, target) => { + edges.push({ key, source, target, attributes: { ...attributes } }); + }); + return { nodes, edges }; } - // @ts-ignore - function runLayout(graph, { reinitialize } = {}) { - if (reinitialize) initPositions(graph); - - const iterations = clampNumber(layoutSettings.layoutIterations, LAYOUT_ITERATIONS_MIN, LAYOUT_ITERATIONS_MAX); + /** + * Run ForceAtlas2 + Noverlap layout in a Web Worker. + * Requests are serialized: a new call waits for the previous one to finish, + * preventing concurrent callbacks from stepping on each other. + * @param {Graph} graph + * @param {object} options + * @param {boolean} [options.reinitialize] + * @returns {Promise} + */ + async function runLayout(graph, { reinitialize } = {}) { 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; lastLayoutSpacing = spacing; - const inferred = forceAtlas2.inferSettings ? forceAtlas2.inferSettings(graph) : {}; - const settings = { - ...inferred, - barnesHutOptimize: true, - barnesHutTheta: 0.8, - strongGravityMode: true, - gravity: lerp(1.6, 0.6, clampedT), - scalingRatio: spacing, - slowDown: 2 - }; + const { nodes, edges } = serializeGraph(graph); + const worker = getLayoutWorker(); + const id = ++layoutRequestId; - 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 }); - 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) + for (const [nodeId, pos] of Object.entries(positions)) { + if (graph.hasNode(nodeId)) { + graph.mergeNodeAttributes(nodeId, { x: pos.x, y: pos.y }); + } } }); + + // Keep the queue going even if this request fails + layoutQueue = task.catch(() => {}); + return task; } function applyEdgeCurvature(graph) { @@ -755,14 +803,18 @@ function scheduleLayoutUpdate({ runLayout: shouldRunLayout }) { if (!currentGraph) return; if (pendingLayoutUpdate) clearTimeout(pendingLayoutUpdate); - pendingLayoutUpdate = setTimeout(() => { + pendingLayoutUpdate = setTimeout(async () => { pendingLayoutUpdate = null; - applyEdgeCurvature(currentGraph); - if (shouldRunLayout) { - runLayout(currentGraph, { reinitialize: false }); - applyCommunitySeparation(currentGraph); + try { + applyEdgeCurvature(currentGraph); + if (shouldRunLayout) { + await runLayout(currentGraph, { reinitialize: false }); + applyCommunitySeparation(currentGraph); + } + renderGraph(currentGraph); + } catch (err) { + console.error('[MutualNetworkGraph] Layout update failed', err); } - renderGraph(currentGraph); }, 100); } @@ -780,7 +832,7 @@ }); } - function buildGraphFromMutualMap(mutualMap) { + async function buildGraphFromMutualMap(mutualMap) { const graph = new Graph({ type: 'undirected', multi: false, @@ -836,7 +888,7 @@ }); if (graph.order > 1) { - runLayout(graph, { reinitialize: true }); + await runLayout(graph, { reinitialize: true }); assignCommunitiesAndColors(graph); applyCommunitySeparation(graph); applyEdgeCurvature(graph); @@ -1023,9 +1075,9 @@ sigmaInstance.refresh(); } - function applyGraph(mutualMap) { + async function applyGraph(mutualMap) { lastMutualMap = mutualMap; - const graph = buildGraphFromMutualMap(mutualMap); + const graph = await buildGraphFromMutualMap(mutualMap); currentGraph = graph; renderGraph(graph); } @@ -1074,7 +1126,7 @@ return; } - applyGraph(mutualMap); + await applyGraph(mutualMap); chartsStore.markMutualGraphLoaded({ notify: false }); fetchState.processedFriends = Math.min(mutualMap.size, totalFriends.value || mutualMap.size); status.friendSignature = totalFriends.value; @@ -1122,7 +1174,11 @@ if (isFetching.value || isOptOut.value) return; const mutualMap = await chartsStore.fetchMutualGraph(); if (!mutualMap) return; - applyGraph(mutualMap); + try { + await applyGraph(mutualMap); + } catch (err) { + console.error('[MutualNetworkGraph] Failed to apply graph after fetch', err); + } } function cancelFetch() { diff --git a/src/views/Charts/graphLayoutWorker.js b/src/views/Charts/graphLayoutWorker.js new file mode 100644 index 00000000..7f3d7f9c --- /dev/null +++ b/src/views/Charts/graphLayoutWorker.js @@ -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 }); + } +});