From d1e87d0eec4b588e364a84cf12d06c6bda76efe8 Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Mon, 19 Oct 2026 14:01:27 +0200 Subject: [PATCH] =?UTF-8?q?Placement=20:=20rang=C3=A9es=20=3D=20g=C3=A9n?= =?UTF-8?q?=C3=A9ration=20de=20parent=C3=A9=20depuis=20la=20personne=20de?= =?UTF-8?q?=20r=C3=A9f=C3=A9rence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La génération était comptée depuis le haut de chaque branche (profondeur sous l'ancêtre le plus ancien documenté). Une branche courte mariée dans une branche longue n'était tirée vers le bas qu'à une seule personne : ses parents, frères et cousins restaient en haut, 3 à 6 rangées au-dessus de leur vraie génération (26 % des personnes sur un arbre de 385, 35 % sur une descendance fictive de 1 454). L'issue #67 en était le symptôme en petit. Les conjoints sont soudés, puis chaque foyer est visité en largeur depuis le foyer de la personne de référence (rootPersonId, sinon la personne la plus entourée) : parents une rangée au-dessus, enfants une rangée en dessous. Une filiation en conflit (cousins de degrés différents, oncle et nièce) est comptée dans generationConflicts et dessinée telle quelle. Une famille non reliée est calée par ses années de naissance, sinon posée en haut. Vérifié sans écran : sur les arbres de test, rangée dessinée = génération de parenté pour 100 % des personnes hors conflits, et les médianes des années de naissance montent de 25 à 35 ans par rangée. Sur le tracé réel, croisements 51 → 22 (descendance fictive), 129 → 25 (pres2020), 362 → 115 (royal92). Closes #67 Co-Authored-By: Claude Fable 5.1 --- frontend/src/components/Galaxy.jsx | 5 +- frontend/src/components/galaxy/blockLayout.js | 4 +- frontend/src/components/galaxy/elkLayout.js | 200 ++++++++++-------- .../src/components/galaxy/elkLayout.test.js | 83 ++++++++ 4 files changed, 203 insertions(+), 89 deletions(-) diff --git a/frontend/src/components/Galaxy.jsx b/frontend/src/components/Galaxy.jsx index 805e834..4d37ed6 100644 --- a/frontend/src/components/Galaxy.jsx +++ b/frontend/src/components/Galaxy.jsx @@ -189,7 +189,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig unions: graphUnions, filiations: filiationsWithOrder(graphFiliations, graphUnions, group.source, order), } - const result = computeBlockLayout(graph) + const result = computeBlockLayout(graph, focusPersonIdRef.current) const baseSource = base.children.find((n) => n.id === group.source) const source = result.children.find((n) => n.id === group.source) const shift = baseSource && source ? baseSource.x - source.x : 0 @@ -428,7 +428,8 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig // Layout useEffect(() => { - const result = USE_BLOCK_LAYOUT ? computeBlockLayout() : computeLayout() + // La personne de départ sert de référence aux rangées (génération de parenté depuis elle) + const result = USE_BLOCK_LAYOUT ? computeBlockLayout(null, focusPersonIdRef.current) : computeLayout(null, focusPersonIdRef.current) const canvas = canvasRef.current if (!canvas) return diff --git a/frontend/src/components/galaxy/blockLayout.js b/frontend/src/components/galaxy/blockLayout.js index 055e797..65cca81 100644 --- a/frontend/src/components/galaxy/blockLayout.js +++ b/frontend/src/components/galaxy/blockLayout.js @@ -40,8 +40,8 @@ export function filCroiseAutreFamille(nodes, childrenOfUnion, spouseKeysOf, u, c && n.x + n.width > lo && n.x < hi) } -export function computeBlockLayout(graphData = null) { - const result = computeLayout(graphData) +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])) diff --git a/frontend/src/components/galaxy/elkLayout.js b/frontend/src/components/galaxy/elkLayout.js index 4a2c3ec..8d88ada 100644 --- a/frontend/src/components/galaxy/elkLayout.js +++ b/frontend/src/components/galaxy/elkLayout.js @@ -200,7 +200,7 @@ const LARGE_FAMILY_GAP = 80 const LARGE_FAMILY_THRESHOLD = 3 const CLUSTER_SPREAD_FACTOR = 6 -export function computeLayout(graphData = null) { +export function computeLayout(graphData = null, referenceId = null) { const persons = graphData?.persons || globalPersons const filiations = graphData?.filiations || globalFiliations const savedUnions = graphData?.unions || globalUnions @@ -216,93 +216,123 @@ export function computeLayout(graphData = null) { }) // ---------------------------------------------------------- - // PHASE 1 : Assigner les générations (Y) + // PHASE 1 : Rangées = génération de parenté depuis la personne de référence + // + // Les conjoints sont soudés (toujours la même rangée). Chaque foyer (personnes + // reliées par des unions) est visité en largeur depuis le foyer de la référence : + // parents une rangée au-dessus, enfants une rangée en dessous. Une branche courte + // mariée dans une branche longue suit donc entière (parents, fratrie, cousins), + // au lieu de rester accrochée à son ancêtre le plus ancien. + // + // Une filiation qui contredit la rangée déjà attribuée (cousins de degrés + // différents, oncle et nièce, erreur de saisie) est comptée dans + // generationConflicts et dessinée telle quelle. Une famille non reliée à la + // référence est calée par ses années de naissance si elle en a, sinon posée en haut. // ---------------------------------------------------------- - const genMap = new Map() - - function calcGen(pid, visited = new Set()) { - if (genMap.has(pid)) return genMap.get(pid) - if (visited.has(pid)) return 0 - visited.add(pid) - const parentFils = filiationsByChild.get(pid) || [] - if (parentFils.length === 0) { genMap.set(pid, 0); return 0 } - - let best = Number.MAX_SAFE_INTEGER - for (const f of parentFils) { - if (f.unionId) { - const u = unionsById.get(f.unionId) - if (!u) continue - const g = Math.max( - calcGen(u.partner1Id, new Set(visited)), - calcGen(u.partner2Id, new Set(visited)), - ) + 1 - if (g < best) best = g - } else if (f.parentId) { - const g = calcGen(f.parentId, new Set(visited)) + 1 - if (g < best) best = g - } - } - const gen = best === Number.MAX_SAFE_INTEGER ? 0 : best - genMap.set(pid, gen) - return gen - } - - persons.forEach(p => calcGen(p.id)) - - // Aligner les partenaires sur la même génération + const personIds = new Set(persons.map(p => p.id)) + const uf = new Map(persons.map(p => [p.id, p.id])) + const find = (x) => { while (uf.get(x) !== x) { uf.set(x, uf.get(uf.get(x))); x = uf.get(x) } return x } + const relatives = new Map(persons.map(p => [p.id, new Set()])) unions.forEach(u => { - const maxG = Math.max(genMap.get(u.partner1Id) ?? 0, genMap.get(u.partner2Id) ?? 0) - genMap.set(u.partner1Id, maxG) - genMap.set(u.partner2Id, maxG) + if (!personIds.has(u.partner1Id) || !personIds.has(u.partner2Id)) return + uf.set(find(u.partner1Id), find(u.partner2Id)) + relatives.get(u.partner1Id).add(u.partner2Id) + relatives.get(u.partner2Id).add(u.partner1Id) }) - // 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 => { - const childGen = genMap.get(f.childId) - if (childGen === undefined) return - const parentIds = [] - if (f.unionId) { - const u = unionsById.get(f.unionId) - if (u?.partner1Id) parentIds.push(u.partner1Id) - if (u?.partner2Id) parentIds.push(u.partner2Id) - } else if (f.parentId) { - parentIds.push(f.parentId) - } - for (const pid of parentIds) { - if (!rootIds.has(pid)) continue - if ((maxChildGenByRoot.get(pid) ?? -1) < childGen) maxChildGenByRoot.set(pid, childGen) - } - }) - - let anyAdjusted = false - for (const [pid, maxChildGen] of maxChildGenByRoot) { - const idealGen = maxChildGen - 1 - if (idealGen > 0) { genMap.set(pid, idealGen); anyAdjusted = true } - } - - if (anyAdjusted) { - unions.forEach(u => { - const maxG = Math.max(genMap.get(u.partner1Id) ?? 0, genMap.get(u.partner2Id) ?? 0) - genMap.set(u.partner1Id, maxG) - genMap.set(u.partner2Id, maxG) - }) - persons.forEach(p => { if (!rootIds.has(p.id)) genMap.delete(p.id) }) - persons.forEach(p => calcGen(p.id)) - unions.forEach(u => { - const maxG = Math.max(genMap.get(u.partner1Id) ?? 0, genMap.get(u.partner2Id) ?? 0) - genMap.set(u.partner1Id, maxG) - genMap.set(u.partner2Id, maxG) - }) - } - if (persons.map(p => genMap.get(p.id)).join() === before) break + // Arêtes entre foyers : foyer des parents → foyer de l'enfant (+1), et retour (−1) + const kinAdj = new Map() + const kinEdges = new Set() + const addKin = (parentId, childId) => { + if (!personIds.has(parentId) || !personIds.has(childId)) return + relatives.get(parentId).add(childId) + relatives.get(childId).add(parentId) + const a = find(parentId), b = find(childId) + if (kinEdges.has(`${a}>${b}`)) return + kinEdges.add(`${a}>${b}`) + if (!kinAdj.has(a)) kinAdj.set(a, []) + if (!kinAdj.has(b)) kinAdj.set(b, []) + kinAdj.get(a).push([b, 1]) + kinAdj.get(b).push([a, -1]) } + normalizedFiliations.forEach(f => { + if (f.unionId) { + const u = unionsById.get(f.unionId) + if (u) { addKin(u.partner1Id, f.childId); addKin(u.partner2Id, f.childId) } + } else if (f.parentId) { + addKin(f.parentId, f.childId) + } + }) + + // Référence : la personne donnée, sinon celle qui a le plus de proches à deux degrés + const closeness = (id) => { + const seen = new Set(relatives.get(id)) + for (const r of relatives.get(id)) for (const rr of relatives.get(r)) seen.add(rr) + seen.delete(id) + return seen.size + } + const byCloseness = persons.map((p, i) => [p.id, closeness(p.id), i]) + .sort((a, b) => b[1] - a[1] || a[2] - b[2]) + .map(([id]) => id) + const reference = personIds.has(referenceId) ? referenceId : byCloseness[0] + + const clusterGen = new Map() + const components = [] + const conflictKeys = new Set() + const visitFrom = (start) => { + const members = [start] + clusterGen.set(start, 0) + for (let i = 0; i < members.length; i++) { + const c = members[i] + for (const [n, w] of (kinAdj.get(c) || [])) { + const v = clusterGen.get(c) + w + if (!clusterGen.has(n)) { clusterGen.set(n, v); members.push(n) } + else if (clusterGen.get(n) !== v) conflictKeys.add(c < n ? `${c}|${n}` : `${n}|${c}`) + } + } + components.push(members) + } + if (reference != null) visitFrom(find(reference)) + for (const id of byCloseness) { const c = find(id); if (!clusterGen.has(c)) visitFrom(c) } + const generationConflicts = conflictKeys.size + + // Familles non reliées à la référence : calées sur ses années de naissance, sinon posées en haut + 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 + } + const clusterIndex = new Map() + components.forEach((members, i) => members.forEach(c => clusterIndex.set(c, i))) + const dated = components.map(() => []) + persons.forEach(p => { + const y = birthYear(p) + if (y != null) dated[clusterIndex.get(find(p.id))].push([clusterGen.get(find(p.id)), y]) + }) + let slope = 30, intercept = null + const main = dated[0] || [] + if (main.length) { + const mg = main.reduce((s, [g]) => s + g, 0) / main.length + const my = main.reduce((s, [, y]) => s + y, 0) / main.length + const sxx = main.reduce((s, [g]) => s + (g - mg) ** 2, 0) + const sxy = main.reduce((s, [g, y]) => s + (g - mg) * (y - my), 0) + if (sxx > 0) slope = Math.min(60, Math.max(15, sxy / sxx)) + intercept = my - slope * mg + } + const lowestGen = (i) => Math.min(...components[i].map(c => clusterGen.get(c))) + const offsets = components.map(() => 0) + for (let i = 1; i < components.length; i++) { + if (intercept != null && dated[i].length) { + const ks = dated[i].map(([g, y]) => (y - intercept) / slope - g).sort((a, b) => a - b) + offsets[i] = Math.round(ks[Math.floor(ks.length / 2)]) + } else { + offsets[i] = lowestGen(0) - lowestGen(i) + } + } + const genMap = new Map() + persons.forEach(p => { const c = find(p.id); genMap.set(p.id, clusterGen.get(c) + offsets[clusterIndex.get(c)]) }) + const lowest = persons.length ? Math.min(...genMap.values()) : 0 + persons.forEach(p => genMap.set(p.id, genMap.get(p.id) - lowest)) // ---------------------------------------------------------- // Conjoints isolés : mariés une seule fois, sans parent ni enfant dans l'arbre. @@ -770,7 +800,7 @@ export function computeLayout(graphData = 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 } + return { children: allNodes, edges: allEdges, generationConflicts } } const genMax = sortedGensDesc[0] const genMin = sortedGensDesc[sortedGensDesc.length - 1] @@ -810,7 +840,7 @@ export function computeLayout(graphData = null) { node.y = node._generation * (PERSON_H + LAYER_SPACING) } - return { children: allNodes, edges: allEdges } + return { children: allNodes, edges: allEdges, generationConflicts } } // ============================================================ diff --git a/frontend/src/components/galaxy/elkLayout.test.js b/frontend/src/components/galaxy/elkLayout.test.js index f956604..dff66b9 100644 --- a/frontend/src/components/galaxy/elkLayout.test.js +++ b/frontend/src/components/galaxy/elkLayout.test.js @@ -295,3 +295,86 @@ describe('rangées des ancêtres sans parents', () => { expect(gen.get('henri')).toBe(gen.get('nicolas') - 1) }) }) + +describe('rangées = génération de parenté depuis la référence', () => { + const link = (id, childId, unionId) => ({ id, childId, unionId, parentId: null, parentageType: 'biologique', displayOrder: 1 }) + const direct = (id, childId, parentId) => ({ id, childId, unionId: null, parentId, parentageType: 'biologique', displayOrder: 1 }) + const couple = (id, a, b) => ({ id, partner1Id: a, partner2Id: b, displayOrder: 1 }) + const gens = (graph, reference) => { + const result = computeLayout(graph, reference) + return { gen: new Map(result.children.filter((n) => n._type === 'person').map((n) => [n.id.slice(2), n._generation])), result } + } + + it('issue #67 : les deux couples de grands-parents partagent une rangée, quelle que soit la profondeur documentée', () => { + // Lignée de la mère sur trois générations, lignée du père sur deux (un parent connu par grand-parent) + const persons = ['GGGa', 'GGGb', 'GGa', 'GGb', 'GM1', 'GF1', 'M', 'GM2', 'GF2', 'GGx', 'GGy', 'F', 'Kid'].map((id) => ({ id })) + const unions = [couple('u1', 'GGGa', 'GGGb'), couple('u2', 'GGa', 'GGb'), couple('u3', 'GM1', 'GF1'), couple('u4', 'GM2', 'GF2'), couple('u5', 'M', 'F')] + const filiations = [ + link('f1', 'GGa', 'u1'), link('f2', 'GM1', 'u2'), link('f3', 'M', 'u3'), link('f4', 'F', 'u4'), link('f5', 'Kid', 'u5'), + direct('f6', 'GM2', 'GGy'), direct('f7', 'GF2', 'GGx'), + ] + const { gen, result } = gens({ persons, unions, filiations }, 'Kid') + expect(gen.get('GM2')).toBe(gen.get('GM1')) + expect(gen.get('GGx')).toBe(gen.get('GM2') - 1) + expect(gen.get('GGy')).toBe(gen.get('GM2') - 1) + expect(gen.get('GGa')).toBe(gen.get('GGx')) + expect(gen.get('Kid')).toBe(gen.get('M') + 1) + expect(gen.get('GGGa')).toBe(0) + expect(result.generationConflicts).toBe(0) + }) + + it('une branche courte avec fratrie descend entière au niveau de la branche longue', () => { + // A0 → A1 → A2 → A3 → X. B0 → Y et S (frère de Y). X épouse Y. Y, S et B0 suivent X. + const persons = ['A0', 'A0w', 'A1', 'A1w', 'A2', 'A2w', 'A3', 'A3w', 'X', 'B0', 'B0w', 'Y', 'S', 'K'].map((id) => ({ id })) + const unions = [couple('uA0', 'A0', 'A0w'), couple('uA1', 'A1', 'A1w'), couple('uA2', 'A2', 'A2w'), couple('uA3', 'A3', 'A3w'), couple('uB0', 'B0', 'B0w'), couple('uXY', 'X', 'Y')] + const filiations = [link('f1', 'A1', 'uA0'), link('f2', 'A2', 'uA1'), link('f3', 'A3', 'uA2'), link('f4', 'X', 'uA3'), link('f5', 'Y', 'uB0'), link('f6', 'S', 'uB0'), link('f7', 'K', 'uXY')] + const { gen, result } = gens({ persons, unions, filiations }, 'K') + expect(gen.get('X')).toBe(4) + expect(gen.get('Y')).toBe(4) + expect(gen.get('S')).toBe(4) + expect(gen.get('B0')).toBe(3) + expect(gen.get('B0w')).toBe(3) + expect(gen.get('K')).toBe(5) + expect(result.generationConflicts).toBe(0) + }) + + it('oncle et nièce mariés : les conjoints restent sur la même rangée, le conflit est compté', () => { + // G + Gw → O (oncle) et P. P + Pw → N (nièce). O épouse N. + const persons = ['G', 'Gw', 'O', 'P', 'Pw', 'N'].map((id) => ({ id })) + const unions = [couple('uG', 'G', 'Gw'), couple('uP', 'P', 'Pw'), couple('uON', 'O', 'N')] + const filiations = [link('f1', 'O', 'uG'), link('f2', 'P', 'uG'), link('f3', 'N', 'uP')] + const { gen, result } = gens({ persons, unions, filiations }, 'G') + expect(gen.get('O')).toBe(gen.get('N')) + expect(gen.get('P')).toBe(gen.get('G') + 1) + expect(result.generationConflicts).toBe(1) + }) + + it('deux familles non reliées sans dates : les plus anciens de chacune en haut', () => { + const persons = ['a', 'aw', 'b', 'c', 'cw', 'd'].map((id) => ({ id })) + const unions = [couple('ua', 'a', 'aw'), couple('uc', 'c', 'cw')] + const filiations = [link('f1', 'b', 'ua'), link('f2', 'd', 'uc')] + const { gen } = gens({ persons, unions, filiations }, 'a') + expect(gen.get('a')).toBe(0) + expect(gen.get('c')).toBe(0) + expect(gen.get('d')).toBe(1) + }) + + it('deux familles non reliées datées : calées par les années de naissance', () => { + const p = (id, year) => ({ id, birthDate: `${year}-01-01` }) + const persons = [p('a', 1900), p('aw', 1902), p('b', 1930), p('bw', 1931), p('e', 1960), p('c', 1932), p('cw', 1935), p('d', 1961)] + const unions = [couple('ua', 'a', 'aw'), couple('ub', 'b', 'bw'), couple('uc', 'c', 'cw')] + const filiations = [link('f1', 'b', 'ua'), link('f2', 'e', 'ub'), link('f3', 'd', 'uc')] + const { gen } = gens({ persons, unions, filiations }, 'a') + expect(gen.get('c')).toBe(gen.get('b')) + expect(gen.get('d')).toBe(gen.get('e')) + }) + + it('sans référence valable, part de la personne la plus entourée et ne plante pas', () => { + const persons = ['a', 'aw', 'b'].map((id) => ({ id })) + const unions = [couple('ua', 'a', 'aw')] + const filiations = [link('f1', 'b', 'ua')] + const { gen } = gens({ persons, unions, filiations }, 'inconnu') + expect(gen.get('a')).toBe(0) + expect(gen.get('b')).toBe(1) + }) +})