diff --git a/frontend/src/components/galaxy/__snapshots__/archetypes/belle-famille.txt b/frontend/src/components/galaxy/__snapshots__/archetypes/belle-famille.txt index bcfedfa..a86995c 100644 --- a/frontend/src/components/galaxy/__snapshots__/archetypes/belle-famille.txt +++ b/frontend/src/components/galaxy/__snapshots__/archetypes/belle-famille.txt @@ -1,13 +1,13 @@ - SG────•───SGw - │ - ┬────┴ + SG────•───SGw + │ + ┬────┴ + │ + SP────•───SM Pa────•───Ma + │ │ + ┬────────┼────────┬ ┼────────┬ + │ │ │ │ │ +S3 S2 S────•────E E2 │ -Pa────•───Ma SP────•───SM - │ │ - ┼────────┬ ┬───┴────┬────────┬ - │ │ │ │ │ - E2 E────•────S S2 S3 - │ - ┬───┴────┬ - │ │ - K1 K2 + ┬───┴────┬ + │ │ + K1 K2 diff --git a/frontend/src/components/galaxy/__snapshots__/archetypes/branche-courte.txt b/frontend/src/components/galaxy/__snapshots__/archetypes/branche-courte.txt index 72b4313..7ae92a0 100644 --- a/frontend/src/components/galaxy/__snapshots__/archetypes/branche-courte.txt +++ b/frontend/src/components/galaxy/__snapshots__/archetypes/branche-courte.txt @@ -10,12 +10,12 @@ A2────•───A2w │ ┬────┴ │ -A3────•───A3w B0────•───B0w - │ │ - ┬────┴ ┬──────────────┼ - │ │ │ - X────•────Y S - │ - ┼ - │ - K +A3────•───A3w B0────•───B0w + │ │ + ┴────┬ ┬───┴────┬ + │ │ │ + X────•────Y S + │ + ┼ + │ + K diff --git a/frontend/src/components/galaxy/__snapshots__/archetypes/issue-67.txt b/frontend/src/components/galaxy/__snapshots__/archetypes/issue-67.txt index e677fb6..9a2ed4f 100644 --- a/frontend/src/components/galaxy/__snapshots__/archetypes/issue-67.txt +++ b/frontend/src/components/galaxy/__snapshots__/archetypes/issue-67.txt @@ -2,16 +2,16 @@ GGGa───•──GGGb │ ┬────┴ │ - GGa───•───GGb GGy┄┄┄•┄┄? GGx┄┄┄•┄┄┄? - │ │ │ - ┬────┴ ┬──────┴ ┬────────────┴ - │ │ │ - GM1───•───GF1 GM2───•───GF2 - │ │ - ┬────┴ ┬────────────────┴ - │ │ - M────•────F - │ - ┼ - │ - Kid + GGa───•───GGb GGy┄┄┄•┄┄┄? GGx┄┄┄┄•┄┄? + │ │ │ + ┴────┬ ┴────┬ ┬─┴ + │ │ │ + GM1───•───GF1 GM2───•───GF2 + │ │ + ┴────────┬ ┼ + │ │ + M────•────F + │ + ┼ + │ + Kid diff --git a/frontend/src/components/galaxy/__snapshots__/archetypes/marc-nadia.txt b/frontend/src/components/galaxy/__snapshots__/archetypes/marc-nadia.txt index 641ecfd..800b0fd 100644 --- a/frontend/src/components/galaxy/__snapshots__/archetypes/marc-nadia.txt +++ b/frontend/src/components/galaxy/__snapshots__/archetypes/marc-nadia.txt @@ -1,6 +1,6 @@ - Pierre──•──Odile Louis──•─Helene - │ │ - ┬────────┼────────┬────────┬ ┬────────┬───┴────┬────────┬────────┬ + Pierre───•──Odile Louis──•─Helene + │ │ + ┬────────┬────┴───┬────────┬ ┬────────┬───┴────┬────────┬────────┬ │ │ │ │ │ │ │ │ │ Luc Paul Mireil Nadia──•──Marc Claire Julien Sophie Eric │ diff --git a/frontend/src/components/galaxy/__snapshots__/archetypes/oncle-niece.txt b/frontend/src/components/galaxy/__snapshots__/archetypes/oncle-niece.txt index 2ed4de5..19a8200 100644 --- a/frontend/src/components/galaxy/__snapshots__/archetypes/oncle-niece.txt +++ b/frontend/src/components/galaxy/__snapshots__/archetypes/oncle-niece.txt @@ -1,6 +1,6 @@ G────•───Gw │ -┬──────────────┴───┬ -│ │ -O────•────N P────•───Pw + ┬────┴───┬ + │ │ +N────•────O P────•───Pw renvoi : P+Pw → N diff --git a/frontend/src/components/galaxy/__snapshots__/archetypes/trois-belles-familles.txt b/frontend/src/components/galaxy/__snapshots__/archetypes/trois-belles-familles.txt index a4c984f..cc4ad86 100644 --- a/frontend/src/components/galaxy/__snapshots__/archetypes/trois-belles-familles.txt +++ b/frontend/src/components/galaxy/__snapshots__/archetypes/trois-belles-familles.txt @@ -1,9 +1,9 @@ -A────•───Aw B────•───Bw C─────•───Cw - │ │ │ - ┼────────┬ ┬────┴───┬ ┬────┴ - │ │ │ │ │ - A2 A1────•───B1 B2────•────C1 - │ │ - ┼ ┼ - │ │ - X Y +C────•───Cw B────•───Bw A─────•───Aw + │ │ │ + ┼ ┬────────┼ ┬────────┼ + │ │ │ │ │ + C1────•───B2 B1────•────A1 A2 + │ │ + ┼ ┼ + │ │ + Y X diff --git a/frontend/src/components/galaxy/archetypes.test.js b/frontend/src/components/galaxy/archetypes.test.js index d04b221..52f31aa 100644 --- a/frontend/src/components/galaxy/archetypes.test.js +++ b/frontend/src/components/galaxy/archetypes.test.js @@ -7,19 +7,13 @@ import { checkInvariants, HARD } from '../../../../tools/layout-bench/invariants // Chaque archétype a un rendu texte validé une fois à l'œil (__snapshots__/archetypes/.txt) : il devient // le témoin. Un changement de placement qui le modifie doit être relu et le témoin mis à jour (vitest -u). -// -// Échecs connus, jusqu'à l'étape X du placement (le sang de la référence garde son enfant sous ses parents) : -// la belle-famille, plus nombreuse, revendique le couple la première et la fratrie de la référence se retrouve -// dans le désordre. Quand X est fait, ces cas passent et il faut les retirer d'ici (le test le signale). -const ECHECS_CONNUS = new Set(['belle-famille', 'trois-belles-familles']) describe('archétypes du placement', () => { for (const a of archetypes) { describe(a.slug, () => { const result = computeBlockLayout(a.graph, a.reference) const meta = [...coupleBarMeta] - const check = ECHECS_CONNUS.has(a.slug) ? it.fails : it - check('respecte les invariants', () => { + it('respecte les invariants', () => { const inv = checkInvariants(result, meta, a.graph, a.reference) expect(Object.fromEntries(HARD.map((k) => [k, inv[k]]))).toEqual(Object.fromEntries(HARD.map((k) => [k, 0]))) }) diff --git a/frontend/src/components/galaxy/blockLayout.js b/frontend/src/components/galaxy/blockLayout.js index 65cca81..1205e9f 100644 --- a/frontend/src/components/galaxy/blockLayout.js +++ b/frontend/src/components/galaxy/blockLayout.js @@ -4,27 +4,35 @@ 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, générations, chaînes de couples, +// (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 génération) + les blocs de -// ses enfants imbriqués en dessous (contours par génération), la -// chaîne centrée au-dessus. Fratries contiguës et parents centrés -// par construction. +// 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. // -// Mariage entre deux familles de l'arbre : la chaîne du couple n'est -// placée qu'une fois, sous la première lignée qui la revendique (les -// grandes lignées d'abord). L'autre lignée garde une arête longue. +// 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. // -// Un bloc racine dont la chaîne a un enfant déjà placé ailleurs -// (beaux-parents) est ancré : centré sur cet enfant, puis décalé au -// créneau libre le plus proche. +// 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. // -// Benchmark et variantes écartées : tools/layout-bench/. +// 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 @@ -40,12 +48,38 @@ export function filCroiseAutreFamille(nodes, childrenOfUnion, spouseKeysOf, u, c && 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 génération) telles que computeLayout les a ordonnées + // 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)) { @@ -72,16 +106,37 @@ export function computeBlockLayout(graphData = null, referenceId = null) { 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)) - const descCount = new Map() - function countDesc(chain, seen = new Set()) { - if (descCount.has(chain)) return descCount.get(chain) - if (seen.has(chain)) return 0 - seen.add(chain) - let n = 0 - for (const id of chain) for (const k of (childrenOfUnion.get(id) || [])) n += 1 + countDesc(chainOf.get(k), seen) - descCount.set(chain, n) - return n + // 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) @@ -92,159 +147,216 @@ export function computeBlockLayout(graphData = null, referenceId = null) { 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 placé sous une autre lignée + 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, génération par génération + // 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, [l]] of b.rows) { + for (const [g, list] of b.rows) { const a = acc.get(g) - if (a) off = Math.max(off, a[1] + gap - l) + if (a?.length) off = Math.max(off, a[a.length - 1][1] + gap - list[0][0]) } xs.push(off) - for (const [g, [l, r]] of b.rows) { - const a = acc.get(g) - acc.set(g, a ? [Math.min(a[0], l + off), Math.max(a[1], r + off)] : [l + off, r + off]) - } + mergeRows(acc, b.rows, off) } return { xs, rows: acc } } - function buildBlock(chain) { + // 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 rowW = chainWidth(chain) - const offsets = chainOffsets(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) || [])) { - const kc = chainOf.get(k) - if (!placed.has(kc)) childBlocks.push(buildBlock(kc)) - else { external.push({ kid: k, unionId: id }); externalEdges.add(`${id}>${k}`) } - } + 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 + // 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.rows.get(b.gen)[0])) - const kidRowR = Math.max(...childBlocks.map((b, i) => packed.xs[i] + b.rows.get(b.gen)[1])) + 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)) + } } - - const rows = new Map([[gen, [rowX, rowX + rowW]]]) - for (const [g, [l, r]] of packed.rows) { - const a = rows.get(g) - rows.set(g, a ? [Math.min(a[0], l), Math.max(a[1], r)] : [l, r]) - } - const minL = Math.min(...[...rows.values()].map(v => v[0])) - for (const [g, v] of rows) rows.set(g, [v[0] - minL, v[1] - minL]) - rowX -= minL - const kidXs = packed.xs.map(x => x - minL) - + 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) - for (let i = 0; i < childBlocks.length; i++) if (childBlocks[i].members.has(id)) return kidXs[i] + childBlocks[i].rel(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, - get width() { return Math.max(...[...rows.values()].map(v => v[1])) }, + 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)) }, } } - // 1) Racines : chaînes sans parents, grandes lignées d'abord - const roots = chains.filter(c => !hasParents(c)).sort((a, b) => countDesc(b) - countDesc(a) || genOf(a) - genOf(b)) - const free = [], anchored = [] - for (const r of roots) { - if (placed.has(r)) continue - const block = buildBlock(r) - const ownExternal = block.external.some(({ unionId }) => r.includes(unionId)) - if (ownExternal) anchored.push(block) - else free.push(block) - } + // 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])) - let cursor = Math.max(0, ...[...forest.rows.values()].map(v => v[1])) + FAMILY_GAP * 2 - // 2) Blocs ancrés : l'union concernée centrée sur son enfant déjà placé, générations profondes d'abord - const occupied = (exclude) => { - const byGen = new Map() - for (const n of nodes) { - if (!pos.has(n.id) || exclude.has(n.id)) continue - if (!byGen.has(n._generation)) byGen.set(n._generation, []) - byGen.get(n._generation).push([pos.get(n.id), pos.get(n.id) + n.width]) - } - return byGen - } - anchored.sort((a, b) => b.gen - a.gen) - for (const block of anchored) { - const occ = occupied(block.members) - const targets = block.external - .filter(({ kid }) => pos.has(kid)) - .map(({ kid, unionId }) => pos.get(kid) + byId.get(kid).width / 2 - (block.rel(unionId) + byId.get(unionId).width / 2)) - if (!targets.length) { block.place(cursor); cursor += block.width + FAMILY_GAP * 2; continue } - const ideal = targets.reduce((s, v) => s + v, 0) / targets.length - const fits = (x0) => { - block.place(x0) - for (const id of block.members) { - const n = byId.get(id); const x = pos.get(id) - for (const [s, e] of (occ.get(n._generation) || [])) { - if (x < e + FAMILY_GAP && x + n.width > s - FAMILY_GAP) return false - } - } - return true - } - let found = false - for (let d = 0; d < 40000 && !found; d += 20) { - if (fits(ideal + d)) found = true - else if (d > 0 && fits(ideal - d)) found = true - } - if (!found) { block.place(cursor); cursor += block.width + FAMILY_GAP * 2 } - } - - // Couple dont les deux conjoints ont leurs parents dans l'arbre, chacun du côté de la famille de l'autre : - // on intervertit les deux conjoints (même place, l'union reste au milieu), les deux fils ne se croisent plus. - // Répété : intervertir un couple peut remettre à l'endroit ou à l'envers celui de ses enfants. - const centerOf = (id) => pos.get(id) + byId.get(id).width / 2 - for (let pass = 0; pass < 4; pass++) { - let swapped = 0 - for (const chain of chains) { - if (chain.length !== 3 || !pos.has(chain[0])) continue - const [a, , b] = chain - const pa = parentUnionOf.get(a), pb = parentUnionOf.get(b) - if (!pa || !pb || pa === pb || !pos.has(pa) || !pos.has(pb)) continue - if ((centerOf(a) - centerOf(b)) * (centerOf(pa) - centerOf(pb)) >= 0) continue - const xa = pos.get(a), xb = pos.get(b) - pos.set(a, xb + byId.get(b).width - byId.get(a).width) - pos.set(b, xa) - swapped++ - } - if (!swapped) break - } - - // Réinjecter le X, normalisé à 0 ; Y inchangé (générations) + // 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 placé sous l'autre lignée : renvoi (voir drawFiliations) seulement si le fil + // 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 (traceBus) : + // 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() @@ -266,5 +378,40 @@ export function computeBlockLayout(graphData = null, referenceId = null) { 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 } diff --git a/frontend/src/components/galaxy/elkLayout.js b/frontend/src/components/galaxy/elkLayout.js index 8d88ada..26aa803 100644 --- a/frontend/src/components/galaxy/elkLayout.js +++ b/frontend/src/components/galaxy/elkLayout.js @@ -800,7 +800,7 @@ export function computeLayout(graphData = null, referenceId = 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, generationConflicts } + return { children: allNodes, edges: allEdges, generationConflicts, referenceId: reference ?? null } } const genMax = sortedGensDesc[0] const genMin = sortedGensDesc[sortedGensDesc.length - 1] @@ -840,7 +840,7 @@ export function computeLayout(graphData = null, referenceId = null) { node.y = node._generation * (PERSON_H + LAYER_SPACING) } - return { children: allNodes, edges: allEdges, generationConflicts } + return { children: allNodes, edges: allEdges, generationConflicts, referenceId: reference ?? null } } // ============================================================ diff --git a/frontend/src/components/galaxy/renderers.js b/frontend/src/components/galaxy/renderers.js index 1063d40..1e00585 100644 --- a/frontend/src/components/galaxy/renderers.js +++ b/frontend/src/components/galaxy/renderers.js @@ -107,19 +107,19 @@ const BUS_DY = 100 const SIB_R = 24 const SIB_STRAIGHT_DX = SIB_R * 2 -function busY(sourceId, sy) { - let h = 0 - for (let i = 0; i < sourceId.length; i++) h = (h * 31 + sourceId.charCodeAt(i)) | 0 - // Léger décalage par union : deux fratries voisines ne fusionnent pas leurs lignes - return sy + BUS_DY + (Math.abs(h) % 3) * 4 +// Les lignes de fratrie s'emboîtent : une famille collée au conjoint d'un enfant dessine sa ligne sous celle +// de la famille qui l'entoure. Le placement pose le niveau (_railLift, 0 = la plus proche des enfants). +const RAIL_STEP = 8 +function busY(lift, sy) { + return sy + BUS_DY - RAIL_STEP * (lift || 0) } // Une union, toute sa fratrie : un seul tracé. Tronc depuis l'union jusqu'à la barre, coins arrondis // de même rayon à chaque jonction, puis descente vers chaque enfant. Chaque enfant a sa propre trajectoire // (sur la même barre), donc le surlignage du chemin suit exactement la même géométrie. -export function traceSibship(ctx, sourceId, sourcePos, items) { +export function traceSibship(ctx, sourceId, sourcePos, items, lift = 0) { const sx = sourcePos.cx, sy = sourcePos.cy - const by = busY(sourceId, sy) + const by = busY(lift, sy) const rr = Math.min(SIB_R, by - sy - 2) ctx.beginPath() for (const { cx: ex, cy: ey } of items) { @@ -312,7 +312,7 @@ export function drawFiliations(ctx, edges, posMap, nodeMap, nodeRandomData, entr ctx.lineCap = 'round' const p = progress / items.length ctx.globalAlpha = Math.min(1, p * 1.5) * p - traceSibship(ctx, sourceId, sourcePos, items) + traceSibship(ctx, sourceId, sourcePos, items, nodeMap.get(sourceId)?._railLift) ctx.globalAlpha = 1 ctx.setLineDash([]) } @@ -389,7 +389,7 @@ export function drawPathHighlight(ctx, { ctx.globalAlpha = 1 ctx.globalAlpha = hp - traceSibship(ctx, edge.sources[0], sourcePos, [{ cx: targetPos.cx, cy: targetPos.cy - childR }]) + traceSibship(ctx, edge.sources[0], sourcePos, [{ cx: targetPos.cx, cy: targetPos.cy - childR }], nodeMap.get(edge.sources[0])?._railLift) ctx.globalAlpha = 1 }) } diff --git a/tools/layout-bench/defects.mjs b/tools/layout-bench/defects.mjs index 4fb95ba..42df479 100644 --- a/tools/layout-bench/defects.mjs +++ b/tools/layout-bench/defects.mjs @@ -20,6 +20,8 @@ export function computeDefects(result, coupleBarMeta, graph, referenceId = null) if (!kidsOf.has(e.sources[0])) kidsOf.set(e.sources[0], []) kidsOf.get(e.sources[0]).push(e.targets[0]) } + const siblingsOf = new Map() + for (const [, kids] of kidsOf) for (const k of kids) siblingsOf.set(k, kids) const spousesOf = new Map() for (const c of coupleBarMeta) { if (!spousesOf.has(c.p1Key)) spousesOf.set(c.p1Key, new Set()) @@ -34,7 +36,7 @@ export function computeDefects(result, coupleBarMeta, graph, referenceId = null) const u = byId.get(uid) const ks = kids.map((k) => byId.get(k)).filter(Boolean) if (!u || !ks.length) continue - const sx = cx(u), sy = u.y + u.height / 2, by = sy + BUS_DY + busOffset(uid) + const sx = cx(u), sy = u.y + u.height / 2, by = sy + BUS_DY + (u._railLift != null ? -8 * u._railLift : busOffset(uid)) let l = sx, r = sx for (const k of ks) { const ex = cx(k) @@ -63,8 +65,12 @@ export function computeDefects(result, coupleBarMeta, graph, referenceId = null) for (const hh of households) { const h = H.find((x) => x.u === hh.union) if (!h) continue + // Un seul arbre : la fratrie du conjoint d'un enfant peut se trouver sous la ligne (famille collée), pas un étranger const ok = new Set(hh.kids) - for (const k of hh.kids) for (const s of (spousesOf.get(k) || [])) ok.add(s) + for (const k of hh.kids) for (const s of (spousesOf.get(k) || [])) { + ok.add(s) + for (const sib of (siblingsOf.get(s) || [])) { ok.add(sib); for (const ss of (spousesOf.get(sib) || [])) ok.add(ss) } + } if (nodes.some((n) => n._type === 'person' && n._generation === hh.gen + 1 && !ok.has(n.id) && cx(n) > h.a && cx(n) < h.b)) fils_sur_etranger++ } const foyers_larges = households.filter((h) => h.etendue > SCREEN).length diff --git a/tools/layout-bench/invariants.mjs b/tools/layout-bench/invariants.mjs index 9304c36..c4ffec3 100644 --- a/tools/layout-bench/invariants.mjs +++ b/tools/layout-bench/invariants.mjs @@ -89,23 +89,33 @@ export function checkInvariants(result, coupleBarMeta, graph, referenceId = null // Fratries : contiguës (seuls les frères, sœurs et leurs conjoints entre le premier et le dernier) et dans l'ordre choisi const kidsOf = new Map() + const siblingsOf = new Map() for (const e of result.edges) { if (e._renvoi) continue if (!kidsOf.has(e.sources[0])) kidsOf.set(e.sources[0], []) kidsOf.get(e.sources[0]).push(e) } + for (const edges of kidsOf.values()) for (const e of edges) siblingsOf.set(e.targets[0], edges.map((x) => x.targets[0])) let fratries_coupees = 0, fratries_desordre = 0 for (const edges of kidsOf.values()) { const kids = edges.map((e) => byId.get(e.targets[0])).filter(Boolean) if (kids.length < 2) continue + // Un seul arbre : la famille du conjoint d'un enfant (sa fratrie, leurs conjoints) se colle à lui, elle peut + // donc se trouver entre deux frères. Seule une personne sans ce lien coupe la fratrie. const allowed = new Set(kids.map((k) => k.id)) - for (const k of kids) for (const s of (spousesOf.get(k.id) || [])) allowed.add(s) + for (const k of kids) for (const s of (spousesOf.get(k.id) || [])) { + allowed.add(s) + for (const sib of (siblingsOf.get(s) || [])) { allowed.add(sib); for (const ss of (spousesOf.get(sib) || [])) allowed.add(ss) } + } const lo = Math.min(...kids.map((k) => k.x)), hi = Math.max(...kids.map((k) => k.x)) const gen = kids[0]._generation if ((byGen.get(gen) || []).some((n) => n._type !== 'union' && !allowed.has(n.id) && n.x > lo && n.x < hi)) fratries_coupees++ + // Ordre choisi, de gauche à droite, ou en miroir (famille attachée à un conjoint, retournée vers lui) const byX = [...edges].sort((a, b) => cx(byId.get(a.targets[0])) - cx(byId.get(b.targets[0]))) const orders = byX.map((e) => e._displayOrder).filter((o) => Number.isFinite(o)) - if (orders.some((o, i) => i > 0 && o < orders[i - 1])) fratries_desordre++ + const ascending = orders.every((o, i) => i === 0 || o >= orders[i - 1]) + const descending = orders.every((o, i) => i === 0 || o <= orders[i - 1]) + if (!ascending && !descending) fratries_desordre++ } // Rangées = génération de parenté (décalage calé par famille non reliée)