mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-06 23:32:08 +00:00
Merge pull request #111 from IliesAllali/fix/generation-parente
Placement : rangées = génération de parenté depuis la personne de référence
This commit is contained in:
4 files changed
+203
-89
No files matched your search
@@ -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
|
||||
|
||||
@@ -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]))
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user