Merge pull request #112 from IliesAllali/feat/layout-bench-v2

Banc du placement v2 : invariants, tracé réel, archétypes en texte, stabilité
This commit is contained in:
IliesAllali authored and GitHub committed 2026-10-20 13:59:14 +02:00
commit caa940f4ca
36 files changed
+979 -702

No files matched your search

+2
View File
@@ -2,3 +2,5 @@
* text=auto
# Scripts lancés sous Linux (serveur, conteneurs, CI) : fins de ligne LF même sur un clone Windows
*.sh text eol=lf
# Témoins texte du placement : comparés octet pour octet par vitest, toujours en LF
frontend/src/components/galaxy/__snapshots__/** text eol=lf
@@ -0,0 +1,5 @@
Pa────•───Ma
│
┬───┴────┬
│ │
A B
@@ -0,0 +1,13 @@
SG────•───SGw
│
┬────┴
│
Pa────•───Ma SP────•───SM
│ │
┼────────┬ ┬───┴────┬────────┬
│ │ │ │ │
E2 E────•────S S2 S3
│
┬───┴────┬
│ │
K1 K2
@@ -0,0 +1,21 @@
A0────•───A0w
│
┬────┴
│
A1────•───A1w
│
┬────┴
│
A2────•───A2w
│
┬────┴
│
A3────•───A3w B0────•───B0w
│ │
┬────┴ ┬──────────────┼
│ │ │
X────•────Y S
│
┼
│
K
@@ -0,0 +1,13 @@
G────•───Gw
│
┬──────────────┴───┬
│ │
P1────•───P1w P2────•───P2w
│ │
┬────┴ ┬─────────────┴
│ │
C1────•───C2
│
┼
│
K
@@ -0,0 +1,9 @@
A────•───Aw
│
┬────┴
│
B────•───Bw C────•───Cw
│ │
┼ ┼
│ │
E D
@@ -0,0 +1,9 @@
Pa────•───Ma
│
┬──────────────────┬──────────────────┬───────────────────┬──────────────────┬──────────────────┬──────────────┴───┬──────────────────┬───────────────────┬──────────────────┬──────────────────┬──────────────────┬
│ │ │ │ │ │ │ │ │ │ │ │
K1────•───S1 K2────•───S2 K3─────•───S3 K4────•───S4 K5────•───S5 K6────•───S6 K7────•───S7 K8─────•───S8 K9────•───S9 K10───•───S10 K11───•───S11 K12───•───S12
│ │ │ │ │ │ │ │ │ │ │ │
┬───┴────┬ ┬───┴────┬ ┬────┴───┬ ┬────┴───┬ ┬────┴───┬ ┬───┴────┬ ┼ ┼ ┼ ┼ ┼ ┼
│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │
K1a K1b K2a K2b K3a K3b K4a K4b K5a K5b K6a K6b K7a K8a K9a K10a K11a K12a
@@ -0,0 +1,5 @@
Pa────•───Ma
│
┬────────┼────────┬
│ │ │
C A B
@@ -0,0 +1,17 @@
GGGa───•──GGGb
│
┬────┴
│
GGa───•───GGb GGy┄┄┄•┄┄? GGx┄┄┄•┄┄┄?
│ │ │
┬────┴ ┬──────┴ ┬────────────┴
│ │ │
GM1───•───GF1 GM2───•───GF2
│ │
┬────┴ ┬────────────────┴
│ │
M────•────F
│
┼
│
Kid
@@ -0,0 +1,9 @@
Pierre──•──Odile Louis──•─Helene
│ │
┬────────┼────────┬────────┬ ┬────────┬───┴────┬────────┬────────┬
│ │ │ │ │ │ │ │ │
Luc Paul Mireil Nadia──•──Marc Claire Julien Sophie Eric
│
┼
│
Anais
@@ -0,0 +1,6 @@
G────•───Gw
│
┬──────────────┴───┬
│ │
O────•────N P────•───Pw
renvoi : P+Pw → N
@@ -0,0 +1,5 @@
M┄┄┄┄•┄┄┄? X┄┄┄┄•┄┄┄┄Y
│ │
┬─────┴──┬ ┼
│ │ │
A B Z
@@ -0,0 +1,9 @@
Pa────•───Ma
│
┬─────────┴────────┬
│ │
E1────•───S1 E2─s2
│
┼
│
K
@@ -0,0 +1,5 @@
W1────•────H────•───W2
│ │
┬───┴────┬ ┴───┬
│ │ │
A B C
@@ -0,0 +1,9 @@
A────•───Aw B────•───Bw C─────•───Cw
│ │ │
┼────────┬ ┬────┴───┬ ┬────┴
│ │ │ │ │
A2 A1────•───B1 B2────•────C1
│ │
┼ ┼
│ │
X Y
@@ -0,0 +1,31 @@
import { describe, it, expect } from 'vitest'
import { computeBlockLayout } from './blockLayout'
import { coupleBarMeta } from './elkLayout'
import { archetypes } from '../../../../tools/layout-bench/archetypes.mjs'
import { renderAscii } from '../../../../tools/layout-bench/ascii.mjs'
import { checkInvariants, HARD } from '../../../../tools/layout-bench/invariants.mjs'
// Chaque archétype a un rendu texte validé une fois à l'œil (__snapshots__/archetypes/<slug>.txt) : il devient
// le témoin. Un changement de placement qui le modifie doit être relu et le témoin mis à jour (vitest -u).
//
// Échecs connus, jusqu'à l'étape X du placement (le sang de la référence garde son enfant sous ses parents) :
// la belle-famille, plus nombreuse, revendique le couple la première et la fratrie de la référence se retrouve
// dans le désordre. Quand X est fait, ces cas passent et il faut les retirer d'ici (le test le signale).
const ECHECS_CONNUS = new Set(['belle-famille', 'trois-belles-familles'])
describe('archétypes du placement', () => {
for (const a of archetypes) {
describe(a.slug, () => {
const result = computeBlockLayout(a.graph, a.reference)
const meta = [...coupleBarMeta]
const check = ECHECS_CONNUS.has(a.slug) ? it.fails : it
check('respecte les invariants', () => {
const inv = checkInvariants(result, meta, a.graph, a.reference)
expect(Object.fromEntries(HARD.map((k) => [k, inv[k]]))).toEqual(Object.fromEntries(HARD.map((k) => [k, 0])))
})
it('garde son rendu validé', async () => {
await expect(renderAscii(result, meta) + '\n').toMatchFileSnapshot(`./__snapshots__/archetypes/${a.slug}.txt`)
})
})
}
})
+20 -8
View File
@@ -1,22 +1,34 @@
# layout-bench
Outils de mesure de la galaxie, sur un arbre réel : comparaison des algorithmes de placement, sonde de performance, scénario de bout en bout du parcours compte, audit de la landing.
Banc du placement de l'arbre, sur le code de production (`computeBlockLayout`), sans écran. Plus une sonde de performance, un scénario de bout en bout du parcours compte et un audit de la landing.
## Installation
`npm i`
## Récupérer un arbre de test
## Ce qu'on mesure, et dans quel ordre
`TREE_PASSWORD='...' npm run fixture -- <slug> [nom]` récupère un arbre par le chemin visiteur (slug, mot de passe, graphe) dans `fixtures/<nom>-graph.json`. Le dossier `fixtures/` est ignoré par git : ce sont les données d'une famille, elles ne quittent pas votre machine.
La beauté d'un arbre ne se score pas, ses défauts se comptent. Quatre étages, lus dans l'ordre, jamais additionnés.
## Placement
1. **Invariants** (`invariants.mjs`), doivent valoir zéro sinon le placement est faux. Chevauchement, couple non adjacent ou sur deux rangées, fratrie coupée par un étranger ou hors de l'ordre choisi, rangée différente de la génération de parenté (recalculée ici depuis la personne de référence : conjoint 0, parent −1, enfant +1), médianes des années de naissance qui redescendent d'une rangée à l'autre. Les conflits de parenté (cousins de degrés différents, oncle et nièce) sont une propriété des données, pas un défaut du placement.
2. **Défauts sur le tracé réel** (`defects.mjs`), comptés sur la géométrie de `traceSibship` (descente de l'union, ligne de fratrie, descente sur l'enfant), pas sur des segments droits. Dans l'ordre : renvois (filiations en pastilles, et le degré de parenté à la référence du plus proche), lignes de fratrie au-dessus d'un portrait étranger, croisements, lignes plus larges qu'un écran, foyers plus larges qu'un écran, proches à deux degrés de la référence visibles dans un écran. Le rapport liste les foyers les plus étendus, jamais une moyenne.
3. **Archétypes** (`archetypes.mjs`, `ascii.mjs`), une douzaine de petits arbres, un par situation (ordre d'une fratrie, issue #67, branche courte, Marc et Nadia, petit conjoint, fratrie de douze, remariage, cousins, oncle et nièce, parent seul, adoption, deux familles, belle-famille, trois familles enchaînées). Chacun a un rendu texte validé une fois à l'œil, dans `frontend/src/components/galaxy/__snapshots__/archetypes/`, qui sert de témoin en CI (`archetypes.test.js`). Un placement qui change un témoin doit être relu, puis `vitest -u`.
4. **Stabilité** (`perturb.mjs`), retirer une feuille ne devrait déplacer que son foyer. On compte les nœuds qui bougent de plus de 40 px.
- `npm run bench` : tous les modèles, `npm run bench -- M0 M5` pour filtrer par nom. `FIXTURE=<nom>` choisit l'arbre (défaut `famille`).
- `node crop.mjs m5a <prénom> 3000 1100 0.8` : recadrage autour d'une personne (largeur, hauteur, échelle).
- Sorties dans `out/` : `<modèle>.svg/.png/.json` et `metrics.json`.
La largeur totale ne départage qu'une égalité parfaite. Pas de score composite, pas de capture pendant le travail : l'app une fois, à la recette.
Chaque modèle de `models/` exporte `variants: [{ name, layout(graphData) }]` et renvoie le format de `computeLayout` (`{ result: { children, edges }, coupleBarMeta }`) : un modèle se branche dans `Galaxy.jsx` sans toucher aux renderers. `load.mjs` importe le code du dépôt tel quel ; `register.mjs` ajoute le resolver des imports sans extension (style Vite).
## Lancer
- `npm run bench` : tous les archétypes, `desc-3` et `asc-23` (arbres fictifs générés, `gen-fr.mjs`), et chaque `fixtures/*.json`. `npm run bench -- issue-67 desc-3` pour choisir.
- `ASCII=1` imprime le rendu texte des archétypes. `PERTURB=0` saute la stabilité (lente sur les gros arbres).
- `SRC=<dossier frontend/src> TAG=<nom>` mesure une autre version du code (un autre worktree) et nomme le rapport. `npm run diff -- <tag A> <tag B>` compare deux rapports, arbre par arbre.
- Sorties dans `out/` : `rapport-<tag>.json`, `<archétype>.txt`, `<arbre>.svg` pour les gros arbres.
## Arbres
- Archétypes : dans le dépôt, personnes fictives.
- Fictifs : `node gen-fr.mjs <asc|desc> <sortie.json> [graine]`, ou directement `asc-<graine>` et `desc-<graine>` dans le banc. `asc` imite ce qu'importe un généalogiste (12 générations, fratries d'ancêtres, mariages entre cousins réglables par `IMPLEX`), `desc` ce qu'une famille saisit à la main (un couple fondateur, sa descendance, une belle-famille sur trois).
- Réels : `TREE_PASSWORD='...' npm run fixture -- <slug> [nom]` récupère un arbre par le chemin visiteur dans `fixtures/<nom>-graph.json`. Le dossier `fixtures/` est ignoré par git : ce sont les données d'une famille, elles ne quittent pas votre machine.
## Performance, parcours compte, landing
+165
View File
@@ -0,0 +1,165 @@
// Archétypes : petits arbres de 6 à 40 personnes, un par situation que le placement doit savoir dessiner.
// Format d'entrée de computeLayout (persons, unions, filiations). Aucune dépendance, importable par vitest.
// Le rendu texte validé de chacun est le témoin : frontend/src/components/galaxy/__snapshots__/archetypes/.
const P = (id, extra = {}) => ({ id, firstName: id, lastName: '', ...extra })
const U = (id, a, b, displayOrder = 1) => ({ id, partner1Id: a, partner2Id: b, displayOrder })
const L = (childId, unionId, displayOrder = 1, parentageType = 'biologique') => ({ id: `f-${childId}-${unionId}`, childId, unionId, parentId: null, parentageType, displayOrder })
const D = (childId, parentId, displayOrder = 1) => ({ id: `f-${childId}-${parentId}`, childId, unionId: null, parentId, parentageType: 'biologique', displayOrder })
const ids = (...lists) => lists.flat().map((x) => (typeof x === 'string' ? P(x) : x))
export const archetypes = [
{
slug: 'fratrie-ordre',
note: "Trois enfants dans l'ordre choisi par la famille (C, A, B), pas celui de saisie.",
reference: 'A',
graph: {
persons: ids(['Pa', 'Ma', 'A', 'B', 'C']),
unions: [U('u', 'Pa', 'Ma')],
filiations: [L('A', 'u', 2), L('B', 'u', 3), L('C', 'u', 1)],
},
},
{
slug: 'issue-67',
note: 'Lignée de la mère sur trois générations, lignée du père sur deux. Les deux couples de grands-parents partagent une rangée.',
reference: 'Kid',
graph: {
persons: ids(['GGGa', 'GGGb', 'GGa', 'GGb', 'GM1', 'GF1', 'M', 'GM2', 'GF2', 'GGx', 'GGy', 'F', 'Kid']),
unions: [U('u1', 'GGGa', 'GGGb'), U('u2', 'GGa', 'GGb'), U('u3', 'GM1', 'GF1'), U('u4', 'GM2', 'GF2'), U('u5', 'M', 'F')],
filiations: [L('GGa', 'u1'), L('GM1', 'u2'), L('M', 'u3'), L('F', 'u4'), L('Kid', 'u5'), D('GM2', 'GGy'), D('GF2', 'GGx')],
},
},
{
slug: 'branche-courte',
note: 'Branche courte (B0 → Y et son frère S) mariée dans une branche longue (A0 → A3 → X). Toute la branche courte descend.',
reference: 'K',
graph: {
persons: ids(['A0', 'A0w', 'A1', 'A1w', 'A2', 'A2w', 'A3', 'A3w', 'X', 'B0', 'B0w', 'Y', 'S', 'K']),
unions: [U('uA0', 'A0', 'A0w'), U('uA1', 'A1', 'A1w'), U('uA2', 'A2', 'A2w'), U('uA3', 'A3', 'A3w'), U('uB0', 'B0', 'B0w'), U('uXY', 'X', 'Y')],
filiations: [L('A1', 'uA0'), L('A2', 'uA1'), L('A3', 'uA2'), L('X', 'uA3'), L('Y', 'uB0', 1), L('S', 'uB0', 2), L('K', 'uXY')],
},
},
{
slug: 'marc-nadia',
note: 'Deux fratries saisies à la main, un enfant de chaque se marie (cas Anaïs). Le couple se tourne vers ses familles.',
reference: 'Anais',
graph: {
persons: ids(['Pierre', 'Odile', 'Louis', 'Helene', 'Luc', 'Paul', 'Mireil', 'Nadia', 'Marc', 'Claire', 'Julien', 'Sophie', 'Eric', 'Anais']),
unions: [U('uN', 'Pierre', 'Odile'), U('uB', 'Louis', 'Helene'), U('uM', 'Marc', 'Nadia')],
filiations: [
L('Luc', 'uN', 1), L('Paul', 'uN', 2), L('Mireil', 'uN', 3), L('Nadia', 'uN', 4),
L('Marc', 'uB', 1), L('Claire', 'uB', 2), L('Julien', 'uB', 3), L('Sophie', 'uB', 4), L('Eric', 'uB', 5),
L('Anais', 'uM'),
],
},
},
{
slug: 'petit-conjoint',
note: 'E2 épouse S2, sans parent ni enfant dans l arbre : petit portrait collé. S1 a un enfant : taille normale.',
reference: 'E1',
graph: {
persons: ids(['Pa', 'Ma', 'E1', 'E2', 'S1', 'S2', 'K']),
unions: [U('u', 'Pa', 'Ma'), U('u1', 'E1', 'S1'), U('u2', 'E2', 'S2')],
filiations: [L('E1', 'u', 1), L('E2', 'u', 2), L('K', 'u1')],
},
},
{
slug: 'fratrie-12',
note: 'Douze enfants, chacun marié, les six premiers avec deux enfants, les autres avec un. La ligne de fratrie est longue, c est normal.',
reference: 'K1',
graph: (() => {
const persons = ids(['Pa', 'Ma'])
const unions = [U('u', 'Pa', 'Ma')]
const filiations = []
for (let i = 1; i <= 12; i++) {
persons.push(P(`K${i}`), P(`S${i}`))
unions.push(U(`u${i}`, `K${i}`, `S${i}`))
filiations.push(L(`K${i}`, 'u', i))
const n = i <= 6 ? 2 : 1
for (let j = 1; j <= n; j++) { persons.push(P(`K${i}${String.fromCharCode(96 + j)}`)); filiations.push(L(`K${i}${String.fromCharCode(96 + j)}`, `u${i}`, j)) }
}
return { persons, unions, filiations }
})(),
},
{
slug: 'remariage',
note: 'H a deux unions, deux enfants de la première, un de la seconde. Les deux foyers restent côte à côte.',
reference: 'A',
graph: {
persons: ids(['H', 'W1', 'W2', 'A', 'B', 'C']),
unions: [U('u1', 'H', 'W1', 1), U('u2', 'H', 'W2', 2)],
filiations: [L('A', 'u1', 1), L('B', 'u1', 2), L('C', 'u2')],
},
},
{
slug: 'cousins',
note: 'Deux cousins germains se marient (implexe de même degré). Aucun conflit de rangée.',
reference: 'K',
graph: {
persons: ids(['G', 'Gw', 'P1', 'P1w', 'P2', 'P2w', 'C1', 'C2', 'K']),
unions: [U('uG', 'G', 'Gw'), U('u1', 'P1', 'P1w'), U('u2', 'P2', 'P2w'), U('uC', 'C1', 'C2')],
filiations: [L('P1', 'uG', 1), L('P2', 'uG', 2), L('C1', 'u1'), L('C2', 'u2'), L('K', 'uC')],
},
},
{
slug: 'oncle-niece',
note: 'O épouse N, la fille de son frère P. Conflit de rangée assumé : les conjoints restent ensemble, un fil saute.',
reference: 'G',
graph: {
persons: ids(['G', 'Gw', 'O', 'P', 'Pw', 'N']),
unions: [U('uG', 'G', 'Gw'), U('uP', 'P', 'Pw'), U('uON', 'O', 'N')],
filiations: [L('O', 'uG', 1), L('P', 'uG', 2), L('N', 'uP')],
},
},
{
slug: 'parent-seul',
note: 'M seule avec deux enfants (partenaire inconnu). X et Y, jamais en couple, ont un enfant Z (couple séparé en pointillés).',
reference: 'A',
graph: {
persons: ids(['M', 'A', 'B', 'X', 'Y', 'Z']),
unions: [],
filiations: [D('A', 'M', 1), D('B', 'M', 2), D('Z', 'X'), D('Z', 'Y')],
},
},
{
slug: 'adoption',
note: 'A est né du couple, B est adopté. Même fratrie, le fil de B est pointillé.',
reference: 'A',
graph: {
persons: ids(['Pa', 'Ma', 'A', 'B']),
unions: [U('u', 'Pa', 'Ma')],
filiations: [L('A', 'u', 1), L('B', 'u', 2, 'adoption')],
},
},
{
slug: 'deux-familles',
note: 'Deux familles sans lien entre elles, datées : la seconde se cale sur les années de la première.',
reference: 'A',
graph: {
persons: ids([P('A', { birthDate: '1900-01-01' }), P('Aw', { birthDate: '1902-01-01' }), P('B', { birthDate: '1930-01-01' }), P('Bw', { birthDate: '1931-01-01' }), P('E', { birthDate: '1960-01-01' }),
P('C', { birthDate: '1932-01-01' }), P('Cw', { birthDate: '1935-01-01' }), P('D', { birthDate: '1961-01-01' })]),
unions: [U('ua', 'A', 'Aw'), U('ub', 'B', 'Bw'), U('uc', 'C', 'Cw')],
filiations: [L('B', 'ua'), L('E', 'ub'), L('D', 'uc')],
},
},
{
slug: 'belle-famille',
note: 'E (de la référence) épouse S, dont les parents, la fratrie et les grands-parents sont saisis. La belle-famille se pose à côté.',
reference: 'E',
graph: {
persons: ids(['Pa', 'Ma', 'E', 'E2', 'S', 'S2', 'S3', 'SP', 'SM', 'SG', 'SGw', 'K1', 'K2']),
unions: [U('u', 'Pa', 'Ma'), U('uE', 'E', 'S'), U('uS', 'SP', 'SM'), U('uG', 'SG', 'SGw')],
filiations: [L('E', 'u', 1), L('E2', 'u', 2), L('S', 'uS', 1), L('S2', 'uS', 2), L('S3', 'uS', 3), L('SP', 'uG'), L('K1', 'uE', 1), L('K2', 'uE', 2)],
},
},
{
slug: 'trois-belles-familles',
note: 'A1 épouse B1, B2 épouse C1 : trois familles enchaînées par deux mariages.',
reference: 'X',
graph: {
persons: ids(['A', 'Aw', 'A1', 'A2', 'B', 'Bw', 'B1', 'B2', 'C', 'Cw', 'C1', 'X', 'Y']),
unions: [U('uA', 'A', 'Aw'), U('uB', 'B', 'Bw'), U('uC', 'C', 'Cw'), U('uAB', 'A1', 'B1'), U('uBC', 'B2', 'C1')],
filiations: [L('A1', 'uA', 1), L('A2', 'uA', 2), L('B1', 'uB', 1), L('B2', 'uB', 2), L('C1', 'uC'), L('X', 'uAB'), L('Y', 'uBC')],
},
},
]
+81
View File
@@ -0,0 +1,81 @@
// Rendu texte d'un placement : une ligne par rangée, étiquettes centrées sur l'abscisse de chaque nœud,
// fils de fratrie en caractères de boîte entre deux rangées. Lisible dans un terminal, comparable en CI.
//
// Pa ─ • ─ Ma personnes et union (•) d'un couple, sur la même ligne
// │ descente de l'union vers la ligne de fratrie
// ┬────┴────┬ ligne de fratrie, ┴ sous l'union, ┬ au-dessus de chaque enfant
// │ │ descente vers chaque enfant
// A B
// ═ deux lignes de fratrie qui se superposent
// pa (minuscules) petit conjoint (sans parent ni enfant dans l'arbre)
// renvoi : ... filiation dessinée en pastilles, pas en fil
export function renderAscii(result, coupleBarMeta, { px = 20 } = {}) {
const nodes = result.children
if (!nodes.length) return ''
const byId = new Map(nodes.map((n) => [n.id, n]))
const minX = Math.min(...nodes.map((n) => n.x))
const col = (x) => Math.round((x - minX) / px)
const cx = (n) => n.x + n.width / 2
const width = Math.max(...nodes.map((n) => col(n.x + n.width))) + 4
const label = (n) => {
if (n._type === 'union') return n._compact ? '' : '•'
if (n._type === 'unknown') return '?'
const text = String(n._data?.firstName || n.id).slice(0, 6)
return n._compact ? text.toLowerCase() : text
}
const line = () => Array(width).fill(' ')
const put = (arr, c, s) => {
const start = Math.max(0, c - Math.floor(s.length / 2))
for (let i = 0; i < s.length && start + i < arr.length; i++) arr[start + i] = s[i]
}
const gens = [...new Set(nodes.map((n) => n._generation))].sort((a, b) => a - b)
const kidsOf = new Map()
const renvois = []
for (const e of result.edges) {
if (e._renvoi) { renvois.push(e); continue }
if (!kidsOf.has(e.sources[0])) kidsOf.set(e.sources[0], [])
kidsOf.get(e.sources[0]).push(e.targets[0])
}
const out = []
for (const g of gens) {
const row = line()
// traits de couple, puis étiquettes par-dessus
for (const c of coupleBarMeta) {
const p1 = byId.get(c.p1Key), p2 = byId.get(c.p2Key), u = byId.get(c.elkId)
if (!p1 || !p2 || !u || p1._generation !== g) continue
const from = Math.min(col(cx(p1)), col(cx(p2))), to = Math.max(col(cx(p1)), col(cx(p2)))
for (let i = from; i <= to; i++) row[i] = c.virtual ? '┄' : '─'
}
for (const n of nodes) if (n._generation === g) put(row, col(cx(n)), label(n))
out.push(row)
const drop1 = line(), bus = line(), drop2 = line()
const coverage = new Array(width).fill(0)
let any = false
for (const n of nodes) {
if (n._generation !== g || (n._type !== 'union')) continue
const kids = (kidsOf.get(n.id) || []).map((id) => byId.get(id)).filter(Boolean)
if (!kids.length) continue
any = true
const uc = col(cx(n))
const cols = [uc, ...kids.map((k) => col(cx(k)))]
const l = Math.min(...cols), r = Math.max(...cols)
for (let i = l; i <= r; i++) { if (bus[i] === ' ') bus[i] = '─'; coverage[i]++ }
drop1[uc] = '│'
bus[uc] = '┴'
for (const k of kids) { const kc = col(cx(k)); bus[kc] = kc === uc ? '┼' : '┬'; drop2[kc] = '│' }
}
for (let i = 0; i < width; i++) if (coverage[i] > 1 && bus[i] === '─') bus[i] = '═'
if (any) out.push(drop1, bus, drop2)
}
const lines = out.map((l) => l.join('').trimEnd())
const indent = Math.min(...lines.filter((l) => l.trim()).map((l) => l.length - l.trimStart().length))
const text = lines.map((l) => l.slice(indent)).join('\n')
const extra = renvois.map((e) => {
const u = byId.get(e.sources[0]), k = byId.get(e.targets[0])
const c = coupleBarMeta.find((m) => m.elkId === u?.id)
const parents = c ? `${label(byId.get(c.p1Key) || {})}+${label(byId.get(c.p2Key) || {})}` : u?.id
return `renvoi : ${parents} → ${label(k || {})}`
})
return [text, ...extra].join('\n')
}
+113
View File
@@ -0,0 +1,113 @@
// Défauts comptés sur la géométrie vraiment dessinée (traceSibship dans renderers.js) : l'union descend de
// BUS_DY sous son centre jusqu'à la ligne de fratrie (décalée de 0, 4 ou 8 px selon l'union), file à
// l'horizontale, puis descend sur chaque enfant. Les renvois (pastilles) ne sont pas dessinés.
// Tenir en phase avec busY / BUS_STRAIGHT_DX de renderers.js.
import { kinshipGenerations } from './invariants.mjs'
const BUS_DY = 100
const BUS_STRAIGHT_DX = 30
const SCREEN = 1440
const cx = (n) => n.x + n.width / 2
export function busOffset(id) { let h = 0; for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) | 0; return (Math.abs(h) % 3) * 4 }
export function computeDefects(result, coupleBarMeta, graph, referenceId = null) {
const nodes = result.children
const byId = new Map(nodes.map((n) => [n.id, n]))
const kidsOf = new Map()
const renvois = []
for (const e of result.edges) {
if (e._renvoi) { renvois.push(e); continue }
if (!kidsOf.has(e.sources[0])) kidsOf.set(e.sources[0], [])
kidsOf.get(e.sources[0]).push(e.targets[0])
}
const spousesOf = new Map()
for (const c of coupleBarMeta) {
if (!spousesOf.has(c.p1Key)) spousesOf.set(c.p1Key, new Set())
if (!spousesOf.has(c.p2Key)) spousesOf.set(c.p2Key, new Set())
spousesOf.get(c.p1Key).add(c.p2Key); spousesOf.get(c.p2Key).add(c.p1Key)
}
// Tracé : troncs, lignes de fratrie, descentes
const H = [], V = []
const households = []
for (const [uid, kids] of kidsOf) {
const u = byId.get(uid)
const ks = kids.map((k) => byId.get(k)).filter(Boolean)
if (!u || !ks.length) continue
const sx = cx(u), sy = u.y + u.height / 2, by = sy + BUS_DY + busOffset(uid)
let l = sx, r = sx
for (const k of ks) {
const ex = cx(k)
if (Math.abs(ex - sx) >= BUS_STRAIGHT_DX) { l = Math.min(l, ex); r = Math.max(r, ex) }
V.push({ u: uid, x: ex, a: by, b: k.y })
}
V.push({ u: uid, x: sx, a: sy, b: by })
if (r > l) H.push({ u: uid, y: by, a: l, b: r })
households.push({ union: uid, enfants: ks.length, etendue: Math.round(r - l), gen: u._generation, kids: new Set(kids) })
}
let croisements = 0
const endroits = new Set()
for (const v of V) for (const h of H) {
if (h.u === v.u) continue
if (v.x > h.a + 1 && v.x < h.b - 1 && h.y > v.a + 1 && h.y < v.b - 1) { croisements++; endroits.add(v.u < h.u ? `${v.u}|${h.u}` : `${h.u}|${v.u}`) }
}
let superpositions = 0
for (let i = 0; i < H.length; i++) for (let j = i + 1; j < H.length; j++) {
const p = H[i], q = H[j]
if (Math.abs(p.y - q.y) < 6 && Math.min(p.b, q.b) - Math.max(p.a, q.a) > 10) superpositions++
}
const cables_ecran = H.filter((h) => h.b - h.a > SCREEN).length
// Ligne de fratrie au-dessus d'un portrait étranger (ni enfant, ni conjoint d'un enfant)
let fils_sur_etranger = 0
for (const hh of households) {
const h = H.find((x) => x.u === hh.union)
if (!h) continue
const ok = new Set(hh.kids)
for (const k of hh.kids) for (const s of (spousesOf.get(k) || [])) ok.add(s)
if (nodes.some((n) => n._type === 'person' && n._generation === hh.gen + 1 && !ok.has(n.id) && cx(n) > h.a && cx(n) < h.b)) fils_sur_etranger++
}
const foyers_larges = households.filter((h) => h.etendue > SCREEN).length
// Renvois : combien, et à quelle distance de parenté de la référence le plus proche
const kin = kinshipGenerations(graph, referenceId)
const degree = kinshipDegrees(graph, referenceId)
const renvoi_degre_min = renvois.length ? Math.min(...renvois.map((e) => degree.get(byId.get(e.targets[0])?._data?.id) ?? 99)) : null
// Proches à deux degrés de la référence (parents, enfants, conjoint, fratrie, grands-parents, beaux-parents…)
// visibles dans un écran autour d'elle
const ref = nodes.find((n) => n._type === 'person' && n._data?.id === referenceId)
let proches = 0, proches_ecran = 0
if (ref) {
for (const n of nodes) {
if (n._type !== 'person' || n === ref) continue
const d = degree.get(n._data.id)
if (d == null || d > 2) continue
proches++
if (Math.abs(cx(n) - cx(ref)) <= SCREEN / 2 && Math.abs(n._generation - ref._generation) <= 2) proches_ecran++
}
}
const pires_foyers = households.sort((a, b) => b.etendue - a.etendue).slice(0, 5).map(({ union, enfants, etendue }) => ({ union, enfants, etendue }))
return {
renvois: renvois.length, renvoi_degre_min, croisements, endroits_croises: endroits.size, superpositions, fils_sur_etranger, cables_ecran, foyers_larges,
proches, proches_ecran, conflits: kin.conflicts, pires_foyers,
}
}
// Degré de parenté (nombre de liens conjoint / parent / enfant) depuis la référence
export function kinshipDegrees(graph, referenceId) {
const adj = new Map()
const add = (a, b) => { if (!adj.has(a)) adj.set(a, []); adj.get(a).push(b) }
for (const u of graph.unions) if (u.partner1Id && u.partner2Id) { add(u.partner1Id, u.partner2Id); add(u.partner2Id, u.partner1Id) }
const unionById = new Map(graph.unions.map((u) => [u.id, u]))
for (const f of graph.filiations) {
const parents = f.unionId ? [unionById.get(f.unionId)?.partner1Id, unionById.get(f.unionId)?.partner2Id] : [f.parentId]
for (const p of parents) if (p) { add(p, f.childId); add(f.childId, p) }
}
const d = new Map()
if (referenceId == null) return d
d.set(referenceId, 0)
const queue = [referenceId]
while (queue.length) { const cur = queue.shift(); for (const n of (adj.get(cur) || [])) if (!d.has(n)) { d.set(n, d.get(cur) + 1); queue.push(n) } }
return d
}
+13
View File
@@ -0,0 +1,13 @@
// Compare deux rapports du banc (out/rapport-<tag>.json), par arbre et par colonne. Usage : node diff.mjs <tag A> <tag B>
import { readFileSync } from 'node:fs'
const [a, b] = process.argv.slice(2)
if (!a || !b) { console.error('Usage : node diff.mjs <tag A> <tag B>'); process.exit(1) }
const read = (t) => new Map(JSON.parse(readFileSync(new URL(`./out/rapport-${t}.json`, import.meta.url), 'utf8')).map((r) => [r.arbre, r]))
const A = read(a), B = read(b)
const cols = ['invariants', 'rangees_fausses', 'fratries_coupees', 'renvois', 'renvois_sang', 'fils_sur_etranger', 'croisements', 'superpositions', 'cables_ecran', 'foyers_larges', 'proches_ecran', 'max_bouges', 'max_deplacement_px', 'largeur']
for (const [name, ra] of A) {
const rb = B.get(name)
if (!rb) continue
const changes = cols.filter((c) => ra[c] !== rb[c]).map((c) => `${c} ${ra[c]} → ${rb[c]}`)
console.log(name.padEnd(22), changes.length ? changes.join(' · ') : 'identique')
}
-53
View File
@@ -1,53 +0,0 @@
// Socle commun des modèles ELK : on réutilise du code du repo ce qui n'est pas en cause
// (normalisation des filiations, assignation des générations, chaînes de couples par génération)
// et on ne confie à ELK que ce qui est en cause : l'ordre et le placement X.
import ELK from 'elkjs/lib/elk.bundled.js'
import { layoutModule, constants } from './load.mjs'
export const elk = new ELK()
export const { PERSON_W, PERSON_H, UNION_W, UNKNOWN_W } = constants
export const NODE_SPACING = 30
export const FAMILY_GAP = 60
export const LAYER_GAP = 60
// Modèle de base issu du code repo : nœuds typés (+ génération), arêtes de filiation, couples, chaînes
export function baseModel(graphData) {
const result = layoutModule.computeLayout(graphData)
const nodes = result.children.map(n => ({ ...n }))
const chainsPerGen = new Map([...layoutModule.componentOrderPerGen].map(([g, comps]) => [g, comps.map(c => [...c])]))
return {
nodes,
byId: new Map(nodes.map(n => [n.id, n])),
edges: result.edges.map(e => ({ ...e })),
coupleBarMeta: layoutModule.coupleBarMeta.map(c => ({ ...c })),
chainsPerGen,
}
}
// Réinjecte les positions calculées (map id → {x,y}) dans une copie des nœuds, Y forcé par génération
export function finalize(base, posById) {
const children = base.nodes.map(n => {
const p = posById.get(n.id)
return { ...n, x: p ? p.x : 0, y: n._generation * (PERSON_H + LAYER_GAP) }
})
let minX = Math.min(...children.map(n => n.x))
for (const n of children) n.x -= minX
return { result: { children, edges: base.edges }, coupleBarMeta: base.coupleBarMeta }
}
export function chainWidth(base, ids, spacing = NODE_SPACING) {
const ns = ids.map(id => base.byId.get(id))
return ns.reduce((s, n) => s + n.width, 0) + Math.max(0, ns.length - 1) * spacing
}
// Offsets X de chaque membre d'une chaîne, dans l'ordre de la chaîne
export function chainOffsets(base, ids, spacing = NODE_SPACING) {
const offsets = new Map()
let x = 0
for (const id of ids) {
const n = base.byId.get(id)
offsets.set(id, x)
x += n.width + spacing
}
return offsets
}
+146
View File
@@ -0,0 +1,146 @@
// Arbres de test réalistes, au format de GET /trees/:id/graph. Personnes fictives, noms tirés au hasard,
// générateur déterministe (même graine = même arbre).
// asc : ce qu'importe un généalogiste. Un « moi » né en 1985, son ascendance sur 12 générations qui se
// raréfie en remontant, les frères et sœurs de chaque ancêtre (familles nombreuses avant 1900),
// une partie des collatéraux redescendus jusqu'à aujourd'hui, quelques mariages entre cousins (implexe).
// desc : ce que saisit une famille à la main. Un couple fondateur vers 1840, toute sa descendance, et pour
// une partie des conjoints leurs parents et leur fratrie.
// Usage en script : node gen-fr.mjs <asc|desc> <sortie.json> [graine]
import { writeFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
const M = ['Jean', 'Pierre', 'Louis', 'Joseph', 'François', 'Jacques', 'Antoine', 'Michel', 'André', 'René', 'Henri', 'Paul', 'Marcel', 'Georges', 'Étienne', 'Claude', 'Nicolas', 'Julien', 'Thomas', 'Lucas', 'Hugo', 'Marc', 'Éric', 'Philippe', 'Gaston', 'Émile', 'Victor', 'Auguste', 'Alphonse', 'Lucien']
const F = ['Marie', 'Jeanne', 'Anne', 'Marguerite', 'Louise', 'Catherine', 'Françoise', 'Madeleine', 'Germaine', 'Suzanne', 'Odile', 'Hélène', 'Simone', 'Yvonne', 'Claire', 'Sophie', 'Julie', 'Camille', 'Léa', 'Emma', 'Nadia', 'Rose', 'Victorine', 'Joséphine', 'Eugénie', 'Augustine', 'Céline', 'Mireille', 'Isabelle', 'Anaïs']
const S = ['Martin', 'Bernard', 'Dubois', 'Thomas', 'Robert', 'Richard', 'Petit', 'Durand', 'Leroy', 'Moreau', 'Simon', 'Laurent', 'Lefebvre', 'Michel', 'Garcia', 'David', 'Bertrand', 'Roux', 'Vincent', 'Fournier', 'Morel', 'Girard', 'André', 'Mercier', 'Dupont', 'Lambert', 'Bonnet', 'François', 'Martinez', 'Legrand', 'Garnier', 'Faure', 'Rousseau', 'Blanc', 'Guerin', 'Muller', 'Henry', 'Roussel', 'Nicolas', 'Perrin', 'Morin', 'Mathieu', 'Clement', 'Gauthier', 'Dumont', 'Lopez', 'Fontaine', 'Chevalier', 'Robin', 'Masson']
export function generate(kind, seedArg = 7, { implex = 0.08 } = {}) {
let seed = Number(seedArg)
const rnd = () => { seed = (seed * 1664525 + 1013904223) % 4294967296; return seed / 4294967296 }
const pick = (a) => a[Math.floor(rnd() * a.length)]
const between = (a, b) => a + Math.floor(rnd() * (b - a + 1))
const persons = [], unions = [], links = []
const sibOrder = new Map()
let pid = 0, uid = 0, lid = 0
function person(sex, year, lastName, firstName) {
const p = { id: `p${++pid}`, firstName: firstName || pick(sex === 'male' ? M : F), lastName, sex, birthDate: `${year}-01-01`, deathDate: year < 1935 ? `${year + between(40, 85)}-01-01` : null, _y: year, _married: false }
persons.push(p)
return p
}
function union(a, b) {
const u = { id: `u${++uid}`, partner1PersonId: a.id, partner2PersonId: b.id, unionType: 'mariage', displayOrder: uid }
unions.push(u)
a._married = b._married = true
return u
}
function link(child, u) {
for (const par of [u.partner1PersonId, u.partner2PersonId]) {
const n = (sibOrder.get(par) || 0) + 1
sibOrder.set(par, n)
links.push({ id: `l${++lid}`, childPersonId: child.id, parentPersonId: par, viaUnionId: u.id, parentageType: 'biologique', displayOrder: n })
}
}
const familySize = (y) => y < 1850 ? between(3, 10) : y < 1920 ? between(2, 7) : y < 1960 ? between(1, 4) : between(1, 3)
const spouseFor = (p) => person(p.sex === 'male' ? 'female' : 'male', p._y + between(-4, 4), pick(S))
// Une fratrie autour d'un enfant déjà connu : frères et sœurs dans l'ordre de naissance
function siblingsAround(known, u, father, { descend }) {
const n = familySize(known._y)
const at = between(0, n - 1)
const kids = []
for (let i = 0; i < n; i++) {
if (i === at) { kids.push(known); continue }
kids.push(person(rnd() < 0.5 ? 'male' : 'female', known._y + (i - at) * between(1, 3), father.lastName))
}
for (const k of kids) link(k, u)
for (const k of kids) if (k !== known) descend(k)
return kids
}
// Descendance d'un collatéral, jusqu'à aujourd'hui, qui s'amenuise
function descendants(p, depth, pMarry, pKids) {
if (p._y > 1995 || rnd() > pMarry) return
const s = spouseFor(p)
const u = union(p, s)
if (depth <= 0 || rnd() > pKids) return
const n = familySize(p._y + 28)
const father = p.sex === 'male' ? p : s
for (let i = 0; i < n; i++) {
const k = person(rnd() < 0.5 ? 'male' : 'female', p._y + between(22, 34) + i * 2, father.lastName)
link(k, u)
descendants(k, depth - 1, pMarry, pKids * 0.8)
}
}
if (kind === 'asc') {
const cousins = []
const me = person('male', 1985, 'Martin', 'Thomas')
const maxDepth = 12
function ascend(p, depth) {
const pKnow = depth === 0 ? 1 : Math.max(0.3, 0.97 - depth * 0.06)
if (depth >= maxDepth || rnd() > pKnow) return
const y = p._y - between(24, 34)
const father = person('male', y, p.lastName)
let mother = null
// Implexe : la mère est parfois une cousine déjà dans l'arbre (ses parents y sont déjà)
if (depth >= 4 && rnd() < implex) {
const c = cousins.find((c) => !c._married && c.sex === 'female' && Math.abs(c._y - y) <= 6)
if (c) mother = c
}
const motherIsCousin = Boolean(mother)
if (!mother) mother = person('female', y + between(-6, 3), pick(S))
const u = union(father, mother)
const recent = p._y > 1880
siblingsAround(p, u, father, {
descend: (k) => {
cousins.push(k)
if (recent) descendants(k, 3, 0.7, 0.6)
else if (rnd() < 0.25) descendants(k, 2, 0.6, 0.5)
else if (rnd() < 0.5) { const s = spouseFor(k); union(k, s) }
},
})
ascend(father, depth + 1)
if (!motherIsCousin) ascend(mother, depth + 1)
}
ascend(me, 0)
const wife = person('female', 1987, 'Leroy', 'Julie')
const u = union(me, wife)
for (const n of ['Lucas', 'Emma']) link(person(n === 'Lucas' ? 'male' : 'female', 2015, 'Martin', n), u)
} else if (kind === 'desc') {
const a = person('male', 1838, 'Dupont', 'Jean'), b = person('female', 1842, 'Moreau', 'Marie')
const root = union(a, b)
function family(u, father, y, depth) {
const n = familySize(y + 28)
for (let i = 0; i < n; i++) {
const k = person(rnd() < 0.5 ? 'male' : 'female', y + between(24, 32) + i * 2, father.lastName)
link(k, u)
if (k._y > 1995 || rnd() > 0.8) continue
const s = spouseFor(k)
// Un conjoint sur trois arrive avec ses parents et sa fratrie (la belle-famille saisie aussi)
if (rnd() < 0.33) {
const sf = person('male', s._y - between(25, 32), s.lastName), sm = person('female', s._y - between(23, 30), pick(S))
const su = union(sf, sm)
siblingsAround(s, su, sf, { descend: (sib) => { if (rnd() < 0.4) union(sib, spouseFor(sib)) } })
if (rnd() < 0.3) { const gf = person('male', sf._y - 28, sf.lastName), gm = person('female', sf._y - 26, pick(S)); siblingsAround(sf, union(gf, gm), gf, { descend: () => {} }) }
}
const ku = union(k, s)
if (depth < 6) family(ku, k.sex === 'male' ? k : s, k._y, depth + 1)
}
}
family(root, a, a._y, 1)
} else {
throw new Error(`genre inconnu : ${kind} (asc ou desc)`)
}
for (const p of persons) { delete p._y; delete p._married }
return { rootPersonId: 'p1', graph: { persons, unions, filiations: links, medias: [], annotations: [] } }
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
const [kind, outFile, seedArg] = process.argv.slice(2)
if (!kind || !outFile) { console.error('Usage : node gen-fr.mjs <asc|desc> <sortie.json> [graine]'); process.exit(1) }
const out = generate(kind, seedArg || 7, { implex: Number(process.env.IMPLEX ?? 0.08) })
writeFileSync(outFile, JSON.stringify(out))
console.log(outFile, out.graph.persons.length, 'personnes', out.graph.unions.length, 'unions')
}
+147
View File
@@ -0,0 +1,147 @@
// Invariants d'un placement : ce qui doit valoir zéro, sinon le placement est faux (pas « moins beau », faux).
// Calculés sans écran, depuis le graphe d'entrée et le placement produit. La génération de parenté est
// recalculée ici, indépendamment du code de production, pour pouvoir le contredire.
import { PERSON_W, UNION_W } from '../../frontend/src/components/galaxy/constants.js'
const cx = (n) => n.x + n.width / 2
// Génération de parenté depuis une référence : conjoint 0, parent −1, enfant +1, en largeur.
// Renvoie gen (Map personId → entier), conflits (arêtes qui contredisent), et le nombre de familles non reliées.
export function kinshipGenerations(graph, referenceId = null) {
const adj = new Map()
const add = (a, b, w) => { if (!adj.has(a)) adj.set(a, []); adj.get(a).push([b, w]) }
const ids = new Set(graph.persons.map((p) => p.id))
for (const u of graph.unions) if (ids.has(u.partner1Id) && ids.has(u.partner2Id)) { add(u.partner1Id, u.partner2Id, 0); add(u.partner2Id, u.partner1Id, 0) }
const unionById = new Map(graph.unions.map((u) => [u.id, u]))
for (const f of graph.filiations) {
const parents = f.unionId ? [unionById.get(f.unionId)?.partner1Id, unionById.get(f.unionId)?.partner2Id] : [f.parentId]
for (const p of parents) if (p && ids.has(p) && ids.has(f.childId)) { add(p, f.childId, 1); add(f.childId, p, -1) }
}
const gen = new Map()
const conflicts = new Set()
const component = new Map()
let components = 0
const visit = (start) => {
const queue = [start]
gen.set(start, 0)
component.set(start, components)
while (queue.length) {
const cur = queue.shift()
for (const [nxt, w] of (adj.get(cur) || [])) {
const v = gen.get(cur) + w
if (!gen.has(nxt)) { gen.set(nxt, v); component.set(nxt, components); queue.push(nxt) }
else if (gen.get(nxt) !== v) conflicts.add(cur < nxt ? `${cur}|${nxt}` : `${nxt}|${cur}`)
}
}
components++
}
if (referenceId != null && ids.has(referenceId)) visit(referenceId)
for (const p of graph.persons) if (!gen.has(p.id)) visit(p.id)
return { gen, conflicts: conflicts.size, components, component }
}
// Parents par le sang de la référence : tout ce qu'on atteint par des liens parent/enfant seulement
export function bloodRelatives(graph, referenceId) {
const adj = new Map()
const add = (a, b) => { if (!adj.has(a)) adj.set(a, []); adj.get(a).push(b) }
const unionById = new Map(graph.unions.map((u) => [u.id, u]))
for (const f of graph.filiations) {
const parents = f.unionId ? [unionById.get(f.unionId)?.partner1Id, unionById.get(f.unionId)?.partner2Id] : [f.parentId]
for (const p of parents) if (p) { add(p, f.childId); add(f.childId, p) }
}
const seen = new Set()
if (referenceId == null) return seen
const queue = [referenceId]
seen.add(referenceId)
while (queue.length) { const cur = queue.shift(); for (const n of (adj.get(cur) || [])) if (!seen.has(n)) { seen.add(n); queue.push(n) } }
return seen
}
const birthYear = (p) => { const raw = p?.birthYear ?? p?.birthDate; const y = raw != null ? parseInt(String(raw).slice(0, 4), 10) : NaN; return Number.isFinite(y) ? y : null }
export function checkInvariants(result, coupleBarMeta, graph, referenceId = null) {
const nodes = result.children
const byId = new Map(nodes.map((n) => [n.id, n]))
const persons = nodes.filter((n) => n._type === 'person')
const byGen = new Map()
for (const n of nodes) { if (!byGen.has(n._generation)) byGen.set(n._generation, []); byGen.get(n._generation).push(n) }
// Chevauchements sur une rangée (les unions de largeur nulle et les petits conjoints collés sont exclus)
let chevauchements = 0
for (const list of byGen.values()) {
const solid = list.filter((n) => n.width > 0 && !n._compact).sort((a, b) => a.x - b.x)
for (let i = 1; i < solid.length; i++) if (solid[i].x < solid[i - 1].x + solid[i - 1].width - 0.5) chevauchements++
}
// Couples : adjacents et sur la même rangée
let couples_separes = 0, couples_rangee = 0
const maxAdj = PERSON_W + UNION_W + 2 * 40 + 1
const spousesOf = new Map()
for (const c of coupleBarMeta) {
const p1 = byId.get(c.p1Key), p2 = byId.get(c.p2Key)
if (!p1 || !p2) continue
if (!spousesOf.has(p1.id)) spousesOf.set(p1.id, new Set())
if (!spousesOf.has(p2.id)) spousesOf.set(p2.id, new Set())
spousesOf.get(p1.id).add(p2.id); spousesOf.get(p2.id).add(p1.id)
if (p1._generation !== p2._generation) couples_rangee++
else if (Math.abs(p1.x - p2.x) > maxAdj) couples_separes++
}
// Fratries : contiguës (seuls les frères, sœurs et leurs conjoints entre le premier et le dernier) et dans l'ordre choisi
const kidsOf = new Map()
for (const e of result.edges) {
if (e._renvoi) continue
if (!kidsOf.has(e.sources[0])) kidsOf.set(e.sources[0], [])
kidsOf.get(e.sources[0]).push(e)
}
let fratries_coupees = 0, fratries_desordre = 0
for (const edges of kidsOf.values()) {
const kids = edges.map((e) => byId.get(e.targets[0])).filter(Boolean)
if (kids.length < 2) continue
const allowed = new Set(kids.map((k) => k.id))
for (const k of kids) for (const s of (spousesOf.get(k.id) || [])) allowed.add(s)
const lo = Math.min(...kids.map((k) => k.x)), hi = Math.max(...kids.map((k) => k.x))
const gen = kids[0]._generation
if ((byGen.get(gen) || []).some((n) => n._type !== 'union' && !allowed.has(n.id) && n.x > lo && n.x < hi)) fratries_coupees++
const byX = [...edges].sort((a, b) => cx(byId.get(a.targets[0])) - cx(byId.get(b.targets[0])))
const orders = byX.map((e) => e._displayOrder).filter((o) => Number.isFinite(o))
if (orders.some((o, i) => i > 0 && o < orders[i - 1])) fratries_desordre++
}
// Rangées = génération de parenté (décalage calé par famille non reliée)
const kin = kinshipGenerations(graph, referenceId)
const deltas = new Map()
for (const n of persons) {
const k = kin.gen.get(n._data?.id)
if (k == null) continue
const comp = kin.component.get(n._data.id)
if (!deltas.has(comp)) deltas.set(comp, new Map())
const d = n._generation - k
deltas.get(comp).set(d, (deltas.get(comp).get(d) || 0) + 1)
}
const offsetOf = new Map()
for (const [comp, hist] of deltas) offsetOf.set(comp, [...hist.entries()].sort((a, b) => b[1] - a[1])[0][0])
let rangees_fausses = 0
for (const n of persons) {
const k = kin.gen.get(n._data?.id)
if (k == null) continue
if (n._generation - k !== offsetOf.get(kin.component.get(n._data.id))) rangees_fausses++
}
// Années de naissance : la médiane par rangée doit monter (rangées d'au moins 3 personnes datées)
const years = new Map()
for (const n of persons) { const y = birthYear(n._data); if (y == null) continue; if (!years.has(n._generation)) years.set(n._generation, []); years.get(n._generation).push(y) }
const medians = [...years.entries()].filter(([, ys]) => ys.length >= 3).sort((a, b) => a[0] - b[0]).map(([, ys]) => { ys.sort((a, b) => a - b); return ys[Math.floor(ys.length / 2)] })
let annees_descendantes = 0
for (let i = 1; i < medians.length; i++) if (medians[i] < medians[i - 1]) annees_descendantes++
// Renvois sur la ligne de sang de la référence
const blood = bloodRelatives(graph, referenceId)
const renvois_sang = result.edges.filter((e) => e._renvoi && blood.has(byId.get(e.targets[0])?._data?.id)).length
return { chevauchements, couples_separes, couples_rangee, fratries_coupees, fratries_desordre, rangees_fausses, conflits: kin.conflicts, annees_descendantes, renvois_sang }
}
// Les invariants durs : ceux qui font échouer un test. Les conflits de parenté sont une propriété des données,
// les renvois sur la ligne de sang relèvent du placement en X (étape à part).
export const HARD = ['chevauchements', 'couples_separes', 'couples_rangee', 'fratries_coupees', 'fratries_desordre', 'rangees_fausses', 'annees_descendantes']
+39 -15
View File
@@ -1,20 +1,44 @@
// Charge une fixture (arbre réel) dans le format Galaxy via loadGraphData (code du repo, inchangé)
import { readFileSync } from 'node:fs'
// Charge le code de production (SRC=<dossier frontend/src> pour mesurer une autre version) et les arbres :
// archétypes, arbres fictifs générés, et fixtures locales (fixtures/<nom>.json au format de GET /trees/:id/graph,
// jamais versionnées : ce sont les données d'une famille).
import { readFileSync, existsSync } from 'node:fs'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { archetypes } from './archetypes.mjs'
import { generate } from './gen-fr.mjs'
export const REPO = fileURLToPath(new URL('../../frontend/src/', import.meta.url))
const mockData = await import(pathToFileURL(`${REPO}data/mockData.js`).href)
export const SRC = (process.env.SRC || fileURLToPath(new URL('../../frontend/src/', import.meta.url))).replace(/\\/g, '/').replace(/\/?$/, '/')
const mockData = await import(pathToFileURL(`${SRC}data/mockData.js`).href)
export const constants = await import(pathToFileURL(`${SRC}components/galaxy/constants.js`).href)
export const layoutModule = await import(pathToFileURL(`${SRC}components/galaxy/elkLayout.js`).href)
const blockLayout = await import(pathToFileURL(`${SRC}components/galaxy/blockLayout.js`).href)
export async function loadFixture(name = process.env.FIXTURE || 'famille') {
const raw = JSON.parse(readFileSync(new URL(`./fixtures/${name}-graph.json`, import.meta.url), 'utf8'))
mockData.loadGraphData(raw.graph, { apiBaseUrl: '', authToken: '' })
return {
persons: mockData.persons,
unions: mockData.unions,
filiations: mockData.filiations,
medias: mockData.medias,
}
// Graphe de l'API → entrée de computeLayout (même conversion que l'app)
export function toGraph(rawGraph) {
mockData.loadGraphData(rawGraph, { apiBaseUrl: '' })
return { persons: mockData.persons, unions: mockData.unions, filiations: mockData.filiations, medias: mockData.medias }
}
export const constants = await import(pathToFileURL(`${REPO}components/galaxy/constants.js`).href)
export const layoutModule = await import(pathToFileURL(`${REPO}components/galaxy/elkLayout.js`).href)
// Placement de production. Renvoie aussi coupleBarMeta, état partagé peuplé par computeLayout.
export function layout(graph, referenceId = null) {
const result = blockLayout.computeBlockLayout(graph, referenceId)
return { result, coupleBarMeta: layoutModule.coupleBarMeta }
}
// Un arbre par nom : archétype (slug), « desc-<graine> » ou « asc-<graine> » (générés), ou fixtures/<nom>.json
export function loadTree(name) {
const arch = archetypes.find((a) => a.slug === name)
if (arch) return { name, graph: arch.graph, reference: arch.reference, archetype: true }
const gen = name.match(/^(asc|desc)-(\d+)$/)
if (gen) {
const out = generate(gen[1], Number(gen[2]))
return { name, graph: toGraph(out.graph), reference: out.rootPersonId }
}
const file = fileURLToPath(new URL(`./fixtures/${name}.json`, import.meta.url))
const legacy = fileURLToPath(new URL(`./fixtures/${name}-graph.json`, import.meta.url))
const path = existsSync(file) ? file : existsSync(legacy) ? legacy : null
if (!path) throw new Error(`arbre inconnu : ${name} (archétype, desc-<graine>, asc-<graine> ou fixtures/${name}.json)`)
const raw = JSON.parse(readFileSync(path, 'utf8'))
return { name, graph: toGraph(raw.graph || raw), reference: raw.rootPersonId || null }
}
export const archetypeNames = archetypes.map((a) => a.slug)
-121
View File
@@ -1,121 +0,0 @@
// Métriques de qualité d'un layout { children, edges, coupleBarMeta }
// Toutes les positions sont en coordonnées layout (x,y = coin haut-gauche).
import { constants } from './load.mjs'
const { PERSON_W, UNION_W } = constants
function center(n) { return { x: n.x + n.width / 2, y: n.y + n.height / 2 } }
function segIntersect(a1, a2, b1, b2) {
const d = (a2.x - a1.x) * (b2.y - b1.y) - (a2.y - a1.y) * (b2.x - b1.x)
if (Math.abs(d) < 1e-9) return false
const t = ((b1.x - a1.x) * (b2.y - b1.y) - (b1.y - a1.y) * (b2.x - b1.x)) / d
const u = ((b1.x - a1.x) * (a2.y - a1.y) - (b1.y - a1.y) * (a2.x - a1.x)) / d
return t > 1e-6 && t < 1 - 1e-6 && u > 1e-6 && u < 1 - 1e-6
}
export function computeMetrics(result, coupleBarMeta, tMs) {
const nodes = result.children
const byId = new Map(nodes.map(n => [n.id, n]))
const byGen = new Map()
for (const n of nodes) {
if (!byGen.has(n._generation)) byGen.set(n._generation, [])
byGen.get(n._generation).push(n)
}
// Chevauchements (même génération, intervalles X qui se coupent)
let overlaps = 0
for (const list of byGen.values()) {
const sorted = [...list].sort((a, b) => a.x - b.x)
for (let i = 0; i < sorted.length; i++) {
for (let j = i + 1; j < sorted.length; j++) {
if (sorted[j].x >= sorted[i].x + sorted[i].width - 0.5) break
overlaps++
}
}
}
// Arêtes de filiation : segment centre-bas de l'union → centre-haut de l'enfant
const segs = []
let longEdges = 0
let sumDx = 0
for (const e of result.edges) {
const s = byId.get(e.sources[0]); const t = byId.get(e.targets[0])
if (!s || !t) continue
const a = { x: s.x + s.width / 2, y: s.y + s.height }
const b = { x: t.x + t.width / 2, y: t.y }
segs.push({ a, b, id: e.id })
sumDx += Math.abs(b.x - a.x)
if ((t._generation - s._generation) > 1) longEdges++
}
let crossings = 0
for (let i = 0; i < segs.length; i++) {
for (let j = i + 1; j < segs.length; j++) {
// même source = pas un vrai croisement (fratrie)
if (segs[i].a.x === segs[j].a.x && segs[i].a.y === segs[j].a.y) continue
if (segIntersect(segs[i].a, segs[i].b, segs[j].a, segs[j].b)) crossings++
}
}
// Couples : partenaires adjacents ? même Y ?
let couplesSplit = 0, couplesDiffY = 0
const maxAdj = PERSON_W + UNION_W + 2 * 40 + 1 // largeur perso + union + 2 espacements larges
for (const c of coupleBarMeta) {
const p1 = byId.get(c.p1Key); const p2 = byId.get(c.p2Key)
if (!p1 || !p2) continue
if (Math.abs(p1.x - p2.x) > maxAdj) couplesSplit++
if (p1.y !== p2.y) couplesDiffY++
}
// Parents centrés au-dessus des enfants + fratries contiguës
const childrenByUnion = new Map()
for (const e of result.edges) {
if (!childrenByUnion.has(e.sources[0])) childrenByUnion.set(e.sources[0], [])
childrenByUnion.get(e.sources[0]).push(e.targets[0])
}
let parentOffsetSum = 0, parentOffsetN = 0, siblingSplits = 0
for (const [uid, kids] of childrenByUnion) {
const u = byId.get(uid); if (!u) continue
const kn = kids.map(k => byId.get(k)).filter(Boolean)
if (kn.length === 0) continue
const kc = kn.reduce((s, k) => s + center(k).x, 0) / kn.length
parentOffsetSum += Math.abs(center(u).x - kc); parentOffsetN++
if (kn.length >= 2) {
const minX = Math.min(...kn.map(k => k.x)), maxX = Math.max(...kn.map(k => k.x))
const kidSet = new Set(kids)
const gen = kn[0]._generation
const intruders = (byGen.get(gen) || []).filter(n => n._type === 'person' && !kidSet.has(n.id) && n.x > minX && n.x < maxX)
// un conjoint d'un des enfants n'est pas un intrus
const spouses = new Set()
for (const c of coupleBarMeta) {
if (kidSet.has(c.p1Key)) spouses.add(c.p2Key)
if (kidSet.has(c.p2Key)) spouses.add(c.p1Key)
}
if (intruders.some(n => !spouses.has(n.id))) siblingSplits++
}
}
const dxs = segs.map(s => Math.abs(s.b.x - s.a.x)).sort((a, b) => a - b)
const q = (p) => Math.round(dxs.length ? dxs[Math.min(dxs.length - 1, Math.floor(p * dxs.length))] : 0)
const minX = Math.min(...nodes.map(n => n.x)), maxX = Math.max(...nodes.map(n => n.x + n.width))
const minY = Math.min(...nodes.map(n => n.y)), maxY = Math.max(...nodes.map(n => n.y + n.height))
return {
t_ms: Math.round(tMs * 10) / 10,
nodes: nodes.length,
edges: result.edges.length,
overlaps,
crossings,
couples_split: couplesSplit,
couples_diffY: couplesDiffY,
sibling_splits: siblingSplits,
parent_offset_avg: Math.round(parentOffsetN ? parentOffsetSum / parentOffsetN : 0),
edge_dx_avg: Math.round(segs.length ? sumDx / segs.length : 0),
edge_dx_med: q(0.5),
edge_dx_p90: q(0.9),
long_edges: longEdges,
edges_far: dxs.filter(d => d > 800).length,
width: Math.round(maxX - minX),
height: Math.round(maxY - minY),
}
}
-9
View File
@@ -1,9 +0,0 @@
// Modèle 0 : layout actuel du repo (Sugiyama custom), tel quel
import { layoutModule } from '../load.mjs'
export const name = 'M0 actuel (Sugiyama custom)'
export async function layout(graphData) {
const result = layoutModule.computeLayout(graphData)
return { result, coupleBarMeta: layoutModule.coupleBarMeta }
}
-58
View File
@@ -1,58 +0,0 @@
// M1 : ELK "naïf".
// Nœuds plats (personne, union, inconnu) partitionnés par génération, aucune arête de couple,
// ordre du modèle = chaînes calculées côté repo. Deux variantes : options minimales (crossing NONE,
// placement SIMPLE) et options par défaut d'ELK (LAYER_SWEEP + BRANDES_KOEPF) pour voir ce qu'ELK
// fait des couples quand on le laisse libre.
import { elk, baseModel, finalize, NODE_SPACING, LAYER_GAP } from '../elk-common.mjs'
function build(base, extra) {
const children = []
const sortedGens = [...base.chainsPerGen.keys()].sort((a, b) => a - b)
for (const g of sortedGens) {
for (const chain of base.chainsPerGen.get(g)) {
for (const id of chain) {
const n = base.byId.get(id)
children.push({ id, width: n.width, height: n.height, layoutOptions: { 'elk.partitioning.partition': String(g) } })
}
}
}
return {
id: 'root',
layoutOptions: {
'elk.algorithm': 'layered',
'elk.direction': 'DOWN',
'elk.partitioning.activate': 'true',
'elk.layered.spacing.nodeNodeBetweenLayers': String(LAYER_GAP),
'elk.spacing.nodeNode': String(NODE_SPACING),
'elk.separateConnectedComponents': 'false',
'elk.edgeRouting': 'POLYLINE',
...extra,
},
children,
edges: base.edges.map(e => ({ id: e.id, sources: e.sources, targets: e.targets })),
}
}
async function run(graphData, extra) {
const base = baseModel(graphData)
const out = await elk.layout(build(base, extra))
const pos = new Map(out.children.map(c => [c.id, { x: c.x, y: c.y }]))
return finalize(base, pos)
}
export const variants = [
{
name: 'M1a ELK naïf (NONE + SIMPLE)',
layout: (g) => run(g, {
'elk.layered.crossingMinimization.strategy': 'NONE',
'elk.layered.nodePlacement.strategy': 'SIMPLE',
'elk.layered.considerModelOrder.strategy': 'NODES_AND_EDGES',
}),
},
{
name: 'M1b ELK naïf (défauts : LAYER_SWEEP + BK)',
layout: (g) => run(g, {
'elk.layered.considerModelOrder.strategy': 'NODES_AND_EDGES',
}),
},
]
@@ -1,76 +0,0 @@
// M2 : chaînes de couples = un seul nœud ELK, ports à position fixe.
// Une chaîne (A - u1 - B - u2 - C, même génération) devient un nœud large ; chaque union est un port
// SUD (sortie vers les enfants), chaque personne un port NORD (entrée depuis ses parents).
// ELK ne peut donc jamais séparer un couple : il ne voit que des blocs. Il fait ce qu'il sait faire :
// minimiser les croisements entre blocs et placer les blocs (Brandes-Köpf / network simplex).
import { elk, baseModel, finalize, chainWidth, chainOffsets, NODE_SPACING, FAMILY_GAP, LAYER_GAP, PERSON_H } from '../elk-common.mjs'
function build(base, extra, spacing = NODE_SPACING) {
const children = []
const portOwner = new Map() // nodeId → { chainId, offset }
const sortedGens = [...base.chainsPerGen.keys()].sort((a, b) => a - b)
for (const g of sortedGens) {
base.chainsPerGen.get(g).forEach((chain, i) => {
const chainId = `c-${g}-${i}`
const offsets = chainOffsets(base, chain, spacing)
const ports = []
for (const id of chain) {
const n = base.byId.get(id)
const off = offsets.get(id)
portOwner.set(id, { chainId, offset: off })
if (n._type === 'union') {
ports.push({ id: `${id}:out`, width: 1, height: 1, x: off + n.width / 2, y: PERSON_H, layoutOptions: { 'elk.port.side': 'SOUTH' } })
} else {
ports.push({ id: `${id}:in`, width: 1, height: 1, x: off + n.width / 2, y: 0, layoutOptions: { 'elk.port.side': 'NORTH' } })
}
}
children.push({
id: chainId, width: chainWidth(base, chain, spacing), height: PERSON_H, ports,
layoutOptions: { 'elk.partitioning.partition': String(g), 'elk.portConstraints': 'FIXED_POS' },
_chain: chain, _offsets: offsets,
})
})
}
const edges = base.edges.map(e => ({ id: e.id, sources: [`${e.sources[0]}:out`], targets: [`${e.targets[0]}:in`] }))
return {
graph: {
id: 'root',
layoutOptions: {
'elk.algorithm': 'layered',
'elk.direction': 'DOWN',
'elk.partitioning.activate': 'true',
'elk.layered.spacing.nodeNodeBetweenLayers': String(LAYER_GAP),
'elk.spacing.nodeNode': String(FAMILY_GAP),
'elk.separateConnectedComponents': 'false',
'elk.edgeRouting': 'POLYLINE',
'elk.layered.thoroughness': '10',
...extra,
},
children,
edges,
},
portOwner,
}
}
async function run(graphData, extra, spacing) {
const base = baseModel(graphData)
const { graph } = build(base, extra, spacing)
const out = await elk.layout(graph)
const pos = new Map()
for (const c of out.children) {
const src = graph.children.find(n => n.id === c.id)
for (const id of src._chain) pos.set(id, { x: c.x + src._offsets.get(id), y: c.y })
}
return finalize(base, pos)
}
export const variants = [
{ name: 'M2a chaînes+ports, BRANDES_KOEPF', layout: (g) => run(g, { 'elk.layered.nodePlacement.strategy': 'BRANDES_KOEPF' }) },
{ name: 'M2b chaînes+ports, NETWORK_SIMPLEX', layout: (g) => run(g, { 'elk.layered.nodePlacement.strategy': 'NETWORK_SIMPLEX' }) },
{ name: 'M2c chaînes+ports, LINEAR_SEGMENTS', layout: (g) => run(g, { 'elk.layered.nodePlacement.strategy': 'LINEAR_SEGMENTS' }) },
{
name: 'M2d chaînes+ports, BK + ordre modèle',
layout: (g) => run(g, { 'elk.layered.nodePlacement.strategy': 'BRANDES_KOEPF', 'elk.layered.considerModelOrder.strategy': 'PREFER_NODES' }),
},
]
@@ -1,58 +0,0 @@
// M3 : chaînes de couples = nœuds composés (hiérarchie ELK, INCLUDE_CHILDREN).
// Les personnes/unions restent de vrais nœuds ELK à l'intérieur d'un conteneur par chaîne, sans arêtes
// internes (tous au même layer → côte à côte). Les filiations relient les nœuds internes à travers la
// hiérarchie. Test : ELK garde-t-il les couples ensemble quand il voit les membres ?
import { elk, baseModel, finalize, NODE_SPACING, FAMILY_GAP, LAYER_GAP } from '../elk-common.mjs'
function build(base, extra) {
const children = []
const sortedGens = [...base.chainsPerGen.keys()].sort((a, b) => a - b)
for (const g of sortedGens) {
base.chainsPerGen.get(g).forEach((chain, i) => {
children.push({
id: `c-${g}-${i}`,
layoutOptions: {
'elk.partitioning.partition': String(g),
'elk.padding': '[top=0,left=0,bottom=0,right=0]',
'elk.spacing.nodeNode': String(NODE_SPACING),
},
children: chain.map(id => {
const n = base.byId.get(id)
return { id, width: n.width, height: n.height }
}),
})
})
}
return {
id: 'root',
layoutOptions: {
'elk.algorithm': 'layered',
'elk.direction': 'DOWN',
'elk.hierarchyHandling': 'INCLUDE_CHILDREN',
'elk.partitioning.activate': 'true',
'elk.layered.spacing.nodeNodeBetweenLayers': String(LAYER_GAP),
'elk.spacing.nodeNode': String(FAMILY_GAP),
'elk.separateConnectedComponents': 'false',
'elk.edgeRouting': 'POLYLINE',
...extra,
},
children,
edges: base.edges.map(e => ({ id: e.id, sources: e.sources, targets: e.targets })),
}
}
async function run(graphData, extra) {
const base = baseModel(graphData)
const out = await elk.layout(build(base, extra))
const pos = new Map()
for (const c of out.children) for (const m of c.children) pos.set(m.id, { x: c.x + m.x, y: c.y + m.y })
return finalize(base, pos)
}
export const variants = [
{ name: 'M3a composés INCLUDE_CHILDREN, défauts', layout: (g) => run(g, {}) },
{
name: 'M3b composés, ordre modèle forcé',
layout: (g) => run(g, { 'elk.layered.considerModelOrder.strategy': 'NODES_AND_EDGES', 'elk.layered.crossingMinimization.forceNodeModelOrder': 'true' }),
},
]
@@ -1,41 +0,0 @@
// M4 : ELK en mode interactif, amorcé par le layout actuel.
// Nœuds plats avec x,y issus du Sugiyama custom ; ELK garde l'ORDRE (crossing minimization INTERACTIVE)
// et ne refait que le placement X. Test : "notre ordre + le placement d'ELK" bat-il "notre ordre + notre placement" ?
import { elk, baseModel, finalize, NODE_SPACING, LAYER_GAP } from '../elk-common.mjs'
function build(base, extra) {
const children = base.nodes.map(n => ({
id: n.id, width: n.width, height: n.height, x: n.x, y: n.y,
layoutOptions: { 'elk.partitioning.partition': String(n._generation) },
}))
return {
id: 'root',
layoutOptions: {
'elk.algorithm': 'layered',
'elk.direction': 'DOWN',
'elk.interactive': 'true',
'elk.partitioning.activate': 'true',
'elk.layered.crossingMinimization.strategy': 'INTERACTIVE',
'elk.layered.cycleBreaking.strategy': 'INTERACTIVE',
'elk.layered.spacing.nodeNodeBetweenLayers': String(LAYER_GAP),
'elk.spacing.nodeNode': String(NODE_SPACING),
'elk.separateConnectedComponents': 'false',
'elk.edgeRouting': 'POLYLINE',
...extra,
},
children,
edges: base.edges.map(e => ({ id: e.id, sources: e.sources, targets: e.targets })),
}
}
async function run(graphData, extra) {
const base = baseModel(graphData)
const out = await elk.layout(build(base, extra))
const pos = new Map(out.children.map(c => [c.id, { x: c.x, y: c.y }]))
return finalize(base, pos)
}
export const variants = [
{ name: 'M4a interactif + BRANDES_KOEPF', layout: (g) => run(g, { 'elk.layered.nodePlacement.strategy': 'BRANDES_KOEPF' }) },
{ name: 'M4b interactif + NETWORK_SIMPLEX', layout: (g) => run(g, { 'elk.layered.nodePlacement.strategy': 'NETWORK_SIMPLEX' }) },
]
@@ -1,219 +0,0 @@
// M5 : placement récursif par blocs familiaux (Reingold-Tilford adapté aux couples). Pas d'ELK.
// Un bloc = une chaîne de couples (même génération) + les blocs de ses enfants imbriqués en dessous
// (contours par génération), la chaîne centrée au-dessus. Fratries contiguës et parents centrés PAR CONSTRUCTION.
//
// Mariage entre deux familles de l'arbre : la chaîne du couple n'est placée qu'une fois, sous UNE de ses
// deux lignées (règle `owner`). L'autre lignée garde une arête longue vers elle, inévitable.
// Un bloc qui contient un enfant "externe" (placé ailleurs) est ancré : centré sur cet enfant, puis décalé
// au créneau libre le plus proche. Ça couvre beaux-parents, grands-parents de pièces rapportées, etc.
import { baseModel, finalize, chainWidth, chainOffsets, NODE_SPACING, FAMILY_GAP } from '../elk-common.mjs'
// anchor : 'direct' = un bloc racine n'est ancré que si sa PROPRE chaîne a un enfant externe (beaux-parents) ;
// 'any' = ancré dès qu'un descendant quelconque a un enfant externe (peut tirer une grande lignée)
function run(graphData, { familyGap = FAMILY_GAP, spacing = NODE_SPACING, centerUnions = false, owner = 'first', anchor = 'direct' } = {}) {
const base = baseModel(graphData)
const chainOf = new Map()
const chains = []
for (const [, comps] of base.chainsPerGen) for (const c of comps) { chains.push(c); for (const id of c) chainOf.set(id, c) }
const genOf = (chain) => base.byId.get(chain[0])._generation
const childrenOfUnion = new Map()
const parentUnionOf = new Map()
for (const e of base.edges) {
const u = e.sources[0], c = e.targets[0]
if (!childrenOfUnion.has(u)) childrenOfUnion.set(u, [])
childrenOfUnion.get(u).push(c)
if (!parentUnionOf.has(c)) parentUnionOf.set(c, u)
}
const hasParents = (chain) => chain.some(id => parentUnionOf.has(id))
// Taille de descendance par chaîne
const descCount = new Map()
function countDesc(chain, seen = new Set()) {
if (descCount.has(chain)) return descCount.get(chain)
if (seen.has(chain)) return 0
seen.add(chain)
let n = 0
for (const id of chain) for (const k of (childrenOfUnion.get(id) || [])) { n += 1 + countDesc(chainOf.get(k), seen) }
descCount.set(chain, n)
return n
}
// ----------------------------------------------------------
// Appartenance : quelle union parente "possède" une chaîne qui a plusieurs parents dans l'arbre ?
// first : la première lignée construite la revendique (dynamique)
// fewestSiblings : l'union qui a le moins d'enfants (un enfant unique reste chez ses parents)
// largestParent : l'union dont la chaîne a la plus grande descendance
// ----------------------------------------------------------
const ownerOf = new Map()
if (owner !== 'first') {
for (const chain of chains) {
const candidates = [...new Set(chain.map(id => parentUnionOf.get(id)).filter(Boolean))]
if (candidates.length < 2) { if (candidates.length === 1) ownerOf.set(chain, candidates[0]); continue }
const score = (u) => owner === 'fewestSiblings'
? -(childrenOfUnion.get(u) || []).length
: countDesc(chainOf.get(u))
candidates.sort((a, b) => score(b) - score(a) || String(a).localeCompare(String(b)))
ownerOf.set(chain, candidates[0])
}
}
const owns = (unionId, kidChain) => {
const o = ownerOf.get(kidChain)
return o === undefined ? !placed.has(kidChain) : o === unionId
}
const pos = new Map()
const placed = new Set()
// Contours : Map gen → [left, right] relatifs à l'origine du bloc.
function packBlocks(blocks, gap) {
const acc = new Map()
const xs = []
for (const b of blocks) {
let off = 0
for (const [g, [l]] of b.rows) {
const a = acc.get(g)
if (a) off = Math.max(off, a[1] + gap - l)
}
xs.push(off)
for (const [g, [l, r]] of b.rows) {
const a = acc.get(g)
acc.set(g, a ? [Math.min(a[0], l + off), Math.max(a[1], r + off)] : [l + off, r + off])
}
}
return { xs, rows: acc }
}
// Construit un bloc : { rows, members, external, gen, rel(id), place(x0) }
function buildBlock(chain) {
placed.add(chain)
const gen = genOf(chain)
const rowW = chainWidth(base, chain, spacing)
const offsets = chainOffsets(base, chain, spacing)
const groups = []
const external = [] // { kid, unionId } enfants placés sous une autre lignée
for (const id of chain) {
const n = base.byId.get(id)
if (n._type !== 'union') continue
const blocks = []
for (const k of (childrenOfUnion.get(id) || [])) {
const kc = chainOf.get(k)
if (owns(id, kc)) blocks.push(buildBlock(kc))
else external.push({ kid: k, unionId: id })
}
groups.push({ unionId: id, blocks })
}
const allBlocks = groups.flatMap(g => g.blocks)
const packed = packBlocks(allBlocks, familyGap)
let rowX = 0
if (allBlocks.length) {
const kidRowL = Math.min(...allBlocks.map((b, i) => packed.xs[i] + b.rows.get(b.gen)[0]))
const kidRowR = Math.max(...allBlocks.map((b, i) => packed.xs[i] + b.rows.get(b.gen)[1]))
let anchorOff = rowW / 2
let target = (kidRowL + kidRowR) / 2
if (centerUnions && groups.length > 1) {
const first = groups.findIndex(g => g.blocks.length)
if (first >= 0) {
const g = groups[first]
const idx = allBlocks.indexOf(g.blocks[0])
const gl = Math.min(...g.blocks.map((b, j) => packed.xs[idx + j] + b.rows.get(b.gen)[0]))
const gr = Math.max(...g.blocks.map((b, j) => packed.xs[idx + j] + b.rows.get(b.gen)[1]))
const u = base.byId.get(g.unionId)
anchorOff = offsets.get(g.unionId) + u.width / 2
target = (gl + gr) / 2
}
}
rowX = target - anchorOff
}
const rows = new Map()
rows.set(gen, [rowX, rowX + rowW])
for (const [g, [l, r]] of packed.rows) {
const a = rows.get(g)
rows.set(g, a ? [Math.min(a[0], l), Math.max(a[1], r)] : [l, r])
}
const minL = Math.min(...[...rows.values()].map(v => v[0]))
for (const [g, v] of rows) rows.set(g, [v[0] - minL, v[1] - minL])
rowX -= minL
const kidXs = packed.xs.map(x => x - minL)
const members = new Set(chain)
for (const b of allBlocks) { for (const m of b.members) members.add(m); external.push(...b.external) }
// position relative d'un nœud dans le bloc
const rel = (id) => {
if (offsets.has(id)) return rowX + offsets.get(id)
for (let i = 0; i < allBlocks.length; i++) if (allBlocks[i].members.has(id)) return kidXs[i] + allBlocks[i].rel(id)
return null
}
return {
rows, members, external, gen, rel,
get width() { return Math.max(...[...rows.values()].map(v => v[1])) },
place(x0) {
for (const id of chain) pos.set(id, { x: x0 + rowX + offsets.get(id) })
allBlocks.forEach((b, i) => b.place(x0 + kidXs[i]))
},
}
}
// 1) Racines (chaînes sans parents), grandes lignées d'abord
const roots = chains.filter(c => !hasParents(c)).sort((a, b) => countDesc(b) - countDesc(a) || genOf(a) - genOf(b))
const free = [], anchored = []
for (const r of roots) {
if (placed.has(r)) continue
const block = buildBlock(r)
const ownExternal = block.external.filter(({ unionId }) => r.includes(unionId))
if (anchor === 'any' ? block.external.length : ownExternal.length) anchored.push(block)
else free.push(block)
}
const forest = packBlocks(free, familyGap * 2)
free.forEach((b, i) => b.place(forest.xs[i]))
let cursor = Math.max(0, ...[...forest.rows.values()].map(v => v[1])) + familyGap * 2
// 2) Blocs ancrés : l'union concernée centrée sur son enfant externe, générations profondes d'abord
const occupied = (exclude) => {
const byGen = new Map()
for (const n of base.nodes) {
const p = pos.get(n.id); if (!p || exclude.has(n.id)) continue
if (!byGen.has(n._generation)) byGen.set(n._generation, [])
byGen.get(n._generation).push([p.x, p.x + n.width])
}
return byGen
}
anchored.sort((a, b) => b.gen - a.gen)
for (const block of anchored) {
const occ = occupied(block.members)
// cible : moyenne, sur les enfants externes déjà placés, de (centre enfant − position relative de son union)
const targets = block.external
.filter(({ kid }) => pos.has(kid))
.map(({ kid, unionId }) => {
const kn = base.byId.get(kid); const un = base.byId.get(unionId)
return pos.get(kid).x + kn.width / 2 - (block.rel(unionId) + un.width / 2)
})
if (!targets.length) { block.place(cursor); cursor += block.width + familyGap * 2; continue }
const ideal = targets.reduce((s, v) => s + v, 0) / targets.length
const fits = (x0) => {
block.place(x0)
for (const id of block.members) {
const n = base.byId.get(id); const p = pos.get(id)
for (const [s, e] of (occ.get(n._generation) || [])) if (p.x < e + familyGap && p.x + n.width > s - familyGap) return false
}
return true
}
let found = false
for (let d = 0; d < 40000 && !found; d += 20) {
if (fits(ideal + d)) found = true
else if (d > 0 && fits(ideal - d)) found = true
}
if (!found) { block.place(cursor); cursor += block.width + familyGap * 2 }
}
return finalize(base, pos)
}
export const variants = [
{ name: 'M5a blocs familiaux, 1re lignée, ancrage direct', layout: (g) => run(g, { owner: 'first', anchor: 'direct' }) },
{ name: 'M5b blocs familiaux, 1re lignée, ancrage profond', layout: (g) => run(g, { owner: 'first', anchor: 'any' }) },
{ name: 'M5c blocs familiaux, moins de fratrie, direct', layout: (g) => run(g, { owner: 'fewestSiblings', anchor: 'direct' }) },
{ name: 'M5d blocs familiaux, plus grande lignée, direct', layout: (g) => run(g, { owner: 'largestParent', anchor: 'direct' }) },
]
+1 -8
View File
@@ -6,8 +6,7 @@
"": {
"name": "layout-bench",
"dependencies": {
"@resvg/resvg-js": "^2.6.2",
"elkjs": "^0.11.0"
"@resvg/resvg-js": "^2.6.2"
}
},
"node_modules/@resvg/resvg-js": {
@@ -224,12 +223,6 @@
"engines": {
"node": ">= 10"
}
},
"node_modules/elkjs": {
"version": "0.11.1",
"resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.11.1.tgz",
"integrity": "sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==",
"license": "EPL-2.0"
}
}
}
+3 -3
View File
@@ -4,10 +4,10 @@
"type": "module",
"scripts": {
"bench": "node --import ./register.mjs run.mjs",
"fixture": "node fetch-fixture.mjs"
"fixture": "node fetch-fixture.mjs",
"diff": "node diff.mjs"
},
"dependencies": {
"@resvg/resvg-js": "^2.6.2",
"elkjs": "^0.11.0"
"@resvg/resvg-js": "^2.6.2"
}
}
+36
View File
@@ -0,0 +1,36 @@
// Stabilité : retirer une feuille (personne sans enfant) ne devrait déplacer que son foyer.
// On retire tour à tour quelques feuilles prises à des positions réparties, on recalcule, et on compte
// les nœuds qui bougent de plus de 40 px une fois la translation d'ensemble retirée.
const SEUIL = 40
export function perturbation(layoutFn, graph, referenceId = null, { picks = 5 } = {}) {
const base = layoutFn(graph, referenceId)
const before = new Map(base.children.map((n) => [n.id, n.x]))
const hasKids = new Set()
for (const e of base.edges) {
const u = base.children.find((n) => n.id === e.sources[0])
if (u?._data?.p1Key) { hasKids.add(u._data.p1Key); hasKids.add(u._data.p2Key) }
}
const leaves = base.children.filter((n) => n._type === 'person' && !n._compact && !hasKids.has(n.id))
if (!leaves.length) return { feuilles: 0, max_bouges: 0, max_deplacement_px: 0 }
const chosen = [...new Set(Array.from({ length: picks }, (_, i) => leaves[Math.floor((i / Math.max(1, picks - 1)) * (leaves.length - 1))]))]
let maxMoved = 0, maxShift = 0
for (const leaf of chosen) {
const pid = leaf._data.id
const g = {
...graph,
persons: graph.persons.filter((p) => p.id !== pid),
unions: graph.unions.filter((u) => u.partner1Id !== pid && u.partner2Id !== pid),
filiations: graph.filiations.filter((f) => f.childId !== pid && f.parentId !== pid),
}
const after = layoutFn(g, referenceId === pid ? null : referenceId)
const common = after.children.filter((n) => before.has(n.id))
if (!common.length) continue
const shift = common.reduce((s, n) => s + (n.x - before.get(n.id)), 0) / common.length
const moved = common.filter((n) => Math.abs(n.x - before.get(n.id) - shift) > SEUIL).length
const far = Math.max(...common.map((n) => Math.abs(n.x - before.get(n.id) - shift)))
maxMoved = Math.max(maxMoved, moved)
maxShift = Math.max(maxShift, far)
}
return { feuilles: chosen.length, max_bouges: maxMoved, max_deplacement_px: Math.round(maxShift) }
}
+47 -33
View File
@@ -1,41 +1,55 @@
// Usage : node --import ./register.mjs run.mjs [filtre ...] (sans arg = tous les modèles)
import { readdirSync, writeFileSync } from 'node:fs'
// Banc du placement, sur le code de production.
// Usage : node --import ./register.mjs run.mjs [arbre ...]
// sans argument : tous les archétypes, desc-3, asc-23 et chaque fixtures/*.json
// SRC=<dossier frontend/src> code mesuré (défaut : celui du dépôt)
// TAG=<nom> nom du rapport, out/rapport-<nom>.json (défaut : « courant »)
// ASCII=1 imprime le rendu texte des archétypes
// PERTURB=0 saute le test de stabilité (lent sur les gros arbres)
// Lecture : d'abord les invariants (doivent valoir 0), puis les défauts sur le tracé réel dans l'ordre
// renvois, fils sur portraits étrangers, croisements, câbles d'un écran, foyers larges, puis la stabilité.
// La largeur ne départage qu'une égalité parfaite. Pas de score composite.
import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { performance } from 'node:perf_hooks'
import { loadFixture } from './load.mjs'
import { computeMetrics } from './metrics.mjs'
import { loadTree, layout, archetypeNames } from './load.mjs'
import { checkInvariants, HARD } from './invariants.mjs'
import { computeDefects } from './defects.mjs'
import { perturbation } from './perturb.mjs'
import { renderAscii } from './ascii.mjs'
import { renderSvg } from './svg.mjs'
import { svgToPng } from './png.mjs'
const graphData = await loadFixture()
const wanted = process.argv.slice(2)
const files = readdirSync(new URL('./models/', import.meta.url)).filter(f => f.endsWith('.mjs')).sort()
const OUT = fileURLToPath(new URL('./out/', import.meta.url))
mkdirSync(OUT, { recursive: true })
const tag = process.env.TAG || 'courant'
const fixturesDir = fileURLToPath(new URL('./fixtures/', import.meta.url))
const fixtures = (existsSync(fixturesDir) ? readdirSync(fixturesDir, { withFileTypes: true }) : []).filter((f) => f.isFile() && f.name.endsWith('.json')).map((f) => f.name.replace(/(-graph)?\.json$/, ''))
const names = process.argv.slice(2).length ? process.argv.slice(2) : [...archetypeNames, 'desc-3', 'asc-23', ...fixtures]
const rows = []
for (const f of files) {
const mod = await import(new URL(`./models/${f}`, import.meta.url))
const variants = mod.variants || [{ name: mod.name, layout: mod.layout }]
for (const v of variants) {
if (wanted.length && !wanted.some(w => v.name.toLowerCase().includes(w.toLowerCase()) || f.includes(w))) continue
let best = Infinity, out
try {
for (let i = 0; i < 3; i++) {
const t0 = performance.now()
out = await v.layout(graphData)
best = Math.min(best, performance.now() - t0)
}
} catch (err) {
rows.push({ model: v.name, error: String(err.message || err).slice(0, 80) })
continue
}
const m = computeMetrics(out.result, out.coupleBarMeta, best)
rows.push({ model: v.name, ...m })
const slug = v.name.split(' ')[0].toLowerCase()
const svgFile = fileURLToPath(new URL(`./out/${slug}.svg`, import.meta.url))
renderSvg(out.result, out.coupleBarMeta, svgFile, v.name)
writeFileSync(svgFile.replace('.svg', '.json'), JSON.stringify(out.result.children.map(n => ({ id: n.id, x: n.x, y: n.y, w: n.width, h: n.height, t: n._type, g: n._generation, name: n._data?.firstName }))))
svgToPng(svgFile, svgFile.replace('.svg', '.png'), { width: 2400 })
for (const name of names) {
const tree = loadTree(name)
const t0 = performance.now()
const { result, coupleBarMeta } = layout(tree.graph, tree.reference)
const ms = Math.round(performance.now() - t0)
const inv = checkInvariants(result, coupleBarMeta, tree.graph, tree.reference)
const def = computeDefects(result, coupleBarMeta, tree.graph, tree.reference)
const stab = process.env.PERTURB === '0' ? {} : perturbation((g, r) => layout(g, r).result, tree.graph, tree.reference)
const nodes = result.children
const largeur = Math.round(Math.max(...nodes.map((n) => n.x + n.width)) - Math.min(...nodes.map((n) => n.x)))
const { pires_foyers, ...defauts } = def
const row = { arbre: name, personnes: nodes.filter((n) => n._type === 'person').length, ms, ...inv, ...defauts, ...stab, largeur }
row.invariants = HARD.every((k) => inv[k] === 0) ? 'ok' : 'FAUX'
rows.push({ ...row, pires_foyers })
if (tree.archetype) {
const text = renderAscii(result, coupleBarMeta)
writeFileSync(`${OUT}${name}.txt`, text + '\n')
if (process.env.ASCII) console.log(`\n── ${name} ──\n${text}`)
} else {
renderSvg(result, coupleBarMeta, `${OUT}${name}.svg`, name)
}
}
console.table(rows)
writeFileSync(new URL('./out/metrics.json', import.meta.url), JSON.stringify(rows, null, 2))
const shown = ['arbre', 'personnes', 'invariants', 'rangees_fausses', 'conflits', 'fratries_coupees', 'renvois', 'renvois_sang', 'fils_sur_etranger', 'croisements', 'superpositions', 'cables_ecran', 'foyers_larges', 'proches_ecran', 'proches', 'max_bouges', 'max_deplacement_px', 'largeur', 'ms']
console.table(rows.map((r) => Object.fromEntries(shown.filter((k) => k in r).map((k) => [k, r[k]]))))
for (const r of rows) if (r.pires_foyers?.length && r.pires_foyers[0].etendue > 1440) console.log(`${r.arbre} : foyers les plus étendus`, r.pires_foyers.map((h) => `${h.enfants} enfants sur ${h.etendue} px`).join(' · '))
writeFileSync(`${OUT}rapport-${tag}.json`, JSON.stringify(rows, null, 2))
console.log(`\nrapport : out/rapport-${tag}.json · rendus texte des archétypes dans out/<slug>.txt · comparer deux rapports : node diff.mjs <tag A> <tag B>`)