perf(onglets): liste des familles calculée dans un worker

Quand l'arbre affiché est celui de la famille de départ, il ne dépend pas de
la liste des onglets : il s'affiche tout de suite, la liste est calculée dans
galaxy/familyTabsWorker.js et la barre suit, sans geler la page. Les pastilles
prennent le nom et le compte de leur onglet à l'arrivée de la liste.

Calcul sur place comme avant dans un autre onglet, à l'arrivée par le lien
d'un onglet, sans Worker ou si le worker échoue (couples de l'arbre affiché
remis). Une demande en cours est abandonnée dès qu'une autre part.

Royal92 (3 010 personnes) : arbre utilisable en 1,6 s au lieu de 8,7 s,
barre des onglets à 6 s sans bloquer (médianes de 3, machine chargée).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
IliesAllaliandClaude Opus 5.5 committed 2026-10-27 16:08:04 +01:00
1 parent 14f62aee90
commit d7eac40cff
3 files changed
+103 -9

No files matched your search

+76 -7
View File
@@ -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=<charnière>) : 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(() => {
@@ -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)
})
})
@@ -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) })
}
}