placement(familles): côté du conjoint, carte au plus près, bandeau et carte des familles sans fondu

- Un cluster se range du côté où son conjoint se trouve dans la lignée principale (seconde passe si la
  première construction l'avait mis de l'autre côté) ; le couple se tourne vers sa famille.
- La carte de famille se pose au plus près au-dessus du conjoint, en préférant le côté de sa famille.
- La carte inverse réserve sa place sous l'écho du conjoint dans le cluster (plus de chevauchement).
- La reconstruction d'un cluster ne laisse aucune trace (clusters imbriqués, chaînes posées, arêtes
  externes) : 0 chevauchement sur l'arbre réel, 8 à 0 sur asc-3.
- Bandeau à l'écran avec le nom et la taille de la famille où l'on est.
- Carte des familles : bascule nette sous une échelle, sans fondu ni glissement ; famille principale au
  milieu, familles par alliance en colonne de leur côté, traits de bloc à bloc avec les prénoms du mariage.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
IliesAllaliandClaude Fable 5.1 committed 2026-10-19 17:54:20 +02:00
1 parent c3d0ac30e4
commit e2af4fb580
3 files changed
+173 -89

No files matched your search

+15 -21
View File
@@ -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()
+69 -29
View File
@@ -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
+89 -39
View File
@@ -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()
}