mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-06 23:32:08 +00:00
Merge pull request #179 from IliesAllali/perf/gros-arbres
Gros arbres : ouverture plus rapide, onglets calculés à part
This commit is contained in:
9 files changed
+323
-38
No files matched your search
@@ -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(() => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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) })
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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(' '),
|
||||
}))
|
||||
}
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user