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) + }) +})