From a54bcf9f18b6edc9f991c7e3cf0248d08cda6d74 Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Mon, 19 Oct 2026 10:21:23 +0200 Subject: [PATCH] =?UTF-8?q?fix(placement):=20rang=C3=A9es=20stables,=20cou?= =?UTF-8?q?ples=20tourn=C3=A9s=20vers=20leurs=20familles,=20fil=20libre=20?= =?UTF-8?q?malgr=C3=A9=20les=20conjoints=20de=20la=20fratrie?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rangées : le calage des ancêtres sans parents tourne jusqu'à stabilité. Un enfant descendu par son conjoint fait descendre ses parents et sa fratrie, au lieu de pendre plusieurs rangées sous eux. - Couples : quand les deux conjoints ont leurs parents dans l'arbre et sont chacun du côté de la famille de l'autre, on les intervertit (même place, l'union au milieu). Les deux fils ne se croisent plus. - Fil : les conjoints des frères et sœurs ne comptent plus comme une autre famille dans le test du renvoi. Un petit conjoint collé à un frère faisait passer le fil en pastille « Voir les parents ». Co-Authored-By: Claude Opus 5.5 --- frontend/src/components/galaxy/blockLayout.js | 26 +++++++++++++++-- .../src/components/galaxy/blockLayout.test.js | 27 ++++++++++++++++++ frontend/src/components/galaxy/elkLayout.js | 6 +++- .../src/components/galaxy/elkLayout.test.js | 28 +++++++++++++++++++ 4 files changed, 84 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/galaxy/blockLayout.js b/frontend/src/components/galaxy/blockLayout.js index c20275b..055e797 100644 --- a/frontend/src/components/galaxy/blockLayout.js +++ b/frontend/src/components/galaxy/blockLayout.js @@ -27,12 +27,14 @@ const NODE_SPACING = 30 const FAMILY_GAP = 60 // 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 conjoint(e)s de l'enfant ne comptent pas. +// 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 +// (un petit conjoint collé à un frère faisait passer le fil en pastille). export function filCroiseAutreFamille(nodes, childrenOfUnion, spouseKeysOf, u, c) { const byIdLocal = new Map(nodes.map(n => [n.id, n])) const kid = byIdLocal.get(c) const cx = (id) => byIdLocal.get(id).x + byIdLocal.get(id).width / 2 - const excluded = new Set([c, ...(childrenOfUnion.get(u) || []), ...(spouseKeysOf.get(c) || [])]) + const fratrie = [...new Set([c, ...(childrenOfUnion.get(u) || [])])] + const excluded = new Set([...fratrie, ...fratrie.flatMap(k => [...(spouseKeysOf.get(k) || [])])]) const lo = Math.min(cx(u), cx(c)), hi = Math.max(cx(u), cx(c)) return nodes.some(n => n._type === 'person' && n._generation === kid._generation && !excluded.has(n.id) && n.x + n.width > lo && n.x < hi) @@ -215,6 +217,26 @@ export function computeBlockLayout(graphData = null) { 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) let minX = Infinity for (const n of nodes) if (pos.has(n.id)) minX = Math.min(minX, pos.get(n.id)) diff --git a/frontend/src/components/galaxy/blockLayout.test.js b/frontend/src/components/galaxy/blockLayout.test.js index 80e1148..1cc18b1 100644 --- a/frontend/src/components/galaxy/blockLayout.test.js +++ b/frontend/src/components/galaxy/blockLayout.test.js @@ -102,8 +102,35 @@ describe('filCroiseAutreFamille', () => { expect(filCroiseAutreFamille(nodes, childrenOfUnion, spouseKeysOf, 'u', 'c')).toBe(false) }) + it('ne croise pas pour le conjoint d\'un frère ou d\'une sœur', () => { + const nodes = base().filter(n => n.id !== 'other').concat(at('beaufrere', 420)) + const spouses = new Map([...spouseKeysOf, ['sib', new Set(['beaufrere'])]]) + expect(filCroiseAutreFamille(nodes, childrenOfUnion, spouses, 'u', 'c')).toBe(false) + }) + it('ignore une personne d\'une autre génération sur la même abscisse', () => { const nodes = base().filter(n => n.id !== 'other').concat(at('x', 400, 'person', 2)) expect(filCroiseAutreFamille(nodes, childrenOfUnion, spouseKeysOf, 'u', 'c')).toBe(false) }) }) + +describe('computeBlockLayout, couple tourné vers les familles de chacun', () => { + // Marc (fratrie de Louis et Hélène) épouse Nadia (fratrie de Pierre et Odile). La fratrie de Nadia est saisie + // en premier, Marc avant Nadia : Marc se retrouvait à gauche du couple alors que sa famille est à droite, + // et les deux fils se croisaient. + const graph = { + persons: ['pierre', 'odile', 'louis', 'helene', 'marc', 'claire', 'julien', 'sophie', 'eric', 'luc', 'paul', 'mireille', 'nadia', 'anais'].map(person), + unions: [union('uPO', 'pierre', 'odile'), { ...union('uLH', 'louis', 'helene'), displayOrder: 2 }, { ...union('uMN', 'marc', 'nadia'), displayOrder: 3 }], + filiations: [ + ...['luc', 'paul', 'mireille', 'nadia'].map((k, i) => ({ ...fil(`f-${k}`, k, 'uPO'), displayOrder: i + 1 })), + ...['marc', 'claire', 'julien', 'sophie', 'eric'].map((k, i) => ({ ...fil(`f-${k}`, k, 'uLH'), displayOrder: i + 1 })), + fil('f-anais', 'anais', 'uMN'), + ], + } + + it('place chaque conjoint du côté de ses propres parents', () => { + const { children } = computeBlockLayout(graph) + const x = (id) => { const n = children.find(c => c.id === id); return n.x + n.width / 2 } + expect(Math.sign(x('p-marc') - x('p-nadia'))).toBe(Math.sign(x('u-uLH') - x('u-uPO'))) + }) +}) diff --git a/frontend/src/components/galaxy/elkLayout.js b/frontend/src/components/galaxy/elkLayout.js index 9151b81..4a2c3ec 100644 --- a/frontend/src/components/galaxy/elkLayout.js +++ b/frontend/src/components/galaxy/elkLayout.js @@ -258,7 +258,10 @@ export function computeLayout(graphData = null) { // Ajuster les racines orphelines : si un ancêtre sans parents connus // a des enfants à gen N, il doit être à gen N-1 (pas gen 0). - { + // Répété jusqu'à stabilité : un enfant descendu par son conjoint fait à son tour descendre ses parents + // sans ascendance (sinon ils restent en haut, et l'enfant pend plusieurs rangées sous sa fratrie). + for (let pass = 0; pass < 12; pass++) { + const before = persons.map(p => genMap.get(p.id)).join() const rootIds = new Set(persons.filter(p => !filiationsByChild.has(p.id)).map(p => p.id)) const maxChildGenByRoot = new Map() normalizedFiliations.forEach(f => { @@ -298,6 +301,7 @@ export function computeLayout(graphData = null) { genMap.set(u.partner2Id, maxG) }) } + if (persons.map(p => genMap.get(p.id)).join() === before) break } // ---------------------------------------------------------- diff --git a/frontend/src/components/galaxy/elkLayout.test.js b/frontend/src/components/galaxy/elkLayout.test.js index dee8f93..f956604 100644 --- a/frontend/src/components/galaxy/elkLayout.test.js +++ b/frontend/src/components/galaxy/elkLayout.test.js @@ -267,3 +267,31 @@ describe('separated parents', () => { expect(separatedPairUnions([filiations[0]], [])).toEqual([]) }) }) + +describe('rangées des ancêtres sans parents', () => { + // Henri + Isabelle (sans ascendance) ont Joseph et Nicolas. Nicolas épouse Madeleine (sans ascendance) ; + // leur fille Anne épouse Jean, d'une lignée qui remonte sur cinq générations. Anne descend au niveau de Jean, + // Nicolas avec elle : ses parents et son frère doivent suivre, au lieu de rester tout en haut. + const ids = ['henri', 'isabelle', 'joseph', 'nicolas', 'madeleine', 'anne', 'jean', 'l0', 'l0w', 'l1', 'l1w', 'l2', 'l2w', 'l3', 'l3w', 'l4', 'l4w'] + const persons = ids.map((id) => ({ id })) + const unions = [ + { id: 'uHI', partner1Id: 'henri', partner2Id: 'isabelle', displayOrder: 1 }, + { id: 'uNM', partner1Id: 'nicolas', partner2Id: 'madeleine', displayOrder: 2 }, + { id: 'uAJ', partner1Id: 'anne', partner2Id: 'jean', displayOrder: 3 }, + ...[0, 1, 2, 3, 4].map((i) => ({ id: `uL${i}`, partner1Id: `l${i}`, partner2Id: `l${i}w`, displayOrder: 4 + i })), + ] + const link = (id, childId, unionId) => ({ id, childId, unionId, parentId: null, parentageType: 'biologique', displayOrder: 1 }) + const filiations = [ + link('f1', 'joseph', 'uHI'), link('f2', 'nicolas', 'uHI'), link('f3', 'anne', 'uNM'), + link('f4', 'l1', 'uL0'), link('f5', 'l2', 'uL1'), link('f6', 'l3', 'uL2'), link('f7', 'l4', 'uL3'), link('f8', 'jean', 'uL4'), + ] + + it('fait descendre les parents et la fratrie de celui qui a suivi son conjoint', () => { + const result = computeLayout({ persons, unions, filiations }) + const gen = new Map(result.children.filter((n) => n._type === 'person').map((n) => [n.id.slice(2), n._generation])) + expect(gen.get('anne')).toBe(gen.get('jean')) + expect(gen.get('nicolas')).toBe(gen.get('anne') - 1) + expect(gen.get('joseph')).toBe(gen.get('nicolas')) + expect(gen.get('henri')).toBe(gen.get('nicolas') - 1) + }) +})