From c3d0ac30e475a3c31d8c0899438850adcdb46392 Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Mon, 19 Oct 2026 17:19:15 +0200 Subject: [PATCH] =?UTF-8?q?Carte=20des=20familles=20v2=20:=20blocs=20calcu?= =?UTF-8?q?l=C3=A9s=20par=20le=20placement,=20repli=20net,=20traits=20de?= =?UTF-8?q?=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() }