Onglets de familles : noms, repère et liens entre onglets (#192)

- Nom d'un onglet : le nom le plus porté par ses membres (naissance, sinon usage), et non plus celui
  de la seule personne de référence. Deux onglets
  au même nom : le plus petit prend le nom suivant le plus porté chez lui, les prénoms de la charnière
  en dernier recours.
- Changer d'onglet garde la personne charnière à sa place et à son zoom : seules les familles autour changent.
- Survol d'un onglet : la charnière s'allume dans l'arbre affiché (halo de la recherche), sans texte.
- Barre rangée comme l'arbre (chaque famille après celle par laquelle on y entre), groupée par branche
  (10 px dans une branche, 16 px entre branches), trait d'union entre deux onglets dont le second part
  du premier, terracotta sur le chemin de l'onglet ouvert.
- Pastille de retour : même place que celle de l'aller, flèche retournée (« ‹ Famille X · n »).
- Nombre d'un onglet : « n personnes » au survol.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
IliesAllaliandClaude Opus 5.5 authored and GitHub committed 2026-10-29 13:08:25 +01:00
1 parent b444c1ad39
commit c1734501ae
5 files changed
+117 -12

No files matched your search

+7
View File
@@ -119,3 +119,10 @@
body:has(.expandable-search.expanded) .family-tabs { top: 80px; max-width: calc(100vw - 20px); }
.family-tab { font-size: 14px; padding: 6px 12px 7px; }
}
/* Trait d'union entre deux onglets voisins quand le second part du premier (planche A, variante 2) */
.family-tabs { gap: 0; }
.family-tab + .family-tab:not([style]) { margin-left: 6px; }
.family-tab--suite { position: relative; }
.family-tab--suite::before { content: ""; position: absolute; right: 100%; top: 50%; width: 10px; height: 1.3px; transform: translateY(-50%); background: #E3A590; pointer-events: none; }
.family-tab--trait-on::before { background: #D2694A; }
+73 -5
View File
@@ -50,6 +50,33 @@ const centerX = (node) => node.x + node.width / 2
const easeOutQuint = (t) => 1 - (1 - t) ** 5
const prefersReducedMotion = () => typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
// Barre des familles rangée comme l'arbre (Patch, 28/10, planche A variante 2 « le trait d'union ») : chaque
// famille juste après celle par laquelle on y entre, la famille de départ en tête. Les onglets d'une même branche
// (sous-arbre d'un enfant direct de la famille de départ) se serrent à 10 px, 16 px entre les branches ; un trait
// relie deux voisins quand le second part du premier (jamais vers la famille de départ, sa place en tête suffit).
// Le trait qui mène à l'onglet ouvert est terracotta, les autres couleur fil.
function barTabs(tabs, activeKey) {
if (!tabs.length) return []
const root = tabs[0]
const kids = new Map()
for (const tb of tabs.slice(1)) { const pk = tabs.some((x) => x.key === tb.parentKey) ? tb.parentKey : root.key; if (!kids.has(pk)) kids.set(pk, []); kids.get(pk).push(tb) }
const order = []
const visit = (tb) => { order.push(tb); for (const c of kids.get(tb.key) || []) visit(c) }
visit(root)
for (const tb of tabs) if (!order.includes(tb)) order.push(tb)
const byKey = new Map(tabs.map((tb) => [tb.key, tb]))
const pathTo = (tb) => { const out = []; for (let t = tb; t; t = byKey.get(t.parentKey)) out.unshift(t.key); return out }
const branchOf = (tb) => { const p = pathTo(tb); return p[1] || p[0] }
const active = byKey.get(activeKey) || root
const lit = new Set(pathTo(active))
return order.map((tb, i) => {
const prev = order[i - 1]
const gap = i === 0 ? 0 : (prev !== root && branchOf(tb) === branchOf(prev) ? 10 : 16)
const trait = i > 0 && prev !== root && tb.parentKey === prev.key ? (lit.has(tb.key) ? 'on' : 'off') : null
return { tb, gap, trait }
})
}
const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlightIds = [], graphRevision = 0, annotationHandlers = null, transformReadRef = null, zoomApiRef = null, focusPersonId = null, assetRevision = 0, reorderEnabled = false, onReorderSiblings = null, familyTabs = true, fullNames = false, annotationViewRef = null, onAnnotationsAnchored = null, selfPersonId = null, ghosts = null, initialFamilyKey = null, onFamilyChange = null }) => {
const canvasRef = useRef(null)
const layoutDataRef = useRef(null)
@@ -94,7 +121,32 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
setActiveKey(focusPersonId != null ? String(focusPersonId) : null)
}, [focusPersonId])
// Onglet choisi par la personne (barre, badge, recherche) : le lien d'arrivée ne s'impose plus
const chooseTab = useCallback((key) => { linkFamilyRef.current = null; pendingFamilyRef.current = null; frameOnRef.current = null; setActiveKey(key) }, [])
// Repère du changement d'onglet (Patch, 28/10 : « on perd un peu rapidement le fil ») : la personne qui relie
// les deux familles garde sa place et son zoom à l'écran, seules les familles autour changent
const anchorRef = useRef(null)
const linkPeople = (key) => {
const list = tabsCacheRef.current.tabs
const target = list.find((tb) => tb.key === String(key)), cur = activeTabRef.current || list[0]
return [target?.referenceId, ...(target?.hinges || []), cur?.referenceId, ...(cur?.hinges || [])].filter((x) => x != null).map(String)
}
const rememberAnchor = (key) => {
const layout = layoutDataRef.current, t = transformRef.current
if (!layout || !t) return
const ids = linkPeople(key)
const node = layout.children.find((n) => n._type === 'person' && ids.includes(String(n._data?.id)))
anchorRef.current = node ? { id: String(node._data.id), sx: (node.x + node.width / 2) * t.scale + t.x, sy: (node.y + node.height / 2) * t.scale + t.y, scale: t.scale } : null
}
const chooseTab = useCallback((key) => { rememberAnchor(key); linkFamilyRef.current = null; pendingFamilyRef.current = null; frameOnRef.current = null; setActiveKey(key) }, [])
// Survol d'un onglet : la personne par qui l'on y passe s'allume dans l'arbre affiché, sans texte (Patch : « que ça les suggère »)
const hoverTab = (key) => {
const layout = layoutDataRef.current
if (!layout) return
const present = new Set(layout.children.filter((n) => n._type === 'person').map((n) => String(n._data?.id)))
const ids = linkPeople(key).filter((id) => present.has(id))
searchHighlightSetRef.current = new Set([...searchHighlightIdsRef.current.map((id) => `p-${id}`), ...ids.map((id) => `p-${id}`)])
needsRedrawRef.current = true
}
const leaveTab = () => { searchHighlightSetRef.current = new Set(searchHighlightIdsRef.current.map((id) => `p-${id}`)); needsRedrawRef.current = true }
// Lu au calcul du cadrage et par le réordonnancement : la référence de l'arbre affiché
const focusPersonIdRef = useRef(focusPersonId)
// La barre défile quand les onglets débordent : l'onglet ouvert se met en vue (sans animation) et les bords
@@ -630,7 +682,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
const parent = tabsCacheRef.current.tabs.find((tb) => tb.key === tab.parentKey)
for (const h of (tab.hinges || [tab.referenceId])) {
const node = result.children.find((n) => n.id === `p-${h}`)
if (parent && node) node._familyTab = { anchorId: node.id, label: parent.label, count: parent.count, tabKey: parent.key, place: 'above' }
if (parent && node) node._familyTab = { anchorId: node.id, label: parent.label, count: parent.count, tabKey: parent.key, place: 'above', back: true }
}
}
@@ -684,6 +736,15 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
offsetY = cssH * (cssW < 768 ? 0.4 : 0.42) - (focusNode.y + focusNode.height / 2) * scale
}
const anc = anchorRef.current
anchorRef.current = null
const ancNode = anc && personNodes.find((n) => String(n._data?.id) === anc.id)
if (ancNode) {
scale = anc.scale
offsetX = anc.sx - (ancNode.x + ancNode.width / 2) * scale
offsetY = anc.sy - (ancNode.y + ancNode.height / 2) * scale
}
const init = { x: offsetX, y: offsetY, scale }
initialTransformRef.current = init
transformRef.current = init
@@ -803,7 +864,9 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
}, [selectedPersonId])
// Sync searchHighlightIds
const searchHighlightIdsRef = useRef(searchHighlightIds)
useEffect(() => {
searchHighlightIdsRef.current = searchHighlightIds
searchHighlightSetRef.current = new Set(searchHighlightIds.map(id => `p-${id}`))
}, [searchHighlightIds])
@@ -1259,18 +1322,23 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
)}
{familyTabs && tabs.length > 1 && (
<nav ref={tabsBarRef} onScroll={updateTabsEdges} className={`family-tabs${annotationHandlers ? ' family-tabs--lowered' : ''}${tabsEdges}`} aria-label={tr('Familles')}>
{tabs.map((tb) => {
{barTabs(tabs, activeKey).map(({ tb, gap, trait }) => {
const active = tb.key === (tabs.some((x) => x.key === activeKey) ? activeKey : tabs[0].key)
return (
<button
key={tb.key}
type="button"
className={`family-tab${active ? ' is-active' : ''}`}
style={gap ? { marginLeft: gap } : undefined}
className={`family-tab${active ? ' is-active' : ''}${trait ? ` family-tab--suite family-tab--trait-${trait}` : ''}`}
aria-current={active ? 'page' : undefined}
onClick={() => chooseTab(tb.key)}
onMouseEnter={() => { if (!active) hoverTab(tb.key) }}
onMouseLeave={leaveTab}
onFocus={() => { if (!active) hoverTab(tb.key) }}
onBlur={leaveTab}
>
<span className="family-tab-name">{tb.label ? tr('Famille {name}', { name: tb.label }) : tr('Famille')}</span>
<span className="family-tab-count">{tb.count}</span>
<span className="family-tab-count" title={tr('{n} personnes', { n: tb.count })}>{tb.count}</span>
</button>
)
})}
+32 -6
View File
@@ -127,6 +127,20 @@ function layoutOnce(graphData, referenceId, forcedSide, familyTabs = true) {
const result = computeLayout(graphData, referenceId)
const nodes = result.children
const byId = new Map(nodes.map(n => [n.id, n]))
// Nom d'une famille : celui que portent le plus ses membres (nom de naissance, sinon nom), pas celui de la seule
// personne de référence. Ex. : 175 personnes dont 5 portent le nom de la référence. Égalité : le nom
// de secours (celui de la référence) s'il est parmi les premiers, sinon le premier rencontré.
const nomMajoritaire = (ids, secours = '') => {
const n = new Map()
for (const id of ids) {
const d = byId.get(id)?._type === 'person' ? byId.get(id)._data : null
const nom = (d?.birthName || d?.lastName || '').trim()
if (nom) n.set(nom, (n.get(nom) || 0) + 1)
}
let best = '', max = 0
for (const [nom, c] of n) if (c > max) { best = nom; max = c }
return secours && n.get(secours) === max ? secours : (best || secours)
}
// Chaînes de couples (composantes d'une rangée) telles que computeLayout les a ordonnées
const chainOf = new Map()
@@ -416,8 +430,8 @@ function layoutOnce(graphData, referenceId, forcedSide, familyTabs = true) {
unplace(block.members)
forgetExternal(snap.externalBefore, snap.externalAfter)
const A = byId.get(id)
const label = A._data?.birthName || byId.get(pcPersons[0])?._data?.lastName || A._data?.lastName || ''
const members = new Set([...extra, ...nested].filter(m => !keep.has(m)))
const label = nomMajoritaire(members, A._data?.birthName || byId.get(pcPersons[0])?._data?.lastName || A._data?.lastName || '')
registerTab({ anchorId: pcPersons[0], pu: parentUnionOf.get(pcPersons[0]) || pu, label, count: members.size, members, hinges: pcPersons.slice(1), hingeEdges: [] })
for (const x of pcPersons) byId.get(x)._familyTab = { anchorId: pcPersons[0], label, count: members.size, tabKey: pcPersons[0].replace(/^p-/, ''), place: 'above' }
const saved = anchor
@@ -461,7 +475,7 @@ function layoutOnce(graphData, referenceId, forcedSide, familyTabs = true) {
anchor = saved
if (block.width > FAMILY_TAB_PX && !familyTabs) {
const A = byId.get(id)
bigFamilies.push({ anchorId: id, label: A._data?.birthName || A._data?.lastName || '', count: [...block.members].filter(m => byId.get(m)?._type === 'person').length })
bigFamilies.push({ anchorId: id, label: nomMajoritaire(block.members, A._data?.birthName || A._data?.lastName || ''), count: [...block.members].filter(m => byId.get(m)?._type === 'person').length })
}
if (block.width > FAMILY_TAB_PX && familyTabs) {
// Trop large pour cet arbre : la famille devient un onglet et n'est pas dessinée ici. Sa construction ne
@@ -474,8 +488,8 @@ function layoutOnce(graphData, referenceId, forcedSide, familyTabs = true) {
forgetExternal(externalBefore, new Set(externalEdges))
const A = byId.get(id)
const partners = [byId.get(pu)?._data?.p1Key, byId.get(pu)?._data?.p2Key].map(k => byId.get(k)?._data).filter(Boolean)
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'), ...nested])
const label = nomMajoritaire(members, A._data?.birthName || partners.find(d => d.lastName)?.lastName || A._data?.lastName || '')
registerTab({ anchorId: id, pu, label, count: members.size, members, hinges: [], hingeEdges: [[pu, id]] })
A._familyTab = { anchorId: id, label, count: members.size, tabKey: id.replace(/^p-/, ''), place: 'above' }
continue
@@ -818,7 +832,7 @@ function layoutOnce(graphData, referenceId, forcedSide, familyTabs = true) {
// 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 },
{ id: 'main', label: nomMajoritaire(drawn.map(n => n.id), 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-/, '')), hinges: [tb.anchorId, ...tb.hinges].map(m => m.replace(/^p-/, '')) })),
...bigFamilies.map(f => ({ id: f.anchorId, anchorId: f.anchorId, label: f.label, count: f.count, members: [], drawn: true })),
]
@@ -925,14 +939,26 @@ export function familyTabsOf(graphData = null, referenceId = null) {
// Deux onglets au même nom (homonymes, ou une branche sortie de sa propre lignée) : sauf le plus grand, chacun
// prend les prénoms de sa charnière, ceux que porte la pastille qui y mène (Patch, 20/10 : « plusieurs fois
// les mêmes familles »)
const firstName = new Map((graphData?.persons || globalPersons).map(p => [String(p.id), p.firstName]))
// 28/10 (Patch : « ptet le Joseph et Marie ») : l'onglet le plus petit prend d'abord le nom suivant le plus
// porté chez lui et pas encore pris par un autre onglet ; les prénoms de la charnière ne servent qu'en dernier recours.
const persons = graphData?.persons || globalPersons
const firstName = new Map(persons.map(p => [String(p.id), p.firstName]))
const familyName = new Map(persons.map(p => [String(p.id), (p.birthName || p.lastName || '').trim()]))
const namesByCount = (tb) => {
const n = new Map()
for (const id of tb.personIds || []) { const nom = familyName.get(String(id).replace(/^p-/, '')); if (nom) n.set(nom, (n.get(nom) || 0) + 1) }
return [...n].sort((x, y) => y[1] - x[1]).map(([nom]) => nom)
}
const byLabel = new Map()
for (const tb of tabs) { if (!byLabel.has(tb.label)) byLabel.set(tb.label, []); byLabel.get(tb.label).push(tb) }
const taken = new Set(tabs.map(tb => tb.label))
for (const group of byLabel.values()) {
if (group.length < 2) continue
const biggest = group.reduce((a, b) => (b.count > a.count ? b : a))
for (const tb of group) {
for (const tb of [...group].sort((a, b) => b.count - a.count)) {
if (tb === biggest) continue
const next = namesByCount(tb).find(nom => !taken.has(nom))
if (next) { tb.label = next; taken.add(next); continue }
const names = [...new Set((tb.hinges || [tb.referenceId]).map(id => firstName.get(String(id))).filter(Boolean))].slice(0, 2)
if (names.length) tb.label = `${tb.label} · ${names.join(' et ')}`
}
+4 -1
View File
@@ -697,7 +697,10 @@ function drawPersonNode(ctx, node, pos, nodeProgress, {
// la charnière peut avoir des enfants dessous, jamais rien au-dessus que le fil de ses parents.
if (withDetail && node._familyTab) {
const tab = node._familyTab
const text = `${tab.label ? t('Famille {name}', { name: tab.label }) : t('Famille')} · ${tab.count} ›`
// Retour vers la famille d'où l'on vient : même place que la pastille de l'aller (la charnière ne bouge pas),
// flèche retournée (Patch, 28/10, planche D)
const name = tab.label ? t('Famille {name}', { name: tab.label }) : t('Famille')
const text = tab.back ? `‹ ${name} · ${tab.count}` : `${name} · ${tab.count} ›`
const py = cy - PERSON_R * personScale - RENVOI_CHILD_GAP
familyPills.push({ text, cx, cy: py, anchorId: tab.anchorId, tabKey: tab.tabKey, alpha: nodeProgress * labelAlpha })
}
+1
View File
@@ -1,6 +1,7 @@
// Traductions anglaises, clé = texte français exact passé à t().
export default {
'Famille {name}': 'The {name} family',
'{n} personnes': '{n} people',
'Famille': 'Family',
'Familles': 'Families',
'Affichage': 'Display',