Files
parenthese/frontend/src/components/galaxy/blockLayout.js
T
IliesAllaliandClaude Fable 5.1 e4ee286920 Placement : un seul arbre, la famille d'un conjoint se colle à lui
Avant, la famille d'un conjoint (ses parents, sa fratrie, ses ancêtres)
était posée au premier créneau libre, souvent au bout de l'arbre, et la
filiation devenait une pastille « Voir ». L'arbre se lisait comme plusieurs
arbres reliés par des pastilles.

Maintenant chaque famille de conjoint est construite entière depuis son
ancêtre le plus haut et collée au conjoint, de son côté, en poussant les
voisins pour faire la place. Le conjoint est tourné vers l'extérieur de sa
fratrie ; sa propre fratrie est dessinée en miroir s'il faut qu'il finisse
au bout, contre le couple ; un enfant déjà dessiné ailleurs garde une place
fantôme dans l'ordre de sa fratrie pour le centrage. Les lignées de la
personne de référence sont dessinées d'abord (le sang gagne) ; une famille
reliée deux fois (cousins, deux grandes lignées) n'est dessinée qu'une fois,
l'autre filiation reste une pastille si son fil croiserait une famille ; une
famille qu'il faudrait pousser de plus d'un écran est posée à part. L'ordre
des lignées ne dépend plus des effectifs (rangée, année, identifiant).

Les lignes de fratrie s'emboîtent : la ligne d'une famille qui en entoure
une autre passe au-dessus (_railLift posé par le placement, plus de hachage),
plus aucune superposition.

Mesuré par le banc (tools/layout-bench) : pastilles 4 → 2 sur une famille
réelle de 385 personnes, 78 → 12 sur une descendance fictive de 1 454 ;
les 14 archétypes passent leurs invariants, Marc et Nadia et l'issue #67
sans aucune pastille ni croisement.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-19 14:47:32 +02:00

418 lines
19 KiB
JavaScript

import { computeLayout, componentOrderPerGen, chainGap } from './elkLayout'
// ============================================================
// Layout par blocs familiaux (Reingold-Tilford adapté aux couples)
//
// S'appuie sur computeLayout pour tout ce qui n'est pas en cause
// (normalisation des filiations, rangées, chaînes de couples,
// coupleBarMeta, componentOrderPerGen) et ne recalcule que le X.
//
// Un bloc = une chaîne de couples (même rangée) + les blocs de ses
// enfants imbriqués en dessous, la chaîne centrée au-dessus. Fratries
// contiguës et parents centrés par construction.
//
// Un seul arbre. La famille d'un conjoint (ses parents, sa fratrie,
// ses ancêtres) se colle à lui, au-dessus et de son côté, en poussant
// les voisins pour faire la place : jamais posée dans un trou ailleurs.
// Le conjoint est tourné vers l'extérieur de sa fratrie pour que sa
// famille ne passe pas au-dessus des frères et sœurs.
//
// Le sang de la référence gagne : les lignées de la personne de
// référence sont dessinées d'abord, les familles des pièces rapportées
// s'y attachent. Une famille reliée deux fois (cousins qui se marient,
// deux grandes lignées de la référence) n'est dessinée qu'une fois :
// l'autre filiation devient un renvoi si son fil croiserait une famille.
//
// L'ordre ne dépend jamais des effectifs : rangée, année de naissance,
// identifiant. Ajouter une personne ne bouge que son foyer.
//
// Banc, archétypes et témoins : tools/layout-bench/.
// ============================================================
const NODE_SPACING = 30
const FAMILY_GAP = 60
// Au-delà de cet écart entre la place idéale d'une famille de conjoint et la première place libre, on ne la colle pas
const DETACH_PX = 1440
// 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 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 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)
}
// Contours : par rangée, une liste d'intervalles [gauche, droite] triés et fusionnés
function addInterval(list, l, r) {
const out = []
let nl = l, nr = r, inserted = false
for (const [a, b] of list) {
if (b < nl - 1) { out.push([a, b]); continue }
if (a > nr + 1) { if (!inserted) { out.push([nl, nr]); inserted = true } out.push([a, b]); continue }
nl = Math.min(nl, a); nr = Math.max(nr, b)
}
if (!inserted) out.push([nl, nr])
return out
}
function mergeRows(target, source, dx) {
for (const [g, list] of source) {
let cur = target.get(g) || []
for (const [l, r] of list) cur = addInterval(cur, l + dx, r + dx)
target.set(g, cur)
}
}
const extentOf = (rows) => {
let l = Infinity, r = -Infinity
for (const list of rows.values()) for (const [a, b] of list) { l = Math.min(l, a); r = Math.max(r, b) }
return [l, r]
}
const overlaps = (l, r, list, gap) => list.some(([a, b]) => l < b + gap && r > a - gap)
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]))
// Chaînes de couples (composantes d'une rangée) telles que computeLayout les a ordonnées
const chainOf = new Map()
const chains = []
for (const gen of [...componentOrderPerGen.keys()].sort((a, b) => a - b)) {
for (const ids of componentOrderPerGen.get(gen)) {
const chain = [...ids]
chains.push(chain)
for (const id of chain) chainOf.set(id, chain)
}
}
const genOf = (chain) => byId.get(chain[0])._generation
const childrenOfUnion = new Map()
const parentUnionOf = new Map()
for (const e of result.edges) {
const u = e.sources[0], c = e.targets[0]
if (!childrenOfUnion.has(u)) childrenOfUnion.set(u, [])
childrenOfUnion.get(u).push(c)
if (!parentUnionOf.has(c)) parentUnionOf.set(c, u)
}
// Fratrie dans l'ordre choisi par la famille (displayOrder) ; à ordre égal, l'ordre d'arrivée
// est gardé (tri stable), donc un arbre jamais réordonné ne bouge pas
const toOrder = (v) => (Number.isFinite(v) ? v : Number.MAX_SAFE_INTEGER)
const edgeOrder = new Map(result.edges.map(e => [`${e.sources[0]}>${e.targets[0]}`, toOrder(e._displayOrder)]))
for (const [u, kids] of childrenOfUnion) kids.sort((a, b) => edgeOrder.get(`${u}>${a}`) - edgeOrder.get(`${u}>${b}`))
const hasParents = (chain) => chain.some(id => parentUnionOf.has(id))
// Ligne de sang de la référence : tout ce qu'on atteint par les seuls liens parent / enfant
const unionsOfPerson = new Map()
for (const n of nodes) {
if (n._type !== 'union') continue
for (const k of [n._data?.p1Key, n._data?.p2Key]) {
if (!k) continue
if (!unionsOfPerson.has(k)) unionsOfPerson.set(k, [])
unionsOfPerson.get(k).push(n.id)
}
}
const blood = new Set()
const referenceKey = result.referenceId != null ? `p-${result.referenceId}` : null
if (referenceKey && byId.has(referenceKey)) {
// On ne monte que depuis la référence et ses ancêtres (jamais depuis un descendant : son autre
// parent est une pièce rapportée), puis on descend depuis chacun d'eux
const up = [referenceKey]
blood.add(referenceKey)
while (up.length) {
const pu = parentUnionOf.get(up.shift())
if (!pu) continue
for (const p of [byId.get(pu)?._data?.p1Key, byId.get(pu)?._data?.p2Key]) {
if (p && byId.get(p)?._type === 'person' && !blood.has(p)) { blood.add(p); up.push(p) }
}
}
const down = [...blood]
while (down.length) {
const id = down.shift()
for (const u of (unionsOfPerson.get(id) || [])) for (const k of (childrenOfUnion.get(u) || [])) if (!blood.has(k)) { blood.add(k); down.push(k) }
}
}
const isBloodChain = (chain) => chain.some(id => blood.has(id))
const gapAfter = (chain, i) => chainGap(byId.get(chain[i]), byId.get(chain[i + 1]), NODE_SPACING)
const chainWidth = (chain) => chain.reduce((s, id, i) => s + byId.get(id).width + (i < chain.length - 1 ? gapAfter(chain, i) : 0), 0)
const chainOffsets = (chain) => {
const offsets = new Map()
let x = 0
chain.forEach((id, i) => { offsets.set(id, x); x += byId.get(id).width + (i < chain.length - 1 ? gapAfter(chain, i) : 0) })
return offsets
}
// La chaîne la plus haute d'une famille : on remonte de parents en parents
function topChainOf(chain) {
let cur = chain
const seen = new Set()
while (!seen.has(cur)) {
seen.add(cur)
const m = cur.find(id => parentUnionOf.has(id))
if (!m) break
cur = chainOf.get(parentUnionOf.get(m))
}
return cur
}
// Un couple « enfant + pièce rapportée » se tourne vers l'extérieur de la fratrie : la pièce rapportée
// à gauche dans la moitié gauche, à droite dans la moitié droite, pour que sa famille se pose à côté
function orient(chain, kid, index, count) {
if (chain.length !== 3) return
const spouse = chain[0] === kid ? chain[2] : chain[0]
if (chain[1] !== chain.find(id => byId.get(id)._type === 'union') || !parentUnionOf.has(spouse)) return
const left = index < (count - 1) / 2
if ((left && chain[0] !== spouse) || (!left && chain[2] !== spouse)) chain.reverse()
}
const pos = new Map()
const placed = new Set()
const externalEdges = new Set() // `${union}>${enfant}` : enfant dessiné sous une autre lignée
const detached = [] // familles qu'on n'a pas pu coller à leur enfant (trop loin) : posées à part, renvoi
// Imbrique des blocs de gauche à droite, rangée par rangée
function packBlocks(blocks, gap) {
const acc = new Map()
const xs = []
for (const b of blocks) {
let off = 0
for (const [g, list] of b.rows) {
const a = acc.get(g)
if (a?.length) off = Math.max(off, a[a.length - 1][1] + gap - list[0][0])
}
xs.push(off)
mergeRows(acc, b.rows, off)
}
return { xs, rows: acc }
}
// Décale un bloc depuis sa position idéale, du côté demandé, jusqu'à ce qu'il ne touche plus `rows`
function pushUntilFree(blockRows, ideal, rows, side, gap) {
let d = ideal
for (let iter = 0; iter < 500; iter++) {
let push = 0
for (const [g, list] of blockRows) {
const occ = rows.get(g)
if (!occ) continue
for (const [l, r] of list) for (const [ol, or] of occ) {
if (l + d < or + gap && r + d > ol - gap) push = Math.max(push, side > 0 ? or + gap - (l + d) : (r + d) - (ol - gap))
}
}
if (push <= 0) break
d += side * push
}
return d
}
// Place fantôme d'un enfant déjà dessiné ailleurs : il garde sa place dans l'ordre de la fratrie et
// dans le centrage des parents, sans occuper de contour (c'est l'enfant réel qui l'occupe)
const ghostBlock = (kid) => {
const n = byId.get(kid)
return { ghost: true, kid, rows: new Map([[n._generation, [[0, n.width]]]]), members: new Set(), external: [], gen: n._generation, chain: [0, n.width], rel: () => null, width: n.width, place() {} }
}
// Famille attachée à un conjoint : l'enfant à coller (ancre) doit finir au bout de sa fratrie, du côté du
// couple. Si l'ordre choisi l'en empêche, la fratrie est dessinée en miroir (du dernier au premier).
let anchor = null // { kid, end: 'left' | 'right' }
function buildBlock(chain, ownerUnion = null) {
placed.add(chain)
const gen = genOf(chain)
const childBlocks = []
const external = []
const kidsOfChain = []
for (const id of chain) {
if (byId.get(id)._type !== 'union') continue
for (const k of (childrenOfUnion.get(id) || [])) kidsOfChain.push({ unionId: id, kid: k })
}
if (anchor && kidsOfChain.length > 1 && kidsOfChain.some(k => k.kid === anchor.kid)) {
const idx = kidsOfChain.findIndex(k => k.kid === anchor.kid)
if ((anchor.end === 'right' && idx === 0) || (anchor.end === 'left' && idx === kidsOfChain.length - 1)) kidsOfChain.reverse()
anchor = null
}
kidsOfChain.forEach(({ unionId, kid }, i) => {
const kc = chainOf.get(kid)
if (placed.has(kc)) {
external.push({ kid, unionId })
externalEdges.add(`${unionId}>${kid}`)
childBlocks.push(ghostBlock(kid))
return
}
orient(kc, kid, i, kidsOfChain.length)
childBlocks.push(buildBlock(kc, unionId))
})
const packed = packBlocks(childBlocks, FAMILY_GAP)
// Chaîne centrée sur la rangée de ses enfants directs (fantômes compris), décalée au plus près si une
// famille de pièce rapportée (attachée à un enfant) occupe déjà cette rangée
const rowW = chainWidth(chain)
const offsets = chainOffsets(chain)
let rowX = 0
const rows = new Map()
childBlocks.forEach((b, i) => { if (!b.ghost) mergeRows(rows, b.rows, packed.xs[i]) })
if (childBlocks.length) {
const kidRowL = Math.min(...childBlocks.map((b, i) => packed.xs[i] + b.chain[0]))
const kidRowR = Math.max(...childBlocks.map((b, i) => packed.xs[i] + b.chain[1]))
rowX = (kidRowL + kidRowR) / 2 - rowW / 2
const obstacles = rows.get(gen) || []
if (overlaps(rowX, rowX + rowW, obstacles, FAMILY_GAP)) {
const candidates = obstacles.flatMap(([l, r]) => [r + FAMILY_GAP, l - FAMILY_GAP - rowW])
.filter(x => !overlaps(x, x + rowW, obstacles, FAMILY_GAP))
if (candidates.length) rowX = candidates.reduce((best, x) => (Math.abs(x - rowX) < Math.abs(best - rowX) ? x : best))
}
}
mergeRows(rows, new Map([[gen, [[rowX, rowX + rowW]]]]), 0)
const members = new Set(chain)
for (const b of childBlocks) { for (const m of b.members) members.add(m); external.push(...b.external) }
const ghosts = new Map()
childBlocks.forEach((b, i) => { if (b.ghost) ghosts.set(b.kid, packed.xs[i]) })
// Familles des conjoints de la chaîne pas encore dessinées : construites entières depuis leur ancêtre
// le plus haut, collées au conjoint, de son côté. Une famille déjà dessinée (reliée deux fois) ne l'est pas
// une seconde fois : la filiation reste externe, renvoi si son fil croise une famille. Une famille qu'il
// faudrait pousser de plus d'un écran pour la coller est posée à part (cas impossible, renvoi).
const attachments = []
for (const id of chain) {
if (byId.get(id)._type !== 'person') continue
const pu = parentUnionOf.get(id)
if (!pu || pu === ownerUnion) continue
const pc = chainOf.get(pu)
if (placed.has(pc)) continue
const top = topChainOf(pc)
if (placed.has(top)) continue
const side = offsets.get(id) + byId.get(id).width / 2 < rowW / 2 ? -1 : 1
const saved = anchor
anchor = { kid: id, end: side < 0 ? 'right' : 'left' }
const block = buildBlock(top, null)
anchor = saved
const at = block.rel(id)
const ideal = at == null ? rowX + offsets.get(id) - block.width / 2 : rowX + offsets.get(id) - at
const x = pushUntilFree(block.rows, ideal, rows, side, FAMILY_GAP)
if (Math.abs(x - ideal) > DETACH_PX) { detached.push(block); continue }
attachments.push({ block, x })
mergeRows(rows, block.rows, x)
for (const m of block.members) members.add(m)
external.push(...block.external)
}
// Origine du bloc au bord gauche de son contour
const [minL] = extentOf(rows)
for (const [g, list] of rows) rows.set(g, list.map(([l, r]) => [l - minL, r - minL]))
rowX -= minL
const kidXs = packed.xs.map(x => x - minL)
for (const [k, x] of ghosts) ghosts.set(k, x - minL)
for (const a of attachments) a.x -= minL
const rel = (id) => {
if (offsets.has(id)) return rowX + offsets.get(id)
if (ghosts.has(id)) return ghosts.get(id)
for (let i = 0; i < childBlocks.length; i++) {
if (childBlocks[i].ghost) continue
const r = childBlocks[i].rel(id)
if (r != null) return kidXs[i] + r
}
for (const a of attachments) { const r = a.block.rel(id); if (r != null) return a.x + r }
return null
}
return {
rows, members, external, gen, rel,
chain: [rowX, rowX + rowW],
get width() { return extentOf(rows)[1] },
place(x0) {
for (const id of chain) pos.set(id, x0 + rowX + offsets.get(id))
childBlocks.forEach((b, i) => b.place(x0 + kidXs[i]))
attachments.forEach(a => a.block.place(x0 + a.x))
},
}
}
// Racines : chaînes sans parents, le sang de la référence d'abord, puis par rangée, année de naissance, identifiant.
// Jamais par effectifs : ajouter une personne ne change pas l'ordre des lignées.
const birthYearOf = (chain) => Math.min(...chain.map(id => {
const d = byId.get(id)._data
const raw = d?.birthYear ?? d?.birthDate
const y = raw != null ? parseInt(String(raw).slice(0, 4), 10) : NaN
return Number.isFinite(y) ? y : 9999
}))
const roots = chains.filter(c => !hasParents(c)).sort((a, b) => (
(isBloodChain(b) - isBloodChain(a)) || genOf(a) - genOf(b) || birthYearOf(a) - birthYearOf(b) || String(a[0]).localeCompare(String(b[0]))
))
const free = []
for (const r of roots) if (!placed.has(r)) free.push(buildBlock(r))
for (const c of chains) if (!placed.has(c)) free.push(buildBlock(c))
free.push(...detached)
const forest = packBlocks(free, FAMILY_GAP * 2)
free.forEach((b, i) => b.place(forest.xs[i]))
// Réinjecter le X, normalisé à 0 ; Y inchangé (rangées)
let minX = Infinity
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 vers un enfant dessiné 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 (traceSibship) :
// 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}`) || !crossesOtherFamily(u, c)) continue
e._renvoi = true
e._renvoiIndex = renvoisByUnion.get(u) || 0
renvoisByUnion.set(u, e._renvoiIndex + 1)
}
// Lignes de fratrie emboîtées : par rangée, la ligne d'une union qui en contient une autre passe au-dessus
// d'elle (_railLift plus grand = plus haut). Deux lignes qui se chevauchent sans s'emboîter prennent deux
// niveaux différents. Niveau 0 = la ligne la plus proche des enfants.
const rails = new Map()
for (const e of result.edges) {
if (e._renvoi) continue
const u = byId.get(e.sources[0]), k = byId.get(e.targets[0])
if (!u || !k) continue
const r = rails.get(u.id) || { gen: u._generation, l: u.x + u.width / 2, r: u.x + u.width / 2 }
r.l = Math.min(r.l, k.x + k.width / 2); r.r = Math.max(r.r, k.x + k.width / 2)
rails.set(u.id, r)
}
const byRow = new Map()
for (const [id, r] of rails) { if (!byRow.has(r.gen)) byRow.set(r.gen, []); byRow.get(r.gen).push({ id, ...r }) }
for (const list of byRow.values()) {
list.sort((a, b) => (b.r - b.l) - (a.r - a.l) || a.l - b.l)
const level = new Map()
for (let i = 0; i < list.length; i++) {
const me = list[i]
let min = 0
const taken = new Set()
for (let j = 0; j < i; j++) {
const o = list[j]
if (me.r <= o.l || me.l >= o.r) continue
if (o.l <= me.l && o.r >= me.r) min = Math.max(min, level.get(o.id) + 1)
else taken.add(level.get(o.id))
}
let lv = min
while (taken.has(lv)) lv++
level.set(me.id, lv)
}
const deepest = Math.min(3, Math.max(...level.values()))
for (const r of list) byId.get(r.id)._railLift = Math.max(0, deepest - Math.min(3, level.get(r.id)))
}
return result
}