From e4ee2869208f63fdc43693420316124acaaa0bcf Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Mon, 19 Oct 2026 14:47:32 +0200 Subject: [PATCH] =?UTF-8?q?Placement=20:=20un=20seul=20arbre,=20la=20famil?= =?UTF-8?q?le=20d'un=20conjoint=20se=20colle=20=C3=A0=20lui?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Avant, la famille d'un conjoint (ses parents, sa fratrie, ses ancêtres) était posée au premier créneau libre, souvent au bout de l'arbre, et la filiation devenait une pastille « Voir ». L'arbre se lisait comme plusieurs arbres reliés par des pastilles. Maintenant chaque famille de conjoint est construite entière depuis son ancêtre le plus haut et collée au conjoint, de son côté, en poussant les voisins pour faire la place. Le conjoint est tourné vers l'extérieur de sa fratrie ; sa propre fratrie est dessinée en miroir s'il faut qu'il finisse au bout, contre le couple ; un enfant déjà dessiné ailleurs garde une place fantôme dans l'ordre de sa fratrie pour le centrage. Les lignées de la personne de référence sont dessinées d'abord (le sang gagne) ; une famille reliée deux fois (cousins, deux grandes lignées) n'est dessinée qu'une fois, l'autre filiation reste une pastille si son fil croiserait une famille ; une famille qu'il faudrait pousser de plus d'un écran est posée à part. L'ordre des lignées ne dépend plus des effectifs (rangée, année, identifiant). Les lignes de fratrie s'emboîtent : la ligne d'une famille qui en entoure une autre passe au-dessus (_railLift posé par le placement, plus de hachage), plus aucune superposition. Mesuré par le banc (tools/layout-bench) : pastilles 4 → 2 sur une famille réelle de 385 personnes, 78 → 12 sur une descendance fictive de 1 454 ; les 14 archétypes passent leurs invariants, Marc et Nadia et l'issue #67 sans aucune pastille ni croisement. Co-Authored-By: Claude Fable 5.1 --- .../archetypes/belle-famille.txt | 24 +- .../archetypes/branche-courte.txt | 18 +- .../__snapshots__/archetypes/issue-67.txt | 26 +- .../__snapshots__/archetypes/marc-nadia.txt | 6 +- .../__snapshots__/archetypes/oncle-niece.txt | 6 +- .../archetypes/trois-belles-familles.txt | 18 +- .../src/components/galaxy/archetypes.test.js | 8 +- frontend/src/components/galaxy/blockLayout.js | 395 ++++++++++++------ frontend/src/components/galaxy/elkLayout.js | 4 +- frontend/src/components/galaxy/renderers.js | 18 +- tools/layout-bench/defects.mjs | 10 +- tools/layout-bench/invariants.mjs | 14 +- 12 files changed, 352 insertions(+), 195 deletions(-) diff --git a/frontend/src/components/galaxy/__snapshots__/archetypes/belle-famille.txt b/frontend/src/components/galaxy/__snapshots__/archetypes/belle-famille.txt index bcfedfa..a86995c 100644 --- a/frontend/src/components/galaxy/__snapshots__/archetypes/belle-famille.txt +++ b/frontend/src/components/galaxy/__snapshots__/archetypes/belle-famille.txt @@ -1,13 +1,13 @@ - SG────•───SGw - │ - ┬────┴ + SG────•───SGw + │ + ┬────┴ + │ + SP────•───SM Pa────•───Ma + │ │ + ┬────────┼────────┬ ┼────────┬ + │ │ │ │ │ +S3 S2 S────•────E E2 │ -Pa────•───Ma SP────•───SM - │ │ - ┼────────┬ ┬───┴────┬────────┬ - │ │ │ │ │ - E2 E────•────S S2 S3 - │ - ┬───┴────┬ - │ │ - K1 K2 + ┬───┴────┬ + │ │ + K1 K2 diff --git a/frontend/src/components/galaxy/__snapshots__/archetypes/branche-courte.txt b/frontend/src/components/galaxy/__snapshots__/archetypes/branche-courte.txt index 72b4313..7ae92a0 100644 --- a/frontend/src/components/galaxy/__snapshots__/archetypes/branche-courte.txt +++ b/frontend/src/components/galaxy/__snapshots__/archetypes/branche-courte.txt @@ -10,12 +10,12 @@ A2────•───A2w │ ┬────┴ │ -A3────•───A3w B0────•───B0w - │ │ - ┬────┴ ┬──────────────┼ - │ │ │ - X────•────Y S - │ - ┼ - │ - K +A3────•───A3w B0────•───B0w + │ │ + ┴────┬ ┬───┴────┬ + │ │ │ + X────•────Y S + │ + ┼ + │ + K diff --git a/frontend/src/components/galaxy/__snapshots__/archetypes/issue-67.txt b/frontend/src/components/galaxy/__snapshots__/archetypes/issue-67.txt index e677fb6..9a2ed4f 100644 --- a/frontend/src/components/galaxy/__snapshots__/archetypes/issue-67.txt +++ b/frontend/src/components/galaxy/__snapshots__/archetypes/issue-67.txt @@ -2,16 +2,16 @@ GGGa───•──GGGb │ ┬────┴ │ - GGa───•───GGb GGy┄┄┄•┄┄? GGx┄┄┄•┄┄┄? - │ │ │ - ┬────┴ ┬──────┴ ┬────────────┴ - │ │ │ - GM1───•───GF1 GM2───•───GF2 - │ │ - ┬────┴ ┬────────────────┴ - │ │ - M────•────F - │ - ┼ - │ - Kid + GGa───•───GGb GGy┄┄┄•┄┄┄? GGx┄┄┄┄•┄┄? + │ │ │ + ┴────┬ ┴────┬ ┬─┴ + │ │ │ + GM1───•───GF1 GM2───•───GF2 + │ │ + ┴────────┬ ┼ + │ │ + M────•────F + │ + ┼ + │ + Kid diff --git a/frontend/src/components/galaxy/__snapshots__/archetypes/marc-nadia.txt b/frontend/src/components/galaxy/__snapshots__/archetypes/marc-nadia.txt index 641ecfd..800b0fd 100644 --- a/frontend/src/components/galaxy/__snapshots__/archetypes/marc-nadia.txt +++ b/frontend/src/components/galaxy/__snapshots__/archetypes/marc-nadia.txt @@ -1,6 +1,6 @@ - Pierre──•──Odile Louis──•─Helene - │ │ - ┬────────┼────────┬────────┬ ┬────────┬───┴────┬────────┬────────┬ + Pierre───•──Odile Louis──•─Helene + │ │ + ┬────────┬────┴───┬────────┬ ┬────────┬───┴────┬────────┬────────┬ │ │ │ │ │ │ │ │ │ Luc Paul Mireil Nadia──•──Marc Claire Julien Sophie Eric │ diff --git a/frontend/src/components/galaxy/__snapshots__/archetypes/oncle-niece.txt b/frontend/src/components/galaxy/__snapshots__/archetypes/oncle-niece.txt index 2ed4de5..19a8200 100644 --- a/frontend/src/components/galaxy/__snapshots__/archetypes/oncle-niece.txt +++ b/frontend/src/components/galaxy/__snapshots__/archetypes/oncle-niece.txt @@ -1,6 +1,6 @@ G────•───Gw │ -┬──────────────┴───┬ -│ │ -O────•────N P────•───Pw + ┬────┴───┬ + │ │ +N────•────O P────•───Pw renvoi : P+Pw → N diff --git a/frontend/src/components/galaxy/__snapshots__/archetypes/trois-belles-familles.txt b/frontend/src/components/galaxy/__snapshots__/archetypes/trois-belles-familles.txt index a4c984f..cc4ad86 100644 --- a/frontend/src/components/galaxy/__snapshots__/archetypes/trois-belles-familles.txt +++ b/frontend/src/components/galaxy/__snapshots__/archetypes/trois-belles-familles.txt @@ -1,9 +1,9 @@ -A────•───Aw B────•───Bw C─────•───Cw - │ │ │ - ┼────────┬ ┬────┴───┬ ┬────┴ - │ │ │ │ │ - A2 A1────•───B1 B2────•────C1 - │ │ - ┼ ┼ - │ │ - X Y +C────•───Cw B────•───Bw A─────•───Aw + │ │ │ + ┼ ┬────────┼ ┬────────┼ + │ │ │ │ │ + C1────•───B2 B1────•────A1 A2 + │ │ + ┼ ┼ + │ │ + Y X diff --git a/frontend/src/components/galaxy/archetypes.test.js b/frontend/src/components/galaxy/archetypes.test.js index d04b221..52f31aa 100644 --- a/frontend/src/components/galaxy/archetypes.test.js +++ b/frontend/src/components/galaxy/archetypes.test.js @@ -7,19 +7,13 @@ import { checkInvariants, HARD } from '../../../../tools/layout-bench/invariants // Chaque archétype a un rendu texte validé une fois à l'œil (__snapshots__/archetypes/.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', () => { + it('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]))) }) diff --git a/frontend/src/components/galaxy/blockLayout.js b/frontend/src/components/galaxy/blockLayout.js index 65cca81..1205e9f 100644 --- a/frontend/src/components/galaxy/blockLayout.js +++ b/frontend/src/components/galaxy/blockLayout.js @@ -4,27 +4,35 @@ import { computeLayout, componentOrderPerGen, chainGap } from './elkLayout' // Layout par blocs familiaux (Reingold-Tilford adapté aux couples) // // S'appuie sur computeLayout pour tout ce qui n'est pas en cause -// (normalisation des filiations, générations, chaînes de couples, +// (normalisation des filiations, rangées, chaînes de couples, // coupleBarMeta, componentOrderPerGen) et ne recalcule que le X. // -// 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. +// Un bloc = une chaîne de couples (même rangée) + les blocs de ses +// enfants imbriqués en dessous, 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 la première lignée qui la revendique (les -// grandes lignées d'abord). L'autre lignée garde une arête longue. +// Un seul arbre. La famille d'un conjoint (ses parents, sa fratrie, +// ses ancêtres) se colle à lui, au-dessus et de son côté, en poussant +// les voisins pour faire la place : jamais posée dans un trou ailleurs. +// Le conjoint est tourné vers l'extérieur de sa fratrie pour que sa +// famille ne passe pas au-dessus des frères et sœurs. // -// Un bloc racine dont la chaîne a un enfant déjà placé ailleurs -// (beaux-parents) est ancré : centré sur cet enfant, puis décalé au -// créneau libre le plus proche. +// Le sang de la référence gagne : les lignées de la personne de +// référence sont dessinées d'abord, les familles des pièces rapportées +// s'y attachent. Une famille reliée deux fois (cousins qui se marient, +// deux grandes lignées de la référence) n'est dessinée qu'une fois : +// l'autre filiation devient un renvoi si son fil croiserait une famille. // -// Benchmark et variantes écartées : tools/layout-bench/. +// L'ordre ne dépend jamais des effectifs : rangée, année de naissance, +// identifiant. Ajouter une personne ne bouge que son foyer. +// +// Banc, archétypes et témoins : tools/layout-bench/. // ============================================================ const NODE_SPACING = 30 const FAMILY_GAP = 60 +// Au-delà de cet écart entre la place idéale d'une famille de conjoint et la première place libre, on ne la colle pas +const DETACH_PX = 1440 // Le fil d'une filiation union → enfant : une personne d'une autre famille, sur la ligne de l'enfant, // entre l'aplomb de l'union et l'enfant, est croisée. Frères et sœurs et leurs conjoint(e)s ne comptent pas @@ -40,12 +48,38 @@ 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 +} +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 extentOf = (rows) => { + let l = Infinity, r = -Infinity + for (const list of rows.values()) for (const [a, b] of list) { l = Math.min(l, a); r = Math.max(r, b) } + return [l, r] +} +const overlaps = (l, r, list, gap) => list.some(([a, b]) => l < b + gap && r > a - gap) + export function computeBlockLayout(graphData = null, referenceId = null) { const result = computeLayout(graphData, referenceId) const nodes = result.children const byId = new Map(nodes.map(n => [n.id, n])) - // Chaînes de couples (composantes d'une génération) telles que computeLayout les a ordonnées + // Chaînes de couples (composantes d'une rangée) telles que computeLayout les a ordonnées const chainOf = new Map() const chains = [] for (const gen of [...componentOrderPerGen.keys()].sort((a, b) => a - b)) { @@ -72,16 +106,37 @@ export function computeBlockLayout(graphData = null, referenceId = null) { for (const [u, kids] of childrenOfUnion) kids.sort((a, b) => edgeOrder.get(`${u}>${a}`) - edgeOrder.get(`${u}>${b}`)) const hasParents = (chain) => chain.some(id => parentUnionOf.has(id)) - 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 + // Ligne de sang de la référence : tout ce qu'on atteint par les seuls liens parent / enfant + const unionsOfPerson = new Map() + for (const n of nodes) { + if (n._type !== 'union') continue + for (const k of [n._data?.p1Key, n._data?.p2Key]) { + if (!k) continue + if (!unionsOfPerson.has(k)) unionsOfPerson.set(k, []) + unionsOfPerson.get(k).push(n.id) + } } + const blood = new Set() + const referenceKey = result.referenceId != null ? `p-${result.referenceId}` : null + if (referenceKey && byId.has(referenceKey)) { + // On ne monte que depuis la référence et ses ancêtres (jamais depuis un descendant : son autre + // parent est une pièce rapportée), puis on descend depuis chacun d'eux + const up = [referenceKey] + blood.add(referenceKey) + while (up.length) { + const pu = parentUnionOf.get(up.shift()) + if (!pu) continue + for (const p of [byId.get(pu)?._data?.p1Key, byId.get(pu)?._data?.p2Key]) { + if (p && byId.get(p)?._type === 'person' && !blood.has(p)) { blood.add(p); up.push(p) } + } + } + const down = [...blood] + while (down.length) { + const id = down.shift() + for (const u of (unionsOfPerson.get(id) || [])) for (const k of (childrenOfUnion.get(u) || [])) if (!blood.has(k)) { blood.add(k); down.push(k) } + } + } + const isBloodChain = (chain) => chain.some(id => blood.has(id)) const gapAfter = (chain, i) => chainGap(byId.get(chain[i]), byId.get(chain[i + 1]), NODE_SPACING) const chainWidth = (chain) => chain.reduce((s, id, i) => s + byId.get(id).width + (i < chain.length - 1 ? gapAfter(chain, i) : 0), 0) @@ -92,159 +147,216 @@ export function computeBlockLayout(graphData = null, referenceId = null) { return offsets } + // La chaîne la plus haute d'une famille : on remonte de parents en parents + function topChainOf(chain) { + let cur = chain + const seen = new Set() + while (!seen.has(cur)) { + seen.add(cur) + const m = cur.find(id => parentUnionOf.has(id)) + if (!m) break + cur = chainOf.get(parentUnionOf.get(m)) + } + return cur + } + + // Un couple « enfant + pièce rapportée » se tourne vers l'extérieur de la fratrie : la pièce rapportée + // à gauche dans la moitié gauche, à droite dans la moitié droite, pour que sa famille se pose à côté + function orient(chain, kid, index, count) { + if (chain.length !== 3) return + const spouse = chain[0] === kid ? chain[2] : chain[0] + if (chain[1] !== chain.find(id => byId.get(id)._type === 'union') || !parentUnionOf.has(spouse)) return + const left = index < (count - 1) / 2 + if ((left && chain[0] !== spouse) || (!left && chain[2] !== spouse)) chain.reverse() + } + const pos = new Map() const placed = new Set() - const externalEdges = new Set() // `${union}>${enfant}` : enfant placé sous une autre lignée + const externalEdges = new Set() // `${union}>${enfant}` : enfant dessiné sous une autre lignée + const detached = [] // familles qu'on n'a pas pu coller à leur enfant (trop loin) : posées à part, renvoi - // Imbrique des blocs de gauche à droite, génération par génération + // Imbrique des blocs de gauche à droite, rangée par rangée 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) { + for (const [g, list] of b.rows) { const a = acc.get(g) - if (a) off = Math.max(off, a[1] + gap - l) + if (a?.length) off = Math.max(off, a[a.length - 1][1] + gap - list[0][0]) } 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]) - } + mergeRows(acc, b.rows, off) } return { xs, rows: acc } } - function buildBlock(chain) { + // Décale un bloc depuis sa position idéale, du côté demandé, jusqu'à ce qu'il ne touche plus `rows` + function pushUntilFree(blockRows, ideal, rows, side, gap) { + let d = ideal + for (let iter = 0; iter < 500; iter++) { + let push = 0 + for (const [g, list] of blockRows) { + const occ = rows.get(g) + if (!occ) continue + for (const [l, r] of list) for (const [ol, or] of occ) { + if (l + d < or + gap && r + d > ol - gap) push = Math.max(push, side > 0 ? or + gap - (l + d) : (r + d) - (ol - gap)) + } + } + if (push <= 0) break + d += side * push + } + return d + } + + // Place fantôme d'un enfant déjà dessiné ailleurs : il garde sa place dans l'ordre de la fratrie et + // dans le centrage des parents, sans occuper de contour (c'est l'enfant réel qui l'occupe) + const ghostBlock = (kid) => { + const n = byId.get(kid) + return { ghost: true, kid, rows: new Map([[n._generation, [[0, n.width]]]]), members: new Set(), external: [], gen: n._generation, chain: [0, n.width], rel: () => null, width: n.width, place() {} } + } + + // Famille attachée à un conjoint : l'enfant à coller (ancre) doit finir au bout de sa fratrie, du côté du + // couple. Si l'ordre choisi l'en empêche, la fratrie est dessinée en miroir (du dernier au premier). + let anchor = null // { kid, end: 'left' | 'right' } + + function buildBlock(chain, ownerUnion = null) { placed.add(chain) const gen = genOf(chain) - const rowW = chainWidth(chain) - const offsets = chainOffsets(chain) const childBlocks = [] const external = [] + const kidsOfChain = [] for (const id of chain) { if (byId.get(id)._type !== 'union') continue - for (const k of (childrenOfUnion.get(id) || [])) { - const kc = chainOf.get(k) - if (!placed.has(kc)) childBlocks.push(buildBlock(kc)) - else { external.push({ kid: k, unionId: id }); externalEdges.add(`${id}>${k}`) } - } + for (const k of (childrenOfUnion.get(id) || [])) kidsOfChain.push({ unionId: id, kid: k }) } + if (anchor && kidsOfChain.length > 1 && kidsOfChain.some(k => k.kid === anchor.kid)) { + const idx = kidsOfChain.findIndex(k => k.kid === anchor.kid) + if ((anchor.end === 'right' && idx === 0) || (anchor.end === 'left' && idx === kidsOfChain.length - 1)) kidsOfChain.reverse() + anchor = null + } + kidsOfChain.forEach(({ unionId, kid }, i) => { + const kc = chainOf.get(kid) + if (placed.has(kc)) { + external.push({ kid, unionId }) + externalEdges.add(`${unionId}>${kid}`) + childBlocks.push(ghostBlock(kid)) + return + } + orient(kc, kid, i, kidsOfChain.length) + childBlocks.push(buildBlock(kc, unionId)) + }) const packed = packBlocks(childBlocks, FAMILY_GAP) - // Chaîne centrée sur la rangée de ses enfants directs + // Chaîne centrée sur la rangée de ses enfants directs (fantômes compris), décalée au plus près si une + // famille de pièce rapportée (attachée à un enfant) occupe déjà cette rangée + const rowW = chainWidth(chain) + const offsets = chainOffsets(chain) let rowX = 0 + const rows = new Map() + childBlocks.forEach((b, i) => { if (!b.ghost) mergeRows(rows, b.rows, packed.xs[i]) }) if (childBlocks.length) { - const kidRowL = Math.min(...childBlocks.map((b, i) => packed.xs[i] + b.rows.get(b.gen)[0])) - const kidRowR = Math.max(...childBlocks.map((b, i) => packed.xs[i] + b.rows.get(b.gen)[1])) + const kidRowL = Math.min(...childBlocks.map((b, i) => packed.xs[i] + b.chain[0])) + const kidRowR = Math.max(...childBlocks.map((b, i) => packed.xs[i] + b.chain[1])) rowX = (kidRowL + kidRowR) / 2 - rowW / 2 + const obstacles = rows.get(gen) || [] + if (overlaps(rowX, rowX + rowW, obstacles, FAMILY_GAP)) { + const candidates = obstacles.flatMap(([l, r]) => [r + FAMILY_GAP, l - FAMILY_GAP - rowW]) + .filter(x => !overlaps(x, x + rowW, obstacles, FAMILY_GAP)) + if (candidates.length) rowX = candidates.reduce((best, x) => (Math.abs(x - rowX) < Math.abs(best - rowX) ? x : best)) + } } - - const rows = new Map([[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) - + mergeRows(rows, new Map([[gen, [[rowX, rowX + rowW]]]]), 0) const members = new Set(chain) for (const b of childBlocks) { for (const m of b.members) members.add(m); external.push(...b.external) } + const ghosts = new Map() + childBlocks.forEach((b, i) => { if (b.ghost) ghosts.set(b.kid, packed.xs[i]) }) + + // Familles des conjoints de la chaîne pas encore dessinées : construites entières depuis leur ancêtre + // le plus haut, collées au conjoint, de son côté. Une famille déjà dessinée (reliée deux fois) ne l'est pas + // une seconde fois : la filiation reste externe, renvoi si son fil croise une famille. Une famille qu'il + // faudrait pousser de plus d'un écran pour la coller est posée à part (cas impossible, renvoi). + const attachments = [] + for (const id of chain) { + if (byId.get(id)._type !== 'person') continue + const pu = parentUnionOf.get(id) + if (!pu || pu === ownerUnion) continue + const pc = chainOf.get(pu) + if (placed.has(pc)) continue + const top = topChainOf(pc) + if (placed.has(top)) continue + const side = offsets.get(id) + byId.get(id).width / 2 < rowW / 2 ? -1 : 1 + const saved = anchor + anchor = { kid: id, end: side < 0 ? 'right' : 'left' } + const block = buildBlock(top, null) + anchor = saved + const at = block.rel(id) + const ideal = at == null ? rowX + offsets.get(id) - block.width / 2 : rowX + offsets.get(id) - at + const x = pushUntilFree(block.rows, ideal, rows, side, FAMILY_GAP) + if (Math.abs(x - ideal) > DETACH_PX) { detached.push(block); continue } + attachments.push({ block, x }) + mergeRows(rows, block.rows, x) + for (const m of block.members) members.add(m) + external.push(...block.external) + } + + // Origine du bloc au bord gauche de son contour + const [minL] = extentOf(rows) + for (const [g, list] of rows) rows.set(g, list.map(([l, r]) => [l - minL, r - minL])) + rowX -= minL + const kidXs = packed.xs.map(x => x - minL) + for (const [k, x] of ghosts) ghosts.set(k, x - minL) + for (const a of attachments) a.x -= minL + const rel = (id) => { if (offsets.has(id)) return rowX + offsets.get(id) - for (let i = 0; i < childBlocks.length; i++) if (childBlocks[i].members.has(id)) return kidXs[i] + childBlocks[i].rel(id) + if (ghosts.has(id)) return ghosts.get(id) + for (let i = 0; i < childBlocks.length; i++) { + if (childBlocks[i].ghost) continue + const r = childBlocks[i].rel(id) + if (r != null) return kidXs[i] + r + } + for (const a of attachments) { const r = a.block.rel(id); if (r != null) return a.x + r } return null } return { rows, members, external, gen, rel, - get width() { return Math.max(...[...rows.values()].map(v => v[1])) }, + chain: [rowX, rowX + rowW], + get width() { return extentOf(rows)[1] }, place(x0) { for (const id of chain) pos.set(id, x0 + rowX + offsets.get(id)) childBlocks.forEach((b, i) => b.place(x0 + kidXs[i])) + attachments.forEach(a => a.block.place(x0 + a.x)) }, } } - // 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.some(({ unionId }) => r.includes(unionId)) - if (ownExternal) anchored.push(block) - else free.push(block) - } + // Racines : chaînes sans parents, le sang de la référence d'abord, puis par rangée, année de naissance, identifiant. + // Jamais par effectifs : ajouter une personne ne change pas l'ordre des lignées. + const birthYearOf = (chain) => Math.min(...chain.map(id => { + const d = byId.get(id)._data + const raw = d?.birthYear ?? d?.birthDate + const y = raw != null ? parseInt(String(raw).slice(0, 4), 10) : NaN + return Number.isFinite(y) ? y : 9999 + })) + const roots = chains.filter(c => !hasParents(c)).sort((a, b) => ( + (isBloodChain(b) - isBloodChain(a)) || genOf(a) - genOf(b) || birthYearOf(a) - birthYearOf(b) || String(a[0]).localeCompare(String(b[0])) + )) + const free = [] + for (const r of roots) if (!placed.has(r)) free.push(buildBlock(r)) + for (const c of chains) if (!placed.has(c)) free.push(buildBlock(c)) + free.push(...detached) const forest = packBlocks(free, FAMILY_GAP * 2) free.forEach((b, i) => b.place(forest.xs[i])) - let cursor = Math.max(0, ...[...forest.rows.values()].map(v => v[1])) + FAMILY_GAP * 2 - // 2) Blocs ancrés : l'union concernée centrée sur son enfant déjà placé, générations profondes d'abord - const occupied = (exclude) => { - const byGen = new Map() - for (const n of nodes) { - if (!pos.has(n.id) || exclude.has(n.id)) continue - if (!byGen.has(n._generation)) byGen.set(n._generation, []) - byGen.get(n._generation).push([pos.get(n.id), pos.get(n.id) + n.width]) - } - return byGen - } - anchored.sort((a, b) => b.gen - a.gen) - for (const block of anchored) { - const occ = occupied(block.members) - const targets = block.external - .filter(({ kid }) => pos.has(kid)) - .map(({ kid, unionId }) => pos.get(kid) + byId.get(kid).width / 2 - (block.rel(unionId) + byId.get(unionId).width / 2)) - if (!targets.length) { block.place(cursor); cursor += block.width + FAMILY_GAP * 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 = byId.get(id); const x = pos.get(id) - for (const [s, e] of (occ.get(n._generation) || [])) { - if (x < e + FAMILY_GAP && x + n.width > s - FAMILY_GAP) 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 + FAMILY_GAP * 2 } - } - - // Couple dont les deux conjoints ont leurs parents dans l'arbre, chacun du côté de la famille de l'autre : - // on intervertit les deux conjoints (même place, l'union reste au milieu), les deux fils ne se croisent plus. - // Répété : intervertir un couple peut remettre à l'endroit ou à l'envers celui de ses enfants. - const centerOf = (id) => pos.get(id) + byId.get(id).width / 2 - for (let pass = 0; pass < 4; pass++) { - let swapped = 0 - for (const chain of chains) { - if (chain.length !== 3 || !pos.has(chain[0])) continue - const [a, , b] = chain - const pa = parentUnionOf.get(a), pb = parentUnionOf.get(b) - if (!pa || !pb || pa === pb || !pos.has(pa) || !pos.has(pb)) continue - if ((centerOf(a) - centerOf(b)) * (centerOf(pa) - centerOf(pb)) >= 0) continue - const xa = pos.get(a), xb = pos.get(b) - pos.set(a, xb + byId.get(b).width - byId.get(a).width) - pos.set(b, xa) - swapped++ - } - if (!swapped) break - } - - // Réinjecter le X, normalisé à 0 ; Y inchangé (générations) + // Réinjecter le X, normalisé à 0 ; Y inchangé (rangées) let minX = Infinity for (const n of nodes) if (pos.has(n.id)) minX = Math.min(minX, pos.get(n.id)) for (const n of nodes) if (pos.has(n.id)) n.x = pos.get(n.id) - minX - // Arête vers un enfant placé sous l'autre lignée : renvoi (voir drawFiliations) seulement si le fil + // Arête vers un enfant dessiné sous l'autre lignée : renvoi (voir drawFiliations) seulement si le fil // croise une autre famille. Plusieurs renvois sous la même union : pastilles empilées (_renvoiIndex) - // Le fil descend de l'union puis file sur la ligne de la fratrie jusqu'à l'enfant (traceBus) : + // Le fil descend de l'union puis file sur la ligne de la fratrie jusqu'à l'enfant (traceSibship) : // il ne traverse une autre famille que si une personne de cette famille se trouve sur cette ligne, // entre l'aplomb de l'union et l'enfant. Frères et sœurs et conjoint(e)s de l'enfant ne comptent pas. const spouseKeysOf = new Map() @@ -266,5 +378,40 @@ export function computeBlockLayout(graphData = null, referenceId = null) { renvoisByUnion.set(u, e._renvoiIndex + 1) } + // Lignes de fratrie emboîtées : par rangée, la ligne d'une union qui en contient une autre passe au-dessus + // d'elle (_railLift plus grand = plus haut). Deux lignes qui se chevauchent sans s'emboîter prennent deux + // niveaux différents. Niveau 0 = la ligne la plus proche des enfants. + const rails = new Map() + for (const e of result.edges) { + if (e._renvoi) continue + const u = byId.get(e.sources[0]), k = byId.get(e.targets[0]) + if (!u || !k) continue + const r = rails.get(u.id) || { gen: u._generation, l: u.x + u.width / 2, r: u.x + u.width / 2 } + r.l = Math.min(r.l, k.x + k.width / 2); r.r = Math.max(r.r, k.x + k.width / 2) + rails.set(u.id, r) + } + const byRow = new Map() + for (const [id, r] of rails) { if (!byRow.has(r.gen)) byRow.set(r.gen, []); byRow.get(r.gen).push({ id, ...r }) } + for (const list of byRow.values()) { + list.sort((a, b) => (b.r - b.l) - (a.r - a.l) || a.l - b.l) + const level = new Map() + for (let i = 0; i < list.length; i++) { + const me = list[i] + let min = 0 + const taken = new Set() + for (let j = 0; j < i; j++) { + const o = list[j] + if (me.r <= o.l || me.l >= o.r) continue + if (o.l <= me.l && o.r >= me.r) min = Math.max(min, level.get(o.id) + 1) + else taken.add(level.get(o.id)) + } + let lv = min + while (taken.has(lv)) lv++ + level.set(me.id, lv) + } + const deepest = Math.min(3, Math.max(...level.values())) + for (const r of list) byId.get(r.id)._railLift = Math.max(0, deepest - Math.min(3, level.get(r.id))) + } + return result } diff --git a/frontend/src/components/galaxy/elkLayout.js b/frontend/src/components/galaxy/elkLayout.js index 8d88ada..26aa803 100644 --- a/frontend/src/components/galaxy/elkLayout.js +++ b/frontend/src/components/galaxy/elkLayout.js @@ -800,7 +800,7 @@ export function computeLayout(graphData = null, referenceId = null) { // --- PASS B (bottom-up) : placement initial centré sur les enfants --- const sortedGensDesc = [...componentOrderPerGen.keys()].sort((a, b) => b - a) if (sortedGensDesc.length === 0) { - return { children: allNodes, edges: allEdges, generationConflicts } + return { children: allNodes, edges: allEdges, generationConflicts, referenceId: reference ?? null } } const genMax = sortedGensDesc[0] const genMin = sortedGensDesc[sortedGensDesc.length - 1] @@ -840,7 +840,7 @@ export function computeLayout(graphData = null, referenceId = null) { node.y = node._generation * (PERSON_H + LAYER_SPACING) } - return { children: allNodes, edges: allEdges, generationConflicts } + return { children: allNodes, edges: allEdges, generationConflicts, referenceId: reference ?? null } } // ============================================================ diff --git a/frontend/src/components/galaxy/renderers.js b/frontend/src/components/galaxy/renderers.js index 1063d40..1e00585 100644 --- a/frontend/src/components/galaxy/renderers.js +++ b/frontend/src/components/galaxy/renderers.js @@ -107,19 +107,19 @@ const BUS_DY = 100 const SIB_R = 24 const SIB_STRAIGHT_DX = SIB_R * 2 -function busY(sourceId, sy) { - let h = 0 - for (let i = 0; i < sourceId.length; i++) h = (h * 31 + sourceId.charCodeAt(i)) | 0 - // Léger décalage par union : deux fratries voisines ne fusionnent pas leurs lignes - return sy + BUS_DY + (Math.abs(h) % 3) * 4 +// Les lignes de fratrie s'emboîtent : une famille collée au conjoint d'un enfant dessine sa ligne sous celle +// de la famille qui l'entoure. Le placement pose le niveau (_railLift, 0 = la plus proche des enfants). +const RAIL_STEP = 8 +function busY(lift, sy) { + return sy + BUS_DY - RAIL_STEP * (lift || 0) } // Une union, toute sa fratrie : un seul tracé. Tronc depuis l'union jusqu'à la barre, coins arrondis // de même rayon à chaque jonction, puis descente vers chaque enfant. Chaque enfant a sa propre trajectoire // (sur la même barre), donc le surlignage du chemin suit exactement la même géométrie. -export function traceSibship(ctx, sourceId, sourcePos, items) { +export function traceSibship(ctx, sourceId, sourcePos, items, lift = 0) { const sx = sourcePos.cx, sy = sourcePos.cy - const by = busY(sourceId, sy) + const by = busY(lift, sy) const rr = Math.min(SIB_R, by - sy - 2) ctx.beginPath() for (const { cx: ex, cy: ey } of items) { @@ -312,7 +312,7 @@ export function drawFiliations(ctx, edges, posMap, nodeMap, nodeRandomData, entr ctx.lineCap = 'round' const p = progress / items.length ctx.globalAlpha = Math.min(1, p * 1.5) * p - traceSibship(ctx, sourceId, sourcePos, items) + traceSibship(ctx, sourceId, sourcePos, items, nodeMap.get(sourceId)?._railLift) ctx.globalAlpha = 1 ctx.setLineDash([]) } @@ -389,7 +389,7 @@ export function drawPathHighlight(ctx, { ctx.globalAlpha = 1 ctx.globalAlpha = hp - traceSibship(ctx, edge.sources[0], sourcePos, [{ cx: targetPos.cx, cy: targetPos.cy - childR }]) + traceSibship(ctx, edge.sources[0], sourcePos, [{ cx: targetPos.cx, cy: targetPos.cy - childR }], nodeMap.get(edge.sources[0])?._railLift) ctx.globalAlpha = 1 }) } diff --git a/tools/layout-bench/defects.mjs b/tools/layout-bench/defects.mjs index 4fb95ba..42df479 100644 --- a/tools/layout-bench/defects.mjs +++ b/tools/layout-bench/defects.mjs @@ -20,6 +20,8 @@ export function computeDefects(result, coupleBarMeta, graph, referenceId = null) if (!kidsOf.has(e.sources[0])) kidsOf.set(e.sources[0], []) kidsOf.get(e.sources[0]).push(e.targets[0]) } + const siblingsOf = new Map() + for (const [, kids] of kidsOf) for (const k of kids) siblingsOf.set(k, kids) const spousesOf = new Map() for (const c of coupleBarMeta) { if (!spousesOf.has(c.p1Key)) spousesOf.set(c.p1Key, new Set()) @@ -34,7 +36,7 @@ export function computeDefects(result, coupleBarMeta, graph, referenceId = null) 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) + const sx = cx(u), sy = u.y + u.height / 2, by = sy + BUS_DY + (u._railLift != null ? -8 * u._railLift : busOffset(uid)) let l = sx, r = sx for (const k of ks) { const ex = cx(k) @@ -63,8 +65,12 @@ export function computeDefects(result, coupleBarMeta, graph, referenceId = null) for (const hh of households) { const h = H.find((x) => x.u === hh.union) if (!h) continue + // Un seul arbre : la fratrie du conjoint d'un enfant peut se trouver sous la ligne (famille collée), pas un étranger const ok = new Set(hh.kids) - for (const k of hh.kids) for (const s of (spousesOf.get(k) || [])) ok.add(s) + for (const k of hh.kids) for (const s of (spousesOf.get(k) || [])) { + ok.add(s) + for (const sib of (siblingsOf.get(s) || [])) { ok.add(sib); for (const ss of (spousesOf.get(sib) || [])) ok.add(ss) } + } 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 diff --git a/tools/layout-bench/invariants.mjs b/tools/layout-bench/invariants.mjs index 9304c36..c4ffec3 100644 --- a/tools/layout-bench/invariants.mjs +++ b/tools/layout-bench/invariants.mjs @@ -89,23 +89,33 @@ export function checkInvariants(result, coupleBarMeta, graph, referenceId = null // 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() + const siblingsOf = 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) } + for (const edges of kidsOf.values()) for (const e of edges) siblingsOf.set(e.targets[0], edges.map((x) => x.targets[0])) 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 + // Un seul arbre : la famille du conjoint d'un enfant (sa fratrie, leurs conjoints) se colle à lui, elle peut + // donc se trouver entre deux frères. Seule une personne sans ce lien coupe la fratrie. 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) + for (const k of kids) for (const s of (spousesOf.get(k.id) || [])) { + allowed.add(s) + for (const sib of (siblingsOf.get(s) || [])) { allowed.add(sib); for (const ss of (spousesOf.get(sib) || [])) allowed.add(ss) } + } 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++ + // Ordre choisi, de gauche à droite, ou en miroir (famille attachée à un conjoint, retournée vers lui) 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++ + const ascending = orders.every((o, i) => i === 0 || o >= orders[i - 1]) + const descending = orders.every((o, i) => i === 0 || o <= orders[i - 1]) + if (!ascending && !descending) fratries_desordre++ } // Rangées = génération de parenté (décalage calé par famille non reliée)