Renvoi seulement si le fil croise une autre famille

Une filiation vers un enfant placé sous l'autre lignée devenait un renvoi
(pastilles) dès que l'écart union → enfant dépassait 360 px, même quand le fil
ne croisait personne. Le critère est maintenant géométrique : le fil file sur la
ligne de la fratrie entre l'aplomb de l'union et l'enfant ; il ne devient renvoi
que si une personne d'une autre famille se trouve sur cette ligne. Frères et
sœurs et conjoint(e)s de l'enfant ne comptent pas.

Tests : cas à fratrie voisine (fil conservé), et filCroiseAutreFamille sur des
nœuds synthétiques (autre famille, fratrie seule, conjoint, autre génération).

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
IliesAllaliandClaude Haiku 5.5 committed 2026-10-18 19:33:38 +02:00
1 parent f8d4b4113d
commit 3d709ec015
3 files changed
+76 -8

No files matched your search

+28 -5
View File
@@ -1,5 +1,4 @@
import { computeLayout, componentOrderPerGen, chainGap } from './elkLayout'
import { RENVOI_MIN_DX } from './constants'
// ============================================================
// Layout par blocs familiaux (Reingold-Tilford adapté aux couples)
@@ -27,6 +26,18 @@ import { RENVOI_MIN_DX } from './constants'
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.
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 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)
}
export function computeBlockLayout(graphData = null) {
const result = computeLayout(graphData)
const nodes = result.children
@@ -209,13 +220,25 @@ export function computeBlockLayout(graphData = null) {
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 longue vers un enfant placé sous l'autre lignée : dessinée en renvoi (voir drawFiliations)
// Plusieurs renvois sous la même union : pastilles empilées (_renvoiIndex)
const centerX = (id) => byId.get(id).x + byId.get(id).width / 2
// Arête vers un enfant placé 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) :
// 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()
for (const n of nodes) {
if (n._type !== 'union' || !n._data?.p1Key || !n._data?.p2Key) continue
const { p1Key, p2Key } = n._data
if (!spouseKeysOf.has(p1Key)) spouseKeysOf.set(p1Key, new Set())
if (!spouseKeysOf.has(p2Key)) spouseKeysOf.set(p2Key, new Set())
spouseKeysOf.get(p1Key).add(p2Key)
spouseKeysOf.get(p2Key).add(p1Key)
}
const crossesOtherFamily = (u, c) => filCroiseAutreFamille(nodes, childrenOfUnion, spouseKeysOf, u, c)
const renvoisByUnion = new Map()
for (const e of result.edges) {
const u = e.sources[0], c = e.targets[0]
if (!externalEdges.has(`${u}>${c}`) || Math.abs(centerX(u) - centerX(c)) <= RENVOI_MIN_DX) continue
if (!externalEdges.has(`${u}>${c}`) || !crossesOtherFamily(u, c)) continue
e._renvoi = true
e._renvoiIndex = renvoisByUnion.get(u) || 0
renvoisByUnion.set(u, e._renvoiIndex + 1)
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { computeBlockLayout } from './blockLayout'
import { computeBlockLayout, filCroiseAutreFamille } from './blockLayout'
import { COMPACT_W, PERSON_W } from './constants'
const person = (id) => ({ id, firstName: id, lastName: '' })
@@ -60,3 +60,50 @@ describe('computeBlockLayout, ordre de la fratrie', () => {
expect(order(graphWith([1, 1, 1]))).toEqual(['a', 'b', 'c'])
})
})
describe('computeBlockLayout, renvoi seulement si le fil croise une autre famille', () => {
// Structure type : l'enfant (P) a sa fratrie à côté, son conjoint (Z) est dans la lignée de l'autre parent
// Jacqueline + Roger ont 6 enfants, dont Marie, en couple avec Philippe ; Marcel + Yvette ont 5 enfants, dont Philippe
const graph = {
persons: ['jac', 'rog', 'marie', 'philippe', 'anais', 'greg', 'marcel', 'yvette', 'severine', 'isabelle', 'laurent', 'stephane', 'k1', 'k2', 'k3', 'k4', 'k5'].map(person),
unions: [union('uJR', 'jac', 'rog'), union('uMP', 'marie', 'philippe'), union('uMY', 'marcel', 'yvette')],
filiations: [
...['k1', 'k2', 'k3', 'k4', 'k5'].map((k, i) => fil(`f${k}`, k, 'uJR', i + 1)), fil('fmarie', 'marie', 'uJR', 6),
fil('fa', 'anais', 'uMP'), fil('fg', 'greg', 'uMP'),
fil('fs', 'severine', 'uMY', 1), fil('fi', 'isabelle', 'uMY', 2), fil('fl', 'laurent', 'uMY', 3),
fil('fst', 'stephane', 'uMY', 4), fil('fp', 'philippe', 'uMY', 5),
],
}
it('garde le fil vers Philippe quand la fratrie de son autre famille est à côté de lui', () => {
const { edges } = computeBlockLayout(graph)
expect(edges.find(e => e.sources[0] === 'u-uMY' && e.targets[0] === 'p-philippe')._renvoi).toBeUndefined()
})
})
describe('filCroiseAutreFamille', () => {
// Ligne d'une génération : union u (aplomb x=0 à 120), enfant c en x=600
const at = (id, x, type = 'person', gen = 1) => ({ id, x, width: 120, _type: type, _generation: gen })
const base = () => [at('u', 0, 'union', 0), at('c', 600), at('sib', 300), at('spouse', 700), at('other', 400)]
const childrenOfUnion = new Map([['u', ['c', 'sib']]])
const spouseKeysOf = new Map([['c', new Set(['spouse'])]])
it('croise quand une personne d\'une autre famille est entre l\'union et l\'enfant', () => {
expect(filCroiseAutreFamille(base(), childrenOfUnion, spouseKeysOf, 'u', 'c')).toBe(true)
})
it('ne croise pas quand seuls les frères et sœurs sont entre l\'union et l\'enfant', () => {
const nodes = base().filter(n => n.id !== 'other')
expect(filCroiseAutreFamille(nodes, childrenOfUnion, spouseKeysOf, 'u', 'c')).toBe(false)
})
it('ne croise pas pour le conjoint de l\'enfant placé du côté de l\'enfant', () => {
const nodes = base().filter(n => n.id !== 'other').map(n => n.id === 'spouse' ? { ...n, x: 300 } : n)
expect(filCroiseAutreFamille(nodes, childrenOfUnion, spouseKeysOf, '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)
})
})
@@ -16,8 +16,6 @@ export const COMPACT_SCALE = 0.6
export const COMPACT_R = PERSON_R * COMPACT_SCALE
export const COMPACT_W = 40
export const COMPACT_JOIN_GAP = -18
// Filiation qui traverse l'arbre (enfant placé sous l'autre lignée) : renvoi cliquable au lieu du trait
export const RENVOI_MIN_DX = 360
export const MAX_ORBIT_MEDIAS = 3
export const ORBIT_RADIUS = 55
export const ORBIT_MEDIA_SIZE = 22