mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-15 00:30:50 +00:00
- Un cluster se range du côté où son conjoint se trouve dans la lignée principale (seconde passe si la première construction l'avait mis de l'autre côté) ; le couple se tourne vers sa famille. - La carte de famille se pose au plus près au-dessus du conjoint, en préférant le côté de sa famille. - La carte inverse réserve sa place sous l'écho du conjoint dans le cluster (plus de chevauchement). - La reconstruction d'un cluster ne laisse aucune trace (clusters imbriqués, chaînes posées, arêtes externes) : 0 chevauchement sur l'arbre réel, 8 à 0 sur asc-3. - Bandeau à l'écran avec le nom et la taille de la famille où l'on est. - Carte des familles : bascule nette sous une échelle, sans fondu ni glissement ; famille principale au milieu, familles par alliance en colonne de leur côté, traits de bloc à bloc avec les prénoms du mariage. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
711 lines
39 KiB
JavaScript
711 lines
39 KiB
JavaScript
import { computeLayout, componentOrderPerGen, chainGap } from './elkLayout'
|
||
import { PERSON_W, PERSON_H, FAMILY_CARD_W, FAMILY_CARD_H } from './constants'
|
||
|
||
// ============================================================
|
||
// 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
|
||
// Jamais d'îlot (Patch, 19/10) : une famille collée trop loin garde son fil, aussi long soit-il
|
||
const DETACH_PX = Infinity
|
||
// Famille reliée deux fois : au-delà de cet écart, la seconde filiation est une pastille, pas un fil
|
||
const RENVOI_FAR_PX = 1440
|
||
// Famille de conjoint poussée plus loin que ça : ses parents reviennent juste au-dessus de leur enfant
|
||
// Famille de conjoint poussée plus loin que ça par les descendants du couple : la chaîne se décale au bord de sa rangée d'enfants
|
||
const PARENT_SHIFT_PX = 400
|
||
// Côté d'une belle-famille : le côté extérieur de la branche, sauf si l'autre côté la rapproche d'au moins ça
|
||
const SIDE_SWITCH_PX = 1440
|
||
// Carte de famille et pont (maquette A, retenue le 19/10). Une belle-famille plus large que ça ne se colle pas à
|
||
// son enfant : elle devient un cluster dessiné à part, et une carte prend sa place au-dessus du conjoint
|
||
const CLUSTER_PX = 2880
|
||
|
||
// 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)
|
||
|
||
// Les grosses belles-familles (clusters) se rangent du côté où leur conjoint se trouve dans la lignée
|
||
// principale : gauche s'il est dans la moitié gauche, droite sinon. Ce côté ne se connaît qu'une fois la
|
||
// lignée posée, d'où une seconde passe quand un cluster s'est construit du mauvais côté. Le couple se tourne
|
||
// alors vers la famille, et la carte au-dessus du conjoint se pose de ce côté-là.
|
||
export function computeBlockLayout(graphData = null, referenceId = null) {
|
||
const first = layoutOnce(graphData, referenceId, new Map())
|
||
const main = first.families?.find(f => f.id === 'main')
|
||
if (!main || !first.families.some(f => f.anchorId)) return first
|
||
const mid = (main.bbox.x0 + main.bbox.x1) / 2
|
||
const byId = new Map(first.children.map(n => [n.id, n]))
|
||
const forced = new Map()
|
||
for (const f of first.families) {
|
||
if (!f.anchorId) continue
|
||
const a = byId.get(f.anchorId)
|
||
const want = a.x + a.width / 2 < mid ? -1 : 1
|
||
if (want !== f.side) forced.set(f.anchorId, want)
|
||
}
|
||
return forced.size ? layoutOnce(graphData, referenceId, forced) : first
|
||
}
|
||
|
||
function layoutOnce(graphData, referenceId, forcedSide) {
|
||
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, par le sang de la référence
|
||
// quand un membre en est (on retombe alors sur la lignée principale, déjà en cours : rien à attacher,
|
||
// la filiation sera un renvoi), sinon par le premier membre qui a des 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) && blood.has(id)) || 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, side) {
|
||
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
|
||
if ((side < 0 && chain[0] !== spouse) || (side > 0 && 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
|
||
const clusters = [] // grosses belles-familles dessinées à part : { block, anchorId, cardId, echoId, mainCardId }
|
||
const rowStep = (() => { const ys = [...new Set(nodes.map(n => n.y))].sort((a, b) => a - b); return ys.length > 1 ? ys[1] - ys[0] : PERSON_H + 60 })()
|
||
const attachedEdges = new Set() // `${union}>${enfant}` : fil d'une famille collée à son enfant, toujours dessiné
|
||
|
||
// 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 }
|
||
}
|
||
|
||
// Pose une famille de conjoint depuis sa place idéale (parents juste au-dessus de l'enfant), poussée du côté
|
||
// demandé : sur les rangées de l'enfant et en dessous (sa fratrie, leurs descendants), au-delà de toute
|
||
// l'étendue déjà posée, jamais sous la ligne d'une fratrie ; sur les rangées au-dessus (ses ancêtres, de
|
||
// petits couples), au premier trou libre, à côté des autres ancêtres.
|
||
function pushHybrid(blockRows, ideal, rows, side, anchorGen, 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?.length) continue
|
||
if (g >= anchorGen) {
|
||
const l = list[0][0] + d, r = list[list.length - 1][1] + d
|
||
const ol = occ[0][0], or = occ[occ.length - 1][1]
|
||
if (l < or + gap && r > ol - gap) push = Math.max(push, side > 0 ? or + gap - l : r - (ol - gap))
|
||
} else {
|
||
for (const [l0, r0] of list) for (const [ol, or] of occ) {
|
||
const l = l0 + d, r = r0 + d
|
||
if (l < or + gap && r > ol - gap) push = Math.max(push, side > 0 ? or + gap - l : r - (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)
|
||
// Pendant la reconstruction d'un cluster, la place de la carte inverse est réservée sous le fantôme du conjoint
|
||
let cardUnder = null
|
||
const ghostBlock = (kid) => {
|
||
const n = byId.get(kid)
|
||
const rows = new Map([[n._generation, [[0, n.width]]]])
|
||
if (kid === cardUnder) rows.set(n._generation + 1, [[(n.width - FAMILY_CARD_W) / 2, (n.width + FAMILY_CARD_W) / 2]])
|
||
return { ghost: true, withCard: kid === cardUnder, kid, rows, members: new Set(), external: [], gen: n._generation, chain: [0, n.width], rel: () => null, width: n.width, place() {} }
|
||
}
|
||
const CARD_SIDE_PX = 400
|
||
const freeSpotNear = (desired, w, list, prefer = 0) => {
|
||
if (!overlaps(desired, desired + w, list, FAMILY_GAP)) return desired
|
||
const candidates = list.flatMap(([l, r]) => [r + FAMILY_GAP, l - FAMILY_GAP - w]).filter(c => !overlaps(c, c + w, list, FAMILY_GAP))
|
||
if (!candidates.length) return null
|
||
// Le côté de la famille est préféré tant qu'il ne coûte pas plus de CARD_SIDE_PX
|
||
const cost = (c) => Math.abs(c - desired) + (prefer && Math.sign(c - desired) === -prefer ? CARD_SIDE_PX : 0)
|
||
return candidates.reduce((best, c) => (cost(c) < cost(best) ? c : best))
|
||
}
|
||
|
||
// 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' }
|
||
|
||
// `outer` : côté extérieur hérité de la lignée (−1 gauche, +1 droite). Les belles-familles d'une branche vont
|
||
// toutes du même côté, celui où la branche est au bord de l'arbre, pour ne pas se poser entre deux familles.
|
||
function buildBlock(chain, ownerUnion = null, outer = 0) {
|
||
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
|
||
}
|
||
// Remariage : les enfants de chaque union restent groupés dans l'ordre des unions de la chaîne, et une
|
||
// belle-famille va du côté de son union (gauche pour la première, droite pour la dernière), jamais au-delà
|
||
// des demi-frères et demi-sœurs
|
||
const unionsOfChain = chain.filter(id => byId.get(id)._type === 'union')
|
||
const sideOfUnion = (unionId) => {
|
||
if (unionsOfChain.length < 2) return 0
|
||
return unionsOfChain.indexOf(unionId) < (unionsOfChain.length - 1) / 2 ? -1 : 1
|
||
}
|
||
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), unionId })
|
||
return
|
||
}
|
||
const side = sideOfUnion(unionId) || outer || (i < (kidsOfChain.length - 1) / 2 ? -1 : 1)
|
||
childBlocks.push({ ...buildBlock(kc, unionId, side), unionId })
|
||
})
|
||
// Un enfant dont le conjoint amène sa famille va au bout de la fratrie, de son côté : sa belle-famille,
|
||
// qui fait partie de son bloc, se retrouve au bord, sans survoler ses frères et sœurs. L'ordre de
|
||
// naissance s'efface pour lui (choix de Patch, 19/10).
|
||
const kids = unionsOfChain.flatMap(u => {
|
||
const group = childBlocks.filter(b => b.unionId === u)
|
||
return [...group.filter(b => b.attachSide < 0), ...group.filter(b => !b.attachSide), ...group.filter(b => b.attachSide > 0)]
|
||
})
|
||
childBlocks.length = 0
|
||
childBlocks.push(...kids)
|
||
const packed = packBlocks(childBlocks, FAMILY_GAP)
|
||
const rows = new Map()
|
||
// Un fantôme ne compte pas dans le contour, sauf s'il porte la carte inverse d'un cluster (l'écho y est dessiné)
|
||
childBlocks.forEach((b, i) => { if (!b.ghost || b.withCard) mergeRows(rows, b.rows, packed.xs[i]) })
|
||
|
||
// Familles des conjoints de la chaîne pas encore dessinées : construites entières depuis leur ancêtre
|
||
// le plus haut. Le couple se tourne vers cette famille (conjoint du côté où elle se pose). 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 autre famille.
|
||
const built = []
|
||
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 = forcedSide.get(id) ?? (chain.length > 3 ? (chain.indexOf(id) < chain.length / 2 ? -1 : 1) : (outer || (chain.indexOf(id) < chain.length / 2 ? -1 : 1)))
|
||
const saved = anchor
|
||
const clustersBefore = clusters.length
|
||
const externalBefore = new Set(externalEdges)
|
||
anchor = { kid: id, end: side < 0 ? 'right' : 'left' }
|
||
let block = buildBlock(top, null, side)
|
||
anchor = saved
|
||
if (block.width > CLUSTER_PX) {
|
||
// La famille devient un cluster. On la reconstruit avec, sous le fantôme du conjoint, la place réservée
|
||
// de la carte inverse. La première construction ne laisse aucune trace : ses clusters imbriqués (et leurs
|
||
// chaînes), les chaînes qu'elle a posées et les arêtes externes qu'elle a notées sont oubliés avec elle.
|
||
const unplace = (members) => { for (const ch of new Set([...members].map(m => chainOf.get(m)).filter(Boolean))) placed.delete(ch) }
|
||
for (const c of clusters.splice(clustersBefore)) { const i = nodes.findIndex(n => n.id === c.cardId); if (i >= 0) nodes.splice(i, 1); byId.delete(c.cardId); unplace(c.block.members) }
|
||
unplace(block.members)
|
||
for (const e of [...externalEdges]) if (!externalBefore.has(e)) externalEdges.delete(e)
|
||
const savedCard = cardUnder
|
||
cardUnder = id
|
||
anchor = { kid: id, end: side < 0 ? 'right' : 'left' }
|
||
block = buildBlock(top, null, side)
|
||
anchor = saved
|
||
cardUnder = savedCard
|
||
// Ici, une carte de famille tient la place de ses parents, juste au-dessus de lui
|
||
const A = byId.get(id)
|
||
const cardId = `fam-${id}`, echoId = `echo-${id}`, mainCardId = `fam-main-${id}`
|
||
const partners = [byId.get(pu)?._data?.p1Key, byId.get(pu)?._data?.p2Key].map(k => byId.get(k)?._data).filter(Boolean)
|
||
const label = partners.find(d => d.lastName)?.lastName || A._data?.birthName || A._data?.lastName || ''
|
||
const people = [...block.members].map(m => byId.get(m)).filter(n => n?._type === 'person')
|
||
const portraits = [...people.filter(n => n._data?.photo), ...people.filter(n => !n._data?.photo)].slice(0, 4).map(n => n._data)
|
||
nodes.push({ id: cardId, _type: 'family', width: FAMILY_CARD_W, height: FAMILY_CARD_H, _generation: gen - 1, y: A.y - rowStep + (PERSON_H - FAMILY_CARD_H) / 2, _data: { label, count: people.length, portraits, targetId: echoId, stem: 'down', stemTo: id } })
|
||
byId.set(cardId, nodes[nodes.length - 1])
|
||
const card = { isCard: true, rows: new Map([[gen - 1, [[0, FAMILY_CARD_W]]]]), members: new Set([cardId]), external: [], gen: gen - 1, rel: (x) => (x === cardId ? 0 : null), chain: [0, FAMILY_CARD_W], width: FAMILY_CARD_W, place(x0) { pos.set(cardId, x0) } }
|
||
clusters.push({ block, anchorId: id, pu, cardId, echoId, mainCardId, label, people, side })
|
||
built.push({ block: card, anchorId: id, side })
|
||
if (chain.length === 3) orient(chain, chain[0] === id ? chain[2] : chain[0], side)
|
||
continue
|
||
}
|
||
built.push({ block, anchorId: id, side })
|
||
if (chain.length === 3) orient(chain, chain[0] === id ? chain[2] : chain[0], side)
|
||
}
|
||
|
||
// 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 conjoint occupe déjà cette rangée
|
||
const rowW = chainWidth(chain)
|
||
let offsets = chainOffsets(chain)
|
||
const kidRowL = childBlocks.length ? Math.min(...childBlocks.map((b, i) => packed.xs[i] + b.chain[0])) : 0
|
||
const kidRowR = childBlocks.length ? Math.max(...childBlocks.map((b, i) => packed.xs[i] + b.chain[1])) : rowW
|
||
const avoidObstacles = (x) => {
|
||
const obstacles = rows.get(gen) || []
|
||
if (!overlaps(x, x + rowW, obstacles, FAMILY_GAP)) return x
|
||
const candidates = obstacles.flatMap(([l, r]) => [r + FAMILY_GAP, l - FAMILY_GAP - rowW])
|
||
.filter(c => !overlaps(c, c + rowW, obstacles, FAMILY_GAP))
|
||
// De préférence une place encore au-dessus de la rangée des enfants (sinon la ligne de fratrie
|
||
// partirait de l'extérieur et croiserait les familles voisines)
|
||
const over = candidates.filter(c => c + rowW > kidRowL && c < kidRowR)
|
||
const pool = over.length ? over : candidates
|
||
return pool.length ? pool.reduce((best, c) => (Math.abs(c - x) < Math.abs(best - x) ? c : best)) : x
|
||
}
|
||
let rowX = (kidRowL + kidRowR) / 2 - rowW / 2
|
||
if (childBlocks.length) {
|
||
// Remariage : chaque union au plus près au-dessus de ses propres enfants. Parmi les positions qui
|
||
// alignent une union sur un bord ou le centre de son groupe, on garde celle où les lignes de fratrie
|
||
// débordent le moins au-dessus des demi-frères et demi-sœurs (égalité : la plus proche du centrage).
|
||
if (unionsOfChain.length > 1) {
|
||
const groups = unionsOfChain.map(u => {
|
||
const g = childBlocks.map((b, i) => (b.unionId === u ? [packed.xs[i] + b.chain[0], packed.xs[i] + b.chain[1]] : null)).filter(Boolean)
|
||
return g.length ? [Math.min(...g.map(r => r[0])), Math.max(...g.map(r => r[1]))] : null
|
||
})
|
||
const at = (u) => offsets.get(u) + byId.get(u).width / 2
|
||
const cost = (x) => unionsOfChain.reduce((s, u, i) => {
|
||
if (!groups[i]) return s
|
||
const c = x + at(u)
|
||
return s + Math.max(0, c - groups[i][1]) + Math.max(0, groups[i][0] - c)
|
||
}, 0)
|
||
const centered = rowX
|
||
const candidates = [centered]
|
||
unionsOfChain.forEach((u, i) => { if (groups[i]) for (const target of [groups[i][0], (groups[i][0] + groups[i][1]) / 2, groups[i][1]]) candidates.push(target - at(u)) })
|
||
rowX = candidates.reduce((best, x) => {
|
||
const cb = cost(best), cx = cost(x)
|
||
return cx < cb - 0.5 || (Math.abs(cx - cb) <= 0.5 && Math.abs(x - centered) < Math.abs(best - centered)) ? x : best
|
||
})
|
||
}
|
||
rowX = avoidObstacles(rowX)
|
||
}
|
||
|
||
// Belles-familles de la chaîne, collées au conjoint depuis leur place idéale (ses parents juste au-dessus
|
||
// de lui), poussées vers l'extérieur du bloc : sur la rangée du couple et en dessous, au-delà de tout le
|
||
// bloc (jamais sous la ligne de la fratrie des enfants) ; au-dessus, au premier trou libre.
|
||
// Elles font partie du bloc : le bloc du dessus les emporte avec l'enfant quand il l'empile au bout de
|
||
// sa fratrie.
|
||
const rowsBase = new Map([...rows].map(([g, l]) => [g, l.map(i => [...i])]))
|
||
const idealOf = (p, rx) => {
|
||
const at = p.block.rel(p.anchorId)
|
||
const anchorX = rx + offsets.get(p.anchorId)
|
||
return at == null ? anchorX - p.block.width / 2 : anchorX - at
|
||
}
|
||
// 1) Le côté de chaque famille : celui prévu (extérieur de la branche), sauf si l'autre côté la rapproche
|
||
// nettement. Le couple se retourne alors et l'enfant ira au bout de sa fratrie de ce côté-là.
|
||
{
|
||
const trial = new Map([...rowsBase].map(([g, l]) => [g, l.map(i => [...i])]))
|
||
mergeRows(trial, new Map([[gen, [[rowX, rowX + rowW]]]]), 0)
|
||
if (ownerUnion) mergeRows(trial, new Map([[gen - 1, [[rowX - FAMILY_GAP, rowX + rowW + FAMILY_GAP]]]]), 0)
|
||
for (const p of built) {
|
||
if (chain.length !== 3) break
|
||
if (p.block.isCard) continue
|
||
const ideal = idealOf(p, rowX)
|
||
const x = pushHybrid(p.block.rows, ideal, trial, p.side, gen, FAMILY_GAP)
|
||
const other = -p.side
|
||
const mirrored = (chain[0] === p.anchorId ? chain[2] : chain[0])
|
||
const idealOther = ideal + (offsets.get(mirrored) - offsets.get(p.anchorId))
|
||
const xOther = pushHybrid(p.block.rows, idealOther, trial, other, gen, FAMILY_GAP)
|
||
if (Math.abs(xOther - idealOther) + SIDE_SWITCH_PX < Math.abs(x - ideal)) {
|
||
p.side = other
|
||
orient(chain, mirrored, other)
|
||
offsets = chainOffsets(chain)
|
||
}
|
||
}
|
||
}
|
||
// 2) Pose. Si une famille serait poussée loin par les descendants du couple, la chaîne se décale au bord
|
||
// de sa rangée d'enfants, du côté de cette famille : les parents ne sont plus au centre de leurs enfants,
|
||
// mais la belle-famille est tout près et son fil reste court.
|
||
// Au-dessus de la chaîne, la place de ses propres parents est réservée : les parents du conjoint se posent
|
||
// à côté, pas dessus (sinon le couple des parents, posé après, ne trouve plus sa place au-dessus des enfants)
|
||
const reserveParents = (r, rx) => { if (ownerUnion) mergeRows(r, new Map([[gen - 1, [[rx - FAMILY_GAP, rx + rowW + FAMILY_GAP]]]]), 0) }
|
||
const placeAll = (rx) => {
|
||
const r = new Map([...rowsBase].map(([g, l]) => [g, l.map(i => [...i])]))
|
||
mergeRows(r, new Map([[gen, [[rx, rx + rowW]]]]), 0)
|
||
reserveParents(r, rx)
|
||
const list = []
|
||
let maxPush = 0, sideOfMax = 0
|
||
for (const p of built) {
|
||
const ideal = idealOf(p, rx)
|
||
const x = p.block.isCard ? (freeSpotNear(ideal, p.block.width, r.get(gen - 1) || [], p.side) ?? pushHybrid(p.block.rows, ideal, r, p.side, gen, FAMILY_GAP)) : pushHybrid(p.block.rows, ideal, r, p.side, gen, FAMILY_GAP)
|
||
if (!p.block.isCard && Math.abs(x - ideal) > maxPush) { maxPush = Math.abs(x - ideal); sideOfMax = p.side }
|
||
list.push({ block: p.block, x, ideal, anchorId: p.anchorId, side: p.side })
|
||
mergeRows(r, p.block.rows, x)
|
||
}
|
||
// Le contour rendu ne garde pas la réservation (elle ne sert qu'à pousser les belles-familles)
|
||
const out = new Map([...rowsBase].map(([g, l]) => [g, l.map(i => [...i])]))
|
||
mergeRows(out, new Map([[gen, [[rx, rx + rowW]]]]), 0)
|
||
for (const a of list) mergeRows(out, a.block.rows, a.x)
|
||
return { rows: out, list, maxPush, sideOfMax }
|
||
}
|
||
let result = placeAll(rowX)
|
||
if (childBlocks.length && result.maxPush > PARENT_SHIFT_PX && kidRowR - kidRowL > rowW) {
|
||
const edge = avoidObstacles(result.sideOfMax < 0 ? kidRowL : kidRowR - rowW)
|
||
const alt = placeAll(edge)
|
||
if (alt.maxPush < result.maxPush) { rowX = edge; result = alt }
|
||
}
|
||
rows.clear()
|
||
for (const [g, l] of result.rows) rows.set(g, l)
|
||
const attachments = result.list.map(a => ({ block: a.block, x: a.x }))
|
||
for (const a of result.list) {
|
||
attachedEdges.add(`${parentUnionOf.get(a.anchorId)}>${a.anchorId}`)
|
||
}
|
||
const attachSide = built.length ? (built.every(p => p.side < 0) ? -1 : built.every(p => p.side > 0) ? 1 : 0) : 0
|
||
|
||
const members = new Set(chain)
|
||
for (const b of childBlocks) { for (const m of b.members) members.add(m); external.push(...b.external) }
|
||
for (const a of attachments) { for (const m of a.block.members) members.add(m); external.push(...a.block.external) }
|
||
const ghosts = new Map()
|
||
childBlocks.forEach((b, i) => { if (b.ghost) ghosts.set(b.kid, packed.xs[i]) })
|
||
|
||
// 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; if (a.parents) a.parents.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) {
|
||
if (a.parents?.offsets.has(id)) return a.parents.x + a.parents.offsets.get(id)
|
||
const r = a.block.rel(id)
|
||
if (r != null) return a.x + r
|
||
}
|
||
return null
|
||
}
|
||
return {
|
||
rows, members, external, gen, rel, attachSide,
|
||
chain: [rowX, rowX + rowW],
|
||
width: 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)
|
||
if (a.parents) for (const id of a.parents.chain) pos.set(id, x0 + a.parents.x + a.parents.offsets.get(id))
|
||
})
|
||
},
|
||
}
|
||
}
|
||
|
||
// 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
|
||
}))
|
||
// La plus grande lignée d'abord : construite en premier, c'est elle qui reçoit les autres en belles-familles.
|
||
// Dans l'autre sens, une petite branche construite d'abord aspirerait toute la lignée principale comme
|
||
// « famille du conjoint » et la pousserait loin (vu le 19/10 sur un arbre réel : 249 personnes poussées).
|
||
const descCount = new Map()
|
||
const countDesc = (chain, seen = new Set()) => {
|
||
if (descCount.has(chain)) return descCount.get(chain)
|
||
if (seen.has(chain)) return 0
|
||
seen.add(chain)
|
||
let n = chain.length
|
||
for (const id of chain) for (const k of (childrenOfUnion.get(id) || [])) n += countDesc(chainOf.get(k), seen)
|
||
descCount.set(chain, n)
|
||
return n
|
||
}
|
||
const roots = chains.filter(c => !hasParents(c)).sort((a, b) => (
|
||
(isBloodChain(b) - isBloodChain(a)) || countDesc(b) - countDesc(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)
|
||
// Clusters : chaque grosse belle-famille à part, sur les mêmes rangées. Le conjoint y apparaît dans sa fratrie
|
||
// (écho de la même personne) avec, sous lui, la carte de la lignée qu'il a rejointe. Les deux cartes se répondent.
|
||
// Les clusters se rangent du côté de leur conjoint : à gauche de la lignée si le conjoint est dans la
|
||
// moitié gauche de sa fratrie, à droite sinon. L'orientation des gens et la place des familles se répondent.
|
||
const ordered = [...clusters.filter(c => c.side < 0).reverse().map(c => c.block), ...free, ...clusters.filter(c => c.side >= 0).map(c => c.block)]
|
||
const forest = packBlocks(ordered, FAMILY_GAP * 2)
|
||
ordered.forEach((b, i) => b.place(forest.xs[i]))
|
||
const xOfBlock = new Map(ordered.map((b, i) => [b, forest.xs[i]]))
|
||
const referenceNode = result.referenceId != null ? byId.get(`p-${result.referenceId}`) : null
|
||
const inCluster = new Set(clusters.flatMap(c => [...c.block.members]))
|
||
const mainPeople = nodes.filter(n => n._type === 'person' && !inCluster.has(n.id) && !n._echo)
|
||
clusters.forEach((c) => {
|
||
const A = byId.get(c.anchorId)
|
||
const ghostX = c.block.rel(c.anchorId)
|
||
const x0 = xOfBlock.get(c.block)
|
||
if (ghostX == null) return
|
||
pos.set(c.echoId, x0 + ghostX)
|
||
nodes.push({ id: c.echoId, _type: 'person', _data: A._data, width: PERSON_W, height: PERSON_H, _generation: A._generation, y: A.y, _echo: true, _echoOf: c.anchorId })
|
||
byId.set(c.echoId, nodes[nodes.length - 1])
|
||
const mainChain = chainOf.get(c.anchorId) || []
|
||
const around = [...mainChain.filter(id => byId.get(id)._type === 'person' && id !== c.anchorId), ...mainChain.flatMap(id => childrenOfUnion.get(id) || [])].map(id => byId.get(id)?._data).filter(Boolean)
|
||
const mainLabel = referenceNode?._data?.lastName || around[0]?.lastName || ''
|
||
nodes.push({ id: c.mainCardId, _type: 'family', width: FAMILY_CARD_W, height: FAMILY_CARD_H, _generation: A._generation + 1, y: A.y + rowStep + (PERSON_H - FAMILY_CARD_H) / 2, _data: { label: mainLabel, count: mainPeople.length, portraits: around.slice(0, 4), targetId: c.anchorId, stem: 'up', stemTo: c.echoId } })
|
||
byId.set(c.mainCardId, nodes[nodes.length - 1])
|
||
pos.set(c.mainCardId, pos.get(c.echoId) + (PERSON_W - FAMILY_CARD_W) / 2)
|
||
// Le fil des parents va à l'écho dans le cluster ; vers le conjoint du plan principal, c'est la carte qui parle
|
||
for (const e of [...result.edges]) {
|
||
if (e.targets[0] !== c.anchorId || !c.block.members.has(e.sources[0])) continue
|
||
e._hidden = true
|
||
result.edges.push({ ...e, id: `${e.id}#echo`, targets: [c.echoId], _hidden: false })
|
||
}
|
||
})
|
||
|
||
// 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 (e._hidden) continue
|
||
if (!externalEdges.has(`${u}>${c}`) || attachedEdges.has(`${u}>${c}`)) continue
|
||
// Famille reliée deux fois à l'arbre (dessinée sous l'autre lien) : pastille si le fil croiserait une
|
||
// famille, ou s'il devrait traverser plus d'un écran
|
||
const far = Math.abs((byId.get(u).x + byId.get(u).width / 2) - (byId.get(c).x + byId.get(c).width / 2)) > RENVOI_FAR_PX
|
||
if (!far && !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. Une ligne qui en contient une autre passe au-dessus d'elle.
|
||
// Deux lignes qui se chevauchent sans s'emboîter : celle dont l'union est en dehors de l'autre passe
|
||
// au-dessus (son fil n'entre dans l'autre famille qu'une fois, à l'enfant collé), sinon la plus longue.
|
||
// _railLift plus grand = plus haut ; 0 = la ligne la plus proche des enfants.
|
||
const rails = new Map()
|
||
for (const e of result.edges) {
|
||
if (e._renvoi || e._hidden) continue
|
||
const u = byId.get(e.sources[0]), k = byId.get(e.targets[0])
|
||
if (!u || !k) continue
|
||
const ux = u.x + u.width / 2
|
||
const r = rails.get(u.id) || { id: u.id, gen: u._generation, ux, l: ux, r: ux }
|
||
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 r of rails.values()) { if (!byRow.has(r.gen)) byRow.set(r.gen, []); byRow.get(r.gen).push(r) }
|
||
for (const list of byRow.values()) {
|
||
list.sort((a, b) => a.l - b.l)
|
||
const below = new Map(list.map(r => [r.id, []]))
|
||
for (let i = 0; i < list.length; i++) for (let j = i + 1; j < list.length; j++) {
|
||
const a = list[i], b = list[j]
|
||
if (b.l >= a.r) break
|
||
const aIn = a.l <= b.l && a.r >= b.r, bIn = b.l <= a.l && b.r >= a.r
|
||
let upper
|
||
if (aIn && !bIn) upper = a
|
||
else if (bIn && !aIn) upper = b
|
||
else {
|
||
const aOut = a.ux < b.l || a.ux > b.r, bOut = b.ux < a.l || b.ux > a.r
|
||
upper = aOut && !bOut ? a : bOut && !aOut ? b : (a.r - a.l >= b.r - b.l ? a : b)
|
||
}
|
||
below.get(upper.id).push(upper === a ? b.id : a.id)
|
||
}
|
||
const lift = new Map()
|
||
const visiting = new Set()
|
||
const liftOf = (id) => {
|
||
if (lift.has(id)) return lift.get(id)
|
||
if (visiting.has(id)) return 0
|
||
visiting.add(id)
|
||
const v = below.get(id).reduce((m, o) => Math.max(m, liftOf(o) + 1), 0)
|
||
visiting.delete(id)
|
||
lift.set(id, v)
|
||
return v
|
||
}
|
||
for (const r of list) byId.get(r.id)._railLift = Math.min(4, liftOf(r.id))
|
||
}
|
||
|
||
// Carte des familles (maquette J, 19/10) : au dézoom, chaque famille (la lignée principale, chaque cluster)
|
||
// devient une tuile, et chaque mariage entre familles un trait. Le dessin se fait dans Galaxy / renderers.
|
||
const familyOf = new Map()
|
||
clusters.forEach(c => { for (const m of c.block.members) familyOf.set(m, c.cardId); familyOf.set(c.echoId, c.cardId); familyOf.set(c.mainCardId, c.cardId) })
|
||
const bboxOf = (ids) => {
|
||
const ps = ids.map(id => byId.get(id)).filter(n => n && n._type === 'person' && n.x != null)
|
||
if (!ps.length) return null
|
||
return { x0: Math.min(...ps.map(n => n.x)), y0: Math.min(...ps.map(n => n.y)), x1: Math.max(...ps.map(n => n.x + n.width)), y1: Math.max(...ps.map(n => n.y + n.height)) }
|
||
}
|
||
const portraitsOf = (ids) => {
|
||
const ps = ids.map(id => byId.get(id)).filter(n => n?._type === 'person' && !n._echo)
|
||
return [...ps.filter(n => n._data?.photo), ...ps.filter(n => !n._data?.photo)].slice(0, 6).map(n => n._data)
|
||
}
|
||
const mainIds = nodes.filter(n => !familyOf.has(n.id)).map(n => n.id)
|
||
nodes.forEach(n => { n._family = familyOf.get(n.id) || 'main' })
|
||
const partnerOf = (id) => { const ch = chainOf.get(id) || []; return ch.find(x => x !== id && byId.get(x)?._type === 'person') }
|
||
const families = [
|
||
{ id: 'main', label: referenceNode?._data?.lastName || '', count: mainPeople.length, portraits: portraitsOf(mainIds), bbox: bboxOf(mainIds), links: [] },
|
||
...clusters.map(c => ({ id: c.cardId, side: c.side, anchorId: c.anchorId, label: c.label, count: c.people.length, portraits: portraitsOf([...c.block.members]), bbox: bboxOf([...c.block.members]), links: [{ to: 'main', label: [byId.get(c.anchorId)?._data?.firstName, byId.get(partnerOf(c.anchorId))?._data?.firstName].filter(Boolean).join(' et ') }] })),
|
||
].filter(f => f.bbox)
|
||
families.forEach(f => { f.spread = { x: (f.bbox.x0 + f.bbox.x1) / 2, y: (f.bbox.y0 + f.bbox.y1) / 2 } })
|
||
result.families = families
|
||
|
||
return result
|
||
}
|