Merge pull request #179 from IliesAllali/perf/gros-arbres

Gros arbres : ouverture plus rapide, onglets calculés à part
This commit is contained in:
IliesAllali authored and GitHub committed 2026-10-27 17:13:39 +01:00
commit a11b71a1f4
9 files changed
+323 -38

No files matched your search

+81 -8
View File
@@ -2,8 +2,8 @@ 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 { computeBlockLayout, familyTabsOf, lineToFamily, subgraphOf } from './galaxy/blockLayout'
import { computeLayout, generateNodeRandomData, coupleBarMeta, restoreCoupleBarMeta } from './galaxy/elkLayout'
import { computeBlockLayout, familyTabsOf, lineToFamily, subgraphOf, takeRootLayout } from './galaxy/blockLayout'
import { hasGhosts, withGhosts } from '../utils/ghosts'
// Placement par blocs familiaux par défaut (mesures : tools/layout-bench). ?layout=sugiyama = placement Sugiyama.
@@ -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,17 +502,79 @@ 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
// avant l'arbre affiché, parce que chaque calcul de placement remplit les couples du module (coupleBarMeta)
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)) {
tabsCacheRef.current = { rev: graphRevision, start, tabs: familyTabsOf(null, startRefRef.current) }
setTabs(tabsCacheRef.current.tabs)
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
@@ -513,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
@@ -528,7 +599,9 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
// placement de l'onglet de départ, puis enregistrées. Calculé avant l'arbre affiché (coupleBarMeta, voir plus haut)
const legacy = annotationData.some((a) => !readAnchor(a))
const departure = legacy && USE_BLOCK_LAYOUT && graph ? computeBlockLayout(null, startRefRef.current, { familyTabs }) : null
const result = USE_BLOCK_LAYOUT ? computeBlockLayout(graph, focusPersonIdRef.current, { familyTabs }) : computeLayout(null, focusPersonIdRef.current)
// Famille de départ sans propositions : son placement vient d'être calculé avec la liste des onglets
const kept = USE_BLOCK_LAYOUT && !graph && tab === freshTabs?.[0] ? takeRootLayout(freshTabs, focusPersonIdRef.current) : null
const result = kept || (USE_BLOCK_LAYOUT ? computeBlockLayout(graph, focusPersonIdRef.current, { familyTabs }) : computeLayout(null, focusPersonIdRef.current))
if (legacy) {
const anchored = anchorLegacy(annotationData, layoutCenters(departure || result))
for (const ann of anchored) updateAnnotationInStore(ann.id, { style: ann.style })
@@ -684,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(() => {
+26 -16
View File
@@ -1,4 +1,4 @@
import { computeLayout, componentOrderPerGen, chainGap } from './elkLayout'
import { computeLayout, componentOrderPerGen, chainGap, coupleBarMeta, restoreCoupleBarMeta } from './elkLayout'
import { persons as globalPersons, unions as globalUnions, filiations as globalFiliations } from '../../data/mockData'
// ============================================================
@@ -72,23 +72,19 @@ export function filCroiseAutreFamille(nodes, childrenOfUnion, spouseKeysOf, u, c
&& n.x + n.width > lo && n.x < hi)
}
// Contours : par rangée, une liste d'intervalles [gauche, droite] triés et fusionnés
function addInterval(list, l, r) {
const out = []
let nl = l, nr = r, inserted = false
for (const [a, b] of list) {
if (b < nl - 1) { out.push([a, b]); continue }
if (a > nr + 1) { if (!inserted) { out.push([nl, nr]); inserted = true } out.push([a, b]); continue }
nl = Math.min(nl, a); nr = Math.max(nr, b)
}
if (!inserted) out.push([nl, nr])
return out
}
// Contours : par rangée, une liste d'intervalles [gauche, droite] triés et fusionnés. Les intervalles qui se
// touchent (écart ≤ 1) sont réunis. Un seul tri par rangée : insérer les intervalles un à un recopiait la
// liste à chaque fois, quadratique sur un gros arbre.
function mergeRows(target, source, dx) {
for (const [g, list] of source) {
let cur = target.get(g) || []
for (const [l, r] of list) cur = addInterval(cur, l + dx, r + dx)
target.set(g, cur)
const all = (target.get(g) || []).concat(list.map(([l, r]) => [l + dx, r + dx])).sort((x, y) => x[0] - y[0])
const out = []
for (const [a, b] of all) {
const last = out[out.length - 1]
if (last && a <= last[1] + 1) last[1] = Math.max(last[1], b)
else out.push([a, b])
}
target.set(g, out)
}
}
const extentOf = (rows) => {
@@ -915,6 +911,9 @@ export function familyTabsOf(graphData = null, referenceId = null) {
if (fams[0]) { tab.label = fams[0].label; tab.count = fams[0].count }
// La personne de départ réellement retenue (l'arbre sans personne de départ en choisit une)
if (tab === root) root.resolvedRef = result.referenceId != null ? String(result.referenceId).replace(/^p-/, '') : null
// Le placement de la famille de départ est celui que l'arbre affiche ensuite : gardé pour takeRootLayout,
// il n'est pas recalculé (plusieurs secondes sur un arbre de quelques milliers de personnes)
if (tab === root && !graphData) Object.defineProperty(tabs, 'rootLayout', { value: { result, coupleBarMeta, referenceId }, writable: true })
for (const f of fams.slice(1)) {
if (f.drawn) continue
const key = String(f.anchorId).replace(/^p-/, '')
@@ -940,3 +939,14 @@ export function familyTabsOf(graphData = null, referenceId = null) {
}
return tabs
}
// Le placement de la famille de départ calculé par familyTabsOf (arbre du module, onglets allumés), rendu une
// seule fois : l'arbre affiché le modifie ensuite (pastilles, ligne directe, réordonnancement)
export function takeRootLayout(tabs, referenceId) {
const kept = tabs?.rootLayout
if (!kept) return null
tabs.rootLayout = null
if (String(kept.referenceId ?? '') !== String(referenceId ?? '')) return null
restoreCoupleBarMeta(kept.coupleBarMeta)
return kept.result
}
@@ -1,5 +1,8 @@
import { describe, it, expect } from 'vitest'
import { computeBlockLayout, filCroiseAutreFamille, lineToFamily } from './blockLayout'
import { computeBlockLayout, familyTabsOf, filCroiseAutreFamille, lineToFamily, takeRootLayout } from './blockLayout'
import * as elk from './elkLayout'
import * as mockData from '../../data/mockData'
import { generate } from '../../../../tools/layout-bench/gen-fr.mjs'
import { COMPACT_W, PERSON_W } from './constants'
const person = (id) => ({ id, firstName: id, lastName: '' })
@@ -168,3 +171,31 @@ describe('lineToFamily, ligne directe vers un onglet de famille', () => {
expect(drawn.has('p-oncle')).toBe(false)
})
})
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)
mockData.loadGraphData(tree.graph, { apiBaseUrl: '' })
const fresh = computeBlockLayout(null, tree.rootPersonId)
const freshMeta = JSON.stringify(elk.coupleBarMeta)
const tabs = familyTabsOf(null, tree.rootPersonId)
expect(tabs.length).toBeGreaterThan(1)
const kept = takeRootLayout(tabs, tree.rootPersonId)
expect(JSON.stringify(elk.coupleBarMeta)).toBe(freshMeta)
expect(kept.children.map((n) => [n.id, n.x, n.y])).toEqual(fresh.children.map((n) => [n.id, n.x, n.y]))
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)
})
})
+5 -2
View File
@@ -10,6 +10,8 @@ import {
// État partagé — peuplé par computeLayout, lu par renderers
// ============================================================
export let coupleBarMeta = []
// Remet les couples d'un placement déjà calculé (placement repris d'un cache, voir familyTabsOf)
export function restoreCoupleBarMeta(meta) { coupleBarMeta = meta }
export let componentOrderPerGen = new Map()
// ============================================================
@@ -372,9 +374,12 @@ export function computeLayout(graphData = null, referenceId = null) {
const personByElkId = new Map(persons.map(p => [`p-${p.id}`, p]))
const unionEntryByElkId = new Map()
// Rang de chaque nœud dans allNodes, tenu à jour à chaque ajout (le reconstruire à chaque chaîne coûtait N² sur un gros arbre)
const nodeIdx = new Map()
function pushNode(node) {
if (pushedNodeIds.has(node.id)) return
pushedNodeIds.add(node.id)
nodeIdx.set(node.id, allNodes.length)
allNodes.push(node)
}
@@ -482,7 +487,6 @@ export function computeLayout(graphData = null, referenceId = null) {
// Si plusieurs extrémités, choisir celle dont les enfants sont le plus à gauche
if (leaves.length > 1) {
const nodeIdx = new Map(allNodes.map((n, i) => [n.id, i]))
let bestIdx = Number.MAX_SAFE_INTEGER
for (const leafKey of leaves) {
const children = childrenByParentKey.get(leafKey) || []
@@ -555,7 +559,6 @@ export function computeLayout(graphData = null, referenceId = null) {
// Heuristique barycentrique : trier par position des parents déjà placés
if (gen > 0) {
const nodeIdx = new Map(allNodes.map((n, i) => [n.id, i]))
const getAvgParentPos = (comp) => {
let sum = 0, count = 0
for (const key of comp) {
@@ -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) })
}
}
+28 -11
View File
@@ -8,11 +8,12 @@ import { nameKinship, normalizeSex } from '../../utils/kinship/index.js'
// noeuds intermédiaires invisibles ; seuls les noeuds personne
// comptent comme "étapes".
// ============================================================
// persons : les personnes à jour (sexe modifié sans recalcul du placement) ; à défaut, celles du placement
export function findPathBFS(fromElkId, toElkId, layoutData, persons = null) {
if (!layoutData || fromElkId === toElkId) return null
// Construire l'adjacence
// Adjacence d'un placement, calculée une fois par placement et par jeu de couples (le survol la redemandait
// à chaque personne survolée)
const adjacencyCache = new WeakMap()
function adjacencyOf(layoutData) {
const cached = adjacencyCache.get(layoutData)
if (cached && cached.meta === coupleBarMeta && cached.edges === layoutData.edges) return cached.adj
const adj = new Map()
const ensure = (id) => { if (!adj.has(id)) adj.set(id, []) }
@@ -32,14 +33,32 @@ export function findPathBFS(fromElkId, toElkId, layoutData, persons = null) {
adj.get(tgt).push({ neighbor: src, type: 'filiation', id: edge.id })
})
}
adjacencyCache.set(layoutData, { meta: coupleBarMeta, edges: layoutData.edges, adj })
return adj
}
// BFS
// Index des personnes par identifiant du placement, une fois par tableau (le tableau change d'identité à chaque
// modification, voir replaceGraph)
const personIndexCache = new WeakMap()
function personIndexOf(persons) {
let index = personIndexCache.get(persons)
if (!index) { index = new Map(persons.map((person) => [`p-${person.id}`, person])); personIndexCache.set(persons, index) }
return index
}
// persons : les personnes à jour (sexe modifié sans recalcul du placement) ; à défaut, celles du placement
export function findPathBFS(fromElkId, toElkId, layoutData, persons = null) {
if (!layoutData || fromElkId === toElkId) return null
const adj = adjacencyOf(layoutData)
// BFS (file lue par index : shift() recopie la file à chaque pas, quadratique sur un gros arbre)
const visited = new Set([fromElkId])
const prev = new Map()
const queue = [fromElkId]
while (queue.length > 0) {
const cur = queue.shift()
for (let head = 0; head < queue.length; head++) {
const cur = queue[head]
if (cur === toElkId) break
for (const link of (adj.get(cur) || [])) {
if (!visited.has(link.neighbor)) {
@@ -64,9 +83,7 @@ export function findPathBFS(fromElkId, toElkId, layoutData, persons = null) {
}
nodePath.unshift(fromElkId)
const personById = persons
? new Map(persons.map((person) => [`p-${person.id}`, person]))
: new Map((layoutData.children || []).filter((node) => node._type === 'person').map((node) => [node.id, node._data]))
const personById = persons ? personIndexOf(persons) : new Map((layoutData.children || []).filter((node) => node._type === 'person').map((node) => [node.id, node._data]))
const kinship = kinshipPath(edgePath, nodePath, (id) => personById.get(id))
return { nodePath, edgePath, kinship, label: nameKinship(kinship) }
}
+2
View File
@@ -33,5 +33,7 @@ La largeur totale ne départage qu'une égalité parfaite. Pas de score composit
## Performance, parcours compte, landing
- `TREE_PASSWORD='...' node perf-probe.mjs <url d'un arbre>` : temps main thread par frame au repos, en drag et en zoom, appels canvas par frame, profil CPU. Brave ou Chrome headless piloté en CDP.
- `node --import ./register.mjs layout-time.mjs [arbre ...]` : temps du placement sans écran (liste des onglets, arbre de départ, arbre entier, chaque onglet) et une empreinte des positions, arêtes, couples et onglets. Avant toute optimisation du placement, relever les empreintes, puis vérifier qu'elles sortent identiques (`SRC=` pour mesurer l'autre version).
- `node load-probe.mjs <url> [nom] [répétitions]` : temps jusqu'à l'arbre utilisable et jusqu'à la barre des onglets, médiane des répétitions, fonctions les plus coûteuses au chargement. Sur un build (`vite build --minify false`, `vite preview`), pas en mode dev. `CPU=4` simule un téléphone moyen. Sur une machine occupée, les temps varient du simple au double : comparer deux versions en alternant les passes.
- `node e2e-account.mjs <front> <api>` : scénario complet sur un backend local (lien partagé, compte créé, rattachement, contributions). `CLEANUP=1` supprime l'arbre de test.
- `node landing-check.mjs <url>` : titres, liens, formulaires et erreurs JS d'une page, avec capture.
+46
View File
@@ -0,0 +1,46 @@
// Temps du placement sans écran, et empreinte du résultat pour prouver qu'une optimisation ne change rien.
// Usage : node --import ./register.mjs layout-time.mjs [arbre ...] (mêmes noms d'arbres que run.mjs)
// SRC=<dossier frontend/src> mesure une autre version du code (un autre worktree), comme le banc.
// Par arbre : la liste des onglets (familyTabsOf), l'arbre de départ avec onglets, l'arbre entier sans onglets, puis
// chaque onglet comme l'app l'ouvre. L'empreinte couvre positions, arêtes, couples et onglets : deux versions qui
// rendent les mêmes trois empreintes placent l'arbre à l'identique.
import { createHash } from 'node:crypto'
import { pathToFileURL } from 'node:url'
import { performance } from 'node:perf_hooks'
import { loadTree, SRC, archetypeNames } from './load.mjs'
const bl = await import(pathToFileURL(`${SRC}components/galaxy/blockLayout.js`).href)
const elk = await import(pathToFileURL(`${SRC}components/galaxy/elkLayout.js`).href)
const fingerprint = (result) => {
const h = createHash('sha1')
for (const n of result.children) h.update(`${n.id}|${n.x.toFixed(2)}|${n.y.toFixed(2)}|${n.width}|${n._generation}|${n._familyTab ? JSON.stringify(n._familyTab) : ''};`)
for (const e of result.edges || []) h.update(`${e.id}|${e.sources}|${e.targets}|${e._external ? 1 : 0};`)
h.update(JSON.stringify(elk.coupleBarMeta.map((m) => [m.elkId, m.p1Key, m.p2Key])))
return h.digest('hex').slice(0, 12)
}
const timed = (f) => { const t = performance.now(); const v = f(); return [v, Math.round(performance.now() - t)] }
const names = process.argv.slice(2)
for (const name of names.length ? names : [...archetypeNames, 'desc-3', 'asc-23']) {
const tree = loadTree(name)
const ref = tree.reference
const [tabs, msTabs] = timed(() => bl.familyTabsOf(tree.graph, ref))
const [withTabs, msWithTabs] = timed(() => bl.computeBlockLayout(tree.graph, ref, { familyTabs: true }))
const fpTabs = fingerprint(withTabs)
const [whole, msWhole] = timed(() => bl.computeBlockLayout(tree.graph, ref, { familyTabs: false }))
const fpWhole = fingerprint(whole)
const h = createHash('sha1')
h.update(JSON.stringify(tabs.map((tb) => [tb.key, tb.label, tb.count, tb.referenceId, tb.parentKey, tb.hinges, tb.personIds ? [...tb.personIds].sort() : null])))
let msEachTab = 0, msWorstTab = 0
for (const tb of tabs) {
if (!tb.personIds) continue
const [r, ms] = timed(() => bl.computeBlockLayout(bl.subgraphOf(tb.personIds, tree.graph), tb.referenceId, { familyTabs: true }))
msEachTab += ms; msWorstTab = Math.max(msWorstTab, ms); h.update(fingerprint(r))
}
console.log(JSON.stringify({
name, persons: tree.graph.persons.length, tabs: tabs.length,
ms_familyTabsOf: msTabs, ms_layout_tabs: msWithTabs, ms_layout_whole: msWhole, ms_each_tab: msEachTab, ms_worst_tab: msWorstTab,
fingerprint: [fpTabs, fpWhole, h.digest('hex').slice(0, 12)].join(' '),
}))
}
+91
View File
@@ -0,0 +1,91 @@
// Ouverture d'un arbre dans un navigateur headless (CDP, sans dépendance) : temps jusqu'à l'arbre utilisable,
// jusqu'à la barre des onglets, et fonctions les plus coûteuses pendant le chargement.
// Usage : [TREE_PASSWORD='...'] node load-probe.mjs <url> [nom] [répétitions]
// url : un arbre sur un front local, de préférence un build (`vite build --minify false` puis `vite preview` :
// le mode dev fausse les temps, la minification les noms du profil)
// CPU=4 ralentit le processeur comme un téléphone moyen ; W et H fixent la fenêtre (1600 × 900).
// L'arbre est utilisable quand deux images passent après son chargement : le placement bloque le fil principal.
// Avec ?fixture= (chargeur local, jamais commité), on attend window.__pzFixtureLoaded posé par ce chargeur.
import { spawn } from 'node:child_process'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
const URL_ = process.argv[2]
const NAME = process.argv[3] || 'arbre'
const RUNS = Number(process.argv[4] || 1)
const PASSWORD = process.env.TREE_PASSWORD
const BRAVE = process.env.BRAVE_PATH || 'C:\\Program Files\\BraveSoftware\\Brave-Browser\\Application\\brave.exe'
const CPU = Number(process.env.CPU || 1)
const W = Number(process.env.W || 1600), H = Number(process.env.H || 900)
if (!URL_) { console.error('usage : node load-probe.mjs <url> [nom] [répétitions]'); process.exit(1) }
const FIXTURE = new URL(URL_).searchParams.has('fixture')
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
class CDP {
constructor(ws) { this.ws = ws; this.id = 0; this.pending = new Map()
ws.onmessage = (m) => { const msg = JSON.parse(m.data); if (msg.id) { const p = this.pending.get(msg.id); this.pending.delete(msg.id); msg.error ? p.reject(new Error(msg.error.message)) : p.resolve(msg.result) } } }
send(method, params = {}) { const id = ++this.id; this.ws.send(JSON.stringify({ id, method, params })); return new Promise((resolve, reject) => this.pending.set(id, { resolve, reject })) }
async eval(expression, awaitPromise = true) { const r = await this.send('Runtime.evaluate', { expression, awaitPromise, returnByValue: true }); if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || 'eval'); return r.result.value }
}
function topFunctions(profile, n) {
const byNode = new Map(profile.nodes.map(x => [x.id, x])); const self = new Map()
profile.samples.forEach((id, i) => { const cf = byNode.get(id).callFrame
const key = `${cf.functionName || '(anonyme)'} ${cf.url.split('/').slice(-1)[0].split('?')[0]}:${cf.lineNumber + 1}`
self.set(key, (self.get(key) || 0) + (profile.timeDeltas[i] || 0)) })
return [...self.entries()].sort((a, b) => b[1] - a[1]).slice(0, n).map(([fn, us]) => ({ fn, ms: Math.round(us / 1000) }))
}
async function once() {
const port = 9333 + Math.floor(Math.random() * 400)
const profileDir = mkdtempSync(path.join(tmpdir(), 'parenthese-load-'))
const browser = spawn(BRAVE, ['--headless=new', `--remote-debugging-port=${port}`, `--user-data-dir=${profileDir}`, `--window-size=${W},${H}`,
'--no-first-run', '--disable-extensions', '--disable-features=Translate,BraveRewards,BraveAds', 'about:blank'], { stdio: 'ignore' })
try {
let version
for (let i = 0; i < 100 && !version; i++) { try { const r = await fetch(`http://127.0.0.1:${port}/json/version`); if (r.ok) version = await r.json() } catch {} if (!version) await sleep(200) }
const bws = new WebSocket(version.webSocketDebuggerUrl); await new Promise(r => bws.onopen = r)
const { targetId } = await new CDP(bws).send('Target.createTarget', { url: 'about:blank' })
const list = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json()
const pws = new WebSocket(list.find(t => t.id === targetId).webSocketDebuggerUrl); await new Promise(r => pws.onopen = r)
const p = new CDP(pws)
await p.send('Page.enable'); await p.send('Runtime.enable')
await p.send('Emulation.setDeviceMetricsOverride', { width: W, height: H, deviceScaleFactor: 1, mobile: false })
if (CPU > 1) await p.send('Emulation.setCPUThrottlingRate', { rate: CPU })
await p.send('Profiler.enable'); await p.send('Profiler.setSamplingInterval', { interval: 250 })
await p.send('Profiler.start')
const t0 = Date.now()
await p.send('Page.navigate', { url: URL_ })
if (PASSWORD) {
for (let i = 0; i < 100; i++) { if (await p.eval(`!!document.querySelector('input[type=password]')`, false).catch(() => false)) break; await sleep(100) }
await p.eval(`(() => {
const input = document.querySelector('input[type=password]')
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set.call(input, ${JSON.stringify(PASSWORD)})
input.dispatchEvent(new Event('input', { bubbles: true }))
;[...document.querySelectorAll('button')].find(b => /ouvrir/i.test(b.textContent))?.click()
})()`, false)
}
const loaded = FIXTURE ? `window.__pzFixtureLoaded === true` : `!!document.querySelector('canvas.galaxy-canvas')`
for (let i = 0; i < 600; i++) { if (await p.eval(loaded, false).catch(() => false)) break; await sleep(50) }
await p.eval(`new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))`)
const tree = Date.now() - t0
let tabs = null
for (let i = 0; i < 600; i++) { if (await p.eval(`document.querySelectorAll('.family-tab').length > 1`, false).catch(() => false)) { tabs = Date.now() - t0; break } await sleep(50) }
const { profile } = await p.send('Profiler.stop')
return { tree, tabs, top: topFunctions(profile, 10) }
} finally {
try { browser.kill() } catch {}
try { rmSync(profileDir, { recursive: true, force: true }) } catch {}
}
}
const runs = []
for (let i = 0; i < RUNS; i++) runs.push(await once())
const median = (xs) => { const s = xs.filter(x => x != null).sort((a, b) => a - b); return s.length ? s[Math.floor(s.length / 2)] : null }
console.log(JSON.stringify({
name: NAME, url: URL_, cpu: CPU, runs: RUNS,
tree_ms: median(runs.map(r => r.tree)), tabs_ms: median(runs.map(r => r.tabs)),
each: runs.map(r => [r.tree, r.tabs]), cpu_top_first_run: runs[0].top,
}, null, 1))
process.exit(0)