diff --git a/frontend/src/components/Galaxy.jsx b/frontend/src/components/Galaxy.jsx index 690d79a..57dc865 100644 --- a/frontend/src/components/Galaxy.jsx +++ b/frontend/src/components/Galaxy.jsx @@ -2,7 +2,7 @@ import { memo, useEffect, useImperativeHandle, useRef, useState, useCallback } f import { getPersonMedias, getParents, getChildren, getPersonUnions, persons as graphPersons, unions as graphUnions, filiations as graphFiliations } from '../data/mockData' import { PERSON_R, FLOAT_SPEED, FLOAT_AMPLITUDE, REPULSION_RADIUS, REPULSION_STRENGTH, MAX_ORBIT_MEDIAS, ENTRANCE_GEN_DELAY, ENTRANCE_NODE_DURATION, ENTRANCE_NODE_STAGGER, ENTRANCE_MAX_GEN_SPAN, ENTRANCE_MAX_NODE_SPAN, ENTRANCE_LINE_DELAY, ENTRANCE_LINE_DURATION, ENTRANCE_ORBIT_DELAY, ENTRANCE_ORBIT_DURATION, ENTRANCE_ORBIT_STAGGER } from './galaxy/constants' import { easeOutCubic } from './galaxy/utils' -import { computeLayout, generateNodeRandomData, coupleBarMeta } from './galaxy/elkLayout' +import { computeLayout, generateNodeRandomData, coupleBarMeta, restoreCoupleBarMeta } from './galaxy/elkLayout' import { computeBlockLayout, familyTabsOf, lineToFamily, subgraphOf, takeRootLayout } from './galaxy/blockLayout' import { hasGhosts, withGhosts } from '../utils/ghosts' @@ -53,6 +53,15 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig const [tabs, setTabs] = useState([]) const [activeKey, setActiveKey] = useState(focusPersonId != null ? String(focusPersonId) : null) const tabsCacheRef = useRef({ rev: -1, start: undefined, tabs: [] }) + // Liste des onglets calculée dans un worker (galaxy/familyTabsWorker.js) quand l'arbre affiché est celui de la + // famille de départ : il ne dépend pas de cette liste, il s'affiche tout de suite et la barre suit. Une demande + // en cours est abandonnée (worker arrêté) dès qu'une autre part ou qu'on calcule la liste sur place. + const tabsRequestRef = useRef(null) // { rev, start, worker } + const cancelTabsRequest = useCallback(() => { + tabsRequestRef.current?.worker.terminate() + tabsRequestRef.current = null + }, []) + useEffect(() => cancelTabsRequest, [cancelTabsRequest]) const pendingFocusRef = useRef(null) // personne cherchée dans un autre onglet, visée après le changement // Lien d'un onglet (?famille=) : ouvert dès que l'arbre et ses onglets sont connus, et de nouveau si // la personne de départ arrive après (visiteur), tant qu'on n'a pas soi-même changé d'onglet @@ -493,6 +502,51 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig } }, []) + // Liste arrivée : l'arbre affiché (famille de départ) ne change pas, ses pastilles prennent le nom et le compte de + // l'onglet où elles mènent + const applyTabs = useCallback((req, list) => { + if (tabsRequestRef.current !== req) return + tabsRequestRef.current = null + tabsCacheRef.current = { rev: req.rev, start: req.start, tabs: list } + setTabs(list) + for (const n of layoutDataRef.current?.children || []) { + const target = n._familyTab && list.find((tb) => tb.key === n._familyTab.tabKey) + if (target) n._familyTab = { ...n._familyTab, label: target.label, count: target.count } + } + needsRedrawRef.current = true + }, []) + + // Lance le calcul de la liste dans un worker ; faux si le navigateur ne le permet pas (on calcule alors sur place) + const requestTabs = useCallback((rev, start, referenceId) => { + cancelTabsRequest() + if (typeof Worker === 'undefined') return false + let worker + try { + worker = new Worker(new URL('./galaxy/familyTabsWorker.js', import.meta.url), { type: 'module' }) + worker.postMessage({ graph: { persons: graphPersons, unions: graphUnions, filiations: graphFiliations }, referenceId }) + } catch { + worker?.terminate() + return false + } + const req = { rev, start, worker } + tabsRequestRef.current = req + // Échec du worker : la liste est calculée sur place, sans toucher aux couples de l'arbre affiché + const fallback = () => { + if (tabsRequestRef.current !== req) return + const meta = coupleBarMeta + const list = familyTabsOf(null, referenceId) + restoreCoupleBarMeta(meta) + applyTabs(req, list) + } + worker.onmessage = ({ data }) => { + worker.terminate() + if (data.error) fallback() + else applyTabs(req, data.tabs) + } + worker.onerror = () => { worker.terminate(); fallback() } + return true + }, [cancelTabsRequest, applyTabs]) + // Layout useEffect(() => { // Onglets : la liste des familles, une fois par version du graphe et par personne de départ. Calculée @@ -500,12 +554,27 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig const start = startRefRef.current != null ? String(startRefRef.current) : null const cache = tabsCacheRef.current let freshTabs = null + // Liste en attente du worker : l'arbre affiché est celui de la famille de départ + let tabsPending = false if (USE_BLOCK_LAYOUT && familyTabs && (cache.rev !== graphRevision || cache.start !== start)) { - freshTabs = familyTabsOf(null, startRefRef.current) - tabsCacheRef.current = { rev: graphRevision, start, tabs: freshTabs } - setTabs(freshTabs) + const pending = tabsRequestRef.current + // Dans un onglet d'une autre famille, ou à l'arrivée par le lien d'un onglet, l'arbre affiché dépend de la + // liste : elle est calculée sur place + const inOtherTab = Boolean(cache.tabs.find((tb) => tb.key === activeKey)?.personIds) + if (!inOtherTab && pendingFamilyRef.current == null) { + tabsPending = (pending && pending.rev === graphRevision && pending.start === start) || requestTabs(graphRevision, start, startRefRef.current) + } + if (!tabsPending) { + cancelTabsRequest() + freshTabs = familyTabsOf(null, startRefRef.current) + tabsCacheRef.current = { rev: graphRevision, start, tabs: freshTabs } + setTabs(freshTabs) + } + } + if (!familyTabs) { + cancelTabsRequest() + if (tabsCacheRef.current.tabs.length) { tabsCacheRef.current = { rev: -1, start: undefined, tabs: [] }; setTabs([]) } } - if (!familyTabs && tabsCacheRef.current.tabs.length) { tabsCacheRef.current = { rev: -1, start: undefined, tabs: [] }; setTabs([]) } // La famille du lien : son onglet s'il existe dans ce navigateur, sinon l'arbre entier ouvert sur sa charnière if (pendingFamilyRef.current != null && graphPersons.length) { const key = pendingFamilyRef.current @@ -515,7 +584,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig if (key !== activeKey) { setActiveKey(key); return } } else frameOnRef.current = key } - const tab = familyTabs ? (tabsCacheRef.current.tabs.find((tb) => tb.key === activeKey) || tabsCacheRef.current.tabs[0] || null) : null + const tab = familyTabs && !tabsPending ? (tabsCacheRef.current.tabs.find((tb) => tb.key === activeKey) || tabsCacheRef.current.tabs[0] || null) : null activeTabRef.current = tab // La personne de départ (ou la charnière de l'onglet) sert de référence aux rangées focusPersonIdRef.current = tab ? tab.referenceId : startRefRef.current @@ -688,7 +757,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig pendingFocusRef.current = null requestAnimationFrame(() => zoomApiRef?.current?.focusPerson?.(id)) } - }, [graphRevision, activeKey, familyTabs, selfPersonId, ghosts]) + }, [graphRevision, activeKey, familyTabs, selfPersonId, ghosts, requestTabs, cancelTabsRequest]) // Onglet ouvert, remonté pour l'adresse de la page et le lien de partage ; null pour la famille de départ useEffect(() => { diff --git a/frontend/src/components/galaxy/blockLayout.test.js b/frontend/src/components/galaxy/blockLayout.test.js index 646120e..77d5120 100644 --- a/frontend/src/components/galaxy/blockLayout.test.js +++ b/frontend/src/components/galaxy/blockLayout.test.js @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest' import { computeBlockLayout, familyTabsOf, filCroiseAutreFamille, lineToFamily, takeRootLayout } from './blockLayout' import * as elk from './elkLayout' -import { loadGraphData } from '../../data/mockData' +import * as mockData from '../../data/mockData' import { generate } from '../../../../tools/layout-bench/gen-fr.mjs' import { COMPACT_W, PERSON_W } from './constants' @@ -175,7 +175,7 @@ describe('lineToFamily, ligne directe vers un onglet de famille', () => { describe('familyTabsOf, placement de la famille de départ gardé', () => { it('rend le même placement et les mêmes couples qu’un calcul neuf, une seule fois', () => { const tree = generate('desc', 5) - loadGraphData(tree.graph, { apiBaseUrl: '' }) + mockData.loadGraphData(tree.graph, { apiBaseUrl: '' }) const fresh = computeBlockLayout(null, tree.rootPersonId) const freshMeta = JSON.stringify(elk.coupleBarMeta) const tabs = familyTabsOf(null, tree.rootPersonId) @@ -186,3 +186,16 @@ describe('familyTabsOf, placement de la famille de départ gardé', () => { expect(takeRootLayout(tabs, tree.rootPersonId)).toBeNull() }) }) + +describe('familyTabsOf, graphe passé en entier (worker)', () => { + it('rend la même liste d’onglets sans les données du module', () => { + const tree = generate('desc', 5) + mockData.loadGraphData(tree.graph, { apiBaseUrl: '' }) + const graph = structuredClone({ persons: mockData.persons, unions: mockData.unions, filiations: mockData.filiations }) + const expected = familyTabsOf(null, tree.rootPersonId) + mockData.replaceGraph({ persons: [], unions: [], filiations: [] }) + const fromWorker = familyTabsOf(graph, tree.rootPersonId) + expect(fromWorker.length).toBeGreaterThan(1) + expect(fromWorker).toEqual(expected) + }) +}) diff --git a/frontend/src/components/galaxy/familyTabsWorker.js b/frontend/src/components/galaxy/familyTabsWorker.js new file mode 100644 index 0000000..030c3a2 --- /dev/null +++ b/frontend/src/components/galaxy/familyTabsWorker.js @@ -0,0 +1,12 @@ +// Liste des onglets de familles calculée hors du fil principal : sur un arbre de quelques milliers de personnes, +// familyTabsOf place chaque onglet pour trouver ceux qu'il cache, plusieurs secondes pendant lesquelles la page +// ne répondrait plus. Le graphe arrive avec la demande (ce module n'a pas les données de l'arbre). +import { familyTabsOf } from './blockLayout' + +self.onmessage = ({ data: { id, graph, referenceId } }) => { + try { + self.postMessage({ id, tabs: familyTabsOf(graph, referenceId) }) + } catch (error) { + self.postMessage({ id, error: String(error?.message || error) }) + } +}