nettoyage : l'ancien découpage par largeur retiré du placement, des pastilles, de la barre et du CSS

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
IliesAllaliandClaude Opus 5.5 committed 2026-11-07 01:18:18 +01:00
1 parent 5eb1d9e7bd
commit 1256f09c8f
8 files changed
+35 -282

No files matched your search

+1 -1
View File
@@ -570,7 +570,7 @@ function App() {
})
const [adminPanelVisible, setAdminPanelVisible] = useState(false)
// Onglet de famille ouvert : l'adresse de la page le porte (?famille=<charnière>), et le lien à envoyer à la
// Onglet de famille ouvert : l'adresse de la page le porte (?famille=<ancêtre de la branche>), et le lien à envoyer à la
// famille aussi, pour qu'il ouvre l'arbre sur cette famille
const [initialFamilyKey] = useState(() => new URLSearchParams(window.location.search).get('famille'))
const [openFamily, setOpenFamily] = useState(null)
+1 -7
View File
@@ -58,7 +58,7 @@
left: 16px;
z-index: 500;
display: flex;
gap: 6px;
gap: 16px;
max-width: calc(100vw - 120px);
overflow-x: auto;
scrollbar-width: none;
@@ -120,9 +120,3 @@
.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; }
+12 -42
View File
@@ -51,49 +51,21 @@ 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, tabMinCount = 8, fullNames = false, annotationViewRef = null, onAnnotationsAnchored = null, selfPersonId = null, ghosts = null, initialFamilyKey = null, onFamilyChange = null }) => {
const canvasRef = useRef(null)
const layoutDataRef = useRef(null)
// Onglets de familles (réglage `familyTabs`) : la liste des familles de l'arbre, calculée une fois par
// version du graphe et par personne de départ, et l'onglet ouvert. Un onglet est le sous-arbre d'une
// grande belle-famille, posé avec le conjoint charnière comme référence.
// Onglets de familles (réglage `familyTabs`, galaxy/nameTabs.js) : « Tout l'arbre » puis un onglet par nom de
// famille assez porté, calculés une fois par version du graphe et par seuil, et l'onglet ouvert
const [tabs, setTabs] = useState([])
const [activeKey, setActiveKey] = useState(null)
const tabsCacheRef = useRef({ rev: -1, min: null, tabs: [] })
const pendingFocusRef = useRef(null) // personne cherchée dans un autre onglet, visée après le changement
const pendingFrameRef = useRef(null) // personnes à cadrer une fois l'arbre reposé (ajout de plusieurs prénoms)
// Lien d'un onglet (?famille=<charnière>) : ouvert dès que l'arbre et ses onglets sont connus, et de nouveau si
// Lien d'un onglet (?famille=<ancêtre de la branche>) : ouvert dès que l'arbre et ses onglets sont connus, et de nouveau si
// la personne de départ arrive après (visiteur), tant qu'on n'a pas soi-même changé d'onglet
const linkFamilyRef = useRef(initialFamilyKey != null ? String(initialFamilyKey) : null)
const pendingFamilyRef = useRef(initialFamilyKey != null ? String(initialFamilyKey) : null)
// Famille du lien sans onglet dans ce navigateur (onglets coupés) : l'arbre entier s'ouvre sur sa charnière
// Famille du lien sans onglet dans ce navigateur (onglets coupés, ancien lien) : l'arbre entier s'ouvre sur cette personne
const frameOnRef = useRef(null)
const onFamilyChangeRef = useRef(onFamilyChange)
useEffect(() => { onFamilyChangeRef.current = onFamilyChange }, [onFamilyChange])
@@ -166,7 +138,6 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
return () => window.removeEventListener('resize', updateTabsEdges)
}, [activeKey, tabs, updateTabsEdges])
// Clic sur un badge : l'onglet de cette famille (ou, depuis un onglet, celui d'où l'on vient)
const openFamily = useCallback((tabKey) => { if (tabKey != null) chooseTab(String(tabKey)) }, [chooseTab])
// Propriétaire : un portrait vide survolé montre « + photo »
const [layoutReady, setLayoutReady] = useState(false)
const transformRef = useRef({ x: 0, y: 0, scale: 1 })
@@ -260,7 +231,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
canvasRef, transformRef, targetTransformRef,
layoutDataRef, nodeRandomDataRef, entranceRef,
hoveredNodeIdRef: hoveredNodeId, hoveredMediaKeyRef: hoveredMediaKey, mouseWorldPosRef: mouseWorldPos,
getAnimatedPos, onPersonSelect, onMediaSelect, onOpenFamily: openFamily,
getAnimatedPos, onPersonSelect, onMediaSelect,
annotationHandlers,
minScale: MIN_SCALE,
maxScale: MAX_SCALE,
@@ -313,7 +284,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
unions: src.unions,
filiations: filiationsWithOrder(src.filiations, src.unions, group.source, order),
}
const result = computeBlockLayout(graph, focusPersonIdRef.current, { familyTabs: false })
const result = computeBlockLayout(graph, focusPersonIdRef.current)
const baseSource = base.children.find((n) => n.id === group.source)
const source = result.children.find((n) => n.id === group.source)
const shift = baseSource && source ? baseSource.x - source.x : 0
@@ -503,7 +474,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
if (!node && familyTabsRef.current) {
const id = String(personId)
const all = tabsCacheRef.current.tabs
const home = all.find((tb) => tb.personIds?.has(id) && !(tb.hinges || []).map(String).includes(id)) || all.find((tb) => tb.personIds?.has(id)) || all[0]
const home = all.find((tb) => tb.personIds?.has(id)) || all[0]
if (home && home.key !== activeTabRef.current?.key) { pendingFocusRef.current = id; chooseTab(home.key) }
return
}
@@ -595,7 +566,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
setTabs(list)
}
if (!familyTabs && cache.tabs.length) { tabsCacheRef.current = { rev: -1, min: null, tabs: [] }; setTabs([]) }
// La famille du lien : son onglet s'il existe dans ce navigateur, sinon l'arbre entier ouvert sur sa charnière
// La famille du lien : son onglet s'il existe dans ce navigateur, sinon l'arbre entier ouvert sur cette personne
if (pendingFamilyRef.current != null && graphPersons.length) {
const key = pendingFamilyRef.current
pendingFamilyRef.current = null
@@ -620,8 +591,8 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
// Annotations d'avant l'ancrage (x, y seuls) : rattachées une fois à la personne la plus proche dans le
// placement de l'onglet de départ, puis enregistrées. Calculé avant l'arbre affiché (coupleBarMeta, voir plus haut)
const legacy = annotationData.some((a) => !readAnchor(a))
const departure = legacy && USE_BLOCK_LAYOUT && graph ? computeBlockLayout(null, startRefRef.current, { familyTabs: false }) : null
const result = USE_BLOCK_LAYOUT ? computeBlockLayout(graph, focusPersonIdRef.current, { familyTabs: false }) : computeLayout(null, focusPersonIdRef.current)
const departure = legacy && USE_BLOCK_LAYOUT && graph ? computeBlockLayout(null, startRefRef.current) : null
const result = USE_BLOCK_LAYOUT ? computeBlockLayout(graph, focusPersonIdRef.current) : computeLayout(null, focusPersonIdRef.current)
if (legacy) {
const anchored = anchorLegacy(annotationData, layoutCenters(departure || result))
for (const ann of anchored) updateAnnotationInStore(ann.id, { style: ann.style })
@@ -1270,14 +1241,13 @@ 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')}>
{barTabs(tabs, activeKey).map(({ tb, gap, trait }) => {
{tabs.map((tb) => {
const active = tb.key === (tabs.some((x) => x.key === activeKey) ? activeKey : tabs[0].key)
return (
<button
key={tb.key}
type="button"
style={gap ? { marginLeft: gap } : undefined}
className={`family-tab${active ? ' is-active' : ''}${trait ? ` family-tab--suite family-tab--trait-${trait}` : ''}`}
className={`family-tab${active ? ' is-active' : ''}`}
aria-current={active ? 'page' : undefined}
onClick={() => chooseTab(tb.key)}
onMouseEnter={() => { if (!active) hoverTab(tb.key) }}
+14 -194
View File
@@ -42,18 +42,6 @@ const RENVOI_FAR_PX = 1440
const PARENT_SHIFT_PX = 400
// Côté d'une belle-famille : le côté extérieur de la branche, sauf si l'autre côté la rapproche d'au moins ça
const SIDE_SWITCH_PX = 1440
// Carte de famille et pont (maquette A, retenue le 19/10). Une belle-famille plus large que ça ne se colle pas à
// son enfant : elle devient un cluster dessiné à part, et une carte prend sa place au-dessus du conjoint
// Une belle-famille plus large que ça n'est pas dessinée dans cet arbre : elle devient un onglet (son propre
// arbre, calculé avec le conjoint comme référence) et le conjoint porte un badge vers elle.
const FAMILY_TAB_PX = 2880
// Une belle-famille poussée plus loin que ça de son enfant ne se colle pas : seuls les parents du conjoint
// restent au-dessus de lui, le reste devient un onglet
const FAMILY_FIT_PX = 480
// Une belle-famille qui grimpe au-dessus des parents du conjoint reste dessinée si elle est toute petite
const FAMILY_INLINE_MAX = 6
// En dessous, une famille sortie de l'arbre ne fait pas d'onglet : elle reste dessinée
const FAMILY_TAB_MIN = 10
// Le fil d'une filiation union → enfant : une personne d'une autre famille, sur la ligne de l'enfant,
// entre l'aplomb de l'union et l'enfant, est croisée. Frères et sœurs et leurs conjoint(e)s ne comptent pas
@@ -97,50 +85,34 @@ const overlaps = (l, r, list, gap) => list.some(([a, b]) => l < b + gap && r > a
// Règle globale (Patch, 19/10) : l'arbre s'ouvre à partir de la personne de référence. Toute belle-famille,
// grosse ou petite, part du côté opposé à la référence (à droite pour un conjoint à sa droite), le couple se
// tourne vers elle. Ce côté ne se connaît qu'une fois la lignée posée, d'où une seconde passe.
// `familyTabs` : vrai, une belle-famille trop large devient un onglet ; faux, tout l'arbre est dessiné d'un
// tenant (mode « tout l'arbre »), les grandes familles sont seulement signalées dans `families`.
export function computeBlockLayout(graphData = null, referenceId = null, { familyTabs = true } = {}) {
export function computeBlockLayout(graphData = null, referenceId = null) {
const forced = new Map()
let result = layoutOnce(graphData, referenceId, forced, familyTabs)
let result = layoutOnce(graphData, referenceId, forced)
// Chaque passe fixe le côté des belles-familles d'après la passe précédente ; on s'arrête dès que plus rien
// ne change, ou après quatre passes (les positions bougent d'une passe à l'autre)
for (let pass = 0; pass < 4 && result._inlaws?.length; pass++) {
const byId = new Map(result.children.map(n => [n.id, n]))
const persons = result.children.filter(n => n._type === 'person')
// Pivot : le centre de la famille dessinée. La référence elle-même, souvent au bord (dernière génération,
// charnière d'un onglet), enverrait toutes les belles-familles du même côté (Patch, 19/10 : « moins clair »)
// ou un conjoint), enverrait toutes les belles-familles du même côté (Patch, 19/10 : « moins clair »)
const pivot = persons.length ? (Math.min(...persons.map(n => n.x)) + Math.max(...persons.map(n => n.x + n.width))) / 2 : 0
let changed = false
for (const { anchorId, side } of result._inlaws) {
const a = byId.get(anchorId)
if (!a) continue // conjoint d'une famille devenue onglet : pas dans cet arbre
if (!a) continue
const want = a.x + a.width / 2 < pivot ? -1 : 1
if (want !== side && forced.get(anchorId) !== want) { forced.set(anchorId, want); changed = true }
}
if (!changed) break
result = layoutOnce(graphData, referenceId, forced, familyTabs)
result = layoutOnce(graphData, referenceId, forced)
}
return result
}
function layoutOnce(graphData, referenceId, forcedSide, familyTabs = true) {
function layoutOnce(graphData, referenceId, forcedSide) {
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()
@@ -246,10 +218,6 @@ function layoutOnce(graphData, referenceId, forcedSide, familyTabs = true) {
const placed = new Set()
const externalEdges = new Set() // `${union}>${enfant}` : enfant dessiné sous une autre lignée
const detached = [] // familles qu'on n'a pas pu coller à leur enfant (trop loin) : posées à part, renvoi
const tabs = [] // belles-familles trop larges pour cet arbre, devenues onglets : { anchorId, pu, label, count, members }
const hiddenChains = new Set() // chaînes des familles devenues onglets : pas dessinées dans cet arbre
const tabOfChain = new Map() // chaîne cachée → son onglet : un second conjoint de la même famille renvoie au même onglet
const bigFamilies = [] // mode « tout l'arbre » : les familles qui seraient des onglets, dessinées quand même
const attachedEdges = new Set() // `${union}>${enfant}` : fil d'une famille collée à son enfant, toujours dessiné
// Imbrique des blocs de gauche à droite, rangée par rangée
@@ -320,25 +288,8 @@ function layoutOnce(graphData, referenceId, forcedSide, familyTabs = true) {
// `outer` : côté extérieur hérité de la lignée (−1 gauche, +1 droite). Les belles-familles d'une branche vont
// toutes du même côté, celui où la branche est au bord de l'arbre, pour ne pas se poser entre deux familles.
// Défaire ce qu'une construction a posé : chaînes, onglets nés pendant elle, arêtes externes notées
const chainsOf = (members) => new Set([...members].map(m => chainOf.get(m)).filter(Boolean))
const unplace = (members) => { for (const ch of chainsOf(members)) { placed.delete(ch); hiddenChains.delete(ch); tabOfChain.delete(ch) } }
const forgetTabs = (from, to) => {
const nested = new Set()
for (const tb of tabs.splice(from, to - from)) {
unplace(tb.members)
for (const m of tb.members) nested.add(m)
for (const h of tb.hinges) { byId.get(h)._familyTab = null; nested.add(h) }
byId.get(tb.anchorId)._familyTab = null
}
return nested
}
const forgetExternal = (before, after) => { for (const e of after) if (!before.has(e)) externalEdges.delete(e) }
const registerTab = (tab) => { tabs.push(tab); for (const ch of chainsOf(tab.members)) { hiddenChains.add(ch); tabOfChain.set(ch, tab) } }
// `parentsOnly` : la chaîne est au milieu de sa fratrie, les belles-familles de ses conjoints se réduisent
// aux parents au-dessus du conjoint, le reste devient un onglet
function buildBlock(chain, ownerUnion = null, outer = 0, parentsOnly = false) {
function buildBlock(chain, ownerUnion = null, outer = 0) {
placed.add(chain)
const gen = genOf(chain)
const childBlocks = []
@@ -361,8 +312,6 @@ function layoutOnce(graphData, referenceId, forcedSide, familyTabs = true) {
// Belle-famille en construction : une chaîne dont aucun membre du sang n'est enfant de cette union, mais
// dont un membre du sang a ses parents ailleurs, ira sous ces parents-là (le sang de la référence gagne)
const duSang = anchor && !kc.some(id => blood.has(id) && parentUnionOf.get(id) === unionId) && kc.some(id => blood.has(id) && byId.get(id)._type === 'person' && parentUnionOf.has(id))
// Un enfant parti dans un onglet ne laisse pas de place vide : ses parents se centrent sur les enfants dessinés
if (tabOfChain.has(kc)) return
if (placed.has(kc) || duSang) {
external.push({ kid, unionId })
externalEdges.add(`${unionId}>${kid}`)
@@ -370,10 +319,8 @@ function layoutOnce(graphData, referenceId, forcedSide, familyTabs = true) {
return
}
const side = sideOfUnion(unionId) || outer || (i < (kidsOfChain.length - 1) / 2 ? -1 : 1)
const snap = { tabsBefore: tabs.length, externalBefore: new Set(externalEdges) }
const b = buildBlock(kc, unionId, side)
snap.tabsAfter = tabs.length; snap.externalAfter = new Set(externalEdges)
childBlocks.push({ ...b, unionId, kid, side, snap })
childBlocks.push({ ...b, unionId, kid, side })
})
// Un enfant dont le conjoint amène sa famille va au bout de la fratrie, de son côté : sa belle-famille,
// qui fait partie de son bloc, se retrouve au bord, sans survoler ses frères et sœurs. L'ordre de
@@ -392,20 +339,6 @@ function layoutOnce(graphData, referenceId, forcedSide, familyTabs = true) {
const i = kids.findIndex(b => b.kid != null && anchor.path.has(chainOf.get(b.kid)))
if (i >= 0) { const [b] = kids.splice(i, 1); if (anchor.end === 'right') kids.push(b); else kids.unshift(b) }
}
// Règle du bout (Patch, 19/10 soir) : seule une belle-famille au bout de la fratrie est dessinée entière.
// Un enfant marié au milieu de sa fratrie est rebâti avec les seuls parents de son conjoint au-dessus de
// lui, le reste de la belle-famille devient un onglet. Ainsi aucun fil ne passe sous une autre famille.
const middle = kids.map((b, i) => ({ b, i })).filter(({ b, i }) => i > 0 && i < kids.length - 1 && !b.ghost && b.attachBig)
middle.sort((x, y) => y.b.snap.tabsBefore - x.b.snap.tabsBefore)
for (const { b, i } of middle) {
forgetTabs(b.snap.tabsBefore, b.snap.tabsAfter)
unplace(b.members)
forgetExternal(b.snap.externalBefore, b.snap.externalAfter)
const snap = { tabsBefore: tabs.length, externalBefore: new Set(externalEdges) }
const nb = buildBlock(chainOf.get(b.kid), b.unionId, b.side, true)
snap.tabsAfter = tabs.length; snap.externalAfter = new Set(externalEdges)
kids[i] = { ...nb, unionId: b.unionId, kid: b.kid, side: b.side, snap }
}
childBlocks.length = 0
childBlocks.push(...kids)
const packed = packBlocks(childBlocks, FAMILY_GAP)
@@ -417,35 +350,6 @@ function layoutOnce(graphData, referenceId, forcedSide, familyTabs = true) {
// déjà dessinée (reliée deux fois) ne l'est pas une seconde fois : la filiation reste externe, renvoi si
// son fil croise une autre famille.
const built = []
// Réduire une belle-famille à ses seuls parents : frères, sœurs et ancêtres du conjoint deviennent un onglet
// dont les parents sont les charnières (badge sur eux), puis les parents seuls sont rebâtis au-dessus du conjoint
function reduceToParents(entry) {
const { block, anchorId: id, side, pu, pc, snap } = entry
// Arbre dessiné en entier (onglets par nom, ou pas d'onglets) : personne n'est caché derrière une pastille
if (!familyTabs) return block
const pcPersons = pc.filter(x => byId.get(x)._type === 'person')
const keep = new Set(pcPersons)
const extra = [...block.members].filter(m => byId.get(m)?._type === 'person' && !keep.has(m) && chainOf.get(m) !== pc)
// Une famille de FAMILY_TAB_MIN personnes ou moins reste dessinée : pas d'onglet pour si peu (Patch, 20/10)
if (extra.length <= FAMILY_TAB_MIN) return block
const nested = forgetTabs(snap.tabsBefore, snap.tabsAfter)
unplace(block.members)
forgetExternal(snap.externalBefore, snap.externalAfter)
const A = byId.get(id)
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
anchor = { kid: id, end: side < 0 ? 'right' : 'left', path: pathOf(id) }
snap.tabsBefore = tabs.length
snap.externalBefore = new Set(externalEdges)
const nb = buildBlock(pc, null, side)
snap.tabsAfter = tabs.length
snap.externalAfter = new Set(externalEdges)
anchor = saved
return nb
}
// L'enfant de la lignée dans cette chaîne (celui dont les parents sont ownerUnion), s'il est seul
const kidsHere = chain.filter(id => byId.get(id)._type === 'person' && ownerUnion && parentUnionOf.get(id) === ownerUnion)
const kidOfChain = kidsHere.length === 1 ? kidsHere[0] : null
@@ -457,65 +361,14 @@ function layoutOnce(graphData, referenceId, forcedSide, familyTabs = true) {
if (placed.has(pc)) continue
const top = topChainOf(pc)
if (placed.has(top)) continue
const owner = tabOfChain.get(top) || tabOfChain.get(pc)
if (owner) {
// Sa famille est déjà un onglet (un autre conjoint l'a amenée) : même badge, même onglet
const A = byId.get(id)
A._familyTab = { anchorId: owner.anchorId, label: owner.label, count: owner.count, tabKey: owner.anchorId.replace(/^p-/, ''), place: 'above' }
owner.hinges.push(id)
owner.hingeEdges.push([pu, id])
continue
}
// Le côté extérieur de la branche prime : une branche au bout de sa fratrie pose ses belles-familles au-delà,
// jamais dans la fratrie des aînés (Patch, 20/10 : la branche de Paul à droite d'Eugénie)
const side = (chain.length === 3 && outer) ? outer : (forcedSide.get(id) ?? (chain.length > 3 ? (chain.indexOf(id) < chain.length / 2 ? -1 : 1) : (chain.indexOf(id) < chain.length / 2 ? -1 : 1)))
const saved = anchor
const tabsBefore = tabs.length
const externalBefore = new Set(externalEdges)
anchor = { kid: id, end: side < 0 ? 'right' : 'left', path: pathOf(id) }
let block = buildBlock(top, null, side)
const block = buildBlock(top, null, side)
anchor = saved
if (block.width > FAMILY_TAB_PX && !familyTabs) {
const A = byId.get(id)
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
// laisse aucune trace (onglets imbriqués, chaînes posées, arêtes externes), et le fil de ses parents
// vers le conjoint est caché : c'est le badge du conjoint qui dit où est sa famille.
// Les onglets nés pendant cette construction (familles emboîtées) rejoignent celle-ci : son onglet les
// retrouvera en se dessinant, avec ses propres onglets
const nested = forgetTabs(tabsBefore, tabs.length)
unplace(block.members)
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 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
}
const entry = { block, anchorId: id, side, pu, pc, snap: { tabsBefore, tabsAfter: tabs.length, externalBefore, externalAfter: new Set(externalEdges) } }
// Une belle-famille collée ne déborde pas, au-dessus de la rangée des parents du conjoint, de la place de
// ces parents : des ancêtres plus larges grimperaient dans les rangées des aînés de la lignée et s'y
// glisseraient entre deux frères (Patch, 20/10, Marc et Sophie entre Anaïs et Germaine). Les parents
// restent, le reste devient un onglet. Des ancêtres en colonne au-dessus des parents restent dessinés.
// Une toute petite famille (jusqu'à FAMILY_INLINE_MAX personnes au-delà des parents) reste dessinée,
// à condition que ce qui est au-dessus des parents tienne dans leur largeur (une colonne d'ancêtres),
// sinon elle déborderait dans les rangées des aînés (Paul et ses frères dans la fratrie de Catherine)
const deep = [...block.rows.keys()].some(g => g < gen - 1)
const beyondParents = deep ? [...block.members].filter(m => byId.get(m)?._type === 'person' && chainOf.get(m) !== pc).length : 0
// Au-dessus des parents, seuls les ancêtres en ligne directe du conjoint sont admis : des oncles ou tantes
// élargiraient ces rangées et la famille se glisserait dans la fratrie des aînés de la lignée
const ancestors = new Set()
for (const stack = [id]; stack.length;) {
const u = parentUnionOf.get(stack.pop())
if (!u) continue
for (const k of [byId.get(u)?._data?.p1Key, byId.get(u)?._data?.p2Key]) if (k && !ancestors.has(k)) { ancestors.add(k); stack.push(k) }
}
const collaterals = deep && [...block.members].some(m => { const n = byId.get(m); return n?._type === 'person' && n._generation < gen - 1 && !ancestors.has(m) })
if (parentsOnly || (deep && (beyondParents > FAMILY_INLINE_MAX || collaterals))) entry.block = reduceToParents(entry)
const entry = { block, anchorId: id, side, pu, pc }
built.push(entry)
orient(chain, kidOfChain ?? (chain.length === 3 ? (chain[0] === id ? chain[2] : chain[0]) : null), side)
}
@@ -641,14 +494,7 @@ function layoutOnce(graphData, referenceId, forcedSide, familyTabs = true) {
}
return res
}
let result = placeBest()
// 3) Une belle-famille qui ne se colle pas à son enfant (poussée de plus de FAMILY_FIT_PX) se réduit aux
// parents du conjoint, le reste devient un onglet, et tout se pose à nouveau
const tooFar = result.list.map((a, i) => ({ a, p: built[i] })).filter(({ a, p }) => Math.abs(a.x - a.ideal) > FAMILY_FIT_PX && p.block.rows.size > 1)
if (tooFar.length) {
for (const { p } of [...tooFar].sort((u, v) => v.p.snap.tabsBefore - u.p.snap.tabsBefore)) p.block = reduceToParents(p)
result = placeBest()
}
const result = placeBest()
rows.clear()
for (const [g, l] of result.rows) rows.set(g, l)
const attachments = result.list.map(a => ({ block: a.block, x: a.x }))
@@ -656,8 +502,6 @@ function layoutOnce(graphData, referenceId, forcedSide, familyTabs = true) {
attachedEdges.add(`${parentUnionOf.get(a.anchorId)}>${a.anchorId}`)
}
const attachSide = built.length ? (built.every(p => p.side < 0) ? -1 : built.every(p => p.side > 0) ? 1 : 0) : 0
// Une belle-famille attachée qui dépasse les seuls parents (frères et sœurs ou ancêtres du conjoint)
const attachBig = attachments.some(a => a.block.rows.size > 1)
for (const p of built) inlaws.push({ anchorId: p.anchorId, side: p.side })
const members = new Set(chain)
@@ -690,7 +534,7 @@ function layoutOnce(graphData, referenceId, forcedSide, familyTabs = true) {
return null
}
return {
rows, members, external, gen, rel, attachSide, attachBig,
rows, members, external, gen, rel, attachSide,
chain: [rowX, rowX + rowW],
width: extentOf(rows)[1],
place(x0) {
@@ -729,27 +573,11 @@ function layoutOnce(graphData, referenceId, forcedSide, familyTabs = true) {
(isBloodChain(b) - isBloodChain(a)) || countDesc(b) - countDesc(a) || genOf(a) - genOf(b) || birthYearOf(a) - birthYearOf(b) || String(a[0]).localeCompare(String(b[0]))
))
const free = []
for (const r of roots) if (!placed.has(r) && !hiddenChains.has(r)) free.push(buildBlock(r))
for (const c of chains) if (!placed.has(c) && !hiddenChains.has(c)) free.push(buildBlock(c))
for (const r of roots) if (!placed.has(r)) free.push(buildBlock(r))
for (const c of chains) if (!placed.has(c)) free.push(buildBlock(c))
free.push(...detached)
const forest = packBlocks(free, FAMILY_GAP * 2)
free.forEach((b, i) => b.place(forest.xs[i]))
const referenceNode = result.referenceId != null ? byId.get(`p-${result.referenceId}`) : null
// Le fil des parents d'un conjoint dont la famille est un onglet n'est pas dessiné : son badge dit où elle est
for (const tb of tabs) for (const [pu, id] of tb.hingeEdges) for (const e of result.edges) if (e.sources[0] === pu && e.targets[0] === id) e._hidden = true
// Les familles devenues onglets sortent de cet arbre : leurs nœuds et leurs arêtes ne sont pas dessinés
if (hiddenChains.size) {
const hidden = new Set([...hiddenChains].flatMap(ch => ch))
for (const n of nodes) if (n._type === 'unknown' && hidden.has(n.id.replace(/^unknown-/, 'p-'))) hidden.add(n.id)
result.children = nodes.filter(n => !hidden.has(n.id))
// Un onglet se propose depuis le badge d'un conjoint dessiné : ceux dont le conjoint est caché avec sa famille tombent
for (let i = tabs.length - 1; i >= 0; i--) if (hidden.has(tabs[i].anchorId)) tabs.splice(i, 1)
for (const tb of tabs) tb.hinges = tb.hinges.filter(h => !hidden.has(h))
result.edges = result.edges.filter(e => !hidden.has(e.sources[0]) && !hidden.has(e.targets[0]))
nodes.length = 0
nodes.push(...result.children)
}
// Réinjecter le X, normalisé à 0 ; Y inchangé (rangées)
let minX = Infinity
for (const n of nodes) if (pos.has(n.id)) minX = Math.min(minX, pos.get(n.id))
@@ -830,14 +658,6 @@ function layoutOnce(graphData, referenceId, forcedSide, familyTabs = true) {
for (const r of list) byId.get(r.id)._railLift = Math.min(4, liftOf(r.id))
}
// Les familles de cet arbre : la lignée dessinée, puis chaque belle-famille devenue onglet. Galaxy en fait
// 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: 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 })),
]
result._inlaws = inlaws
return result
@@ -20,10 +20,9 @@ describe('dessin complet, sans onglets par largeur', () => {
it(`${kind} graine ${seed}`, () => {
const tree = generate(kind, seed)
const { graph } = tree
const result = computeBlockLayout(graph, tree.rootPersonId, { familyTabs: false })
const result = computeBlockLayout(graph, tree.rootPersonId)
const people = result.children.filter((n) => n._type === 'person')
expect(people.length).toBe(graph.persons.length)
expect(people.filter((n) => n._familyTab).length).toBe(0)
expect(overlaps(people)).toBe(0)
})
}
@@ -53,9 +52,8 @@ function belleFamille() {
describe('dessin complet, grande belle-famille', () => {
it('tout le monde est dessiné, sans pastille', () => {
const graph = belleFamille()
const result = computeBlockLayout(graph, 'henri', { familyTabs: false })
const result = computeBlockLayout(graph, 'henri')
const people = result.children.filter((n) => n._type === 'person')
expect(people.filter((n) => n._familyTab).length).toBe(0)
expect(people.length).toBe(graph.persons.length)
expect(overlaps(people)).toBe(0)
})
+1 -1
View File
@@ -852,7 +852,7 @@ export function computeLayout(graphData = null, referenceId = null) {
export function generateNodeRandomData(children, edges = []) {
const data = new Map()
// Enfant d'un renvoi : la pastille « Voir les parents » occupe le haut du portrait, l'orbite reste sur les côtés
const renvoiChildren = new Set([...edges.filter(e => e._renvoi).map(e => e.targets[0]), ...children.filter(n => n._familyTab).map(n => n.id)])
const renvoiChildren = new Set(edges.filter(e => e._renvoi).map(e => e.targets[0]))
const deg = (d) => d * Math.PI / 180
// Partenaire d'un conjoint isolé : ses souvenirs en orbite passent du côté opposé au petit portrait
const compactSide = new Map()
@@ -206,10 +206,7 @@ export function drawCoupleLinks(ctx, coupleBarMeta, posMap, nodeMap, nodeRandomD
// useGalaxyInteractions.
// ============================================================
export const renvoiHitAreas = []
// Badges « Famille X » sous les conjoints dont la famille est un onglet : { x0, y0, x1, y1, anchorId }
export const familyHitAreas = []
const renvoiPills = []
const familyPills = [] // badges « Famille X » : { text, cx, cy, anchorId, tabKey, alpha }
const RENVOI_FONT = '500 11px "DM Sans", system-ui, sans-serif'
// Pastilles collées aux personnes : juste sous le couple, juste au-dessus du portrait de l'enfant
const RENVOI_PARENT_DY = 38
@@ -242,11 +239,6 @@ function drawRenvoiPill(ctx, { text, cx, cy, target, alpha }) {
if (alpha > 0.5) renvoiHitAreas.push({ x0: cx - w / 2, y0: cy - h / 2, x1: cx + w / 2, y1: cy + h / 2, ...target })
}
function drawFamilyPill(ctx, { text, cx, cy, anchorId, tabKey, alpha }) {
const { w, h } = drawPillShape(ctx, text, cx, cy, alpha)
if (alpha > 0.5) familyHitAreas.push({ x0: cx - w / 2, y0: cy - h / 2, x1: cx + w / 2, y1: cy + h / 2, anchorId, tabKey })
}
function drawRenvoi(ctx, edge, sourcePos, targetPos, targetNode, childR, view) {
const scale = view?.scale ?? 1
const labelAlpha = Math.min(1, Math.max(0, (scale - LOD_LABEL_FADE_OUT) / (LOD_LABEL_FADE_IN - LOD_LABEL_FADE_OUT)))
@@ -268,7 +260,6 @@ function drawRenvoi(ctx, edge, sourcePos, targetPos, targetNode, childR, view) {
// Après drawNodes : les pastilles passent au-dessus des portraits et des souvenirs
export function drawRenvoiPills(ctx) {
for (const p of familyPills) drawFamilyPill(ctx, p)
renvoiPills.forEach(p => drawRenvoiPill(ctx, p))
renvoiPills.length = 0
}
@@ -279,8 +270,6 @@ export function drawRenvoiPills(ctx) {
export function drawFiliations(ctx, edges, posMap, nodeMap, nodeRandomData, entrance, entranceActive, elapsed, view = null) {
renvoiHitAreas.length = 0
renvoiPills.length = 0
familyPills.length = 0
familyHitAreas.length = 0
if (!edges) return
const sibships = new Map()
@@ -691,19 +680,6 @@ function drawPersonNode(ctx, node, pos, nodeProgress, {
}
ctx.restore()
// Sa famille est un onglet : une pastille le dit, un clic l'ouvre. Au-dessus du portrait, à la place de ses
// parents (l'orbite des souvenirs évite le haut). Même place pour le retour vers la famille d'où l'on vient :
// 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
// 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,10 +1,9 @@
import { useRef, useCallback } from 'react'
import { getPersonMedias } from '../../data/mockData'
import { MAX_ORBIT_MEDIAS, ORBIT_MEDIA_SIZE } from './constants'
import { nodeRadius, renvoiHitAreas, familyHitAreas } from './renderers'
import { nodeRadius, renvoiHitAreas } from './renderers'
const hitRenvoi = (x, y) => renvoiHitAreas.find(a => x >= a.x0 && x <= a.x1 && y >= a.y0 && y <= a.y1)
const hitFamily = (x, y) => familyHitAreas.find(a => x >= a.x0 && x <= a.x1 && y >= a.y0 && y <= a.y1)
import { getEntranceProgress } from './utils'
const CURSOR_GRAB = 'default'
@@ -22,7 +21,7 @@ export function useGalaxyInteractions({
canvasRef, transformRef, targetTransformRef,
layoutDataRef, nodeRandomDataRef, entranceRef,
hoveredNodeIdRef, hoveredMediaKeyRef, mouseWorldPosRef,
getAnimatedPos, onPersonSelect, onMediaSelect, onOpenFamily = null,
getAnimatedPos, onPersonSelect, onMediaSelect,
annotationHandlers,
minScale = 0.2,
maxScale = 4,
@@ -138,7 +137,7 @@ export function useGalaxyInteractions({
break
}
}
if (!isOverClickable && (hitRenvoi(mouseWorldPosRef.current.x, mouseWorldPosRef.current.y) || hitFamily(mouseWorldPosRef.current.x, mouseWorldPosRef.current.y))) isOverClickable = true
if (!isOverClickable && (hitRenvoi(mouseWorldPosRef.current.x, mouseWorldPosRef.current.y))) isOverClickable = true
hoveredNodeIdRef.current = newHoveredNode
hoveredMediaKeyRef.current = newHoveredMedia
@@ -277,10 +276,6 @@ export function useGalaxyInteractions({
}
}
// Badge de famille : ouvre l'onglet de cette famille
const fam = hitFamily(clickX, clickY)
if (fam) { onOpenFamily?.(fam.tabKey); return }
// Renvoi : on glisse vers l'autre extrémité (parents ou enfant), sans dézoomer en dessous de la lecture des prénoms
const renvoi = hitRenvoi(clickX, clickY)
if (renvoi) {
@@ -292,7 +287,7 @@ export function useGalaxyInteractions({
y: rect2.height / 2 - renvoi.targetY * scale,
}
}
}, [canvasRef, transformRef, targetTransformRef, layoutDataRef, nodeRandomDataRef, entranceRef, getAnimatedPos, onPersonSelect, onMediaSelect, onOpenFamily, annotationHandlers])
}, [canvasRef, transformRef, targetTransformRef, layoutDataRef, nodeRandomDataRef, entranceRef, getAnimatedPos, onPersonSelect, onMediaSelect, annotationHandlers])
return {
isDragging, isZooming, zoomTimeoutRef, hasDragged,