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() }