From e4ee2869208f63fdc43693420316124acaaa0bcf Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Mon, 19 Oct 2026 14:47:32 +0200 Subject: [PATCH 01/26] =?UTF-8?q?Placement=20:=20un=20seul=20arbre,=20la?= =?UTF-8?q?=20famille=20d'un=20conjoint=20se=20colle=20=C3=A0=20lui?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../archetypes/belle-famille.txt | 24 +- .../archetypes/branche-courte.txt | 18 +- .../__snapshots__/archetypes/issue-67.txt | 26 +- .../__snapshots__/archetypes/marc-nadia.txt | 6 +- .../__snapshots__/archetypes/oncle-niece.txt | 6 +- .../archetypes/trois-belles-familles.txt | 18 +- .../src/components/galaxy/archetypes.test.js | 8 +- frontend/src/components/galaxy/blockLayout.js | 395 ++++++++++++------ frontend/src/components/galaxy/elkLayout.js | 4 +- frontend/src/components/galaxy/renderers.js | 18 +- tools/layout-bench/defects.mjs | 10 +- tools/layout-bench/invariants.mjs | 14 +- 12 files changed, 352 insertions(+), 195 deletions(-) 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) From c0d057a6757780321cafe6a776964662ab970d83 Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Mon, 19 Oct 2026 15:22:31 +0200 Subject: [PATCH 02/26] =?UTF-8?q?Placement=20:=20la=20belle-famille=20au?= =?UTF-8?q?=20bout=20de=20la=20fratrie,=20l'enfant=20mari=C3=A9=20avec=20e?= =?UTF-8?q?lle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retours de Patch sur un arbre de 102 personnes : une famille de conjoint se glissait dans un trou du contour entre deux frères, et celle d'un enfant du milieu se posait entre lui et ses frères, sa ligne passant au-dessus d'eux. - La famille d'un conjoint est construite par la chaîne de l'enfant puis posée par le bloc du dessus, au-delà de toute l'étendue de la fratrie, jamais dans un trou. - L'enfant dont le conjoint amène sa famille va au bout de sa fratrie, de son côté (l'ordre de naissance s'efface pour lui, choix de Patch), et sa belle-famille se colle à lui sans survoler personne. - Le côté est hérité de la lignée : toutes les belles-familles d'une branche vont du côté où la branche est au bord de l'arbre. - Le couple ne se tourne vers la belle-famille que si elle est vraiment attachée (un cousin marié garde son orientation naturelle). - Le fil d'une famille collée est toujours dessiné ; pastille seulement pour une famille posée à part. - Niveaux des lignes de fratrie : une ligne qui en contient une autre passe au-dessus ; entre deux lignes qui se chevauchent, celle dont l'union est à l'extérieur passe au-dessus (son fil n'entre qu'une fois, à l'enfant collé). Banc : sur l'arbre de 102 personnes, pastilles 2 → 0 et croisements 3 → 0 ; famille réelle de 385, pastilles 4 → 2 ; témoins des archétypes relus. Co-Authored-By: Claude Fable 5.1 --- .../__snapshots__/archetypes/oncle-niece.txt | 6 +- frontend/src/components/galaxy/blockLayout.js | 191 +++++++++++------- tools/layout-bench/invariants.mjs | 6 +- 3 files changed, 128 insertions(+), 75 deletions(-) diff --git a/frontend/src/components/galaxy/__snapshots__/archetypes/oncle-niece.txt b/frontend/src/components/galaxy/__snapshots__/archetypes/oncle-niece.txt index 19a8200..2ed4de5 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 │ - ┬────┴───┬ - │ │ -N────•────O P────•───Pw +┬──────────────┴───┬ +│ │ +O────•────N P────•───Pw renvoi : P+Pw → N diff --git a/frontend/src/components/galaxy/blockLayout.js b/frontend/src/components/galaxy/blockLayout.js index 1205e9f..442355b 100644 --- a/frontend/src/components/galaxy/blockLayout.js +++ b/frontend/src/components/galaxy/blockLayout.js @@ -162,18 +162,18 @@ export function computeBlockLayout(graphData = null, referenceId = null) { // 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) { + 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 - const left = index < (count - 1) / 2 - if ((left && chain[0] !== spouse) || (!left && chain[2] !== spouse)) chain.reverse() + 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 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) { @@ -191,20 +191,17 @@ export function computeBlockLayout(graphData = null, referenceId = null) { 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) { + // Décale un bloc depuis sa position idéale, du côté demandé, jusqu'au-delà de toute l'étendue de `rows` + // sur les rangées partagées : la famille d'un conjoint se pose au bout de la fratrie, jamais dans un trou + function pushOutside(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 + for (const [g, list] of blockRows) { + const occ = rows.get(g) + if (!occ?.length) continue + const l = list[0][0], r = list[list.length - 1][1] + const ol = occ[0][0], or = occ[occ.length - 1][1] + if (side > 0) d = Math.max(d, or + gap - l) + else d = Math.min(d, ol - gap - r) } return d } @@ -220,7 +217,9 @@ export function computeBlockLayout(graphData = null, referenceId = null) { // 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) { + // `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 = [] @@ -243,18 +242,68 @@ export function computeBlockLayout(graphData = null, referenceId = null) { childBlocks.push(ghostBlock(kid)) return } - orient(kc, kid, i, kidsOfChain.length) - childBlocks.push(buildBlock(kc, unionId)) + const side = outer || (i < (kidsOfChain.length - 1) / 2 ? -1 : 1) + childBlocks.push(buildBlock(kc, unionId, side)) }) + // Un enfant dont le conjoint amène sa famille va au bout de la fratrie, de son côté : sa belle-famille + // se colle à lui sans survoler ses frères et sœurs. L'ordre de naissance s'efface pour lui (choix du 19/10). + const sideOf = (b) => { + if (b.ghost || !b.pending?.length) return 0 + const left = b.pending.some(p => p.side < 0), right = b.pending.some(p => p.side > 0) + return left && !right ? -1 : right && !left ? 1 : 0 + } + const ordered = [...childBlocks.filter(b => sideOf(b) < 0), ...childBlocks.filter(b => sideOf(b) === 0), ...childBlocks.filter(b => sideOf(b) > 0)] + childBlocks.length = 0 + childBlocks.push(...ordered) const packed = packBlocks(childBlocks, FAMILY_GAP) + const rows = new Map() + childBlocks.forEach((b, i) => { if (!b.ghost) mergeRows(rows, b.rows, packed.xs[i]) }) + + // Familles des conjoints des enfants (construites par chaque enfant, voir plus bas) : posées au-delà du + // bout de la fratrie entière, du côté du conjoint, la plus proche du couple d'abord. Jamais entre deux frères. + const attachments = [] + const pending = [] + childBlocks.forEach((b, i) => { for (const p of (b.pending || [])) pending.push({ ...p, anchorX: packed.xs[i] + b.rel(p.anchorId) }) }) + for (const side of [1, -1]) { + const list = pending.filter(p => p.side === side).sort((a, b) => side > 0 ? b.anchorX - a.anchorX : a.anchorX - b.anchorX) + for (const p of list) { + const at = p.block.rel(p.anchorId) + const ideal = at == null ? p.anchorX - p.block.width / 2 : p.anchorX - at + const x = pushOutside(p.block.rows, ideal, rows, side, FAMILY_GAP) + if (Math.abs(x - ideal) > DETACH_PX) { detached.push(p.block); continue } + attachedEdges.add(`${parentUnionOf.get(p.anchorId)}>${p.anchorId}`) + attachments.push({ block: p.block, x }) + mergeRows(rows, p.block.rows, x) + } + } + + // Familles des conjoints de la chaîne pas encore dessinées : construites entières depuis leur ancêtre + // le plus haut, puis remises au bloc du dessus qui les pose au bout de la fratrie, du côté du conjoint. + // 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. + const ownPending = [] + 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 = outer || (chain.indexOf(id) < chain.length / 2 ? -1 : 1) + const saved = anchor + anchor = { kid: id, end: side < 0 ? 'right' : 'left' } + const block = buildBlock(top, null, side) + anchor = saved + ownPending.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 pièce rapportée (attachée à un enfant) occupe déjà cette rangée + // famille de conjoint 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])) @@ -269,35 +318,24 @@ export function computeBlockLayout(graphData = null, referenceId = null) { 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) } + 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]) }) - // 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) + // Une chaîne sans bloc au-dessus (racine) pose elle-même ses familles de conjoints, au bout de sa propre étendue + if (ownerUnion == null) { + for (const p of ownPending) { + const at = p.block.rel(p.anchorId) + const anchorX = rowX + offsets.get(p.anchorId) + const ideal = at == null ? anchorX - p.block.width / 2 : anchorX - at + const x = pushOutside(p.block.rows, ideal, rows, p.side, FAMILY_GAP) + attachedEdges.add(`${parentUnionOf.get(p.anchorId)}>${p.anchorId}`) + attachments.push({ block: p.block, x }) + mergeRows(rows, p.block.rows, x) + for (const m of p.block.members) members.add(m) + external.push(...p.block.external) + } + ownPending.length = 0 } // Origine du bloc au bord gauche de son contour @@ -320,7 +358,7 @@ export function computeBlockLayout(graphData = null, referenceId = null) { return null } return { - rows, members, external, gen, rel, + rows, members, external, gen, rel, pending: ownPending, chain: [rowX, rowX + rowW], get width() { return extentOf(rows)[1] }, place(x0) { @@ -372,45 +410,56 @@ export function computeBlockLayout(graphData = null, referenceId = null) { 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 + if (!externalEdges.has(`${u}>${c}`) || attachedEdges.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. + // 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) 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 } + 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 [id, r] of rails) { if (!byRow.has(r.gen)) byRow.set(r.gen, []); byRow.get(r.gen).push({ id, ...r }) } + 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) => (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)) + 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) } - let lv = min - while (taken.has(lv)) lv++ - level.set(me.id, lv) + below.get(upper.id).push(upper === a ? b.id : a.id) } - 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))) + 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(3, liftOf(r.id)) } return result diff --git a/tools/layout-bench/invariants.mjs b/tools/layout-bench/invariants.mjs index c4ffec3..38de306 100644 --- a/tools/layout-bench/invariants.mjs +++ b/tools/layout-bench/invariants.mjs @@ -96,6 +96,7 @@ export function checkInvariants(result, coupleBarMeta, graph, referenceId = null 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])) + const parentsOf = new Set(result.edges.map((e) => e.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) @@ -111,7 +112,10 @@ export function checkInvariants(result, coupleBarMeta, graph, referenceId = null 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]))) + // Un enfant dont le conjoint a ses parents dans l'arbre va au bout de la fratrie (sa belle-famille se colle à + // lui) : l'ordre choisi ne vaut que pour les autres + const hasInLaws = (id) => [...(spousesOf.get(id) || [])].some((s) => parentsOf.has(s)) + const byX = [...edges].filter((e) => !hasInLaws(e.targets[0])).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)) const ascending = orders.every((o, i) => i === 0 || o >= orders[i - 1]) const descending = orders.every((o, i) => i === 0 || o <= orders[i - 1]) From 02177d93b349211ff24b692b2455fc7061de573d Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Mon, 19 Oct 2026 15:30:09 +0200 Subject: [PATCH 03/26] Placement : remariage, chaque union au-dessus de ses propres enfants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retour de Patch sur un arbre de 265 personnes : dans un remariage, la fille de la seconde union partait au bout de la fratrie entière et la ligne de son union passait au-dessus des enfants de la première. - Les enfants de chaque union restent groupés dans l'ordre des unions ; une belle-famille va du côté de son union, jamais au-delà des demi-frères. - La chaîne se cale pour que chaque union soit au plus près au-dessus de son groupe : on garde, parmi les alignements possibles, celui où les lignes débordent le moins au-dessus des autres groupes. Co-Authored-By: Claude Fable 5.1 --- .../__snapshots__/archetypes/remariage.txt | 6 +-- frontend/src/components/galaxy/blockLayout.js | 43 ++++++++++++++++--- 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/galaxy/__snapshots__/archetypes/remariage.txt b/frontend/src/components/galaxy/__snapshots__/archetypes/remariage.txt index d81fe83..5443b79 100644 --- a/frontend/src/components/galaxy/__snapshots__/archetypes/remariage.txt +++ b/frontend/src/components/galaxy/__snapshots__/archetypes/remariage.txt @@ -1,5 +1,5 @@ W1────•────H────•───W2 │ │ - ┬───┴────┬ ┴───┬ - │ │ │ - A B C + ┬────┴───┬ ┴──┬ + │ │ │ + A B C diff --git a/frontend/src/components/galaxy/blockLayout.js b/frontend/src/components/galaxy/blockLayout.js index 442355b..8bc8f3c 100644 --- a/frontend/src/components/galaxy/blockLayout.js +++ b/frontend/src/components/galaxy/blockLayout.js @@ -234,16 +234,24 @@ export function computeBlockLayout(graphData = null, referenceId = null) { 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)) + childBlocks.push({ ...ghostBlock(kid), unionId }) return } - const side = outer || (i < (kidsOfChain.length - 1) / 2 ? -1 : 1) - childBlocks.push(buildBlock(kc, unionId, side)) + 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 // se colle à lui sans survoler ses frères et sœurs. L'ordre de naissance s'efface pour lui (choix du 19/10). @@ -252,7 +260,10 @@ export function computeBlockLayout(graphData = null, referenceId = null) { const left = b.pending.some(p => p.side < 0), right = b.pending.some(p => p.side > 0) return left && !right ? -1 : right && !left ? 1 : 0 } - const ordered = [...childBlocks.filter(b => sideOf(b) < 0), ...childBlocks.filter(b => sideOf(b) === 0), ...childBlocks.filter(b => sideOf(b) > 0)] + const ordered = unionsOfChain.flatMap(u => { + const group = childBlocks.filter(b => b.unionId === u) + return [...group.filter(b => sideOf(b) < 0), ...group.filter(b => sideOf(b) === 0), ...group.filter(b => sideOf(b) > 0)] + }) childBlocks.length = 0 childBlocks.push(...ordered) const packed = packBlocks(childBlocks, FAMILY_GAP) @@ -290,7 +301,7 @@ export function computeBlockLayout(graphData = null, referenceId = null) { if (placed.has(pc)) continue const top = topChainOf(pc) if (placed.has(top)) continue - const side = outer || (chain.indexOf(id) < chain.length / 2 ? -1 : 1) + const side = chain.length > 3 ? (chain.indexOf(id) < chain.length / 2 ? -1 : 1) : (outer || (chain.indexOf(id) < chain.length / 2 ? -1 : 1)) const saved = anchor anchor = { kid: id, end: side < 0 ? 'right' : 'left' } const block = buildBlock(top, null, side) @@ -308,6 +319,28 @@ export function computeBlockLayout(graphData = null, referenceId = null) { 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 + // 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 + }) + } 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]) From 6950a9c21df2c197d29f3a23e5d10bdc3a4aa1f2 Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Mon, 19 Oct 2026 15:43:12 +0200 Subject: [PATCH 04/26] =?UTF-8?q?Placement=20:=20plus=20jamais=20d'=C3=AEl?= =?UTF-8?q?ot,=20belle-famille=20pos=C3=A9e=20depuis=20sa=20place=20id?= =?UTF-8?q?=C3=A9ale?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retour de Patch sur un arbre de 265 personnes : les familles « posées à part » (trop loin pour être collées) faisaient une rangée d'îlots reliés par des pastilles. Plus aucune famille n'est posée à part : une famille collée garde son fil, aussi long soit-il. La belle-famille part de sa place idéale (parents juste au-dessus de l'enfant) et est poussée vers l'extérieur : sur les rangées de l'enfant et en dessous (sa fratrie et leurs descendants), au-delà de tout ce qui est déjà posé, 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. Plusieurs belles-familles du même côté s'emboîtent comme des parenthèses. Une rangée de lignes de fratrie peut prendre cinq niveaux. Banc : famille réelle de 385, pastilles 2 → 0 ; descendance fictive de 1 454, pastilles 17 → 0 ; les 14 archétypes passent leurs invariants. Co-Authored-By: Claude Fable 5.1 --- frontend/src/components/galaxy/blockLayout.js | 82 +++++++++++-------- frontend/src/components/galaxy/renderers.js | 2 +- tools/layout-bench/defects.mjs | 2 +- 3 files changed, 51 insertions(+), 35 deletions(-) diff --git a/frontend/src/components/galaxy/blockLayout.js b/frontend/src/components/galaxy/blockLayout.js index 8bc8f3c..c35cbda 100644 --- a/frontend/src/components/galaxy/blockLayout.js +++ b/frontend/src/components/galaxy/blockLayout.js @@ -32,7 +32,8 @@ import { computeLayout, componentOrderPerGen, chainGap } from './elkLayout' 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 +// Jamais d'îlot (Patch, 19/10) : une famille collée trop loin garde son fil, aussi long soit-il +const DETACH_PX = Infinity // 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 @@ -191,17 +192,30 @@ export function computeBlockLayout(graphData = null, referenceId = null) { return { xs, rows: acc } } - // Décale un bloc depuis sa position idéale, du côté demandé, jusqu'au-delà de toute l'étendue de `rows` - // sur les rangées partagées : la famille d'un conjoint se pose au bout de la fratrie, jamais dans un trou - function pushOutside(blockRows, ideal, rows, side, gap) { + // 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 (const [g, list] of blockRows) { - const occ = rows.get(g) - if (!occ?.length) continue - const l = list[0][0], r = list[list.length - 1][1] - const ol = occ[0][0], or = occ[occ.length - 1][1] - if (side > 0) d = Math.max(d, or + gap - l) - else d = Math.min(d, ol - gap - r) + 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 } @@ -260,36 +274,38 @@ export function computeBlockLayout(graphData = null, referenceId = null) { const left = b.pending.some(p => p.side < 0), right = b.pending.some(p => p.side > 0) return left && !right ? -1 : right && !left ? 1 : 0 } - const ordered = unionsOfChain.flatMap(u => { + const kids = unionsOfChain.flatMap(u => { const group = childBlocks.filter(b => b.unionId === u) return [...group.filter(b => sideOf(b) < 0), ...group.filter(b => sideOf(b) === 0), ...group.filter(b => sideOf(b) > 0)] }) + // Les belles-familles des enfants entrent dans l'empilement comme des frères de plus, au bout de la + // fratrie du côté de leur enfant : elles ne se posent jamais sous la ligne de cette fratrie, et le + // contour les espace rangée par rangée. Plusieurs du même côté s'emboîtent comme des parenthèses : + // celle dont l'enfant est le plus proche du bout, et le plus bas, se colle la plus près. + const pending = [] + kids.forEach((b, i) => { for (const p of (b.pending || [])) pending.push({ ...p, kidIndex: i, anchorGen: byId.get(p.anchorId)._generation }) }) childBlocks.length = 0 - childBlocks.push(...ordered) + childBlocks.push(...kids) const packed = packBlocks(childBlocks, FAMILY_GAP) const rows = new Map() childBlocks.forEach((b, i) => { if (!b.ghost) mergeRows(rows, b.rows, packed.xs[i]) }) - - // Familles des conjoints des enfants (construites par chaque enfant, voir plus bas) : posées au-delà du - // bout de la fratrie entière, du côté du conjoint, la plus proche du couple d'abord. Jamais entre deux frères. const attachments = [] - const pending = [] - childBlocks.forEach((b, i) => { for (const p of (b.pending || [])) pending.push({ ...p, anchorX: packed.xs[i] + b.rel(p.anchorId) }) }) - for (const side of [1, -1]) { - const list = pending.filter(p => p.side === side).sort((a, b) => side > 0 ? b.anchorX - a.anchorX : a.anchorX - b.anchorX) - for (const p of list) { - const at = p.block.rel(p.anchorId) - const ideal = at == null ? p.anchorX - p.block.width / 2 : p.anchorX - at - const x = pushOutside(p.block.rows, ideal, rows, side, FAMILY_GAP) - if (Math.abs(x - ideal) > DETACH_PX) { detached.push(p.block); continue } - attachedEdges.add(`${parentUnionOf.get(p.anchorId)}>${p.anchorId}`) - attachments.push({ block: p.block, x }) - mergeRows(rows, p.block.rows, x) - } + // Du côté droit, la plus près du bout d'abord (enfant le plus à droite, puis le plus bas) ; symétrique à gauche + const rightP = pending.filter(p => p.side > 0).sort((a, b) => b.kidIndex - a.kidIndex || b.anchorGen - a.anchorGen) + const leftP = pending.filter(p => p.side < 0).sort((a, b) => a.kidIndex - b.kidIndex || b.anchorGen - a.anchorGen) + for (const p of [...rightP, ...leftP]) { + const kid = kids[p.kidIndex] + const anchorAbs = packed.xs[p.kidIndex] + kid.rel(p.anchorId) + const at = p.block.rel(p.anchorId) + const ideal = at == null ? anchorAbs : anchorAbs - at + const x = pushHybrid(p.block.rows, ideal, rows, p.side, p.anchorGen, FAMILY_GAP) + attachedEdges.add(`${parentUnionOf.get(p.anchorId)}>${p.anchorId}`) + attachments.push({ block: p.block, x }) + mergeRows(rows, p.block.rows, x) } // Familles des conjoints de la chaîne pas encore dessinées : construites entières depuis leur ancêtre - // le plus haut, puis remises au bloc du dessus qui les pose au bout de la fratrie, du côté du conjoint. + // le plus haut, puis remises au bloc du dessus qui les empile au bout de la fratrie, du côté du conjoint. // 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. const ownPending = [] @@ -361,7 +377,7 @@ export function computeBlockLayout(graphData = null, referenceId = null) { const at = p.block.rel(p.anchorId) const anchorX = rowX + offsets.get(p.anchorId) const ideal = at == null ? anchorX - p.block.width / 2 : anchorX - at - const x = pushOutside(p.block.rows, ideal, rows, p.side, FAMILY_GAP) + const x = pushHybrid(p.block.rows, ideal, rows, p.side, gen, FAMILY_GAP) attachedEdges.add(`${parentUnionOf.get(p.anchorId)}>${p.anchorId}`) attachments.push({ block: p.block, x }) mergeRows(rows, p.block.rows, x) @@ -393,7 +409,7 @@ export function computeBlockLayout(graphData = null, referenceId = null) { return { rows, members, external, gen, rel, pending: ownPending, chain: [rowX, rowX + rowW], - get width() { return extentOf(rows)[1] }, + 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])) @@ -492,7 +508,7 @@ export function computeBlockLayout(graphData = null, referenceId = null) { lift.set(id, v) return v } - for (const r of list) byId.get(r.id)._railLift = Math.min(3, liftOf(r.id)) + for (const r of list) byId.get(r.id)._railLift = Math.min(4, liftOf(r.id)) } return result diff --git a/frontend/src/components/galaxy/renderers.js b/frontend/src/components/galaxy/renderers.js index 1e00585..839030d 100644 --- a/frontend/src/components/galaxy/renderers.js +++ b/frontend/src/components/galaxy/renderers.js @@ -109,7 +109,7 @@ const SIB_STRAIGHT_DX = SIB_R * 2 // 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 +const RAIL_STEP = 6 function busY(lift, sy) { return sy + BUS_DY - RAIL_STEP * (lift || 0) } diff --git a/tools/layout-bench/defects.mjs b/tools/layout-bench/defects.mjs index 42df479..a34e327 100644 --- a/tools/layout-bench/defects.mjs +++ b/tools/layout-bench/defects.mjs @@ -36,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 + (u._railLift != null ? -8 * u._railLift : busOffset(uid)) + const sx = cx(u), sy = u.y + u.height / 2, by = sy + BUS_DY + (u._railLift != null ? -6 * u._railLift : busOffset(uid)) let l = sx, r = sx for (const k of ks) { const ex = cx(k) From b4df1a6b696e80130cc5a61d2f1ac3fb305b58af Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Mon, 19 Oct 2026 16:05:06 +0200 Subject: [PATCH 05/26] =?UTF-8?q?Placement=20:=20belle-famille=20=C3=A0=20?= =?UTF-8?q?c=C3=B4t=C3=A9=20de=20son=20enfant,=20jamais=20au-dessus=20de?= =?UTF-8?q?=20la=20place=20des=20parents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retours de Patch sur l'arbre réel (385 personnes) : de longs fils et des parents séparés de leurs enfants. - La plus grande lignée est construite d'abord (sinon une petite branche aspirait toute la lignée principale comme « famille du conjoint »). - Pour remonter au sommet d'une famille, on passe par le sang de la référence quand un membre en est : on retombe sur la lignée principale, rien à attacher, la seconde filiation reste un renvoi. - Une famille reliée deux fois dont le fil devrait traverser plus d'un écran devient une pastille. - La belle-famille change de côté si l'autre côté la rapproche d'un écran ou plus ; le couple se retourne avec elle. - Si une belle-famille est poussée loin par les descendants du couple, le couple se décale au bord de sa rangée d'enfants, de son côté. - Au-dessus de chaque couple, la place de ses propres parents est réservée : les parents du conjoint se posent à côté, pas dessus. - Les parents qui évitent un obstacle sur leur rangée restent au-dessus de leurs enfants plutôt que de sauter de l'autre côté. Témoins des 14 archétypes relus. Banc : arbre réel, pastilles 0, croisements 9 ; arbre fictif de 265, pastilles 0, croisements 1. Co-Authored-By: Claude Fable 5.1 --- .../archetypes/belle-famille.txt | 18 +- .../archetypes/branche-courte.txt | 18 +- .../__snapshots__/archetypes/issue-67.txt | 26 +-- .../__snapshots__/archetypes/marc-nadia.txt | 18 +- .../archetypes/trois-belles-familles.txt | 18 +- frontend/src/components/galaxy/blockLayout.js | 205 ++++++++++++------ 6 files changed, 186 insertions(+), 117 deletions(-) diff --git a/frontend/src/components/galaxy/__snapshots__/archetypes/belle-famille.txt b/frontend/src/components/galaxy/__snapshots__/archetypes/belle-famille.txt index a86995c..45eda45 100644 --- a/frontend/src/components/galaxy/__snapshots__/archetypes/belle-famille.txt +++ b/frontend/src/components/galaxy/__snapshots__/archetypes/belle-famille.txt @@ -2,12 +2,12 @@ │ ┬────┴ │ - SP────•───SM Pa────•───Ma - │ │ - ┬────────┼────────┬ ┼────────┬ - │ │ │ │ │ -S3 S2 S────•────E E2 - │ - ┬───┴────┬ - │ │ - K1 K2 + SP────•───SM Pa────•───Ma + │ │ + ┬────────┼────────────────┬ ┼────────┬ + │ │ │ │ │ +S3 S2 S────•────E E2 + │ + ┬───┴────┬ + │ │ + 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 7ae92a0..b7ab9be 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 9a2ed4f..0b726fb 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 800b0fd..1600f85 100644 --- a/frontend/src/components/galaxy/__snapshots__/archetypes/marc-nadia.txt +++ b/frontend/src/components/galaxy/__snapshots__/archetypes/marc-nadia.txt @@ -1,9 +1,9 @@ - Pierre───•──Odile Louis──•─Helene - │ │ - ┬────────┬────┴───┬────────┬ ┬────────┬───┴────┬────────┬────────┬ - │ │ │ │ │ │ │ │ │ -Luc Paul Mireil Nadia──•──Marc Claire Julien Sophie Eric - │ - ┼ - │ - Anais + Pierre───•──Odile Louis──•─Helene + │ │ + ┬────────┬────┴───┬────────────┬ ┬────────┬───┴────┬────────┬────────┬ + │ │ │ │ │ │ │ │ │ +Luc Paul Mireil Nadia──•──Marc Claire Julien Sophie Eric + │ + ┼ + │ + Anais 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 cc4ad86..395b0ad 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 @@ -C────•───Cw B────•───Bw A─────•───Aw - │ │ │ - ┼ ┬────────┼ ┬────────┼ - │ │ │ │ │ - C1────•───B2 B1────•────A1 A2 - │ │ - ┼ ┼ - │ │ - Y X +A────•───Aw B────•───Bw C────•───Cw + │ │ │ + ┬───┴────────────────┬ ┬────┴───┬ ┬────────────────┴ + │ │ │ │ │ +A2 A1────•───B1 B2─────•───C1 + │ │ + ┼ ┼ + │ │ + X Y diff --git a/frontend/src/components/galaxy/blockLayout.js b/frontend/src/components/galaxy/blockLayout.js index c35cbda..f7a4ab6 100644 --- a/frontend/src/components/galaxy/blockLayout.js +++ b/frontend/src/components/galaxy/blockLayout.js @@ -34,6 +34,13 @@ 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 // 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 @@ -148,13 +155,15 @@ export function computeBlockLayout(graphData = null, referenceId = null) { return offsets } - // La chaîne la plus haute d'une famille : on remonte de parents en parents + // 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)) + 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)) } @@ -267,48 +276,24 @@ export function computeBlockLayout(graphData = null, referenceId = null) { 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 - // se colle à lui sans survoler ses frères et sœurs. L'ordre de naissance s'efface pour lui (choix du 19/10). - const sideOf = (b) => { - if (b.ghost || !b.pending?.length) return 0 - const left = b.pending.some(p => p.side < 0), right = b.pending.some(p => p.side > 0) - return left && !right ? -1 : right && !left ? 1 : 0 - } + // 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 => sideOf(b) < 0), ...group.filter(b => sideOf(b) === 0), ...group.filter(b => sideOf(b) > 0)] + return [...group.filter(b => b.attachSide < 0), ...group.filter(b => !b.attachSide), ...group.filter(b => b.attachSide > 0)] }) - // Les belles-familles des enfants entrent dans l'empilement comme des frères de plus, au bout de la - // fratrie du côté de leur enfant : elles ne se posent jamais sous la ligne de cette fratrie, et le - // contour les espace rangée par rangée. Plusieurs du même côté s'emboîtent comme des parenthèses : - // celle dont l'enfant est le plus proche du bout, et le plus bas, se colle la plus près. - const pending = [] - kids.forEach((b, i) => { for (const p of (b.pending || [])) pending.push({ ...p, kidIndex: i, anchorGen: byId.get(p.anchorId)._generation }) }) childBlocks.length = 0 childBlocks.push(...kids) const packed = packBlocks(childBlocks, FAMILY_GAP) const rows = new Map() childBlocks.forEach((b, i) => { if (!b.ghost) mergeRows(rows, b.rows, packed.xs[i]) }) - const attachments = [] - // Du côté droit, la plus près du bout d'abord (enfant le plus à droite, puis le plus bas) ; symétrique à gauche - const rightP = pending.filter(p => p.side > 0).sort((a, b) => b.kidIndex - a.kidIndex || b.anchorGen - a.anchorGen) - const leftP = pending.filter(p => p.side < 0).sort((a, b) => a.kidIndex - b.kidIndex || b.anchorGen - a.anchorGen) - for (const p of [...rightP, ...leftP]) { - const kid = kids[p.kidIndex] - const anchorAbs = packed.xs[p.kidIndex] + kid.rel(p.anchorId) - const at = p.block.rel(p.anchorId) - const ideal = at == null ? anchorAbs : anchorAbs - at - const x = pushHybrid(p.block.rows, ideal, rows, p.side, p.anchorGen, FAMILY_GAP) - attachedEdges.add(`${parentUnionOf.get(p.anchorId)}>${p.anchorId}`) - attachments.push({ block: p.block, x }) - mergeRows(rows, p.block.rows, x) - } // Familles des conjoints de la chaîne pas encore dessinées : construites entières depuis leur ancêtre - // le plus haut, puis remises au bloc du dessus qui les empile au bout de la fratrie, du côté du conjoint. - // 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. - const ownPending = [] + // 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) @@ -322,19 +307,29 @@ export function computeBlockLayout(graphData = null, referenceId = null) { anchor = { kid: id, end: side < 0 ? 'right' : 'left' } const block = buildBlock(top, null, side) anchor = saved - ownPending.push({ block, anchorId: id, side }) + 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) - const offsets = chainOffsets(chain) - let rowX = 0 + 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) { - 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 // 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). @@ -357,43 +352,93 @@ export function computeBlockLayout(graphData = null, referenceId = null) { return cx < cb - 0.5 || (Math.abs(cx - cb) <= 0.5 && Math.abs(x - centered) < Math.abs(best - centered)) ? x : best }) } - 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)) + 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 + 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) + } } } - mergeRows(rows, new Map([[gen, [[rowX, rowX + rowW]]]]), 0) + // 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 = pushHybrid(p.block.rows, ideal, r, p.side, gen, FAMILY_GAP) + if (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]) }) - // Une chaîne sans bloc au-dessus (racine) pose elle-même ses familles de conjoints, au bout de sa propre étendue - if (ownerUnion == null) { - for (const p of ownPending) { - const at = p.block.rel(p.anchorId) - const anchorX = rowX + offsets.get(p.anchorId) - const ideal = at == null ? anchorX - p.block.width / 2 : anchorX - at - const x = pushHybrid(p.block.rows, ideal, rows, p.side, gen, FAMILY_GAP) - attachedEdges.add(`${parentUnionOf.get(p.anchorId)}>${p.anchorId}`) - attachments.push({ block: p.block, x }) - mergeRows(rows, p.block.rows, x) - for (const m of p.block.members) members.add(m) - external.push(...p.block.external) - } - ownPending.length = 0 - } - // 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 + 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) @@ -403,17 +448,24 @@ export function computeBlockLayout(graphData = null, referenceId = null) { 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 } + 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, pending: ownPending, + 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)) + 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)) + }) }, } } @@ -426,8 +478,21 @@ export function computeBlockLayout(graphData = null, referenceId = null) { 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)) || genOf(a) - genOf(b) || birthYearOf(a) - birthYearOf(b) || String(a[0]).localeCompare(String(b[0])) + (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)) @@ -459,7 +524,11 @@ export function computeBlockLayout(graphData = null, referenceId = null) { const renvoisByUnion = new Map() for (const e of result.edges) { const u = e.sources[0], c = e.targets[0] - if (!externalEdges.has(`${u}>${c}`) || attachedEdges.has(`${u}>${c}`) || !crossesOtherFamily(u, c)) 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) From 5a069c25c5814842c3ba3364aa12c6fb2e2a9b32 Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Mon, 19 Oct 2026 16:47:14 +0200 Subject: [PATCH 06/26] =?UTF-8?q?Placement=20:=20prototype=20des=20deux=20?= =?UTF-8?q?plans,=20les=20grosses=20belles-familles=20en=20arri=C3=A8re-pl?= =?UTF-8?q?an?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maquette F retenue par Patch le 19/10 au soir. Une belle-famille plus large que deux écrans ne se colle plus devant : elle passe sur un plan arrière, dessinée par le même placement, à l'échelle 0,55, ancrée pour que la place fantôme de son conjoint coïncide avec lui (ses parents juste au-dessus de lui, en retrait). Le plan avant garde ses rangées compactes. Rendu : le plan arrière est dessiné d'abord sur un calque (couples, fils, nœuds à leur échelle), composé sous le plan avant à 42 % avec un léger flou. Le rayon des nœuds, la hauteur des lignes de fratrie et le culling suivent l'échelle du nœud. Les filiations du plan arrière et le fil des parents arrière vers leur enfant avant ne comptent ni pour les renvois ni pour les niveaux de lignes. Banc : le plan avant seul est mesuré, le nombre de personnes en arrière est compté. Famille réelle de 385 : 202 personnes derrière, plan avant à 0 croisement et 7 fils de plus d'un écran (contre 9 et 15). Prototype : pas encore de survol, d'échange des plans ni de masquage sous les portraits. Co-Authored-By: Claude Fable 5.1 --- frontend/src/components/Galaxy.jsx | 45 ++++++++++++++----- frontend/src/components/galaxy/blockLayout.js | 38 +++++++++++++++- frontend/src/components/galaxy/renderers.js | 38 ++++++++++------ tools/layout-bench/defects.mjs | 2 + tools/layout-bench/invariants.mjs | 2 + tools/layout-bench/run.mjs | 4 +- 6 files changed, 100 insertions(+), 29 deletions(-) diff --git a/frontend/src/components/Galaxy.jsx b/frontend/src/components/Galaxy.jsx index 4d37ed6..04c6a4d 100644 --- a/frontend/src/components/Galaxy.jsx +++ b/frontend/src/components/Galaxy.jsx @@ -50,6 +50,8 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig const photoHintRef = useRef(photoHint) useEffect(() => { photoHintRef.current = photoHint }, [photoHint]) const layoutDataRef = useRef(null) + // Calque du plan arrière (grosses belles-familles), composé plus clair et légèrement flou sous le plan avant + const backLayerRef = useRef(null) const [layoutReady, setLayoutReady] = useState(false) const transformRef = useRef({ x: 0, y: 0, scale: 1 }) @@ -811,6 +813,36 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig const cssW = canvas.width / dprRef.current const cssH = canvas.height / dprRef.current const view = { x0: -x / scale, y0: -y / scale, x1: (cssW - x) / scale, y1: (cssH - y) / scale, scale } + const nodeRenderState = { + nodeRandomData: nodeRandomDataRef.current, + imageCache: imageCache.current, + hoverScales: hoverScales.current, + selectedGlowScales: selectedGlowScales.current, + searchGlowScales: searchGlowScales.current, + mediaHoverState: mediaHoverState.current, + entrance, entranceActive, elapsed, + view, + photoHint: photoHintRef.current, + liftScales: liftScalesRef.current, + } + // Plan arrière : les grosses belles-familles, dessinées d'abord sur un calque, plus claires et un peu floues + if (layoutData.children.some((n) => n._plane === 'back')) { + const off = backLayerRef.current || (backLayerRef.current = document.createElement('canvas')) + if (off.width !== canvas.width || off.height !== canvas.height) { off.width = canvas.width; off.height = canvas.height } + const octx = off.getContext('2d') + octx.setTransform(1, 0, 0, 1, 0, 0) + octx.clearRect(0, 0, off.width, off.height) + octx.setTransform(ctx.getTransform()) + drawCoupleLinks(octx, coupleBarMeta, posMap, nodeMap, nodeRandomDataRef.current, entrance, entranceActive, elapsed, view, 'back') + drawFiliations(octx, layoutData.edges, posMap, nodeMap, nodeRandomDataRef.current, entrance, entranceActive, elapsed, view, 'back') + drawNodes(octx, layoutData, posMap, entrance, entranceActive, elapsed, { ...nodeRenderState, plane: 'back' }) + ctx.save() + ctx.setTransform(1, 0, 0, 1, 0, 0) + ctx.globalAlpha = 0.42 + ctx.filter = 'blur(0.6px)' + ctx.drawImage(off, 0, 0) + ctx.restore() + } drawCoupleLinks(ctx, coupleBarMeta, posMap, nodeMap, nodeRandomDataRef.current, entrance, entranceActive, elapsed, view) drawFiliations(ctx, layoutData.edges, posMap, nodeMap, nodeRandomDataRef.current, entrance, entranceActive, elapsed, view) drawPathHighlight(ctx, { @@ -825,18 +857,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig nodeRandomData: nodeRandomDataRef.current, layoutData, }) - drawNodes(ctx, layoutData, posMap, entrance, entranceActive, elapsed, { - nodeRandomData: nodeRandomDataRef.current, - imageCache: imageCache.current, - hoverScales: hoverScales.current, - selectedGlowScales: selectedGlowScales.current, - searchGlowScales: searchGlowScales.current, - mediaHoverState: mediaHoverState.current, - entrance, entranceActive, elapsed, - view, - photoHint: photoHintRef.current, - liftScales: liftScalesRef.current, - }) + drawNodes(ctx, layoutData, posMap, entrance, entranceActive, elapsed, nodeRenderState) drawRenvoiPills(ctx) // Annotations dessinées APRES les noeuds pour que stickers/texte soient au-dessus // Annotations : ancrer aux personnes proches pour qu'elles suivent le layout diff --git a/frontend/src/components/galaxy/blockLayout.js b/frontend/src/components/galaxy/blockLayout.js index f7a4ab6..ba0f89f 100644 --- a/frontend/src/components/galaxy/blockLayout.js +++ b/frontend/src/components/galaxy/blockLayout.js @@ -41,6 +41,10 @@ const RENVOI_FAR_PX = 1440 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 +// Deux plans (maquette F du 19/10). Une belle-famille plus large que ça ne se colle pas devant : elle passe sur +// le plan arrière, dessinée par le même placement, plus petite, ancrée à son conjoint +const BACK_PX = 2880 +const BACK_SCALE = 0.55 // 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 @@ -183,6 +187,7 @@ export function computeBlockLayout(graphData = null, referenceId = null) { 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 backFamilies = [] // familles de conjoints du plan arrière : { block, anchorId } 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 @@ -307,6 +312,7 @@ export function computeBlockLayout(graphData = null, referenceId = null) { anchor = { kid: id, end: side < 0 ? 'right' : 'left' } const block = buildBlock(top, null, side) anchor = saved + if (block.width > BACK_PX) { backFamilies.push({ block, anchorId: id }); continue } built.push({ block, anchorId: id, side }) if (chain.length === 3) orient(chain, chain[0] === id ? chain[2] : chain[0], side) } @@ -506,6 +512,35 @@ export function computeBlockLayout(graphData = null, referenceId = null) { for (const n of nodes) if (pos.has(n.id)) minX = Math.min(minX, pos.get(n.id)) for (const n of nodes) if (pos.has(n.id)) n.x = pos.get(n.id) - minX + // Plan arrière : chaque grosse belle-famille est posée à l'échelle BACK_SCALE autour de son conjoint pivot, + // la place fantôme du conjoint dans sa fratrie d'origine venant coïncider avec lui. Ses parents sont donc + // juste au-dessus de lui, en retrait, et toute sa famille se déploie derrière le plan avant. + for (const { block, anchorId } of backFamilies) { + const A = byId.get(anchorId) + if (!A || A.x == null) continue + block.place(0) + const ghostX = block.rel(anchorId) + if (ghostX == null) continue + const acx = A.x + A.width / 2, acy = A.y + A.height / 2 + const gcx = ghostX + A.width / 2, gcy = acy + for (const m of block.members) { + const n = byId.get(m) + if (!n || !pos.has(m)) continue + const lcx = pos.get(m) + n.width / 2, lcy = n.y + n.height / 2 + const cx = acx + (lcx - gcx) * BACK_SCALE, cy = acy + (lcy - gcy) * BACK_SCALE + n.x = cx - n.width / 2 + n.y = cy - n.height / 2 + n._plane = 'back' + n._scale = BACK_SCALE + } + } + const backIds = new Set(nodes.filter(n => n._plane === 'back').map(n => n.id)) + for (const e of result.edges) { + const s = backIds.has(e.sources[0]), k = backIds.has(e.targets[0]) + if (s && k) e._back = true + else if (s && !k) e._bridge = true // des parents du plan arrière à leur enfant du plan avant + } + // 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) : @@ -524,6 +559,7 @@ export function computeBlockLayout(graphData = null, referenceId = null) { const renvoisByUnion = new Map() for (const e of result.edges) { const u = e.sources[0], c = e.targets[0] + if (e._back || e._bridge) 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 @@ -540,7 +576,7 @@ export function computeBlockLayout(graphData = null, referenceId = null) { // _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) continue + if (e._renvoi || e._back || e._bridge) 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 diff --git a/frontend/src/components/galaxy/renderers.js b/frontend/src/components/galaxy/renderers.js index 839030d..f45e1ad 100644 --- a/frontend/src/components/galaxy/renderers.js +++ b/frontend/src/components/galaxy/renderers.js @@ -24,10 +24,13 @@ const NICKNAME_MAX_W = 150 // ============================================================ // Rayon d'un nœud : inconnu, conjoint isolé (petit) ou personne export function nodeRadius(node) { - if (node?._type === 'unknown') return UNKNOWN_R - if (node?._compact) return COMPACT_R - return PERSON_R + const scale = node?._scale || 1 + if (node?._type === 'unknown') return UNKNOWN_R * scale + if (node?._compact) return COMPACT_R * scale + return PERSON_R * scale } +// Plan d'un nœud : 'front' (la lignée) ou 'back' (une grosse belle-famille, en retrait) +export const planeOf = (node) => node?._plane || 'front' function rectInView(view, minX, minY, maxX, maxY) { if (!view) return true @@ -110,17 +113,17 @@ const SIB_STRAIGHT_DX = SIB_R * 2 // 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 = 6 -function busY(lift, sy) { - return sy + BUS_DY - RAIL_STEP * (lift || 0) +function busY(lift, sy, scale = 1) { + return sy + (BUS_DY - RAIL_STEP * (lift || 0)) * scale } // 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, lift = 0) { +export function traceSibship(ctx, sourceId, sourcePos, items, lift = 0, scale = 1) { const sx = sourcePos.cx, sy = sourcePos.cy - const by = busY(lift, sy) - const rr = Math.min(SIB_R, by - sy - 2) + const by = busY(lift, sy, scale) + const rr = Math.min(SIB_R * scale, by - sy - 2) ctx.beginPath() for (const { cx: ex, cy: ey } of items) { const dx = ex - sx @@ -147,8 +150,9 @@ export function traceSibship(ctx, sourceId, sourcePos, items, lift = 0) { // ============================================================ // Courbes de couple (Bézier) // ============================================================ -export function drawCoupleLinks(ctx, coupleBarMeta, posMap, nodeMap, nodeRandomData, entrance, entranceActive, elapsed, view = null) { +export function drawCoupleLinks(ctx, coupleBarMeta, posMap, nodeMap, nodeRandomData, entrance, entranceActive, elapsed, view = null, plane = 'front') { coupleBarMeta.forEach(({ elkId, p1Key, p2Key, virtual }) => { + if (planeOf(nodeMap.get(p1Key)) !== plane) return const p1Pos = posMap.get(p1Key) const p2Pos = posMap.get(p2Key) const unionPos = posMap.get(elkId) @@ -259,15 +263,15 @@ export function drawRenvoiPills(ctx) { // ============================================================ // Filiations (Bézier union → enfant) // ============================================================ -export function drawFiliations(ctx, edges, posMap, nodeMap, nodeRandomData, entrance, entranceActive, elapsed, view = null) { - renvoiHitAreas.length = 0 - renvoiPills.length = 0 +export function drawFiliations(ctx, edges, posMap, nodeMap, nodeRandomData, entrance, entranceActive, elapsed, view = null, plane = 'front') { + if (plane === 'front') { renvoiHitAreas.length = 0; renvoiPills.length = 0 } if (!edges) return const sibships = new Map() edges.forEach(edge => { const sourceId = edge.sources[0] const targetId = edge.targets[0] + if (planeOf(nodeMap.get(sourceId)) !== plane) return const sourcePos = posMap.get(sourceId) const targetPos = posMap.get(targetId) if (!sourcePos || !targetPos) return @@ -312,7 +316,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, nodeMap.get(sourceId)?._railLift) + traceSibship(ctx, sourceId, sourcePos, items, nodeMap.get(sourceId)?._railLift, nodeMap.get(sourceId)?._scale || 1) ctx.globalAlpha = 1 ctx.setLineDash([]) } @@ -1035,12 +1039,17 @@ function drawImageCover(ctx, img, x, y, w, h, crop = null) { // ============================================================ export function drawNodes(ctx, layoutData, posMap, entrance, entranceActive, elapsed, renderState) { const view = renderState.view || null - photoPlaceholderHitAreas.length = 0 + const plane = renderState.plane || 'front' + if (plane === 'front') photoPlaceholderHitAreas.length = 0 layoutData.children.forEach(node => { + if (planeOf(node) !== plane) return const pos = posMap.get(node.id) if (!pos) return const { cx, cy } = pos if (!pointInView(view, cx, cy)) return + // Plan arrière : le nœud est dessiné à son échelle, autour de son centre + const scale = node._scale || 1 + if (scale !== 1) { ctx.save(); ctx.translate(cx, cy); ctx.scale(scale, scale); ctx.translate(-cx, -cy) } let nodeProgress = 1 if (entranceActive) { @@ -1089,6 +1098,7 @@ export function drawNodes(ctx, layoutData, posMap, entrance, entranceActive, ela ctx.fillText(t('Inconnu'), 0, UNKNOWN_R + 4) ctx.restore() } + if (scale !== 1) ctx.restore() }) } diff --git a/tools/layout-bench/defects.mjs b/tools/layout-bench/defects.mjs index a34e327..20233c8 100644 --- a/tools/layout-bench/defects.mjs +++ b/tools/layout-bench/defects.mjs @@ -11,6 +11,8 @@ const cx = (n) => n.x + n.width / 2 export function busOffset(id) { let h = 0; for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) | 0; return (Math.abs(h) % 3) * 4 } export function computeDefects(result, coupleBarMeta, graph, referenceId = null) { + // Le plan arrière (grosses belles-familles, maquette F) se mesure à part : ici, le plan avant seulement + result = { ...result, children: result.children.filter((n) => n._plane !== 'back'), edges: result.edges.filter((e) => !e._back && !e._bridge) } const nodes = result.children const byId = new Map(nodes.map((n) => [n.id, n])) const kidsOf = new Map() diff --git a/tools/layout-bench/invariants.mjs b/tools/layout-bench/invariants.mjs index 38de306..df78f5f 100644 --- a/tools/layout-bench/invariants.mjs +++ b/tools/layout-bench/invariants.mjs @@ -60,6 +60,8 @@ export function bloodRelatives(graph, referenceId) { const birthYear = (p) => { const raw = p?.birthYear ?? p?.birthDate; const y = raw != null ? parseInt(String(raw).slice(0, 4), 10) : NaN; return Number.isFinite(y) ? y : null } export function checkInvariants(result, coupleBarMeta, graph, referenceId = null) { + // Le plan arrière (grosses belles-familles, maquette F) se mesure à part : ici, le plan avant seulement + result = { ...result, children: result.children.filter((n) => n._plane !== 'back'), edges: result.edges.filter((e) => !e._back && !e._bridge) } const nodes = result.children const byId = new Map(nodes.map((n) => [n.id, n])) const persons = nodes.filter((n) => n._type === 'person') diff --git a/tools/layout-bench/run.mjs b/tools/layout-bench/run.mjs index 8e58480..31459f9 100644 --- a/tools/layout-bench/run.mjs +++ b/tools/layout-bench/run.mjs @@ -37,7 +37,7 @@ for (const name of names) { const nodes = result.children const largeur = Math.round(Math.max(...nodes.map((n) => n.x + n.width)) - Math.min(...nodes.map((n) => n.x))) const { pires_foyers, ...defauts } = def - const row = { arbre: name, personnes: nodes.filter((n) => n._type === 'person').length, ms, ...inv, ...defauts, ...stab, largeur } + const row = { arbre: name, personnes: nodes.filter((n) => n._type === 'person').length, arriere: nodes.filter((n) => n._type === 'person' && n._plane === 'back').length, ms, ...inv, ...defauts, ...stab, largeur } row.invariants = HARD.every((k) => inv[k] === 0) ? 'ok' : 'FAUX' rows.push({ ...row, pires_foyers }) if (tree.archetype) { @@ -48,7 +48,7 @@ for (const name of names) { renderSvg(result, coupleBarMeta, `${OUT}${name}.svg`, name) } } -const shown = ['arbre', 'personnes', 'invariants', 'rangees_fausses', 'conflits', 'fratries_coupees', 'renvois', 'renvois_sang', 'fils_sur_etranger', 'croisements', 'superpositions', 'cables_ecran', 'foyers_larges', 'proches_ecran', 'proches', 'max_bouges', 'max_deplacement_px', 'largeur', 'ms'] +const shown = ['arbre', 'personnes', 'arriere', 'invariants', 'rangees_fausses', 'conflits', 'fratries_coupees', 'renvois', 'renvois_sang', 'fils_sur_etranger', 'croisements', 'superpositions', 'cables_ecran', 'foyers_larges', 'proches_ecran', 'proches', 'max_bouges', 'max_deplacement_px', 'largeur', 'ms'] console.table(rows.map((r) => Object.fromEntries(shown.filter((k) => k in r).map((k) => [k, r[k]])))) for (const r of rows) if (r.pires_foyers?.length && r.pires_foyers[0].etendue > 1440) console.log(`${r.arbre} : foyers les plus étendus`, r.pires_foyers.map((h) => `${h.enfants} enfants sur ${h.etendue} px`).join(' · ')) writeFileSync(`${OUT}rapport-${tag}.json`, JSON.stringify(rows, null, 2)) From fb02139929f0c8b0b6f650c0de2cd868154ec9db Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Mon, 19 Oct 2026 16:57:01 +0200 Subject: [PATCH 07/26] Placement : carte de famille et pont, les grosses belles-familles en cluster MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Patch a écarté le plan arrière (« beaucoup trop d'infos, 150 personnes en plus, illisible ») et retenu la maquette A. Une belle-famille plus large que deux écrans devient un cluster dessiné à part, sur les mêmes rangées, par le même placement. À sa place, au-dessus du conjoint, une carte de famille (nom, quatre portraits, nombre de personnes) reliée à lui par un pont en pointillé. Dans le cluster, le conjoint apparaît dans sa fratrie (écho de la même personne) avec, sous lui, la carte inverse de la lignée qu'il a rejointe. Un clic sur une carte mène de l'autre côté (même mécanique que les pastilles). Le fil des parents vers le conjoint du plan principal est masqué, il va à l'écho. Nouveau type de nœud « family » dans le placement et le rendu, deux constantes de carte, deux textes anglais. Le plan arrière et son calque sont retirés. Co-Authored-By: Claude Fable 5.1 --- frontend/src/components/Galaxy.jsx | 45 ++------ frontend/src/components/galaxy/blockLayout.js | 89 ++++++++------ frontend/src/components/galaxy/constants.js | 3 + frontend/src/components/galaxy/renderers.js | 109 ++++++++++++++---- frontend/src/i18n/en/arbre.js | 2 + tools/layout-bench/defects.mjs | 2 - tools/layout-bench/invariants.mjs | 2 - tools/layout-bench/run.mjs | 4 +- 8 files changed, 156 insertions(+), 100 deletions(-) diff --git a/frontend/src/components/Galaxy.jsx b/frontend/src/components/Galaxy.jsx index 04c6a4d..4d37ed6 100644 --- a/frontend/src/components/Galaxy.jsx +++ b/frontend/src/components/Galaxy.jsx @@ -50,8 +50,6 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig const photoHintRef = useRef(photoHint) useEffect(() => { photoHintRef.current = photoHint }, [photoHint]) const layoutDataRef = useRef(null) - // Calque du plan arrière (grosses belles-familles), composé plus clair et légèrement flou sous le plan avant - const backLayerRef = useRef(null) const [layoutReady, setLayoutReady] = useState(false) const transformRef = useRef({ x: 0, y: 0, scale: 1 }) @@ -813,36 +811,6 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig const cssW = canvas.width / dprRef.current const cssH = canvas.height / dprRef.current const view = { x0: -x / scale, y0: -y / scale, x1: (cssW - x) / scale, y1: (cssH - y) / scale, scale } - const nodeRenderState = { - nodeRandomData: nodeRandomDataRef.current, - imageCache: imageCache.current, - hoverScales: hoverScales.current, - selectedGlowScales: selectedGlowScales.current, - searchGlowScales: searchGlowScales.current, - mediaHoverState: mediaHoverState.current, - entrance, entranceActive, elapsed, - view, - photoHint: photoHintRef.current, - liftScales: liftScalesRef.current, - } - // Plan arrière : les grosses belles-familles, dessinées d'abord sur un calque, plus claires et un peu floues - if (layoutData.children.some((n) => n._plane === 'back')) { - const off = backLayerRef.current || (backLayerRef.current = document.createElement('canvas')) - if (off.width !== canvas.width || off.height !== canvas.height) { off.width = canvas.width; off.height = canvas.height } - const octx = off.getContext('2d') - octx.setTransform(1, 0, 0, 1, 0, 0) - octx.clearRect(0, 0, off.width, off.height) - octx.setTransform(ctx.getTransform()) - drawCoupleLinks(octx, coupleBarMeta, posMap, nodeMap, nodeRandomDataRef.current, entrance, entranceActive, elapsed, view, 'back') - drawFiliations(octx, layoutData.edges, posMap, nodeMap, nodeRandomDataRef.current, entrance, entranceActive, elapsed, view, 'back') - drawNodes(octx, layoutData, posMap, entrance, entranceActive, elapsed, { ...nodeRenderState, plane: 'back' }) - ctx.save() - ctx.setTransform(1, 0, 0, 1, 0, 0) - ctx.globalAlpha = 0.42 - ctx.filter = 'blur(0.6px)' - ctx.drawImage(off, 0, 0) - ctx.restore() - } drawCoupleLinks(ctx, coupleBarMeta, posMap, nodeMap, nodeRandomDataRef.current, entrance, entranceActive, elapsed, view) drawFiliations(ctx, layoutData.edges, posMap, nodeMap, nodeRandomDataRef.current, entrance, entranceActive, elapsed, view) drawPathHighlight(ctx, { @@ -857,7 +825,18 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig nodeRandomData: nodeRandomDataRef.current, layoutData, }) - drawNodes(ctx, layoutData, posMap, entrance, entranceActive, elapsed, nodeRenderState) + drawNodes(ctx, layoutData, posMap, entrance, entranceActive, elapsed, { + nodeRandomData: nodeRandomDataRef.current, + imageCache: imageCache.current, + hoverScales: hoverScales.current, + selectedGlowScales: selectedGlowScales.current, + searchGlowScales: searchGlowScales.current, + mediaHoverState: mediaHoverState.current, + entrance, entranceActive, elapsed, + view, + photoHint: photoHintRef.current, + liftScales: liftScalesRef.current, + }) drawRenvoiPills(ctx) // Annotations dessinées APRES les noeuds pour que stickers/texte soient au-dessus // Annotations : ancrer aux personnes proches pour qu'elles suivent le layout diff --git a/frontend/src/components/galaxy/blockLayout.js b/frontend/src/components/galaxy/blockLayout.js index ba0f89f..5f2a280 100644 --- a/frontend/src/components/galaxy/blockLayout.js +++ b/frontend/src/components/galaxy/blockLayout.js @@ -1,4 +1,5 @@ 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) @@ -41,10 +42,9 @@ const RENVOI_FAR_PX = 1440 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 -// Deux plans (maquette F du 19/10). Une belle-famille plus large que ça ne se colle pas devant : elle passe sur -// le plan arrière, dessinée par le même placement, plus petite, ancrée à son conjoint -const BACK_PX = 2880 -const BACK_SCALE = 0.55 +// 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 @@ -187,7 +187,8 @@ export function computeBlockLayout(graphData = null, referenceId = null) { 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 backFamilies = [] // familles de conjoints du plan arrière : { block, anchorId } + 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 @@ -312,7 +313,22 @@ export function computeBlockLayout(graphData = null, referenceId = null) { anchor = { kid: id, end: side < 0 ? 'right' : 'left' } const block = buildBlock(top, null, side) anchor = saved - if (block.width > BACK_PX) { backFamilies.push({ block, anchorId: id }); continue } + if (block.width > CLUSTER_PX) { + // La famille devient un cluster ; ici, une carte de famille tient la place de ses parents + 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 = { 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 }) + 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) } @@ -504,43 +520,42 @@ export function computeBlockLayout(graphData = null, referenceId = null) { 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. + const clusterStart = free.length + free.push(...clusters.map(c => c.block)) const forest = packBlocks(free, FAMILY_GAP * 2) free.forEach((b, i) => b.place(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, i) => { + const A = byId.get(c.anchorId) + const ghostX = c.block.rel(c.anchorId) + const x0 = forest.xs[clusterStart + i] + 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 - // Plan arrière : chaque grosse belle-famille est posée à l'échelle BACK_SCALE autour de son conjoint pivot, - // la place fantôme du conjoint dans sa fratrie d'origine venant coïncider avec lui. Ses parents sont donc - // juste au-dessus de lui, en retrait, et toute sa famille se déploie derrière le plan avant. - for (const { block, anchorId } of backFamilies) { - const A = byId.get(anchorId) - if (!A || A.x == null) continue - block.place(0) - const ghostX = block.rel(anchorId) - if (ghostX == null) continue - const acx = A.x + A.width / 2, acy = A.y + A.height / 2 - const gcx = ghostX + A.width / 2, gcy = acy - for (const m of block.members) { - const n = byId.get(m) - if (!n || !pos.has(m)) continue - const lcx = pos.get(m) + n.width / 2, lcy = n.y + n.height / 2 - const cx = acx + (lcx - gcx) * BACK_SCALE, cy = acy + (lcy - gcy) * BACK_SCALE - n.x = cx - n.width / 2 - n.y = cy - n.height / 2 - n._plane = 'back' - n._scale = BACK_SCALE - } - } - const backIds = new Set(nodes.filter(n => n._plane === 'back').map(n => n.id)) - for (const e of result.edges) { - const s = backIds.has(e.sources[0]), k = backIds.has(e.targets[0]) - if (s && k) e._back = true - else if (s && !k) e._bridge = true // des parents du plan arrière à leur enfant du plan avant - } - // 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) : @@ -559,7 +574,7 @@ export function computeBlockLayout(graphData = null, referenceId = null) { const renvoisByUnion = new Map() for (const e of result.edges) { const u = e.sources[0], c = e.targets[0] - if (e._back || e._bridge) continue + 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 @@ -576,7 +591,7 @@ export function computeBlockLayout(graphData = null, referenceId = null) { // _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._back || e._bridge) continue + 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 diff --git a/frontend/src/components/galaxy/constants.js b/frontend/src/components/galaxy/constants.js index 5a90fdc..13e9773 100644 --- a/frontend/src/components/galaxy/constants.js +++ b/frontend/src/components/galaxy/constants.js @@ -16,6 +16,9 @@ export const COMPACT_SCALE = 0.6 export const COMPACT_R = PERSON_R * COMPACT_SCALE export const COMPACT_W = 40 export const COMPACT_JOIN_GAP = -18 +// Carte de famille : tient la place d'un couple au-dessus d'un conjoint dont la famille est dessinée à part +export const FAMILY_CARD_W = 200 +export const FAMILY_CARD_H = 76 export const MAX_ORBIT_MEDIAS = 3 export const ORBIT_RADIUS = 55 export const ORBIT_MEDIA_SIZE = 22 diff --git a/frontend/src/components/galaxy/renderers.js b/frontend/src/components/galaxy/renderers.js index f45e1ad..505ead6 100644 --- a/frontend/src/components/galaxy/renderers.js +++ b/frontend/src/components/galaxy/renderers.js @@ -24,13 +24,10 @@ const NICKNAME_MAX_W = 150 // ============================================================ // Rayon d'un nœud : inconnu, conjoint isolé (petit) ou personne export function nodeRadius(node) { - const scale = node?._scale || 1 - if (node?._type === 'unknown') return UNKNOWN_R * scale - if (node?._compact) return COMPACT_R * scale - return PERSON_R * scale + if (node?._type === 'unknown') return UNKNOWN_R + if (node?._compact) return COMPACT_R + return PERSON_R } -// Plan d'un nœud : 'front' (la lignée) ou 'back' (une grosse belle-famille, en retrait) -export const planeOf = (node) => node?._plane || 'front' function rectInView(view, minX, minY, maxX, maxY) { if (!view) return true @@ -113,17 +110,17 @@ const SIB_STRAIGHT_DX = SIB_R * 2 // 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 = 6 -function busY(lift, sy, scale = 1) { - return sy + (BUS_DY - RAIL_STEP * (lift || 0)) * scale +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, lift = 0, scale = 1) { +export function traceSibship(ctx, sourceId, sourcePos, items, lift = 0) { const sx = sourcePos.cx, sy = sourcePos.cy - const by = busY(lift, sy, scale) - const rr = Math.min(SIB_R * scale, by - sy - 2) + const by = busY(lift, sy) + const rr = Math.min(SIB_R, by - sy - 2) ctx.beginPath() for (const { cx: ex, cy: ey } of items) { const dx = ex - sx @@ -150,9 +147,8 @@ export function traceSibship(ctx, sourceId, sourcePos, items, lift = 0, scale = // ============================================================ // Courbes de couple (Bézier) // ============================================================ -export function drawCoupleLinks(ctx, coupleBarMeta, posMap, nodeMap, nodeRandomData, entrance, entranceActive, elapsed, view = null, plane = 'front') { +export function drawCoupleLinks(ctx, coupleBarMeta, posMap, nodeMap, nodeRandomData, entrance, entranceActive, elapsed, view = null) { coupleBarMeta.forEach(({ elkId, p1Key, p2Key, virtual }) => { - if (planeOf(nodeMap.get(p1Key)) !== plane) return const p1Pos = posMap.get(p1Key) const p2Pos = posMap.get(p2Key) const unionPos = posMap.get(elkId) @@ -254,6 +250,74 @@ function drawRenvoi(ctx, edge, sourcePos, targetPos, targetNode, childR, view) { ) } +// ============================================================ +// Carte de famille (maquette A, 19/10) : une belle-famille dessinée à part est représentée, à la place de +// ses parents au-dessus du conjoint, par une carte (nom, portraits, nombre) reliée au conjoint par un +// pont. Dans le cluster, la carte inverse se trouve sous l'écho du conjoint. Un clic mène de l'autre côté. +// ============================================================ +const CARD_FONT = '500 13px "Newsreader", Georgia, serif' +const CARD_SMALL = '400 10px "DM Sans", system-ui, sans-serif' +function drawFamilyCard(ctx, node, pos, posMap, progress, view) { + const { cx, cy } = pos + const w = node.width, h = node.height + const scale = view?.scale ?? 1 + const alpha = Math.min(1, progress * 1.5) + // Pont vers la personne qu'elle remplace ou qu'elle prolonge + const to = posMap.get(node._data.stemTo) + if (to) { + ctx.save() + ctx.globalAlpha = alpha + ctx.strokeStyle = FIL.color + ctx.lineWidth = FIL.width + ctx.setLineDash([3, 4]) + ctx.lineCap = 'round' + ctx.beginPath() + if (node._data.stem === 'down') { ctx.moveTo(cx, cy + h / 2); ctx.lineTo(to.cx, to.cy - PERSON_R - 2) } + else { ctx.moveTo(cx, cy - h / 2); ctx.lineTo(to.cx, to.cy + PERSON_R + 2) } + ctx.stroke() + ctx.restore() + } + ctx.save() + ctx.globalAlpha = alpha + ctx.fillStyle = '#FCF3EF' + ctx.strokeStyle = '#EDB6A4' + ctx.lineWidth = 0.9 + ctx.beginPath() + ctx.roundRect(cx - w / 2, cy - h / 2, w, h, 14) + ctx.fill() + ctx.stroke() + // Quatre petits portraits, en mosaïque + const ps = node._data.portraits || [] + ps.forEach((p, i) => { + const px = cx - w / 2 + 16 + (i % 2) * 24, py = cy - 22 + Math.floor(i / 2) * 24 + ctx.fillStyle = '#F2EFEA' + ctx.strokeStyle = '#E3DED6' + ctx.lineWidth = 0.7 + ctx.beginPath() + ctx.roundRect(px, py, 20, 20, 3) + ctx.fill() + ctx.stroke() + ctx.fillStyle = COLORS.textLight + ctx.font = '400 8px "Newsreader", Georgia, serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText(`${(p.firstName || '')[0] || ''}${(p.lastName || '')[0] || ''}`, px + 10, py + 10.5) + }) + if (scale > LOD_LABEL_FADE_OUT) { + ctx.fillStyle = COLORS.text + ctx.font = CARD_FONT + ctx.textAlign = 'left' + ctx.textBaseline = 'middle' + ctx.fillText(node._data.label ? t('Famille {name}', { name: node._data.label }) : t('Famille'), cx - w / 2 + 72, cy - 8) + ctx.fillStyle = '#93402A' + ctx.font = CARD_SMALL + ctx.fillText(t('{n} personnes', { n: node._data.count }), cx - w / 2 + 72, cy + 10) + } + ctx.restore() + const target = posMap.get(node._data.targetId) + if (target && alpha > 0.5) renvoiHitAreas.push({ x0: cx - w / 2, y0: cy - h / 2, x1: cx + w / 2, y1: cy + h / 2, targetX: target.cx, targetY: target.cy }) +} + // Après drawNodes : les pastilles passent au-dessus des portraits et des souvenirs export function drawRenvoiPills(ctx) { renvoiPills.forEach(p => drawRenvoiPill(ctx, p)) @@ -263,15 +327,16 @@ export function drawRenvoiPills(ctx) { // ============================================================ // Filiations (Bézier union → enfant) // ============================================================ -export function drawFiliations(ctx, edges, posMap, nodeMap, nodeRandomData, entrance, entranceActive, elapsed, view = null, plane = 'front') { - if (plane === 'front') { renvoiHitAreas.length = 0; renvoiPills.length = 0 } +export function drawFiliations(ctx, edges, posMap, nodeMap, nodeRandomData, entrance, entranceActive, elapsed, view = null) { + renvoiHitAreas.length = 0 + renvoiPills.length = 0 if (!edges) return const sibships = new Map() edges.forEach(edge => { + if (edge._hidden) return const sourceId = edge.sources[0] const targetId = edge.targets[0] - if (planeOf(nodeMap.get(sourceId)) !== plane) return const sourcePos = posMap.get(sourceId) const targetPos = posMap.get(targetId) if (!sourcePos || !targetPos) return @@ -316,7 +381,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, nodeMap.get(sourceId)?._railLift, nodeMap.get(sourceId)?._scale || 1) + traceSibship(ctx, sourceId, sourcePos, items, nodeMap.get(sourceId)?._railLift) ctx.globalAlpha = 1 ctx.setLineDash([]) } @@ -1039,17 +1104,12 @@ function drawImageCover(ctx, img, x, y, w, h, crop = null) { // ============================================================ export function drawNodes(ctx, layoutData, posMap, entrance, entranceActive, elapsed, renderState) { const view = renderState.view || null - const plane = renderState.plane || 'front' - if (plane === 'front') photoPlaceholderHitAreas.length = 0 + photoPlaceholderHitAreas.length = 0 layoutData.children.forEach(node => { - if (planeOf(node) !== plane) return const pos = posMap.get(node.id) if (!pos) return const { cx, cy } = pos if (!pointInView(view, cx, cy)) return - // Plan arrière : le nœud est dessiné à son échelle, autour de son centre - const scale = node._scale || 1 - if (scale !== 1) { ctx.save(); ctx.translate(cx, cy); ctx.scale(scale, scale); ctx.translate(-cx, -cy) } let nodeProgress = 1 if (entranceActive) { @@ -1062,6 +1122,8 @@ export function drawNodes(ctx, layoutData, posMap, entrance, entranceActive, ela if (node._type === 'person') { drawPersonNode(ctx, node, pos, nodeProgress, renderState) + } else if (node._type === 'family') { + drawFamilyCard(ctx, node, pos, posMap, nodeProgress, view) } else if (node._type === 'union') { ctx.globalAlpha = nodeProgress ctx.fillStyle = FIL.dot @@ -1098,7 +1160,6 @@ export function drawNodes(ctx, layoutData, posMap, entrance, entranceActive, ela ctx.fillText(t('Inconnu'), 0, UNKNOWN_R + 4) ctx.restore() } - if (scale !== 1) ctx.restore() }) } diff --git a/frontend/src/i18n/en/arbre.js b/frontend/src/i18n/en/arbre.js index 4920545..27f43a8 100644 --- a/frontend/src/i18n/en/arbre.js +++ b/frontend/src/i18n/en/arbre.js @@ -1,5 +1,7 @@ // Traductions anglaises, clé = texte français exact passé à t(). export default { + 'Famille {name}': 'The {name} family', + 'Famille': 'Family', // Arbre sur canvas (components/galaxy/renderers.js) 'Voir les parents': 'See parents', 'Voir {firstName}': 'See {firstName}', diff --git a/tools/layout-bench/defects.mjs b/tools/layout-bench/defects.mjs index 20233c8..a34e327 100644 --- a/tools/layout-bench/defects.mjs +++ b/tools/layout-bench/defects.mjs @@ -11,8 +11,6 @@ const cx = (n) => n.x + n.width / 2 export function busOffset(id) { let h = 0; for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) | 0; return (Math.abs(h) % 3) * 4 } export function computeDefects(result, coupleBarMeta, graph, referenceId = null) { - // Le plan arrière (grosses belles-familles, maquette F) se mesure à part : ici, le plan avant seulement - result = { ...result, children: result.children.filter((n) => n._plane !== 'back'), edges: result.edges.filter((e) => !e._back && !e._bridge) } const nodes = result.children const byId = new Map(nodes.map((n) => [n.id, n])) const kidsOf = new Map() diff --git a/tools/layout-bench/invariants.mjs b/tools/layout-bench/invariants.mjs index df78f5f..38de306 100644 --- a/tools/layout-bench/invariants.mjs +++ b/tools/layout-bench/invariants.mjs @@ -60,8 +60,6 @@ export function bloodRelatives(graph, referenceId) { const birthYear = (p) => { const raw = p?.birthYear ?? p?.birthDate; const y = raw != null ? parseInt(String(raw).slice(0, 4), 10) : NaN; return Number.isFinite(y) ? y : null } export function checkInvariants(result, coupleBarMeta, graph, referenceId = null) { - // Le plan arrière (grosses belles-familles, maquette F) se mesure à part : ici, le plan avant seulement - result = { ...result, children: result.children.filter((n) => n._plane !== 'back'), edges: result.edges.filter((e) => !e._back && !e._bridge) } const nodes = result.children const byId = new Map(nodes.map((n) => [n.id, n])) const persons = nodes.filter((n) => n._type === 'person') diff --git a/tools/layout-bench/run.mjs b/tools/layout-bench/run.mjs index 31459f9..8e58480 100644 --- a/tools/layout-bench/run.mjs +++ b/tools/layout-bench/run.mjs @@ -37,7 +37,7 @@ for (const name of names) { const nodes = result.children const largeur = Math.round(Math.max(...nodes.map((n) => n.x + n.width)) - Math.min(...nodes.map((n) => n.x))) const { pires_foyers, ...defauts } = def - const row = { arbre: name, personnes: nodes.filter((n) => n._type === 'person').length, arriere: nodes.filter((n) => n._type === 'person' && n._plane === 'back').length, ms, ...inv, ...defauts, ...stab, largeur } + const row = { arbre: name, personnes: nodes.filter((n) => n._type === 'person').length, ms, ...inv, ...defauts, ...stab, largeur } row.invariants = HARD.every((k) => inv[k] === 0) ? 'ok' : 'FAUX' rows.push({ ...row, pires_foyers }) if (tree.archetype) { @@ -48,7 +48,7 @@ for (const name of names) { renderSvg(result, coupleBarMeta, `${OUT}${name}.svg`, name) } } -const shown = ['arbre', 'personnes', 'arriere', 'invariants', 'rangees_fausses', 'conflits', 'fratries_coupees', 'renvois', 'renvois_sang', 'fils_sur_etranger', 'croisements', 'superpositions', 'cables_ecran', 'foyers_larges', 'proches_ecran', 'proches', 'max_bouges', 'max_deplacement_px', 'largeur', 'ms'] +const shown = ['arbre', 'personnes', 'invariants', 'rangees_fausses', 'conflits', 'fratries_coupees', 'renvois', 'renvois_sang', 'fils_sur_etranger', 'croisements', 'superpositions', 'cables_ecran', 'foyers_larges', 'proches_ecran', 'proches', 'max_bouges', 'max_deplacement_px', 'largeur', 'ms'] console.table(rows.map((r) => Object.fromEntries(shown.filter((k) => k in r).map((k) => [k, r[k]])))) for (const r of rows) if (r.pires_foyers?.length && r.pires_foyers[0].etendue > 1440) console.log(`${r.arbre} : foyers les plus étendus`, r.pires_foyers.map((h) => `${h.enfants} enfants sur ${h.etendue} px`).join(' · ')) writeFileSync(`${OUT}rapport-${tag}.json`, JSON.stringify(rows, null, 2)) From 71c8e1a93d851b99b7520a3528347de85c30f30e Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Mon, 19 Oct 2026 17:11:22 +0200 Subject: [PATCH 08/26] =?UTF-8?q?Carte=20des=20familles=20:=20au=20d=C3=A9?= =?UTF-8?q?zoom,=20chaque=20famille=20devient=20une=20tuile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maquette J retenue par Patch le 19/10 (« quand on dézoome pas mal, une famille devient un gros bloc »). Entre les échelles 0,3 et 0,2 l'arbre s'efface et les tuiles apparaissent : une par famille (la lignée principale et chaque cluster), de taille constante à l'écran, avec le nom, six portraits et le nombre de personnes ; un trait par mariage entre familles, avec le prénom de la personne qui relie. Les tuiles se rangent autour de la famille principale à distance d'écran fixe, quel que soit l'éloignement réel des familles sur la toile. Un clic sur une tuile s'approche de la famille (même mécanique que les pastilles). Le placement expose `result.families`. Co-Authored-By: Claude Fable 5.1 --- frontend/src/components/Galaxy.jsx | 11 ++- frontend/src/components/galaxy/blockLayout.js | 22 +++++- frontend/src/components/galaxy/renderers.js | 68 +++++++++++++++++++ 3 files changed, 99 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/Galaxy.jsx b/frontend/src/components/Galaxy.jsx index 4d37ed6..3143466 100644 --- a/frontend/src/components/Galaxy.jsx +++ b/frontend/src/components/Galaxy.jsx @@ -8,7 +8,7 @@ import { computeBlockLayout } from './galaxy/blockLayout' // Placement par blocs familiaux par défaut (mesures : tools/layout-bench). ?layout=sugiyama = placement Sugiyama. const USE_BLOCK_LAYOUT = typeof window === 'undefined' || new URLSearchParams(window.location.search).get('layout') !== 'sugiyama' import { findPathBFS } from './galaxy/pathfinding' -import { drawBackground, drawCoupleLinks, drawFiliations, drawAnnotations, drawPathHighlight, drawNodes, drawRenvoiPills, nodeRadius } from './galaxy/renderers' +import { drawBackground, drawCoupleLinks, drawFiliations, drawAnnotations, drawPathHighlight, drawNodes, drawRenvoiPills, nodeRadius, drawFamilyMap } from './galaxy/renderers' import { buildSiblingGroups, moveInOrder, pickSlot, rubberBand, springStep, isSettled, buildOrderPayload, filiationsWithOrder } from './galaxy/siblingReorder' import { annotations as annotationData } from '../data/mockData' import { useImageCache } from './galaxy/useImageCache' @@ -26,6 +26,9 @@ function approach(prev, target, k, eps = 1e-3) { return Math.abs(target - next) < eps ? target : next } const MAX_SCALE = 3.2 +// Carte des familles : l'arbre s'efface entre ces deux échelles, les tuiles de familles apparaissent +const MAP_HI = 0.3 +const MAP_LO = 0.2 // Ouverture sur la personne de départ quand l'arbre entier serait illisible const FOCUS_MIN_READABLE_SCALE = 0.5 const FOCUS_SCALE = 0.95 @@ -811,6 +814,10 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig const cssW = canvas.width / dprRef.current const cssH = canvas.height / dprRef.current const view = { x0: -x / scale, y0: -y / scale, x1: (cssW - x) / scale, y1: (cssH - y) / scale, scale } + // Carte des familles : au dézoom, l'arbre laisse la place aux tuiles de familles + const families = layoutData.families || [] + const mapAlpha = families.length > 1 ? Math.min(1, Math.max(0, (MAP_HI - scale) / (MAP_HI - MAP_LO))) : 0 + if (mapAlpha < 1) { drawCoupleLinks(ctx, coupleBarMeta, posMap, nodeMap, nodeRandomDataRef.current, entrance, entranceActive, elapsed, view) drawFiliations(ctx, layoutData.edges, posMap, nodeMap, nodeRandomDataRef.current, entrance, entranceActive, elapsed, view) drawPathHighlight(ctx, { @@ -838,6 +845,8 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig liftScales: liftScalesRef.current, }) drawRenvoiPills(ctx) + } + if (mapAlpha > 0) drawFamilyMap(ctx, families, view, mapAlpha) // Annotations dessinées APRES les noeuds pour que stickers/texte soient au-dessus // Annotations : ancrer aux personnes proches pour qu'elles suivent le layout const personPosMap = new Map() diff --git a/frontend/src/components/galaxy/blockLayout.js b/frontend/src/components/galaxy/blockLayout.js index 5f2a280..d8459b1 100644 --- a/frontend/src/components/galaxy/blockLayout.js +++ b/frontend/src/components/galaxy/blockLayout.js @@ -324,7 +324,7 @@ export function computeBlockLayout(graphData = null, referenceId = null) { 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 = { 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 }) + clusters.push({ block, anchorId: id, pu, cardId, echoId, mainCardId, label, people }) built.push({ block: card, anchorId: id, side }) if (chain.length === 3) orient(chain, chain[0] === id ? chain[2] : chain[0], side) continue @@ -631,5 +631,25 @@ export function computeBlockLayout(graphData = null, referenceId = null) { 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' }) + result.families = [ + { id: 'main', label: referenceNode?._data?.lastName || '', count: mainPeople.length, portraits: portraitsOf(mainIds), bbox: bboxOf(mainIds), links: [] }, + ...clusters.map(c => ({ id: c.cardId, 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 || '' }] })), + ].filter(f => f.bbox) + return result } diff --git a/frontend/src/components/galaxy/renderers.js b/frontend/src/components/galaxy/renderers.js index 505ead6..673bbe9 100644 --- a/frontend/src/components/galaxy/renderers.js +++ b/frontend/src/components/galaxy/renderers.js @@ -318,6 +318,74 @@ function drawFamilyCard(ctx, node, pos, posMap, progress, view) { if (target && alpha > 0.5) renvoiHitAreas.push({ x0: cx - w / 2, y0: cy - h / 2, x1: cx + w / 2, y1: cy + h / 2, targetX: target.cx, targetY: target.cy }) } +// ============================================================ +// Carte des familles (maquette J, 19/10) : au dézoom, l'arbre s'efface et chaque famille devient une tuile +// de taille constante à l'écran (nom, mosaïque de portraits, nombre), les mariages entre familles des traits. +// Un clic sur une tuile s'approche de la famille (même mécanique que les pastilles). +// ============================================================ +export function drawFamilyMap(ctx, families, view, alpha) { + if (!families?.length || alpha <= 0) return + const s = view?.scale || 1 + const px = (v) => v / s + const real = (f) => ({ x: (f.bbox.x0 + f.bbox.x1) / 2, y: (f.bbox.y0 + f.bbox.y1) / 2 }) + // Les tuiles se rangent autour de la famille principale, à distance d'écran constante, quel que soit + // l'éloignement réel des familles sur la toile ; un clic mène à la vraie place de la famille + const main = families.find((f) => f.id === 'main') || families[0] + const origin = real(main) + const others = families.filter((f) => f !== main) + const tilePos = new Map([[main.id, origin]]) + others.forEach((f, i) => { + const angle = -Math.PI / 2 + (i / Math.max(1, others.length)) * Math.PI * 2 + Math.PI / others.length + tilePos.set(f.id, { x: origin.x + Math.cos(angle) * px(380), y: origin.y + Math.sin(angle) * px(230) }) + }) + const center = (f) => tilePos.get(f.id) + if (alpha >= 1) renvoiHitAreas.length = 0 + ctx.save() + ctx.globalAlpha = alpha + ctx.lineCap = 'round' + for (const f of families) for (const l of (f.links || [])) { + const g = families.find((x) => x.id === l.to) + if (!g) continue + const a = center(f), b = center(g) + ctx.strokeStyle = FIL.color + ctx.lineWidth = px(1.6) + ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke() + if (l.label) { + ctx.fillStyle = COLORS.textLight + ctx.font = `400 ${px(11)}px "DM Sans", system-ui, sans-serif` + ctx.textAlign = 'center'; ctx.textBaseline = 'bottom' + ctx.fillText(l.label, (a.x + b.x) / 2, (a.y + b.y) / 2 - px(6)) + } + } + for (const f of families) { + const c = center(f) + const w = px(290), h = px(112) + ctx.fillStyle = '#FFFFFF' + ctx.strokeStyle = '#E3DED6' + ctx.lineWidth = px(1) + ctx.beginPath(); ctx.roundRect(c.x - w / 2, c.y - h / 2, w, h, px(18)); ctx.fill(); ctx.stroke() + ;(f.portraits || []).forEach((p, i) => { + const cx = c.x - w / 2 + px(24) + (i % 3) * px(30), cy = c.y - px(16) + Math.floor(i / 3) * px(30) + ctx.fillStyle = '#F2EFEA'; ctx.strokeStyle = '#E3DED6'; ctx.lineWidth = px(0.8) + ctx.beginPath(); ctx.roundRect(cx - px(11), cy - px(11), px(22), px(22), px(4)); ctx.fill(); ctx.stroke() + ctx.fillStyle = COLORS.textLight + ctx.font = `400 ${px(9)}px "Newsreader", Georgia, serif` + ctx.textAlign = 'center'; ctx.textBaseline = 'middle' + ctx.fillText(`${(p.firstName || '')[0] || ''}${(p.lastName || '')[0] || ''}`, cx, cy + px(0.5)) + }) + ctx.fillStyle = COLORS.text + ctx.font = `500 ${px(18)}px "Newsreader", Georgia, serif` + ctx.textAlign = 'left'; ctx.textBaseline = 'middle' + ctx.fillText(f.label ? t('Famille {name}', { name: f.label }) : t('Famille'), c.x - w / 2 + px(118), c.y - px(12)) + ctx.fillStyle = '#93402A' + ctx.font = `400 ${px(12)}px "DM Sans", system-ui, sans-serif` + ctx.fillText(t('{n} personnes', { n: f.count }), c.x - w / 2 + px(118), c.y + px(14)) + const target = real(f) + if (alpha > 0.5) renvoiHitAreas.push({ x0: c.x - w / 2, y0: c.y - h / 2, x1: c.x + w / 2, y1: c.y + h / 2, targetX: target.x, targetY: target.y }) + } + ctx.restore() +} + // Après drawNodes : les pastilles passent au-dessus des portraits et des souvenirs export function drawRenvoiPills(ctx) { renvoiPills.forEach(p => drawRenvoiPill(ctx, p)) From c3d0ac30e475a3c31d8c0899438850adcdb46392 Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Mon, 19 Oct 2026 17:19:15 +0200 Subject: [PATCH 09/26] =?UTF-8?q?Carte=20des=20familles=20v2=20:=20blocs?= =?UTF-8?q?=20calcul=C3=A9s=20par=20le=20placement,=20repli=20net,=20trait?= =?UTF-8?q?s=20de=20bloc=20=C3=A0=20bloc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retours de Patch : pas d'animation, familles rangées comme sur le schéma, lien plus naturel, bloc calculé dans l'arbre et non dessiné par-dessus. - Le placement donne à chaque famille sa place réelle et sa place sur la carte (autour de la famille principale, en coordonnées de l'arbre). - Passé le seuil de dézoom, une famille par alliance se replie net en son bloc, qui glisse de sa place réelle vers sa place sur la carte à mesure qu'on dézoome ; plus bas, la famille principale se replie aussi. Plus de fondu, plus de clic sur les blocs. - Les traits entre familles vont de bord à bord, avec les deux prénoms du mariage. Le pont d'une carte de famille est un fil plein. Co-Authored-By: Claude Fable 5.1 --- frontend/src/components/Galaxy.jsx | 28 +++++++--- frontend/src/components/galaxy/blockLayout.js | 19 ++++++- frontend/src/components/galaxy/renderers.js | 52 ++++++++----------- 3 files changed, 60 insertions(+), 39 deletions(-) diff --git a/frontend/src/components/Galaxy.jsx b/frontend/src/components/Galaxy.jsx index 3143466..c0ab4c5 100644 --- a/frontend/src/components/Galaxy.jsx +++ b/frontend/src/components/Galaxy.jsx @@ -29,6 +29,7 @@ const MAX_SCALE = 3.2 // Carte des familles : l'arbre s'efface entre ces deux échelles, les tuiles de familles apparaissent const MAP_HI = 0.3 const MAP_LO = 0.2 +const nodeById = (layoutData, id) => { if (!layoutData._byId) layoutData._byId = new Map(layoutData.children.map((n) => [n.id, n])); return layoutData._byId.get(id) } // Ouverture sur la personne de départ quand l'arbre entier serait illisible const FOCUS_MIN_READABLE_SCALE = 0.5 const FOCUS_SCALE = 0.95 @@ -793,6 +794,19 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig // Rien n'a changé : on ne redessine pas if (!dirty) { rafId = requestAnimationFrame(animate); return } + // Carte des familles : passé MAP_HI, les familles par alliance se replient en blocs qui glissent vers leur + // place sur la carte (progress de 0 à 1 entre MAP_HI et MAP_LO) ; passé MAP_LO, la famille principale aussi + const families = layoutData.families || [] + const mapProgress = families.length > 1 ? Math.min(1, Math.max(0, (MAP_HI - scale) / (MAP_HI - MAP_LO))) : 0 + const familyOffsets = new Map() + for (const f of families) familyOffsets.set(f.id, f.id === 'main' ? { x: 0, y: 0 } : { x: (f.gathered.x - f.spread.x) * mapProgress, y: (f.gathered.y - f.spread.y) * mapProgress }) + const visibleFamily = (n) => mapProgress <= 0 || (mapProgress < 1 && (n?._family || 'main') === 'main') + const drawn = mapProgress <= 0 ? layoutData : { + ...layoutData, + children: layoutData.children.filter(visibleFamily), + edges: layoutData.edges.filter((e) => visibleFamily(nodeById(layoutData, e.sources[0])) && visibleFamily(nodeById(layoutData, e.targets[0]))), + } + // Positions animées, calculées seulement quand on redessine const posMap = new Map() layoutData.children.forEach(node => { @@ -814,12 +828,10 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig const cssW = canvas.width / dprRef.current const cssH = canvas.height / dprRef.current const view = { x0: -x / scale, y0: -y / scale, x1: (cssW - x) / scale, y1: (cssH - y) / scale, scale } - // Carte des familles : au dézoom, l'arbre laisse la place aux tuiles de familles - const families = layoutData.families || [] - const mapAlpha = families.length > 1 ? Math.min(1, Math.max(0, (MAP_HI - scale) / (MAP_HI - MAP_LO))) : 0 - if (mapAlpha < 1) { - drawCoupleLinks(ctx, coupleBarMeta, posMap, nodeMap, nodeRandomDataRef.current, entrance, entranceActive, elapsed, view) - drawFiliations(ctx, layoutData.edges, posMap, nodeMap, nodeRandomDataRef.current, entrance, entranceActive, elapsed, view) + if (mapProgress < 1) { + const drawnCouples = mapProgress <= 0 ? coupleBarMeta : coupleBarMeta.filter((c) => visibleFamily(nodeMap.get(c.p1Key))) + drawCoupleLinks(ctx, drawnCouples, posMap, nodeMap, nodeRandomDataRef.current, entrance, entranceActive, elapsed, view) + drawFiliations(ctx, drawn.edges, posMap, nodeMap, nodeRandomDataRef.current, entrance, entranceActive, elapsed, view) drawPathHighlight(ctx, { pathEdgesRef: pathEdgesRef.current, pathNodesRef: pathNodesRef.current, @@ -832,7 +844,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig nodeRandomData: nodeRandomDataRef.current, layoutData, }) - drawNodes(ctx, layoutData, posMap, entrance, entranceActive, elapsed, { + drawNodes(ctx, drawn, posMap, entrance, entranceActive, elapsed, { nodeRandomData: nodeRandomDataRef.current, imageCache: imageCache.current, hoverScales: hoverScales.current, @@ -846,7 +858,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig }) drawRenvoiPills(ctx) } - if (mapAlpha > 0) drawFamilyMap(ctx, families, view, mapAlpha) + if (mapProgress > 0) drawFamilyMap(ctx, families, view, familyOffsets, mapProgress) // Annotations dessinées APRES les noeuds pour que stickers/texte soient au-dessus // Annotations : ancrer aux personnes proches pour qu'elles suivent le layout const personPosMap = new Map() diff --git a/frontend/src/components/galaxy/blockLayout.js b/frontend/src/components/galaxy/blockLayout.js index d8459b1..2a4c2f4 100644 --- a/frontend/src/components/galaxy/blockLayout.js +++ b/frontend/src/components/galaxy/blockLayout.js @@ -646,10 +646,25 @@ export function computeBlockLayout(graphData = null, referenceId = null) { } const mainIds = nodes.filter(n => !familyOf.has(n.id)).map(n => n.id) nodes.forEach(n => { n._family = familyOf.get(n.id) || 'main' }) - result.families = [ + 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, 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 || '' }] })), + ...clusters.map(c => ({ id: c.cardId, 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) + // Place réelle (centre de la famille sur la toile) et place sur la carte : les familles par alliance se rangent + // autour de la famille principale, en coordonnées de l'arbre, à une distance qui tient dans un écran au dézoom + const centerOf = (f) => ({ x: (f.bbox.x0 + f.bbox.x1) / 2, y: (f.bbox.y0 + f.bbox.y1) / 2 }) + const mainFam = families[0] + const others = families.slice(1) + const RING = 2600 + families.forEach((f, i) => { + f.spread = centerOf(f) + if (i === 0) { f.gathered = f.spread; return } + const k = i - 1, n = others.length + const angle = -Math.PI / 3 + (k / n) * Math.PI * 2 + f.gathered = { x: mainFam.spread.x + Math.cos(angle) * RING, y: mainFam.spread.y + Math.sin(angle) * RING * 0.62 } + }) + result.families = families return result } diff --git a/frontend/src/components/galaxy/renderers.js b/frontend/src/components/galaxy/renderers.js index 673bbe9..335a517 100644 --- a/frontend/src/components/galaxy/renderers.js +++ b/frontend/src/components/galaxy/renderers.js @@ -269,7 +269,7 @@ function drawFamilyCard(ctx, node, pos, posMap, progress, view) { ctx.globalAlpha = alpha ctx.strokeStyle = FIL.color ctx.lineWidth = FIL.width - ctx.setLineDash([3, 4]) + ctx.setLineDash([]) ctx.lineCap = 'round' ctx.beginPath() if (node._data.stem === 'down') { ctx.moveTo(cx, cy + h / 2); ctx.lineTo(to.cx, to.cy - PERSON_R - 2) } @@ -319,47 +319,43 @@ function drawFamilyCard(ctx, node, pos, posMap, progress, view) { } // ============================================================ -// Carte des familles (maquette J, 19/10) : au dézoom, l'arbre s'efface et chaque famille devient une tuile -// de taille constante à l'écran (nom, mosaïque de portraits, nombre), les mariages entre familles des traits. -// Un clic sur une tuile s'approche de la famille (même mécanique que les pastilles). +// Carte des familles (maquette J, 19/10). Passé un seuil de dézoom, chaque famille par alliance se replie en +// son bloc, qui glisse de sa place réelle vers sa place sur la carte (calculée par le placement, en coordonnées +// de l'arbre). Plus bas encore, la famille principale se replie aussi. Les mariages entre familles sont des +// traits de bloc à bloc, avec les deux prénoms. Pas d'animation, pas de clic : le zoom est la navigation. // ============================================================ -export function drawFamilyMap(ctx, families, view, alpha) { - if (!families?.length || alpha <= 0) return +export function familyTileSize(scale) { return { w: 290 / scale, h: 112 / scale } } +export function drawFamilyMap(ctx, families, view, offsets, progress) { + if (!families?.length || progress <= 0) return const s = view?.scale || 1 const px = (v) => v / s - const real = (f) => ({ x: (f.bbox.x0 + f.bbox.x1) / 2, y: (f.bbox.y0 + f.bbox.y1) / 2 }) - // Les tuiles se rangent autour de la famille principale, à distance d'écran constante, quel que soit - // l'éloignement réel des familles sur la toile ; un clic mène à la vraie place de la famille - const main = families.find((f) => f.id === 'main') || families[0] - const origin = real(main) - const others = families.filter((f) => f !== main) - const tilePos = new Map([[main.id, origin]]) - others.forEach((f, i) => { - const angle = -Math.PI / 2 + (i / Math.max(1, others.length)) * Math.PI * 2 + Math.PI / others.length - tilePos.set(f.id, { x: origin.x + Math.cos(angle) * px(380), y: origin.y + Math.sin(angle) * px(230) }) - }) - const center = (f) => tilePos.get(f.id) - if (alpha >= 1) renvoiHitAreas.length = 0 + const { w, h } = familyTileSize(s) + const shown = families.filter((f) => f.id !== 'main' || progress >= 1) + const at = (f) => { const o = offsets.get(f.id) || { x: 0, y: 0 }; return { x: f.spread.x + o.x, y: f.spread.y + o.y } } ctx.save() - ctx.globalAlpha = alpha ctx.lineCap = 'round' - for (const f of families) for (const l of (f.links || [])) { + // Traits de bloc à bloc : du bord du bloc au bord de l'autre, ou jusqu'au centre de la famille encore dépliée + for (const f of shown) for (const l of (f.links || [])) { const g = families.find((x) => x.id === l.to) if (!g) continue - const a = center(f), b = center(g) + const a = at(f), b = shown.includes(g) ? at(g) : g.spread + const dx = b.x - a.x, dy = b.y - a.y, len = Math.hypot(dx, dy) || 1 + const ux = dx / len, uy = dy / len + const clip = (c, toward) => { const kx = Math.abs(toward.x) > 1e-6 ? (w / 2) / Math.abs(toward.x) : Infinity; const ky = Math.abs(toward.y) > 1e-6 ? (h / 2) / Math.abs(toward.y) : Infinity; const k = Math.min(kx, ky); return { x: c.x + toward.x * k, y: c.y + toward.y * k } } + const p1 = clip(a, { x: ux, y: uy }) + const p2 = shown.includes(g) ? clip(b, { x: -ux, y: -uy }) : b ctx.strokeStyle = FIL.color ctx.lineWidth = px(1.6) - ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke() + ctx.beginPath(); ctx.moveTo(p1.x, p1.y); ctx.lineTo(p2.x, p2.y); ctx.stroke() if (l.label) { ctx.fillStyle = COLORS.textLight ctx.font = `400 ${px(11)}px "DM Sans", system-ui, sans-serif` ctx.textAlign = 'center'; ctx.textBaseline = 'bottom' - ctx.fillText(l.label, (a.x + b.x) / 2, (a.y + b.y) / 2 - px(6)) + ctx.fillText(l.label, (p1.x + p2.x) / 2, (p1.y + p2.y) / 2 - px(6)) } } - for (const f of families) { - const c = center(f) - const w = px(290), h = px(112) + for (const f of shown) { + const c = at(f) ctx.fillStyle = '#FFFFFF' ctx.strokeStyle = '#E3DED6' ctx.lineWidth = px(1) @@ -380,8 +376,6 @@ export function drawFamilyMap(ctx, families, view, alpha) { ctx.fillStyle = '#93402A' ctx.font = `400 ${px(12)}px "DM Sans", system-ui, sans-serif` ctx.fillText(t('{n} personnes', { n: f.count }), c.x - w / 2 + px(118), c.y + px(14)) - const target = real(f) - if (alpha > 0.5) renvoiHitAreas.push({ x0: c.x - w / 2, y0: c.y - h / 2, x1: c.x + w / 2, y1: c.y + h / 2, targetX: target.x, targetY: target.y }) } ctx.restore() } From e2af4fb5806aad2cb07135d6adde48af74b72666 Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Mon, 19 Oct 2026 17:54:20 +0200 Subject: [PATCH 10/26] =?UTF-8?q?placement(familles):=20c=C3=B4t=C3=A9=20d?= =?UTF-8?q?u=20conjoint,=20carte=20au=20plus=20pr=C3=A8s,=20bandeau=20et?= =?UTF-8?q?=20carte=20des=20familles=20sans=20fondu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- frontend/src/components/Galaxy.jsx | 36 ++--- frontend/src/components/galaxy/blockLayout.js | 98 ++++++++++---- frontend/src/components/galaxy/renderers.js | 128 ++++++++++++------ 3 files changed, 173 insertions(+), 89 deletions(-) diff --git a/frontend/src/components/Galaxy.jsx b/frontend/src/components/Galaxy.jsx index c0ab4c5..60c722d 100644 --- a/frontend/src/components/Galaxy.jsx +++ b/frontend/src/components/Galaxy.jsx @@ -8,7 +8,7 @@ import { computeBlockLayout } from './galaxy/blockLayout' // Placement par blocs familiaux par défaut (mesures : tools/layout-bench). ?layout=sugiyama = placement Sugiyama. const USE_BLOCK_LAYOUT = typeof window === 'undefined' || new URLSearchParams(window.location.search).get('layout') !== 'sugiyama' import { findPathBFS } from './galaxy/pathfinding' -import { drawBackground, drawCoupleLinks, drawFiliations, drawAnnotations, drawPathHighlight, drawNodes, drawRenvoiPills, nodeRadius, drawFamilyMap } from './galaxy/renderers' +import { drawBackground, drawCoupleLinks, drawFiliations, drawAnnotations, drawPathHighlight, drawNodes, drawRenvoiPills, nodeRadius, drawFamilyMap, familyAt, drawFamilyBanner } from './galaxy/renderers' import { buildSiblingGroups, moveInOrder, pickSlot, rubberBand, springStep, isSettled, buildOrderPayload, filiationsWithOrder } from './galaxy/siblingReorder' import { annotations as annotationData } from '../data/mockData' import { useImageCache } from './galaxy/useImageCache' @@ -27,9 +27,7 @@ function approach(prev, target, k, eps = 1e-3) { } const MAX_SCALE = 3.2 // Carte des familles : l'arbre s'efface entre ces deux échelles, les tuiles de familles apparaissent -const MAP_HI = 0.3 -const MAP_LO = 0.2 -const nodeById = (layoutData, id) => { if (!layoutData._byId) layoutData._byId = new Map(layoutData.children.map((n) => [n.id, n])); return layoutData._byId.get(id) } +const MAP_SCALE = 0.25 // Ouverture sur la personne de départ quand l'arbre entier serait illisible const FOCUS_MIN_READABLE_SCALE = 0.5 const FOCUS_SCALE = 0.95 @@ -794,18 +792,9 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig // Rien n'a changé : on ne redessine pas if (!dirty) { rafId = requestAnimationFrame(animate); return } - // Carte des familles : passé MAP_HI, les familles par alliance se replient en blocs qui glissent vers leur - // place sur la carte (progress de 0 à 1 entre MAP_HI et MAP_LO) ; passé MAP_LO, la famille principale aussi + // Carte des familles : passé MAP_SCALE, l'arbre laisse la place à la carte, bascule nette const families = layoutData.families || [] - const mapProgress = families.length > 1 ? Math.min(1, Math.max(0, (MAP_HI - scale) / (MAP_HI - MAP_LO))) : 0 - const familyOffsets = new Map() - for (const f of families) familyOffsets.set(f.id, f.id === 'main' ? { x: 0, y: 0 } : { x: (f.gathered.x - f.spread.x) * mapProgress, y: (f.gathered.y - f.spread.y) * mapProgress }) - const visibleFamily = (n) => mapProgress <= 0 || (mapProgress < 1 && (n?._family || 'main') === 'main') - const drawn = mapProgress <= 0 ? layoutData : { - ...layoutData, - children: layoutData.children.filter(visibleFamily), - edges: layoutData.edges.filter((e) => visibleFamily(nodeById(layoutData, e.sources[0])) && visibleFamily(nodeById(layoutData, e.targets[0]))), - } + const mapOn = families.length > 1 && scale < MAP_SCALE // Positions animées, calculées seulement quand on redessine const posMap = new Map() @@ -828,10 +817,9 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig const cssW = canvas.width / dprRef.current const cssH = canvas.height / dprRef.current const view = { x0: -x / scale, y0: -y / scale, x1: (cssW - x) / scale, y1: (cssH - y) / scale, scale } - if (mapProgress < 1) { - const drawnCouples = mapProgress <= 0 ? coupleBarMeta : coupleBarMeta.filter((c) => visibleFamily(nodeMap.get(c.p1Key))) - drawCoupleLinks(ctx, drawnCouples, posMap, nodeMap, nodeRandomDataRef.current, entrance, entranceActive, elapsed, view) - drawFiliations(ctx, drawn.edges, posMap, nodeMap, nodeRandomDataRef.current, entrance, entranceActive, elapsed, view) + if (!mapOn) { + drawCoupleLinks(ctx, coupleBarMeta, posMap, nodeMap, nodeRandomDataRef.current, entrance, entranceActive, elapsed, view) + drawFiliations(ctx, layoutData.edges, posMap, nodeMap, nodeRandomDataRef.current, entrance, entranceActive, elapsed, view) drawPathHighlight(ctx, { pathEdgesRef: pathEdgesRef.current, pathNodesRef: pathNodesRef.current, @@ -844,7 +832,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig nodeRandomData: nodeRandomDataRef.current, layoutData, }) - drawNodes(ctx, drawn, posMap, entrance, entranceActive, elapsed, { + drawNodes(ctx, layoutData, posMap, entrance, entranceActive, elapsed, { nodeRandomData: nodeRandomDataRef.current, imageCache: imageCache.current, hoverScales: hoverScales.current, @@ -858,7 +846,13 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig }) drawRenvoiPills(ctx) } - if (mapProgress > 0) drawFamilyMap(ctx, families, view, familyOffsets, mapProgress) + if (mapOn || families.length > 1) { + ctx.save() + ctx.setTransform(dprRef.current, 0, 0, dprRef.current, 0, 0) + if (mapOn) drawFamilyMap(ctx, families, cssW, cssH) + else drawFamilyBanner(ctx, familyAt(families, view)) + ctx.restore() + } // Annotations dessinées APRES les noeuds pour que stickers/texte soient au-dessus // Annotations : ancrer aux personnes proches pour qu'elles suivent le layout const personPosMap = new Map() diff --git a/frontend/src/components/galaxy/blockLayout.js b/frontend/src/components/galaxy/blockLayout.js index 2a4c2f4..be05308 100644 --- a/frontend/src/components/galaxy/blockLayout.js +++ b/frontend/src/components/galaxy/blockLayout.js @@ -86,7 +86,27 @@ const extentOf = (rows) => { } 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])) @@ -237,9 +257,22 @@ export function computeBlockLayout(graphData = null, referenceId = null) { // 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) - 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() {} } + 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 @@ -293,7 +326,8 @@ export function computeBlockLayout(graphData = null, referenceId = null) { childBlocks.push(...kids) const packed = packBlocks(childBlocks, FAMILY_GAP) const rows = new Map() - childBlocks.forEach((b, i) => { if (!b.ghost) mergeRows(rows, b.rows, packed.xs[i]) }) + // 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 @@ -308,13 +342,28 @@ export function computeBlockLayout(graphData = null, referenceId = null) { if (placed.has(pc)) continue const top = topChainOf(pc) if (placed.has(top)) continue - const side = chain.length > 3 ? (chain.indexOf(id) < chain.length / 2 ? -1 : 1) : (outer || (chain.indexOf(id) < chain.length / 2 ? -1 : 1)) + 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' } - const block = buildBlock(top, null, side) + let block = buildBlock(top, null, side) anchor = saved if (block.width > CLUSTER_PX) { - // La famille devient un cluster ; ici, une carte de famille tient la place de ses parents + // 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) @@ -323,8 +372,8 @@ export function computeBlockLayout(graphData = null, referenceId = null) { 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 = { 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 }) + 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 @@ -396,6 +445,7 @@ export function computeBlockLayout(graphData = null, referenceId = null) { 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 @@ -423,8 +473,8 @@ export function computeBlockLayout(graphData = null, referenceId = null) { let maxPush = 0, sideOfMax = 0 for (const p of built) { const ideal = idealOf(p, rx) - const x = pushHybrid(p.block.rows, ideal, r, p.side, gen, FAMILY_GAP) - if (Math.abs(x - ideal) > maxPush) { maxPush = Math.abs(x - ideal); sideOfMax = p.side } + 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) } @@ -522,17 +572,19 @@ export function computeBlockLayout(graphData = null, referenceId = null) { 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. - const clusterStart = free.length - free.push(...clusters.map(c => c.block)) - const forest = packBlocks(free, FAMILY_GAP * 2) - free.forEach((b, i) => b.place(forest.xs[i])) + // 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, i) => { + clusters.forEach((c) => { const A = byId.get(c.anchorId) const ghostX = c.block.rel(c.anchorId) - const x0 = forest.xs[clusterStart + i] + 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 }) @@ -649,21 +701,9 @@ export function computeBlockLayout(graphData = null, referenceId = null) { 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, 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 ') }] })), + ...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) - // Place réelle (centre de la famille sur la toile) et place sur la carte : les familles par alliance se rangent - // autour de la famille principale, en coordonnées de l'arbre, à une distance qui tient dans un écran au dézoom - const centerOf = (f) => ({ x: (f.bbox.x0 + f.bbox.x1) / 2, y: (f.bbox.y0 + f.bbox.y1) / 2 }) - const mainFam = families[0] - const others = families.slice(1) - const RING = 2600 - families.forEach((f, i) => { - f.spread = centerOf(f) - if (i === 0) { f.gathered = f.spread; return } - const k = i - 1, n = others.length - const angle = -Math.PI / 3 + (k / n) * Math.PI * 2 - f.gathered = { x: mainFam.spread.x + Math.cos(angle) * RING, y: mainFam.spread.y + Math.sin(angle) * RING * 0.62 } - }) + 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 diff --git a/frontend/src/components/galaxy/renderers.js b/frontend/src/components/galaxy/renderers.js index 335a517..28ad404 100644 --- a/frontend/src/components/galaxy/renderers.js +++ b/frontend/src/components/galaxy/renderers.js @@ -319,63 +319,113 @@ function drawFamilyCard(ctx, node, pos, posMap, progress, view) { } // ============================================================ -// Carte des familles (maquette J, 19/10). Passé un seuil de dézoom, chaque famille par alliance se replie en -// son bloc, qui glisse de sa place réelle vers sa place sur la carte (calculée par le placement, en coordonnées -// de l'arbre). Plus bas encore, la famille principale se replie aussi. Les mariages entre familles sont des -// traits de bloc à bloc, avec les deux prénoms. Pas d'animation, pas de clic : le zoom est la navigation. +// Le nom de la famille où l'on est, écrit en grand à l'écran (en haut à gauche) : celle dont le bloc contient +// le centre de la vue, sinon la plus proche. Coordonnées d'écran, lisible à tout zoom. // ============================================================ -export function familyTileSize(scale) { return { w: 290 / scale, h: 112 / scale } } -export function drawFamilyMap(ctx, families, view, offsets, progress) { - if (!families?.length || progress <= 0) return - const s = view?.scale || 1 - const px = (v) => v / s - const { w, h } = familyTileSize(s) - const shown = families.filter((f) => f.id !== 'main' || progress >= 1) - const at = (f) => { const o = offsets.get(f.id) || { x: 0, y: 0 }; return { x: f.spread.x + o.x, y: f.spread.y + o.y } } +export function familyAt(families, view) { + if (!families?.length || !view) return null + const cx = (view.x0 + view.x1) / 2, cy = (view.y0 + view.y1) / 2 + const dist = (f) => Math.hypot(Math.max(f.bbox.x0 - cx, 0, cx - f.bbox.x1), Math.max(f.bbox.y0 - cy, 0, cy - f.bbox.y1)) + return families.reduce((best, f) => (f.bbox && (!best || dist(f) < dist(best)) ? f : best), null) +} +export function drawFamilyBanner(ctx, family) { + if (!family) return ctx.save() + ctx.font = '500 30px "Newsreader", Georgia, serif' + ctx.textAlign = 'left' + ctx.textBaseline = 'alphabetic' + const title = family.label ? t('Famille {name}', { name: family.label }) : t('Famille') + // Un voile de la couleur du fond, pour rester lisible quand l'arbre passe dessous au dézoom + const w = ctx.measureText(title).width + 56 + const plate = ctx.createLinearGradient(0, 0, w + 60, 0) + plate.addColorStop(0, 'rgba(251, 250, 247, 0.92)') + plate.addColorStop(1, 'rgba(251, 250, 247, 0)') + ctx.fillStyle = plate + ctx.fillRect(0, 0, w + 60, 100) + ctx.fillStyle = COLORS.text + ctx.fillText(title, 28, 56) + ctx.fillStyle = COLORS.textLight + ctx.font = '400 13px "DM Sans", system-ui, sans-serif' + ctx.fillText(t('{n} personnes', { n: family.count }), 29, 78) + ctx.restore() +} + +// ============================================================ +// Carte des familles (maquette J, 19/10). Passé un seuil de dézoom, l'arbre laisse la place à la carte : +// chaque famille est un bloc (nom, portraits, nombre), les blocs se rangent côte à côte au milieu de l'écran, +// la famille principale au centre, les familles par alliance de leur côté, et chaque mariage entre familles +// est un trait de bloc à bloc avec les deux prénoms. Coordonnées d'écran : la carte ne bouge plus avec le zoom. +// ============================================================ +export function drawFamilyMap(ctx, families, cssW, cssH) { + if (!families?.length) return + const W = 290, H = 112, GAPX = 170 + const main = families.find((f) => f.id === 'main') || families[0] + const left = families.filter((f) => f !== main && f.side < 0) + const right = families.filter((f) => f !== main && !(f.side < 0)) + const cy = cssH / 2 + // La famille principale au milieu ; de chaque côté, les familles par alliance en colonne, chacune reliée + // en droite ligne au bloc principal. Aucun trait ne passe derrière un bloc. + const ROWY = H + 44 + const posOf = new Map([[main.id, { x: 0, y: cy }]]) + const place = (list, dir) => list.forEach((f, k) => posOf.set(f.id, { x: dir * (W + GAPX), y: cy + (k - (list.length - 1) / 2) * ROWY })) + place(left, -1); place(right, 1) + // Le groupe entier est centré à l'écran et réduit s'il déborde + const xs = [...posOf.values()].map((c) => c.x) + const x0 = Math.min(...xs) - W / 2, x1 = Math.max(...xs) + W / 2 + const ys = [...posOf.values()].map((c) => c.y) + const fit = Math.min(1, (cssW - 48) / (x1 - x0), (cssH - 160) / (Math.max(...ys) - Math.min(...ys) + H)) + ctx.save() + ctx.translate(cssW / 2, cy) + ctx.scale(fit, fit) + ctx.translate(-(x0 + x1) / 2, -cy) ctx.lineCap = 'round' - // Traits de bloc à bloc : du bord du bloc au bord de l'autre, ou jusqu'au centre de la famille encore dépliée - for (const f of shown) for (const l of (f.links || [])) { + const labels = [] + for (const f of families) for (const l of (f.links || [])) { const g = families.find((x) => x.id === l.to) - if (!g) continue - const a = at(f), b = shown.includes(g) ? at(g) : g.spread + const a = posOf.get(f.id), b = g && posOf.get(g.id) + if (!a || !b) continue const dx = b.x - a.x, dy = b.y - a.y, len = Math.hypot(dx, dy) || 1 const ux = dx / len, uy = dy / len - const clip = (c, toward) => { const kx = Math.abs(toward.x) > 1e-6 ? (w / 2) / Math.abs(toward.x) : Infinity; const ky = Math.abs(toward.y) > 1e-6 ? (h / 2) / Math.abs(toward.y) : Infinity; const k = Math.min(kx, ky); return { x: c.x + toward.x * k, y: c.y + toward.y * k } } - const p1 = clip(a, { x: ux, y: uy }) - const p2 = shown.includes(g) ? clip(b, { x: -ux, y: -uy }) : b + const clip = (c, tx, ty) => { const kx = Math.abs(tx) > 1e-6 ? (W / 2) / Math.abs(tx) : Infinity; const ky = Math.abs(ty) > 1e-6 ? (H / 2) / Math.abs(ty) : Infinity; const k = Math.min(kx, ky); return { x: c.x + tx * k, y: c.y + ty * k } } + const p1 = clip(a, ux, uy), p2 = clip(b, -ux, -uy) ctx.strokeStyle = FIL.color - ctx.lineWidth = px(1.6) + ctx.lineWidth = 1.6 ctx.beginPath(); ctx.moveTo(p1.x, p1.y); ctx.lineTo(p2.x, p2.y); ctx.stroke() - if (l.label) { - ctx.fillStyle = COLORS.textLight - ctx.font = `400 ${px(11)}px "DM Sans", system-ui, sans-serif` - ctx.textAlign = 'center'; ctx.textBaseline = 'bottom' - ctx.fillText(l.label, (p1.x + p2.x) / 2, (p1.y + p2.y) / 2 - px(6)) - } + if (l.label) labels.push({ text: l.label, x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 }) } - for (const f of shown) { - const c = at(f) + for (const f of families) { + const c = posOf.get(f.id) + if (!c) continue ctx.fillStyle = '#FFFFFF' ctx.strokeStyle = '#E3DED6' - ctx.lineWidth = px(1) - ctx.beginPath(); ctx.roundRect(c.x - w / 2, c.y - h / 2, w, h, px(18)); ctx.fill(); ctx.stroke() + ctx.lineWidth = 1 + ctx.beginPath(); ctx.roundRect(c.x - W / 2, c.y - H / 2, W, H, 18); ctx.fill(); ctx.stroke() ;(f.portraits || []).forEach((p, i) => { - const cx = c.x - w / 2 + px(24) + (i % 3) * px(30), cy = c.y - px(16) + Math.floor(i / 3) * px(30) - ctx.fillStyle = '#F2EFEA'; ctx.strokeStyle = '#E3DED6'; ctx.lineWidth = px(0.8) - ctx.beginPath(); ctx.roundRect(cx - px(11), cy - px(11), px(22), px(22), px(4)); ctx.fill(); ctx.stroke() + const px = c.x - W / 2 + 24 + (i % 3) * 30, py = c.y - 16 + Math.floor(i / 3) * 30 + ctx.fillStyle = '#F2EFEA'; ctx.strokeStyle = '#E3DED6'; ctx.lineWidth = 0.8 + ctx.beginPath(); ctx.roundRect(px - 11, py - 11, 22, 22, 4); ctx.fill(); ctx.stroke() ctx.fillStyle = COLORS.textLight - ctx.font = `400 ${px(9)}px "Newsreader", Georgia, serif` + ctx.font = '400 9px "Newsreader", Georgia, serif' ctx.textAlign = 'center'; ctx.textBaseline = 'middle' - ctx.fillText(`${(p.firstName || '')[0] || ''}${(p.lastName || '')[0] || ''}`, cx, cy + px(0.5)) + ctx.fillText(`${(p.firstName || '')[0] || ''}${(p.lastName || '')[0] || ''}`, px, py + 0.5) }) ctx.fillStyle = COLORS.text - ctx.font = `500 ${px(18)}px "Newsreader", Georgia, serif` + ctx.font = '500 18px "Newsreader", Georgia, serif' ctx.textAlign = 'left'; ctx.textBaseline = 'middle' - ctx.fillText(f.label ? t('Famille {name}', { name: f.label }) : t('Famille'), c.x - w / 2 + px(118), c.y - px(12)) + ctx.fillText(f.label ? t('Famille {name}', { name: f.label }) : t('Famille'), c.x - W / 2 + 118, c.y - 12) ctx.fillStyle = '#93402A' - ctx.font = `400 ${px(12)}px "DM Sans", system-ui, sans-serif` - ctx.fillText(t('{n} personnes', { n: f.count }), c.x - w / 2 + px(118), c.y + px(14)) + ctx.font = '400 12px "DM Sans", system-ui, sans-serif' + ctx.fillText(t('{n} personnes', { n: f.count }), c.x - W / 2 + 118, c.y + 14) + } + // Les prénoms du mariage, sur le trait, au-dessus des blocs + ctx.font = '400 11px "DM Sans", system-ui, sans-serif' + ctx.textAlign = 'center'; ctx.textBaseline = 'middle' + for (const l of labels) { + const w = ctx.measureText(l.text).width + 12 + ctx.fillStyle = '#FBFAF7' + ctx.beginPath(); ctx.roundRect(l.x - w / 2, l.y - 9, w, 18, 9); ctx.fill() + ctx.fillStyle = COLORS.textLight + ctx.fillText(l.text, l.x, l.y + 0.5) } ctx.restore() } From 77eef8e8a032f152bcb1e03d8a7e84fe51b12aff Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Mon, 19 Oct 2026 18:29:39 +0200 Subject: [PATCH 11/26] =?UTF-8?q?placement(remariage):=20l'enfant=20de=20l?= =?UTF-8?q?a=20lign=C3=A9e=20reste=20pr=C3=A8s=20de=20sa=20fratrie,=20la?= =?UTF-8?q?=20carte=20au-dessus=20du=20conjoint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sur une chaîne de remariage où l'enfant de la lignée est à un bout, toute la suite de conjoints (ex-époux compris) part du côté de la belle-famille et l'enfant reste du côté de ses frères et sœurs. - La rangée des parents n'est plus réservée que sur la moitié de l'enfant : la carte de famille peut se poser juste au-dessus du conjoint. Co-Authored-By: Claude Fable 5.1 --- frontend/src/components/galaxy/blockLayout.js | 36 ++++++++++++++----- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/galaxy/blockLayout.js b/frontend/src/components/galaxy/blockLayout.js index be05308..71d5000 100644 --- a/frontend/src/components/galaxy/blockLayout.js +++ b/frontend/src/components/galaxy/blockLayout.js @@ -196,11 +196,19 @@ function layoutOnce(graphData, referenceId, forcedSide) { // 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é + // Remariage (chaîne plus longue) : si l'enfant de la lignée est à un bout, il reste du côté de sa fratrie + // et toute la suite de conjoints (ex-époux compris) part de l'autre côté, vers la belle-famille. 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() + if (!kid) return + if (chain.length === 3) { + 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() + return + } + const i = chain.indexOf(kid) + if (i !== 0 && i !== chain.length - 1) return + if ((side < 0 && i === 0) || (side > 0 && i === chain.length - 1)) chain.reverse() } const pos = new Map() @@ -334,6 +342,9 @@ function layoutOnce(graphData, referenceId, forcedSide) { // 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 = [] + // L'enfant de la lignée dans cette chaîne (celui dont les parents sont ownerUnion), s'il est seul + const kidsHere = chain.filter(id => byId.get(id)._type === 'person' && ownerUnion && parentUnionOf.get(id) === ownerUnion) + const kidOfChain = kidsHere.length === 1 ? kidsHere[0] : null for (const id of [...chain]) { if (byId.get(id)._type !== 'person') continue const pu = parentUnionOf.get(id) @@ -375,11 +386,11 @@ function layoutOnce(graphData, referenceId, forcedSide) { 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) + orient(chain, kidOfChain ?? (chain.length === 3 ? (chain[0] === id ? chain[2] : chain[0]) : null), side) continue } built.push({ block, anchorId: id, side }) - if (chain.length === 3) orient(chain, chain[0] === id ? chain[2] : chain[0], side) + orient(chain, kidOfChain ?? (chain.length === 3 ? (chain[0] === id ? chain[2] : chain[0]) : null), 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 @@ -437,12 +448,22 @@ function layoutOnce(graphData, referenceId, forcedSide) { const anchorX = rx + offsets.get(p.anchorId) return at == null ? anchorX - p.block.width / 2 : anchorX - at } + // Sur un remariage où l'enfant de la lignée est à un bout, seule sa moitié est réservée : au-dessus des + // ex-conjoints, la carte ou la famille du conjoint peut se poser. + const parentsSpan = (rx) => { + if (chain.length > 3 && kidOfChain) { + const k = rx + offsets.get(kidOfChain) + return chain.indexOf(kidOfChain) === 0 ? [rx - FAMILY_GAP, k + byId.get(kidOfChain).width + FAMILY_GAP] : [k - FAMILY_GAP, rx + rowW + FAMILY_GAP] + } + return [rx - FAMILY_GAP, rx + rowW + FAMILY_GAP] + } + const reserveParents = (r, rx) => { if (ownerUnion) mergeRows(r, new Map([[gen - 1, [parentsSpan(rx)]]]), 0) } // 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) + reserveParents(trial, rowX) for (const p of built) { if (chain.length !== 3) break if (p.block.isCard) continue @@ -464,7 +485,6 @@ function layoutOnce(graphData, referenceId, forcedSide) { // 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) From c29610ee1432a9e1b11a981ac39dab50acf3eced Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Mon, 19 Oct 2026 18:43:07 +0200 Subject: [PATCH 12/26] =?UTF-8?q?placement(global):=20l'arbre=20s'ouvre=20?= =?UTF-8?q?=C3=A0=20partir=20de=20la=20r=C3=A9f=C3=A9rence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Toute belle-famille, grosse (cluster) ou petite, part du côté opposé à la personne de référence : à droite pour un conjoint à sa droite, à gauche sinon. Passes successives jusqu'à stabilité (4 au plus). - Dans une belle-famille, la branche qui mène au conjoint longe, à tous les niveaux, le bord qui regarde la famille principale : la carte de la famille principale est au bon bord du cluster. - Les fratries ne sont plus dessinées en miroir pour ça : seul le bloc du conjoint change de place. Co-Authored-By: Claude Fable 5.1 --- .../archetypes/belle-famille.txt | 2 +- frontend/src/components/galaxy/blockLayout.js | 62 +++++++++++++------ 2 files changed, 43 insertions(+), 21 deletions(-) diff --git a/frontend/src/components/galaxy/__snapshots__/archetypes/belle-famille.txt b/frontend/src/components/galaxy/__snapshots__/archetypes/belle-famille.txt index 45eda45..a30461b 100644 --- a/frontend/src/components/galaxy/__snapshots__/archetypes/belle-famille.txt +++ b/frontend/src/components/galaxy/__snapshots__/archetypes/belle-famille.txt @@ -6,7 +6,7 @@ │ │ ┬────────┼────────────────┬ ┼────────┬ │ │ │ │ │ -S3 S2 S────•────E E2 +S2 S3 S────•────E E2 │ ┬───┴────┬ │ │ diff --git a/frontend/src/components/galaxy/blockLayout.js b/frontend/src/components/galaxy/blockLayout.js index 71d5000..6656f72 100644 --- a/frontend/src/components/galaxy/blockLayout.js +++ b/frontend/src/components/galaxy/blockLayout.js @@ -90,20 +90,29 @@ const overlaps = (l, r, list, gap) => list.some(([a, b]) => l < b + gap && r > a // 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à. +// Règle globale (Patch, 19/10) : l'arbre s'ouvre à partir de la personne de référence. Toute belle-famille, +// grosse ou petite, part du côté opposé à la référence (à droite pour un conjoint à sa droite), le couple se +// tourne vers elle. Ce côté ne se connaît qu'une fois la lignée posée, d'où une seconde passe. 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) + let result = layoutOnce(graphData, referenceId, forced) + // Chaque passe fixe le côté des belles-familles d'après la passe précédente ; on s'arrête dès que plus rien + // ne change, ou après quatre passes (les positions bougent d'une passe à l'autre) + for (let pass = 0; pass < 4 && result._inlaws?.length; pass++) { + const byId = new Map(result.children.map(n => [n.id, n])) + const ref = referenceId != null ? byId.get(`p-${referenceId}`) : null + const main = result.families?.find(f => f.id === 'main') + const pivot = ref ? ref.x + ref.width / 2 : main ? (main.bbox.x0 + main.bbox.x1) / 2 : 0 + let changed = false + for (const { anchorId, side } of result._inlaws) { + const a = byId.get(anchorId) + const want = a.x + a.width / 2 < pivot ? -1 : 1 + if (want !== side && forced.get(anchorId) !== want) { forced.set(anchorId, want); changed = true } + } + if (!changed) break + result = layoutOnce(graphData, referenceId, forced) } - return forced.size ? layoutOnce(graphData, referenceId, forced) : first + return result } function layoutOnce(graphData, referenceId, forcedSide) { @@ -285,7 +294,18 @@ function layoutOnce(graphData, referenceId, forcedSide) { // 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' } + let anchor = null // { kid, end: 'left' | 'right', path: Set } : la branche du conjoint longe ce bord + const pathOf = (kid) => { + const s = new Set([chainOf.get(kid)]) + let cur = chainOf.get(parentUnionOf.get(kid)) + while (cur && !s.has(cur)) { + s.add(cur) + const m = cur.find(id => parentUnionOf.has(id) && blood.has(id)) || cur.find(id => parentUnionOf.has(id)) + cur = m ? chainOf.get(parentUnionOf.get(m)) : null + } + return s + } + const inlaws = [] // { anchorId, side } : toutes les belles-familles construites, pour la seconde passe // `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. @@ -299,11 +319,6 @@ function layoutOnce(graphData, referenceId, forcedSide) { 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 @@ -321,7 +336,7 @@ function layoutOnce(graphData, referenceId, forcedSide) { return } const side = sideOfUnion(unionId) || outer || (i < (kidsOfChain.length - 1) / 2 ? -1 : 1) - childBlocks.push({ ...buildBlock(kc, unionId, side), unionId }) + childBlocks.push({ ...buildBlock(kc, unionId, side), unionId, kid }) }) // 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 @@ -330,6 +345,11 @@ function layoutOnce(graphData, referenceId, forcedSide) { 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)] }) + // La branche qui mène au conjoint d'une autre famille va au bout, du côté de cette famille + if (anchor) { + const i = kids.findIndex(b => b.kid != null && anchor.path.has(chainOf.get(b.kid))) + if (i >= 0) { const [b] = kids.splice(i, 1); if (anchor.end === 'right') kids.push(b); else kids.unshift(b) } + } childBlocks.length = 0 childBlocks.push(...kids) const packed = packBlocks(childBlocks, FAMILY_GAP) @@ -357,7 +377,7 @@ function layoutOnce(graphData, referenceId, forcedSide) { const saved = anchor const clustersBefore = clusters.length const externalBefore = new Set(externalEdges) - anchor = { kid: id, end: side < 0 ? 'right' : 'left' } + anchor = { kid: id, end: side < 0 ? 'right' : 'left', path: pathOf(id) } let block = buildBlock(top, null, side) anchor = saved if (block.width > CLUSTER_PX) { @@ -370,7 +390,7 @@ function layoutOnce(graphData, referenceId, forcedSide) { 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' } + anchor = { kid: id, end: side < 0 ? 'right' : 'left', path: pathOf(id) } block = buildBlock(top, null, side) anchor = saved cardUnder = savedCard @@ -517,6 +537,7 @@ function layoutOnce(graphData, referenceId, forcedSide) { 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 + for (const p of built) inlaws.push({ anchorId: p.anchorId, side: p.side }) const members = new Set(chain) for (const b of childBlocks) { for (const m of b.members) members.add(m); external.push(...b.external) } @@ -725,6 +746,7 @@ function layoutOnce(graphData, referenceId, forcedSide) { ].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 + result._inlaws = inlaws return result } From ef46733329418501f04aee5e0f7185d135d827f8 Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Mon, 19 Oct 2026 21:50:40 +0200 Subject: [PATCH 13/26] onglets de familles : une belle-famille trop large devient son propre arbre MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Une belle-famille plus large qu'un écran (FAMILY_TAB_PX) n'est plus dessinée dans l'arbre : elle devient un onglet, le même arbre recalculé avec le conjoint comme référence. Ses personnes sortent du placement, le fil de ses parents vers le conjoint n'est pas dessiné. - Le conjoint porte un badge « Famille X · n › » sous ses dates, un clic ouvre l'onglet. - Barre d'onglets en haut à gauche : la famille de départ d'abord, puis chaque famille rencontrée, sans doublon (une famille déjà en onglet se reconnaît à sa personne de référence parmi les membres). - Retrait des clusters, cartes de famille, échos, bandeau et carte des familles. Co-Authored-By: Claude Fable 5.1 --- frontend/src/components/Galaxy.css | 49 +++++ frontend/src/components/Galaxy.jsx | 71 ++++-- frontend/src/components/galaxy/blockLayout.js | 159 +++++--------- frontend/src/components/galaxy/constants.js | 3 - frontend/src/components/galaxy/renderers.js | 204 ++---------------- .../galaxy/useGalaxyInteractions.js | 13 +- frontend/src/i18n/en/arbre.js | 1 + 7 files changed, 183 insertions(+), 317 deletions(-) diff --git a/frontend/src/components/Galaxy.css b/frontend/src/components/Galaxy.css index dd9c103..b730d50 100644 --- a/frontend/src/components/Galaxy.css +++ b/frontend/src/components/Galaxy.css @@ -43,3 +43,52 @@ font-size: 13px; } } + +/* ============================================================ + Onglets de familles : un onglet par famille, en haut à gauche + ============================================================ */ + +.family-tabs { + position: fixed; + top: 16px; + left: 16px; + z-index: 500; + display: flex; + gap: 6px; + max-width: calc(100vw - 120px); + overflow-x: auto; + scrollbar-width: none; + padding: 4px; +} +.family-tabs::-webkit-scrollbar { display: none; } + +.family-tab { + flex: 0 0 auto; + display: inline-flex; + align-items: baseline; + gap: 7px; + padding: 7px 14px 8px; + border-radius: 999px; + border: 1px solid #EBE6DF; + background: #FFFFFF; + color: #2A2622; + font-family: "Newsreader", Georgia, serif; + font-size: 15px; + line-height: 1; + cursor: pointer; + white-space: nowrap; +} +.family-tab:hover { border-color: #D9CFC6; } +.family-tab:focus-visible { outline: 2px solid #C9553D; outline-offset: 2px; } +.family-tab.is-active { background: #2A2622; border-color: #2A2622; color: #FFFFFF; } +.family-tab-count { + font-family: "DM Sans", system-ui, sans-serif; + font-size: 11px; + color: #93402A; +} +.family-tab.is-active .family-tab-count { color: #EDB6A4; } + +@media (max-width: 768px) { + .family-tabs { top: 10px; left: 10px; max-width: calc(100vw - 84px); } + .family-tab { font-size: 14px; padding: 6px 12px 7px; } +} diff --git a/frontend/src/components/Galaxy.jsx b/frontend/src/components/Galaxy.jsx index 60c722d..d35c2ff 100644 --- a/frontend/src/components/Galaxy.jsx +++ b/frontend/src/components/Galaxy.jsx @@ -8,11 +8,12 @@ import { computeBlockLayout } from './galaxy/blockLayout' // Placement par blocs familiaux par défaut (mesures : tools/layout-bench). ?layout=sugiyama = placement Sugiyama. const USE_BLOCK_LAYOUT = typeof window === 'undefined' || new URLSearchParams(window.location.search).get('layout') !== 'sugiyama' import { findPathBFS } from './galaxy/pathfinding' -import { drawBackground, drawCoupleLinks, drawFiliations, drawAnnotations, drawPathHighlight, drawNodes, drawRenvoiPills, nodeRadius, drawFamilyMap, familyAt, drawFamilyBanner } from './galaxy/renderers' +import { drawBackground, drawCoupleLinks, drawFiliations, drawAnnotations, drawPathHighlight, drawNodes, drawRenvoiPills, nodeRadius } from './galaxy/renderers' import { buildSiblingGroups, moveInOrder, pickSlot, rubberBand, springStep, isSettled, buildOrderPayload, filiationsWithOrder } from './galaxy/siblingReorder' import { annotations as annotationData } from '../data/mockData' import { useImageCache } from './galaxy/useImageCache' import { useGalaxyInteractions } from './galaxy/useGalaxyInteractions' +import { t as tr } from '../i18n/index.js' import './Galaxy.css' // Assez bas pour cadrer un grand arbre entier à l'ouverture @@ -27,7 +28,6 @@ function approach(prev, target, k, eps = 1e-3) { } const MAX_SCALE = 3.2 // Carte des familles : l'arbre s'efface entre ces deux échelles, les tuiles de familles apparaissent -const MAP_SCALE = 0.25 // Ouverture sur la personne de départ quand l'arbre entier serait illisible const FOCUS_MIN_READABLE_SCALE = 0.5 const FOCUS_SCALE = 0.95 @@ -44,14 +44,27 @@ const prefersReducedMotion = () => typeof window !== 'undefined' && window.match const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlightIds = [], graphRevision = 0, annotationHandlers = null, transformReadRef = null, zoomApiRef = null, focusPersonId = null, assetRevision = 0, photoHint = false, onPhotoPlaceholderClick = null, reorderEnabled = false, onReorderSiblings = null }) => { const canvasRef = useRef(null) + // Onglets de familles : la personne de référence de l'arbre affiché (celle de départ, puis le conjoint + // dont on ouvre la famille), et la liste des familles rencontrées, dans l'ordre des mariages + const layoutDataRef = useRef(null) + const [activeRef, setActiveRef] = useState(focusPersonId) + const [tabs, setTabs] = useState([]) + const startRefRef = useRef(focusPersonId) + useEffect(() => { startRefRef.current = focusPersonId; setActiveRef(focusPersonId); setTabs([]) }, [focusPersonId]) // Lu au calcul du cadrage seulement : changer de personne de départ ne recadre pas une vue en cours const focusPersonIdRef = useRef(focusPersonId) // Mis à jour hors du rendu, avant l'effet de cadrage déclaré plus bas (même phase, ordre de déclaration) - useEffect(() => { focusPersonIdRef.current = focusPersonId }, [focusPersonId]) + useEffect(() => { focusPersonIdRef.current = activeRef }, [activeRef]) + // Clic sur un badge : l'onglet de cette famille s'il existe déjà (la même famille vue depuis un autre + // conjoint), sinon un nouvel onglet avec ce conjoint comme référence + const openFamily = useCallback((anchorId) => { + const fam = layoutDataRef.current?.families?.find((f) => f.anchorId === anchorId) + const known = fam && tabs.find((tb) => `p-${tb.referenceId}` === String(anchorId) || fam.members.includes(String(tb.referenceId))) + setActiveRef(known ? known.referenceId : String(anchorId).replace(/^p-/, '')) + }, [tabs]) // Propriétaire : un portrait vide survolé montre « + photo » const photoHintRef = useRef(photoHint) useEffect(() => { photoHintRef.current = photoHint }, [photoHint]) - const layoutDataRef = useRef(null) const [layoutReady, setLayoutReady] = useState(false) const transformRef = useRef({ x: 0, y: 0, scale: 1 }) @@ -141,7 +154,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig canvasRef, transformRef, targetTransformRef, layoutDataRef, nodeRandomDataRef, entranceRef, hoveredNodeIdRef: hoveredNodeId, hoveredMediaKeyRef: hoveredMediaKey, mouseWorldPosRef: mouseWorldPos, - getAnimatedPos, onPersonSelect, onMediaSelect, onPhotoPlaceholderClick, + getAnimatedPos, onPersonSelect, onMediaSelect, onPhotoPlaceholderClick, onOpenFamily: openFamily, annotationHandlers, minScale: MIN_SCALE, maxScale: MAX_SCALE, @@ -561,7 +574,24 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig layoutDataRef.current = result needsRedrawRef.current = true setLayoutReady(true) - }, [graphRevision]) + // Onglets : la famille affichée d'abord, puis chaque belle-famille rencontrée, sans doublon (une famille déjà + // en onglet se reconnaît à sa personne de référence parmi les membres) + const fams = result.families || [] + if (fams.length) setTabs((prev) => { + // La liste commence toujours par la famille de départ ; un placement calculé pour une autre référence + // avant qu'elle soit connue (arbre chargé avant sa personne de départ) ne compte pas + const start = String(startRefRef.current) + const next = prev[0] && String(prev[0].referenceId) === start ? [...prev] : [] + const main = fams[0] + if (!next.length && String(main.referenceId) !== start) return prev + if (!next.some((tb) => String(tb.referenceId) === String(main.referenceId))) next.push({ referenceId: main.referenceId, label: main.label, count: main.count }) + for (const f of fams.slice(1)) { + if (next.some((tb) => `p-${tb.referenceId}` === String(f.anchorId) || f.members.includes(String(tb.referenceId)))) continue + next.push({ referenceId: String(f.anchorId).replace(/^p-/, ''), label: f.label, count: f.count }) + } + return next + }) + }, [graphRevision, activeRef]) // Sync selectedPersonId useEffect(() => { @@ -792,10 +822,6 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig // Rien n'a changé : on ne redessine pas if (!dirty) { rafId = requestAnimationFrame(animate); return } - // Carte des familles : passé MAP_SCALE, l'arbre laisse la place à la carte, bascule nette - const families = layoutData.families || [] - const mapOn = families.length > 1 && scale < MAP_SCALE - // Positions animées, calculées seulement quand on redessine const posMap = new Map() layoutData.children.forEach(node => { @@ -817,7 +843,6 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig const cssW = canvas.width / dprRef.current const cssH = canvas.height / dprRef.current const view = { x0: -x / scale, y0: -y / scale, x1: (cssW - x) / scale, y1: (cssH - y) / scale, scale } - if (!mapOn) { drawCoupleLinks(ctx, coupleBarMeta, posMap, nodeMap, nodeRandomDataRef.current, entrance, entranceActive, elapsed, view) drawFiliations(ctx, layoutData.edges, posMap, nodeMap, nodeRandomDataRef.current, entrance, entranceActive, elapsed, view) drawPathHighlight(ctx, { @@ -845,14 +870,6 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig liftScales: liftScalesRef.current, }) drawRenvoiPills(ctx) - } - if (mapOn || families.length > 1) { - ctx.save() - ctx.setTransform(dprRef.current, 0, 0, dprRef.current, 0, 0) - if (mapOn) drawFamilyMap(ctx, families, cssW, cssH) - else drawFamilyBanner(ctx, familyAt(families, view)) - ctx.restore() - } // Annotations dessinées APRES les noeuds pour que stickers/texte soient au-dessus // Annotations : ancrer aux personnes proches pour qu'elles suivent le layout const personPosMap = new Map() @@ -1050,6 +1067,22 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig {displayLabel && (
{displayLabel}
)} + {tabs.length > 1 && ( + + )} ) } diff --git a/frontend/src/components/galaxy/blockLayout.js b/frontend/src/components/galaxy/blockLayout.js index 6656f72..a8daead 100644 --- a/frontend/src/components/galaxy/blockLayout.js +++ b/frontend/src/components/galaxy/blockLayout.js @@ -1,5 +1,4 @@ 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) @@ -44,7 +43,9 @@ const PARENT_SHIFT_PX = 400 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 +// Une belle-famille plus large que ça n'est pas dessinée dans cet arbre : elle devient un onglet (son propre +// arbre, calculé avec le conjoint comme référence) et le conjoint porte un badge vers elle. +const FAMILY_TAB_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 @@ -86,10 +87,6 @@ const extentOf = (rows) => { } 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à. // Règle globale (Patch, 19/10) : l'arbre s'ouvre à partir de la personne de référence. Toute belle-famille, // grosse ou petite, part du côté opposé à la référence (à droite pour un conjoint à sa droite), le couple se // tourne vers elle. Ce côté ne se connaît qu'une fois la lignée posée, d'où une seconde passe. @@ -101,11 +98,12 @@ export function computeBlockLayout(graphData = null, referenceId = null) { for (let pass = 0; pass < 4 && result._inlaws?.length; pass++) { const byId = new Map(result.children.map(n => [n.id, n])) const ref = referenceId != null ? byId.get(`p-${referenceId}`) : null - const main = result.families?.find(f => f.id === 'main') - const pivot = ref ? ref.x + ref.width / 2 : main ? (main.bbox.x0 + main.bbox.x1) / 2 : 0 + const persons = result.children.filter(n => n._type === 'person') + const pivot = ref ? ref.x + ref.width / 2 : persons.length ? (Math.min(...persons.map(n => n.x)) + Math.max(...persons.map(n => n.x + n.width))) / 2 : 0 let changed = false for (const { anchorId, side } of result._inlaws) { const a = byId.get(anchorId) + if (!a) continue // conjoint d'une famille devenue onglet : pas dans cet arbre const want = a.x + a.width / 2 < pivot ? -1 : 1 if (want !== side && forced.get(anchorId) !== want) { forced.set(anchorId, want); changed = true } } @@ -224,8 +222,8 @@ function layoutOnce(graphData, referenceId, forcedSide) { 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 tabs = [] // belles-familles trop larges pour cet arbre, devenues onglets : { anchorId, pu, label, count, members } + const hiddenChains = new Set() // chaînes des familles devenues onglets : pas dessinées dans cet arbre 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 @@ -274,22 +272,9 @@ function layoutOnce(graphData, referenceId, forcedSide) { // 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)) + 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 @@ -354,8 +339,7 @@ function layoutOnce(graphData, referenceId, forcedSide) { 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]) }) + childBlocks.forEach((b, i) => { if (!b.ghost) 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 @@ -375,38 +359,27 @@ function layoutOnce(graphData, referenceId, forcedSide) { 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 tabsBefore = tabs.length const externalBefore = new Set(externalEdges) anchor = { kid: id, end: side < 0 ? 'right' : 'left', path: pathOf(id) } - let block = buildBlock(top, null, side) + const 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) } + if (block.width > FAMILY_TAB_PX) { + // Trop large pour cet arbre : la famille devient un onglet et n'est pas dessinée ici. Sa construction ne + // laisse aucune trace (onglets imbriqués, chaînes posées, arêtes externes), et le fil de ses parents + // vers le conjoint est caché : c'est le badge du conjoint qui dit où est sa famille. + const chainsOf = (members) => new Set([...members].map(m => chainOf.get(m)).filter(Boolean)) + const unplace = (members) => { for (const ch of chainsOf(members)) { placed.delete(ch); hiddenChains.delete(ch) } } + for (const tb of tabs.splice(tabsBefore)) { unplace(tb.members); byId.get(tb.anchorId)._familyTab = null } 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', path: pathOf(id) } - 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 + for (const ch of chainsOf(block.members)) hiddenChains.add(ch) 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 }) - orient(chain, kidOfChain ?? (chain.length === 3 ? (chain[0] === id ? chain[2] : chain[0]) : null), side) + const label = A._data?.birthName || partners.find(d => d.lastName)?.lastName || A._data?.lastName || '' + const members = new Set([...block.members].filter(m => byId.get(m)?._type === 'person')) + tabs.push({ anchorId: id, pu, label, count: members.size, members }) + A._familyTab = { anchorId: id, label, count: members.size } continue } built.push({ block, anchorId: id, side }) @@ -486,7 +459,6 @@ function layoutOnce(graphData, referenceId, forcedSide) { reserveParents(trial, rowX) 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 @@ -513,8 +485,8 @@ function layoutOnce(graphData, referenceId, forcedSide) { 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 } + const x = pushHybrid(p.block.rows, ideal, r, p.side, gen, FAMILY_GAP) + if (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) } @@ -608,42 +580,26 @@ function layoutOnce(graphData, referenceId, forcedSide) { (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)) + for (const r of roots) if (!placed.has(r) && !hiddenChains.has(r)) free.push(buildBlock(r)) + for (const c of chains) if (!placed.has(c) && !hiddenChains.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 forest = packBlocks(free, FAMILY_GAP * 2) + free.forEach((b, i) => b.place(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 }) - } - }) + // Le fil des parents d'un conjoint dont la famille est un onglet n'est pas dessiné : son badge dit où elle est + for (const tb of tabs) for (const e of result.edges) if (e.sources[0] === tb.pu && e.targets[0] === tb.anchorId) e._hidden = true + // Les familles devenues onglets sortent de cet arbre : leurs nœuds et leurs arêtes ne sont pas dessinés + if (hiddenChains.size) { + const hidden = new Set([...hiddenChains].flatMap(ch => ch)) + for (const n of nodes) if (n._type === 'unknown' && hidden.has(n.id.replace(/^unknown-/, 'p-'))) hidden.add(n.id) + result.children = nodes.filter(n => !hidden.has(n.id)) + // Un onglet se propose depuis le badge d'un conjoint dessiné : ceux dont le conjoint est caché avec sa famille tombent + for (let i = tabs.length - 1; i >= 0; i--) if (hidden.has(tabs[i].anchorId)) tabs.splice(i, 1) + result.edges = result.edges.filter(e => !hidden.has(e.sources[0]) && !hidden.has(e.targets[0])) + nodes.length = 0 + nodes.push(...result.children) + } // 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)) @@ -724,28 +680,13 @@ function layoutOnce(graphData, referenceId, forcedSide) { 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 + // Les familles de cet arbre : la lignée dessinée, puis chaque belle-famille devenue onglet. Galaxy en fait + // la barre d'onglets ; un onglet est le même arbre recalculé avec le conjoint comme référence. + const drawn = nodes.filter(n => n._type === 'person') + result.families = [ + { id: 'main', label: referenceNode?._data?.lastName || drawn[0]?._data?.lastName || '', count: drawn.length, referenceId: result.referenceId ?? null }, + ...tabs.map(tb => ({ id: tb.anchorId, anchorId: tb.anchorId, label: tb.label, count: tb.count, members: [...tb.members].map(m => m.replace(/^p-/, '')) })), + ] result._inlaws = inlaws return result diff --git a/frontend/src/components/galaxy/constants.js b/frontend/src/components/galaxy/constants.js index 13e9773..5a90fdc 100644 --- a/frontend/src/components/galaxy/constants.js +++ b/frontend/src/components/galaxy/constants.js @@ -16,9 +16,6 @@ export const COMPACT_SCALE = 0.6 export const COMPACT_R = PERSON_R * COMPACT_SCALE export const COMPACT_W = 40 export const COMPACT_JOIN_GAP = -18 -// Carte de famille : tient la place d'un couple au-dessus d'un conjoint dont la famille est dessinée à part -export const FAMILY_CARD_W = 200 -export const FAMILY_CARD_H = 76 export const MAX_ORBIT_MEDIAS = 3 export const ORBIT_RADIUS = 55 export const ORBIT_MEDIA_SIZE = 22 diff --git a/frontend/src/components/galaxy/renderers.js b/frontend/src/components/galaxy/renderers.js index 28ad404..3d63e21 100644 --- a/frontend/src/components/galaxy/renderers.js +++ b/frontend/src/components/galaxy/renderers.js @@ -201,6 +201,8 @@ export function drawCoupleLinks(ctx, coupleBarMeta, posMap, nodeMap, nodeRandomD // useGalaxyInteractions. // ============================================================ export const renvoiHitAreas = [] +// Badges « Famille X » sous les conjoints dont la famille est un onglet : { x0, y0, x1, y1, anchorId } +export const familyHitAreas = [] // Zones « + photo » : portraits vides, chez le propriétaire (cercle du nœud, rempli au dessin) export const photoPlaceholderHitAreas = [] const renvoiPills = [] @@ -250,186 +252,6 @@ function drawRenvoi(ctx, edge, sourcePos, targetPos, targetNode, childR, view) { ) } -// ============================================================ -// Carte de famille (maquette A, 19/10) : une belle-famille dessinée à part est représentée, à la place de -// ses parents au-dessus du conjoint, par une carte (nom, portraits, nombre) reliée au conjoint par un -// pont. Dans le cluster, la carte inverse se trouve sous l'écho du conjoint. Un clic mène de l'autre côté. -// ============================================================ -const CARD_FONT = '500 13px "Newsreader", Georgia, serif' -const CARD_SMALL = '400 10px "DM Sans", system-ui, sans-serif' -function drawFamilyCard(ctx, node, pos, posMap, progress, view) { - const { cx, cy } = pos - const w = node.width, h = node.height - const scale = view?.scale ?? 1 - const alpha = Math.min(1, progress * 1.5) - // Pont vers la personne qu'elle remplace ou qu'elle prolonge - const to = posMap.get(node._data.stemTo) - if (to) { - ctx.save() - ctx.globalAlpha = alpha - ctx.strokeStyle = FIL.color - ctx.lineWidth = FIL.width - ctx.setLineDash([]) - ctx.lineCap = 'round' - ctx.beginPath() - if (node._data.stem === 'down') { ctx.moveTo(cx, cy + h / 2); ctx.lineTo(to.cx, to.cy - PERSON_R - 2) } - else { ctx.moveTo(cx, cy - h / 2); ctx.lineTo(to.cx, to.cy + PERSON_R + 2) } - ctx.stroke() - ctx.restore() - } - ctx.save() - ctx.globalAlpha = alpha - ctx.fillStyle = '#FCF3EF' - ctx.strokeStyle = '#EDB6A4' - ctx.lineWidth = 0.9 - ctx.beginPath() - ctx.roundRect(cx - w / 2, cy - h / 2, w, h, 14) - ctx.fill() - ctx.stroke() - // Quatre petits portraits, en mosaïque - const ps = node._data.portraits || [] - ps.forEach((p, i) => { - const px = cx - w / 2 + 16 + (i % 2) * 24, py = cy - 22 + Math.floor(i / 2) * 24 - ctx.fillStyle = '#F2EFEA' - ctx.strokeStyle = '#E3DED6' - ctx.lineWidth = 0.7 - ctx.beginPath() - ctx.roundRect(px, py, 20, 20, 3) - ctx.fill() - ctx.stroke() - ctx.fillStyle = COLORS.textLight - ctx.font = '400 8px "Newsreader", Georgia, serif' - ctx.textAlign = 'center' - ctx.textBaseline = 'middle' - ctx.fillText(`${(p.firstName || '')[0] || ''}${(p.lastName || '')[0] || ''}`, px + 10, py + 10.5) - }) - if (scale > LOD_LABEL_FADE_OUT) { - ctx.fillStyle = COLORS.text - ctx.font = CARD_FONT - ctx.textAlign = 'left' - ctx.textBaseline = 'middle' - ctx.fillText(node._data.label ? t('Famille {name}', { name: node._data.label }) : t('Famille'), cx - w / 2 + 72, cy - 8) - ctx.fillStyle = '#93402A' - ctx.font = CARD_SMALL - ctx.fillText(t('{n} personnes', { n: node._data.count }), cx - w / 2 + 72, cy + 10) - } - ctx.restore() - const target = posMap.get(node._data.targetId) - if (target && alpha > 0.5) renvoiHitAreas.push({ x0: cx - w / 2, y0: cy - h / 2, x1: cx + w / 2, y1: cy + h / 2, targetX: target.cx, targetY: target.cy }) -} - -// ============================================================ -// Le nom de la famille où l'on est, écrit en grand à l'écran (en haut à gauche) : celle dont le bloc contient -// le centre de la vue, sinon la plus proche. Coordonnées d'écran, lisible à tout zoom. -// ============================================================ -export function familyAt(families, view) { - if (!families?.length || !view) return null - const cx = (view.x0 + view.x1) / 2, cy = (view.y0 + view.y1) / 2 - const dist = (f) => Math.hypot(Math.max(f.bbox.x0 - cx, 0, cx - f.bbox.x1), Math.max(f.bbox.y0 - cy, 0, cy - f.bbox.y1)) - return families.reduce((best, f) => (f.bbox && (!best || dist(f) < dist(best)) ? f : best), null) -} -export function drawFamilyBanner(ctx, family) { - if (!family) return - ctx.save() - ctx.font = '500 30px "Newsreader", Georgia, serif' - ctx.textAlign = 'left' - ctx.textBaseline = 'alphabetic' - const title = family.label ? t('Famille {name}', { name: family.label }) : t('Famille') - // Un voile de la couleur du fond, pour rester lisible quand l'arbre passe dessous au dézoom - const w = ctx.measureText(title).width + 56 - const plate = ctx.createLinearGradient(0, 0, w + 60, 0) - plate.addColorStop(0, 'rgba(251, 250, 247, 0.92)') - plate.addColorStop(1, 'rgba(251, 250, 247, 0)') - ctx.fillStyle = plate - ctx.fillRect(0, 0, w + 60, 100) - ctx.fillStyle = COLORS.text - ctx.fillText(title, 28, 56) - ctx.fillStyle = COLORS.textLight - ctx.font = '400 13px "DM Sans", system-ui, sans-serif' - ctx.fillText(t('{n} personnes', { n: family.count }), 29, 78) - ctx.restore() -} - -// ============================================================ -// Carte des familles (maquette J, 19/10). Passé un seuil de dézoom, l'arbre laisse la place à la carte : -// chaque famille est un bloc (nom, portraits, nombre), les blocs se rangent côte à côte au milieu de l'écran, -// la famille principale au centre, les familles par alliance de leur côté, et chaque mariage entre familles -// est un trait de bloc à bloc avec les deux prénoms. Coordonnées d'écran : la carte ne bouge plus avec le zoom. -// ============================================================ -export function drawFamilyMap(ctx, families, cssW, cssH) { - if (!families?.length) return - const W = 290, H = 112, GAPX = 170 - const main = families.find((f) => f.id === 'main') || families[0] - const left = families.filter((f) => f !== main && f.side < 0) - const right = families.filter((f) => f !== main && !(f.side < 0)) - const cy = cssH / 2 - // La famille principale au milieu ; de chaque côté, les familles par alliance en colonne, chacune reliée - // en droite ligne au bloc principal. Aucun trait ne passe derrière un bloc. - const ROWY = H + 44 - const posOf = new Map([[main.id, { x: 0, y: cy }]]) - const place = (list, dir) => list.forEach((f, k) => posOf.set(f.id, { x: dir * (W + GAPX), y: cy + (k - (list.length - 1) / 2) * ROWY })) - place(left, -1); place(right, 1) - // Le groupe entier est centré à l'écran et réduit s'il déborde - const xs = [...posOf.values()].map((c) => c.x) - const x0 = Math.min(...xs) - W / 2, x1 = Math.max(...xs) + W / 2 - const ys = [...posOf.values()].map((c) => c.y) - const fit = Math.min(1, (cssW - 48) / (x1 - x0), (cssH - 160) / (Math.max(...ys) - Math.min(...ys) + H)) - ctx.save() - ctx.translate(cssW / 2, cy) - ctx.scale(fit, fit) - ctx.translate(-(x0 + x1) / 2, -cy) - ctx.lineCap = 'round' - const labels = [] - for (const f of families) for (const l of (f.links || [])) { - const g = families.find((x) => x.id === l.to) - const a = posOf.get(f.id), b = g && posOf.get(g.id) - if (!a || !b) continue - const dx = b.x - a.x, dy = b.y - a.y, len = Math.hypot(dx, dy) || 1 - const ux = dx / len, uy = dy / len - const clip = (c, tx, ty) => { const kx = Math.abs(tx) > 1e-6 ? (W / 2) / Math.abs(tx) : Infinity; const ky = Math.abs(ty) > 1e-6 ? (H / 2) / Math.abs(ty) : Infinity; const k = Math.min(kx, ky); return { x: c.x + tx * k, y: c.y + ty * k } } - const p1 = clip(a, ux, uy), p2 = clip(b, -ux, -uy) - ctx.strokeStyle = FIL.color - ctx.lineWidth = 1.6 - ctx.beginPath(); ctx.moveTo(p1.x, p1.y); ctx.lineTo(p2.x, p2.y); ctx.stroke() - if (l.label) labels.push({ text: l.label, x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 }) - } - for (const f of families) { - const c = posOf.get(f.id) - if (!c) continue - ctx.fillStyle = '#FFFFFF' - ctx.strokeStyle = '#E3DED6' - ctx.lineWidth = 1 - ctx.beginPath(); ctx.roundRect(c.x - W / 2, c.y - H / 2, W, H, 18); ctx.fill(); ctx.stroke() - ;(f.portraits || []).forEach((p, i) => { - const px = c.x - W / 2 + 24 + (i % 3) * 30, py = c.y - 16 + Math.floor(i / 3) * 30 - ctx.fillStyle = '#F2EFEA'; ctx.strokeStyle = '#E3DED6'; ctx.lineWidth = 0.8 - ctx.beginPath(); ctx.roundRect(px - 11, py - 11, 22, 22, 4); ctx.fill(); ctx.stroke() - ctx.fillStyle = COLORS.textLight - ctx.font = '400 9px "Newsreader", Georgia, serif' - ctx.textAlign = 'center'; ctx.textBaseline = 'middle' - ctx.fillText(`${(p.firstName || '')[0] || ''}${(p.lastName || '')[0] || ''}`, px, py + 0.5) - }) - ctx.fillStyle = COLORS.text - ctx.font = '500 18px "Newsreader", Georgia, serif' - ctx.textAlign = 'left'; ctx.textBaseline = 'middle' - ctx.fillText(f.label ? t('Famille {name}', { name: f.label }) : t('Famille'), c.x - W / 2 + 118, c.y - 12) - ctx.fillStyle = '#93402A' - ctx.font = '400 12px "DM Sans", system-ui, sans-serif' - ctx.fillText(t('{n} personnes', { n: f.count }), c.x - W / 2 + 118, c.y + 14) - } - // Les prénoms du mariage, sur le trait, au-dessus des blocs - ctx.font = '400 11px "DM Sans", system-ui, sans-serif' - ctx.textAlign = 'center'; ctx.textBaseline = 'middle' - for (const l of labels) { - const w = ctx.measureText(l.text).width + 12 - ctx.fillStyle = '#FBFAF7' - ctx.beginPath(); ctx.roundRect(l.x - w / 2, l.y - 9, w, 18, 9); ctx.fill() - ctx.fillStyle = COLORS.textLight - ctx.fillText(l.text, l.x, l.y + 0.5) - } - ctx.restore() -} - // Après drawNodes : les pastilles passent au-dessus des portraits et des souvenirs export function drawRenvoiPills(ctx) { renvoiPills.forEach(p => drawRenvoiPill(ctx, p)) @@ -442,6 +264,7 @@ export function drawRenvoiPills(ctx) { export function drawFiliations(ctx, edges, posMap, nodeMap, nodeRandomData, entrance, entranceActive, elapsed, view = null) { renvoiHitAreas.length = 0 renvoiPills.length = 0 + familyHitAreas.length = 0 if (!edges) return const sibships = new Map() @@ -837,6 +660,25 @@ function drawPersonNode(ctx, node, pos, nodeProgress, { ctx.fillText(years, 0, hasNickname ? 31 : 15) } + // Sa famille est un onglet : un badge le dit, un clic l'ouvre + if (withDetail && node._familyTab) { + const tab = node._familyTab + const text = `${tab.label ? t('Famille {name}', { name: tab.label }) : t('Famille')} · ${tab.count} ›` + ctx.font = '500 10.5px "DM Sans", system-ui, sans-serif' + const w = ctx.measureText(text).width + 18, h = 18 + const by = (hasNickname ? 31 : 15) + 21 + ctx.fillStyle = '#FCF3EF' + ctx.strokeStyle = '#EDB6A4' + ctx.lineWidth = 0.9 + ctx.beginPath() + ctx.roundRect(-w / 2, by - h / 2, w, h, h / 2) + ctx.fill() + ctx.stroke() + ctx.fillStyle = '#93402A' + ctx.fillText(text, 0, by + 0.5) + if (nodeProgress * labelAlpha > 0.5) familyHitAreas.push({ x0: cx - w / 2, y0: cy + labelOffsetY + by - h / 2, x1: cx + w / 2, y1: cy + labelOffsetY + by + h / 2, anchorId: tab.anchorId }) + } + ctx.restore() } @@ -1234,8 +1076,6 @@ export function drawNodes(ctx, layoutData, posMap, entrance, entranceActive, ela if (node._type === 'person') { drawPersonNode(ctx, node, pos, nodeProgress, renderState) - } else if (node._type === 'family') { - drawFamilyCard(ctx, node, pos, posMap, nodeProgress, view) } else if (node._type === 'union') { ctx.globalAlpha = nodeProgress ctx.fillStyle = FIL.dot diff --git a/frontend/src/components/galaxy/useGalaxyInteractions.js b/frontend/src/components/galaxy/useGalaxyInteractions.js index f149fa9..9e3644c 100644 --- a/frontend/src/components/galaxy/useGalaxyInteractions.js +++ b/frontend/src/components/galaxy/useGalaxyInteractions.js @@ -1,9 +1,10 @@ import { useRef, useCallback } from 'react' import { getPersonMedias } from '../../data/mockData' import { MAX_ORBIT_MEDIAS, ORBIT_MEDIA_SIZE } from './constants' -import { nodeRadius, renvoiHitAreas, photoPlaceholderHitAreas } from './renderers' +import { nodeRadius, renvoiHitAreas, familyHitAreas, photoPlaceholderHitAreas } from './renderers' const hitRenvoi = (x, y) => renvoiHitAreas.find(a => x >= a.x0 && x <= a.x1 && y >= a.y0 && y <= a.y1) +const hitFamily = (x, y) => familyHitAreas.find(a => x >= a.x0 && x <= a.x1 && y >= a.y0 && y <= a.y1) const hitPhotoPlaceholder = (x, y) => photoPlaceholderHitAreas.find(a => Math.hypot(x - a.cx, y - a.cy) < a.r) import { getEntranceProgress } from './utils' @@ -22,7 +23,7 @@ export function useGalaxyInteractions({ canvasRef, transformRef, targetTransformRef, layoutDataRef, nodeRandomDataRef, entranceRef, hoveredNodeIdRef, hoveredMediaKeyRef, mouseWorldPosRef, - getAnimatedPos, onPersonSelect, onMediaSelect, onPhotoPlaceholderClick = null, + getAnimatedPos, onPersonSelect, onMediaSelect, onPhotoPlaceholderClick = null, onOpenFamily = null, annotationHandlers, minScale = 0.2, maxScale = 4, @@ -138,7 +139,7 @@ export function useGalaxyInteractions({ break } } - if (!isOverClickable && hitRenvoi(mouseWorldPosRef.current.x, mouseWorldPosRef.current.y)) isOverClickable = true + if (!isOverClickable && (hitRenvoi(mouseWorldPosRef.current.x, mouseWorldPosRef.current.y) || hitFamily(mouseWorldPosRef.current.x, mouseWorldPosRef.current.y))) isOverClickable = true if (!isOverClickable && hitPhotoPlaceholder(mouseWorldPosRef.current.x, mouseWorldPosRef.current.y)) isOverClickable = true hoveredNodeIdRef.current = newHoveredNode hoveredMediaKeyRef.current = newHoveredMedia @@ -284,6 +285,10 @@ export function useGalaxyInteractions({ } } + // Badge de famille : ouvre l'onglet de cette famille + const fam = hitFamily(clickX, clickY) + if (fam) { onOpenFamily?.(fam.anchorId); return } + // Renvoi : on glisse vers l'autre extrémité (parents ou enfant), sans dézoomer en dessous de la lecture des prénoms const renvoi = hitRenvoi(clickX, clickY) if (renvoi) { @@ -295,7 +300,7 @@ export function useGalaxyInteractions({ y: rect2.height / 2 - renvoi.targetY * scale, } } - }, [canvasRef, transformRef, targetTransformRef, layoutDataRef, nodeRandomDataRef, entranceRef, getAnimatedPos, onPersonSelect, onMediaSelect, onPhotoPlaceholderClick, annotationHandlers]) + }, [canvasRef, transformRef, targetTransformRef, layoutDataRef, nodeRandomDataRef, entranceRef, getAnimatedPos, onPersonSelect, onMediaSelect, onPhotoPlaceholderClick, onOpenFamily, annotationHandlers]) return { isDragging, isZooming, zoomTimeoutRef, hasDragged, diff --git a/frontend/src/i18n/en/arbre.js b/frontend/src/i18n/en/arbre.js index 27f43a8..5b8273e 100644 --- a/frontend/src/i18n/en/arbre.js +++ b/frontend/src/i18n/en/arbre.js @@ -2,6 +2,7 @@ export default { 'Famille {name}': 'The {name} family', 'Famille': 'Family', + 'Familles': 'Families', // Arbre sur canvas (components/galaxy/renderers.js) 'Voir les parents': 'See parents', 'Voir {firstName}': 'See {firstName}', From 2fab1edbef84bded619a6d4dcd08d07ab4749be8 Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Mon, 19 Oct 2026 22:12:45 +0200 Subject: [PATCH 14/26] =?UTF-8?q?onglets=20:=20l'onglet=20actif=20se=20met?= =?UTF-8?q?=20en=20vue=20dans=20la=20barre=20(t=C3=A9l=C3=A9phone)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- frontend/src/components/Galaxy.jsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/components/Galaxy.jsx b/frontend/src/components/Galaxy.jsx index d35c2ff..aa5238e 100644 --- a/frontend/src/components/Galaxy.jsx +++ b/frontend/src/components/Galaxy.jsx @@ -55,6 +55,8 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig const focusPersonIdRef = useRef(focusPersonId) // Mis à jour hors du rendu, avant l'effet de cadrage déclaré plus bas (même phase, ordre de déclaration) useEffect(() => { focusPersonIdRef.current = activeRef }, [activeRef]) + // Sur téléphone, la barre défile : l'onglet ouvert par un badge se met en vue, sans animation + useEffect(() => { document.querySelector('.family-tab.is-active')?.scrollIntoView({ behavior: 'auto', inline: 'nearest', block: 'nearest' }) }, [activeRef, tabs]) // Clic sur un badge : l'onglet de cette famille s'il existe déjà (la même famille vue depuis un autre // conjoint), sinon un nouvel onglet avec ce conjoint comme référence const openFamily = useCallback((anchorId) => { From 8637d7f323c667fc6c40d639f6ba2bb906806d13 Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Mon, 19 Oct 2026 22:19:48 +0200 Subject: [PATCH 15/26] =?UTF-8?q?familles=20:=20bascule=20=C2=AB=20Tout=20?= =?UTF-8?q?l'arbre=20/=20Par=20famille=20=C2=BB,=20barre=20abaiss=C3=A9e?= =?UTF-8?q?=20en=20mode=20modification,=20cadrage=20t=C3=A9l=C3=A9phone?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Deux modes au choix du spectateur, gardés dans son navigateur : « Par famille » (onglets) et « Tout l'arbre » (une belle-famille trop large reste dessinée d'un tenant). La bascule est en tête de la barre. - En mode modification, la barre laisse la place au message du haut : moitié gauche sur grand écran, sous le message sur téléphone. - Sur un écran étroit, la personne de départ s'ouvre plus bas, la barre ne couvre plus ses parents. Co-Authored-By: Claude Fable 5.1 --- frontend/src/components/Galaxy.css | 30 +++++++++++++++++ frontend/src/components/Galaxy.jsx | 33 +++++++++++++++---- frontend/src/components/galaxy/blockLayout.js | 18 +++++++--- frontend/src/i18n/en/arbre.js | 3 ++ 4 files changed, 72 insertions(+), 12 deletions(-) diff --git a/frontend/src/components/Galaxy.css b/frontend/src/components/Galaxy.css index b730d50..cccc13c 100644 --- a/frontend/src/components/Galaxy.css +++ b/frontend/src/components/Galaxy.css @@ -88,7 +88,37 @@ } .family-tab.is-active .family-tab-count { color: #EDB6A4; } +/* Bascule « Tout l'arbre / Par famille », en tête de la barre */ +.family-mode { + flex: 0 0 auto; + display: inline-flex; + padding: 3px; + border-radius: 999px; + background: #F2EFEA; + margin-right: 6px; +} +.family-mode-btn { + border: 0; + background: transparent; + color: #6F675F; + font-family: "DM Sans", system-ui, sans-serif; + font-size: 12px; + font-weight: 500; + line-height: 1; + padding: 7px 11px; + border-radius: 999px; + cursor: pointer; + white-space: nowrap; +} +.family-mode-btn:focus-visible { outline: 2px solid #C9553D; outline-offset: 2px; } +.family-mode-btn.is-active { background: #FFFFFF; color: #2A2622; box-shadow: 0 1px 2px rgba(42, 38, 34, 0.08); } + +/* En mode modification, le message « Vous proposez des changements » occupe le haut au centre : la barre + se limite à la moitié gauche sur grand écran et descend sous le message sur téléphone */ +.family-tabs--lowered { max-width: calc(50vw - 200px); } + @media (max-width: 768px) { .family-tabs { top: 10px; left: 10px; max-width: calc(100vw - 84px); } + .family-tabs--lowered { top: 64px; max-width: calc(100vw - 20px); } .family-tab { font-size: 14px; padding: 6px 12px 7px; } } diff --git a/frontend/src/components/Galaxy.jsx b/frontend/src/components/Galaxy.jsx index aa5238e..a3dec4d 100644 --- a/frontend/src/components/Galaxy.jsx +++ b/frontend/src/components/Galaxy.jsx @@ -47,14 +47,26 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig // Onglets de familles : la personne de référence de l'arbre affiché (celle de départ, puis le conjoint // dont on ouvre la famille), et la liste des familles rencontrées, dans l'ordre des mariages const layoutDataRef = useRef(null) + // Mode « par famille » (onglets) ou « tout l'arbre » (une belle-famille trop large reste dessinée) : choix + // du spectateur, gardé dans son navigateur + const [familyMode, setFamilyModeState] = useState(() => { try { return localStorage.getItem('parenthese.familyMode') === 'tree' ? 'tree' : 'tabs' } catch { return 'tabs' } }) + const familyModeRef = useRef(familyMode) const [activeRef, setActiveRef] = useState(focusPersonId) const [tabs, setTabs] = useState([]) + const [familiesCount, setFamiliesCount] = useState(0) // familles de l'arbre affiché, onglets compris : la barre apparaît dès 2 const startRefRef = useRef(focusPersonId) useEffect(() => { startRefRef.current = focusPersonId; setActiveRef(focusPersonId); setTabs([]) }, [focusPersonId]) // Lu au calcul du cadrage seulement : changer de personne de départ ne recadre pas une vue en cours const focusPersonIdRef = useRef(focusPersonId) // Mis à jour hors du rendu, avant l'effet de cadrage déclaré plus bas (même phase, ordre de déclaration) useEffect(() => { focusPersonIdRef.current = activeRef }, [activeRef]) + const setFamilyMode = useCallback((mode) => { + familyModeRef.current = mode + setFamilyModeState(mode) + try { localStorage.setItem('parenthese.familyMode', mode) } catch { /* navigateur sans stockage */ } + setTabs([]) + setActiveRef(startRefRef.current) + }, []) // Sur téléphone, la barre défile : l'onglet ouvert par un badge se met en vue, sans animation useEffect(() => { document.querySelector('.family-tab.is-active')?.scrollIntoView({ behavior: 'auto', inline: 'nearest', block: 'nearest' }) }, [activeRef, tabs]) // Clic sur un badge : l'onglet de cette famille s'il existe déjà (la même famille vue depuis un autre @@ -206,7 +218,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig unions: graphUnions, filiations: filiationsWithOrder(graphFiliations, graphUnions, group.source, order), } - const result = computeBlockLayout(graph, focusPersonIdRef.current) + const result = computeBlockLayout(graph, focusPersonIdRef.current, { familyTabs: familyModeRef.current === 'tabs' }) const baseSource = base.children.find((n) => n.id === group.source) const source = result.children.find((n) => n.id === group.source) const shift = baseSource && source ? baseSource.x - source.x : 0 @@ -446,7 +458,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig // Layout useEffect(() => { // La personne de départ sert de référence aux rangées (génération de parenté depuis elle) - const result = USE_BLOCK_LAYOUT ? computeBlockLayout(null, focusPersonIdRef.current) : computeLayout(null, focusPersonIdRef.current) + const result = USE_BLOCK_LAYOUT ? computeBlockLayout(null, focusPersonIdRef.current, { familyTabs: familyMode === 'tabs' }) : computeLayout(null, focusPersonIdRef.current) const canvas = canvasRef.current if (!canvas) return @@ -493,7 +505,8 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig if (focusNode && fitScale < FOCUS_MIN_READABLE_SCALE) { scale = FOCUS_SCALE offsetX = cssW / 2 - (focusNode.x + focusNode.width / 2) * scale - offsetY = cssH * 0.42 - (focusNode.y + focusNode.height / 2) * scale + // Sur un écran étroit, la barre des familles couvre le haut : la personne de départ un peu plus bas + offsetY = cssH * (cssW < 768 ? 0.56 : 0.42) - (focusNode.y + focusNode.height / 2) * scale } const init = { x: offsetX, y: offsetY, scale } @@ -579,6 +592,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig // Onglets : la famille affichée d'abord, puis chaque belle-famille rencontrée, sans doublon (une famille déjà // en onglet se reconnaît à sa personne de référence parmi les membres) const fams = result.families || [] + setFamiliesCount(fams.length) if (fams.length) setTabs((prev) => { // La liste commence toujours par la famille de départ ; un placement calculé pour une autre référence // avant qu'elle soit connue (arbre chargé avant sa personne de départ) ne compte pas @@ -588,12 +602,13 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig if (!next.length && String(main.referenceId) !== start) return prev if (!next.some((tb) => String(tb.referenceId) === String(main.referenceId))) next.push({ referenceId: main.referenceId, label: main.label, count: main.count }) for (const f of fams.slice(1)) { + if (f.drawn) continue if (next.some((tb) => `p-${tb.referenceId}` === String(f.anchorId) || f.members.includes(String(tb.referenceId)))) continue next.push({ referenceId: String(f.anchorId).replace(/^p-/, ''), label: f.label, count: f.count }) } return next }) - }, [graphRevision, activeRef]) + }, [graphRevision, activeRef, familyMode]) // Sync selectedPersonId useEffect(() => { @@ -1069,9 +1084,13 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig {displayLabel && (
{displayLabel}
)} - {tabs.length > 1 && ( -