mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-06 23:32:08 +00:00
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:
1 parent
b444c1ad39
commit
c1734501ae
5 files changed
+117
-12
No files matched your search
@@ -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; }
|
||||
@@ -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>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -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 ')}`
|
||||
}
|
||||
|
||||
@@ -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,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',
|
||||
|
||||
Reference in new issue
Block a user