Carte des familles v2 : blocs calculés par le placement, repli net, traits de bloc à bloc

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 <noreply@anthropic.com>
This commit is contained in:
IliesAllaliandClaude Fable 5.1 committed 2026-10-19 17:19:15 +02:00
1 parent 71c8e1a93d
commit c3d0ac30e4
3 files changed
+60 -39

No files matched your search

+20 -8
View File
@@ -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()
+17 -2
View File
@@ -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
}
+23 -29
View File
@@ -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()
}