diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 8a3b67e..2bb08d8 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -508,6 +508,9 @@ function App() { }) const [adminPanelVisible, setAdminPanelVisible] = useState(false) + // Grandes belles-familles dans des onglets (réglage de ce navigateur, voir Paramètres de l'arbre) + const [familyTabs, setFamilyTabsState] = useState(() => { try { return localStorage.getItem('parenthese.familyTabs') !== 'off' } catch { return true } }) + const setFamilyTabs = useCallback((on) => { setFamilyTabsState(on); try { localStorage.setItem('parenthese.familyTabs', on ? 'on' : 'off') } catch { /* navigateur sans stockage */ } }, []) // Premiers visages : fenêtre ouverte après un import GEDCOM, ou depuis les paramètres de l'arbre const [photoStart, setPhotoStart] = useState({ visible: false, personsImported: null, refs: [], mediaRefs: [], faces: [], bundledFiles: [] }) const photoStartDirtyRef = useRef(false) @@ -3183,6 +3186,7 @@ function App() { onPhotoPlaceholderClick={tree.canEditCurrentTree ? handlePhotoPlaceholderClick : null} reorderEnabled={canReorderSiblings} onReorderSiblings={handleReorderSiblings} + familyTabs={familyTabs} /> {tree.canEditCurrentTree && ( @@ -3296,6 +3300,8 @@ function App() { onGedcomImported={handleGedcomImported} onOpenPhotoStart={() => { setAdminPanelVisible(false); openPhotoStart() }} onClose={() => setAdminPanelVisible(false)} + familyTabs={familyTabs} + onFamilyTabsChange={setFamilyTabs} /> + +
+ {t('Affichage')} + +
)} diff --git a/frontend/src/components/Galaxy.css b/frontend/src/components/Galaxy.css index dd9c103..e08c47c 100644 --- a/frontend/src/components/Galaxy.css +++ b/frontend/src/components/Galaxy.css @@ -43,3 +43,71 @@ font-size: 13px; } } + +/* ============================================================ + Onglets de familles : un onglet par famille, en haut à gauche + ============================================================ */ + +.family-tabs { + position: fixed; + top: 16px; + left: 16px; + z-index: 500; + display: flex; + gap: 6px; + max-width: calc(100vw - 120px); + overflow-x: auto; + scrollbar-width: none; + padding: 4px; +} +.family-tabs::-webkit-scrollbar { display: none; } +/* Bords coupés : un fondu de chaque côté qui défile encore */ +.family-tabs.has-left { mask-image: linear-gradient(to right, transparent, #000 36px); -webkit-mask-image: linear-gradient(to right, transparent, #000 36px); } +.family-tabs.has-right { mask-image: linear-gradient(to left, transparent, #000 36px); -webkit-mask-image: linear-gradient(to left, transparent, #000 36px); } +.family-tabs.has-left.has-right { mask-image: linear-gradient(to right, transparent, #000 36px, #000 calc(100% - 36px), transparent); -webkit-mask-image: linear-gradient(to right, transparent, #000 36px, #000 calc(100% - 36px), transparent); } + +.family-tab { + flex: 0 0 auto; + display: inline-flex; + align-items: baseline; + gap: 7px; + padding: 7px 14px 8px; + border-radius: 999px; + border: 1px solid #EBE6DF; + background: #FFFFFF; + color: #2A2622; + font-family: "Newsreader", Georgia, serif; + font-size: 15px; + line-height: 1; + cursor: pointer; + white-space: nowrap; +} +.family-tab:hover { border-color: #D9CFC6; } +.family-tab:focus-visible { outline: 2px solid #C9553D; outline-offset: 2px; } +.family-tab.is-active { background: #2A2622; border-color: #2A2622; color: #FFFFFF; } +.family-tab-count { + font-family: "DM Sans", system-ui, sans-serif; + font-size: 11px; + color: #93402A; +} +.family-tab.is-active .family-tab-count { color: #EDB6A4; } + +/* En mode modification, le message « Vous proposez des changements » occupe le haut au centre : la barre + se limite à la moitié gauche sur grand écran et descend sous le message sur téléphone */ +.family-tabs--lowered { max-width: calc(50vw - 200px); } + +/* Fiche ouverte sur grand écran (à gauche, 384 px depuis 24 px) : la barre passe à sa droite */ +@media (min-width: 769px) { + body:has(.person-card) .family-tabs { left: 424px; max-width: calc(100vw - 424px - 96px); } + /* Recherche ouverte (environ 540 px à droite) : la barre s'arrête avant elle et se fond au bord */ + body:has(.expandable-search.expanded) .family-tabs { max-width: calc(100vw - 16px - 560px); } + body:has(.person-card):has(.expandable-search.expanded) .family-tabs { max-width: calc(100vw - 424px - 560px); } +} + +@media (max-width: 768px) { + .family-tabs { top: 10px; left: 10px; max-width: calc(100vw - 84px); } + .family-tabs--lowered { top: 64px; max-width: calc(100vw - 20px); } + /* Recherche ouverte sur téléphone : elle prend toute la largeur du haut, la barre passe dessous */ + body:has(.expandable-search.expanded) .family-tabs { top: 80px; max-width: calc(100vw - 20px); } + .family-tab { font-size: 14px; padding: 6px 12px 7px; } +} diff --git a/frontend/src/components/Galaxy.jsx b/frontend/src/components/Galaxy.jsx index 4d37ed6..0cbf709 100644 --- a/frontend/src/components/Galaxy.jsx +++ b/frontend/src/components/Galaxy.jsx @@ -3,7 +3,7 @@ import { getPersonMedias, getParents, getChildren, getPersonUnions, persons as g import { PERSON_R, FLOAT_SPEED, FLOAT_AMPLITUDE, REPULSION_RADIUS, REPULSION_STRENGTH, MAX_ORBIT_MEDIAS, ENTRANCE_GEN_DELAY, ENTRANCE_NODE_DURATION, ENTRANCE_NODE_STAGGER, ENTRANCE_MAX_GEN_SPAN, ENTRANCE_MAX_NODE_SPAN, ENTRANCE_LINE_DELAY, ENTRANCE_LINE_DURATION, ENTRANCE_ORBIT_DELAY, ENTRANCE_ORBIT_DURATION, ENTRANCE_ORBIT_STAGGER } from './galaxy/constants' import { easeOutCubic } from './galaxy/utils' import { computeLayout, generateNodeRandomData, coupleBarMeta } from './galaxy/elkLayout' -import { computeBlockLayout } from './galaxy/blockLayout' +import { computeBlockLayout, familyTabsOf, subgraphOf } from './galaxy/blockLayout' // Placement par blocs familiaux par défaut (mesures : tools/layout-bench). ?layout=sugiyama = placement Sugiyama. const USE_BLOCK_LAYOUT = typeof window === 'undefined' || new URLSearchParams(window.location.search).get('layout') !== 'sugiyama' @@ -13,6 +13,7 @@ import { buildSiblingGroups, moveInOrder, pickSlot, rubberBand, springStep, isSe import { annotations as annotationData } from '../data/mockData' import { useImageCache } from './galaxy/useImageCache' import { useGalaxyInteractions } from './galaxy/useGalaxyInteractions' +import { t as tr } from '../i18n/index.js' import './Galaxy.css' // Assez bas pour cadrer un grand arbre entier à l'ouverture @@ -26,6 +27,7 @@ function approach(prev, target, k, eps = 1e-3) { return Math.abs(target - next) < eps ? target : next } const MAX_SCALE = 3.2 +// Carte des familles : l'arbre s'efface entre ces deux échelles, les tuiles de familles apparaissent // 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 @@ -40,16 +42,44 @@ const DROP_SPRING = { stiffness: 480, dampingRatio: 0.72 } const centerX = (node) => node.x + node.width / 2 const prefersReducedMotion = () => typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches -const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlightIds = [], graphRevision = 0, annotationHandlers = null, transformReadRef = null, zoomApiRef = null, focusPersonId = null, assetRevision = 0, photoHint = false, onPhotoPlaceholderClick = null, reorderEnabled = false, onReorderSiblings = null }) => { +const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlightIds = [], graphRevision = 0, annotationHandlers = null, transformReadRef = null, zoomApiRef = null, focusPersonId = null, assetRevision = 0, photoHint = false, onPhotoPlaceholderClick = null, reorderEnabled = false, onReorderSiblings = null, familyTabs = true }) => { const canvasRef = useRef(null) - // Lu au calcul du cadrage seulement : changer de personne de départ ne recadre pas une vue en cours + 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. + const [tabs, setTabs] = useState([]) + const [activeKey, setActiveKey] = useState(focusPersonId != null ? String(focusPersonId) : null) + const tabsCacheRef = useRef({ rev: -1, start: undefined, tabs: [] }) + const pendingFocusRef = useRef(null) // personne cherchée dans un autre onglet, visée après le changement + const activeTabRef = useRef(null) + const familyTabsRef = useRef(familyTabs) + useEffect(() => { familyTabsRef.current = familyTabs }, [familyTabs]) + const startRefRef = useRef(focusPersonId) + useEffect(() => { startRefRef.current = focusPersonId; setActiveKey(focusPersonId != null ? String(focusPersonId) : null) }, [focusPersonId]) + // Lu au calcul du cadrage et par le réordonnancement : la référence de l'arbre affiché const focusPersonIdRef = useRef(focusPersonId) - // Mis à jour hors du rendu, avant l'effet de cadrage déclaré plus bas (même phase, ordre de déclaration) - useEffect(() => { focusPersonIdRef.current = focusPersonId }, [focusPersonId]) + // La barre défile quand les onglets débordent : l'onglet ouvert se met en vue (sans animation) et les bords + // coupés se signalent par un fondu + const tabsBarRef = useRef(null) + const [tabsEdges, setTabsEdges] = useState('') + const updateTabsEdges = useCallback(() => { + const el = tabsBarRef.current + if (!el) return + const left = el.scrollLeft > 12, right = el.scrollLeft + el.clientWidth < el.scrollWidth - 12 + setTabsEdges(`${left ? ' has-left' : ''}${right ? ' has-right' : ''}`) + }, []) + useEffect(() => { + document.querySelector('.family-tab.is-active')?.scrollIntoView({ behavior: 'auto', inline: 'nearest', block: 'nearest' }) + updateTabsEdges() + window.addEventListener('resize', updateTabsEdges) + 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) setActiveKey(String(tabKey)) }, []) // Propriétaire : un portrait vide survolé montre « + photo » const photoHintRef = useRef(photoHint) useEffect(() => { photoHintRef.current = photoHint }, [photoHint]) - const layoutDataRef = useRef(null) const [layoutReady, setLayoutReady] = useState(false) const transformRef = useRef({ x: 0, y: 0, scale: 1 }) @@ -139,7 +169,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig canvasRef, transformRef, targetTransformRef, layoutDataRef, nodeRandomDataRef, entranceRef, hoveredNodeIdRef: hoveredNodeId, hoveredMediaKeyRef: hoveredMediaKey, mouseWorldPosRef: mouseWorldPos, - getAnimatedPos, onPersonSelect, onMediaSelect, onPhotoPlaceholderClick, + getAnimatedPos, onPersonSelect, onMediaSelect, onPhotoPlaceholderClick, onOpenFamily: openFamily, annotationHandlers, minScale: MIN_SCALE, maxScale: MAX_SCALE, @@ -184,12 +214,14 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig // Placement complet pour un ordre de la fratrie, recalé sur la source (les parents ne bougent pas) const layoutForOrder = useCallback((group, order, base) => { + const tab = activeTabRef.current + const src = tab?.personIds ? subgraphOf(tab.personIds) : { persons: graphPersons, unions: graphUnions, filiations: graphFiliations } const graph = { - persons: graphPersons, - unions: graphUnions, - filiations: filiationsWithOrder(graphFiliations, graphUnions, group.source, order), + persons: src.persons, + unions: src.unions, + filiations: filiationsWithOrder(src.filiations, src.unions, group.source, order), } - const result = computeBlockLayout(graph, focusPersonIdRef.current) + const result = computeBlockLayout(graph, focusPersonIdRef.current, { familyTabs: familyTabsRef.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 @@ -375,6 +407,14 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig focusPerson: (personId) => { const canvas = canvasRef.current const node = layoutDataRef.current?.children.find(n => n.id === `p-${personId}`) + // Rangée dans un autre onglet : on ouvre cet onglet, la personne est visée une fois l'arbre posé + 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] + if (home && home.key !== activeTabRef.current?.key) { pendingFocusRef.current = id; setActiveKey(home.key) } + return + } if (!canvas || !node) return const rect = canvas.getBoundingClientRect() const pos = getAnimatedPos(node, performance.now()) @@ -428,8 +468,35 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig // Layout useEffect(() => { - // La personne de départ sert de référence aux rangées (génération de parenté depuis elle) - const result = USE_BLOCK_LAYOUT ? computeBlockLayout(null, focusPersonIdRef.current) : computeLayout(null, focusPersonIdRef.current) + // Onglets : la liste des familles, une fois par version du graphe et par personne de départ. Calculée + // avant l'arbre affiché, parce que chaque calcul de placement remplit les couples du module (coupleBarMeta) + const start = startRefRef.current != null ? String(startRefRef.current) : null + const cache = tabsCacheRef.current + if (USE_BLOCK_LAYOUT && familyTabs && (cache.rev !== graphRevision || cache.start !== start)) { + tabsCacheRef.current = { rev: graphRevision, start, tabs: familyTabsOf(null, startRefRef.current) } + setTabs(tabsCacheRef.current.tabs) + } + if (!familyTabs && tabsCacheRef.current.tabs.length) { tabsCacheRef.current = { rev: -1, start: undefined, tabs: [] }; setTabs([]) } + const tab = familyTabs ? (tabsCacheRef.current.tabs.find((tb) => tb.key === activeKey) || tabsCacheRef.current.tabs[0] || null) : null + activeTabRef.current = tab + // La personne de départ (ou la charnière de l'onglet) sert de référence aux rangées + focusPersonIdRef.current = tab ? tab.referenceId : startRefRef.current + const graph = tab?.personIds ? subgraphOf(tab.personIds) : null + const result = USE_BLOCK_LAYOUT ? computeBlockLayout(graph, focusPersonIdRef.current, { familyTabs }) : computeLayout(null, focusPersonIdRef.current) + // Les pastilles portent le nom de l'onglet où elles mènent (prénoms ajoutés aux homonymes) + for (const n of result.children) { + const target = n._familyTab && tabsCacheRef.current.tabs.find((tb) => tb.key === n._familyTab.tabKey) + if (target) n._familyTab = { ...n._familyTab, label: target.label, count: target.count } + } + // Dans l'onglet d'une belle-famille, chaque charnière (conjoint marié dans la famille d'où l'on vient) + // porte le badge de retour + if (tab?.parentKey) { + 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' } + } + } const canvas = canvasRef.current if (!canvas) return @@ -476,7 +543,8 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig if (focusNode && fitScale < FOCUS_MIN_READABLE_SCALE) { scale = FOCUS_SCALE offsetX = cssW / 2 - (focusNode.x + focusNode.width / 2) * scale - offsetY = cssH * 0.42 - (focusNode.y + focusNode.height / 2) * scale + // Sur un écran étroit, la barre des familles couvre le haut : la personne de départ un peu plus bas + offsetY = cssH * (cssW < 768 ? 0.56 : 0.42) - (focusNode.y + focusNode.height / 2) * scale } const init = { x: offsetX, y: offsetY, scale } @@ -559,7 +627,12 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig layoutDataRef.current = result needsRedrawRef.current = true setLayoutReady(true) - }, [graphRevision]) + if (pendingFocusRef.current) { + const id = pendingFocusRef.current + pendingFocusRef.current = null + requestAnimationFrame(() => zoomApiRef?.current?.focusPerson?.(id)) + } + }, [graphRevision, activeKey, familyTabs]) // Sync selectedPersonId useEffect(() => { @@ -1035,6 +1108,25 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig {displayLabel && (
{displayLabel}
)} + {familyTabs && tabs.length > 1 && ( + + )} ) } diff --git a/frontend/src/components/galaxy/__snapshots__/archetypes/belle-famille.txt b/frontend/src/components/galaxy/__snapshots__/archetypes/belle-famille.txt index bcfedfa..7eac907 100644 --- a/frontend/src/components/galaxy/__snapshots__/archetypes/belle-famille.txt +++ b/frontend/src/components/galaxy/__snapshots__/archetypes/belle-famille.txt @@ -1,13 +1,13 @@ - SG────•───SGw - │ - ┬────┴ + SG────•───SGw + │ + ┬────┴ + │ + SP────•───SM Pa────•───Ma + │ │ + ┬────────┼────────┬ ┼────────┬ + │ │ │ │ │ +S2 S3 S────•────E E2 │ -Pa────•───Ma SP────•───SM - │ │ - ┼────────┬ ┬───┴────┬────────┬ - │ │ │ │ │ - E2 E────•────S S2 S3 - │ - ┬───┴────┬ - │ │ - K1 K2 + ┬───┴────┬ + │ │ + K1 K2 diff --git a/frontend/src/components/galaxy/__snapshots__/archetypes/branche-courte.txt b/frontend/src/components/galaxy/__snapshots__/archetypes/branche-courte.txt index 72b4313..ca6f5cf 100644 --- a/frontend/src/components/galaxy/__snapshots__/archetypes/branche-courte.txt +++ b/frontend/src/components/galaxy/__snapshots__/archetypes/branche-courte.txt @@ -10,12 +10,12 @@ A2────•───A2w │ ┬────┴ │ -A3────•───A3w B0────•───B0w - │ │ - ┬────┴ ┬──────────────┼ - │ │ │ - X────•────Y S - │ - ┼ - │ - K +A3────•───A3w B0────•───B0w + │ │ + ┴─┬ ┬──────┴────┬ + │ │ │ + X────•────Y S + │ + ┼ + │ + K diff --git a/frontend/src/components/galaxy/__snapshots__/archetypes/cousins.txt b/frontend/src/components/galaxy/__snapshots__/archetypes/cousins.txt index 87ee330..5fa96fd 100644 --- a/frontend/src/components/galaxy/__snapshots__/archetypes/cousins.txt +++ b/frontend/src/components/galaxy/__snapshots__/archetypes/cousins.txt @@ -3,11 +3,12 @@ ┬──────────────┴───┬ │ │ P1────•───P1w P2────•───P2w - │ │ - ┬────┴ ┬─────────────┴ - │ │ + │ + ┬────┴ + │ C1────•───C2 │ ┼ │ K +renvoi : P2+P2w → C2 diff --git a/frontend/src/components/galaxy/__snapshots__/archetypes/issue-67.txt b/frontend/src/components/galaxy/__snapshots__/archetypes/issue-67.txt index e677fb6..a2207d7 100644 --- a/frontend/src/components/galaxy/__snapshots__/archetypes/issue-67.txt +++ b/frontend/src/components/galaxy/__snapshots__/archetypes/issue-67.txt @@ -2,16 +2,16 @@ GGGa───•──GGGb │ ┬────┴ │ - GGa───•───GGb GGy┄┄┄•┄┄? GGx┄┄┄•┄┄┄? - │ │ │ - ┬────┴ ┬──────┴ ┬────────────┴ - │ │ │ - GM1───•───GF1 GM2───•───GF2 - │ │ - ┬────┴ ┬────────────────┴ - │ │ - M────•────F - │ - ┼ - │ - Kid + GGa───•───GGb GGy┄┄┄•┄┄┄? GGx┄┄┄┄•┄┄? + │ │ │ + ┬┴ ┬┴ ┬──────┴ + │ │ │ + GM1───•───GF1 GM2────•───GF2 + │ │ + ┴─┬ ┬──────┴ + │ │ + M─────•────F + │ + ┼ + │ + Kid diff --git a/frontend/src/components/galaxy/__snapshots__/archetypes/marc-nadia.txt b/frontend/src/components/galaxy/__snapshots__/archetypes/marc-nadia.txt index 641ecfd..800b0fd 100644 --- a/frontend/src/components/galaxy/__snapshots__/archetypes/marc-nadia.txt +++ b/frontend/src/components/galaxy/__snapshots__/archetypes/marc-nadia.txt @@ -1,6 +1,6 @@ - Pierre──•──Odile Louis──•─Helene - │ │ - ┬────────┼────────┬────────┬ ┬────────┬───┴────┬────────┬────────┬ + Pierre───•──Odile Louis──•─Helene + │ │ + ┬────────┬────┴───┬────────┬ ┬────────┬───┴────┬────────┬────────┬ │ │ │ │ │ │ │ │ │ Luc Paul Mireil Nadia──•──Marc Claire Julien Sophie Eric │ diff --git a/frontend/src/components/galaxy/__snapshots__/archetypes/remariage.txt b/frontend/src/components/galaxy/__snapshots__/archetypes/remariage.txt index d81fe83..5443b79 100644 --- a/frontend/src/components/galaxy/__snapshots__/archetypes/remariage.txt +++ b/frontend/src/components/galaxy/__snapshots__/archetypes/remariage.txt @@ -1,5 +1,5 @@ W1────•────H────•───W2 │ │ - ┬───┴────┬ ┴───┬ - │ │ │ - A B C + ┬────┴───┬ ┴──┬ + │ │ │ + A B C diff --git a/frontend/src/components/galaxy/__snapshots__/archetypes/trois-belles-familles.txt b/frontend/src/components/galaxy/__snapshots__/archetypes/trois-belles-familles.txt index a4c984f..922ee73 100644 --- a/frontend/src/components/galaxy/__snapshots__/archetypes/trois-belles-familles.txt +++ b/frontend/src/components/galaxy/__snapshots__/archetypes/trois-belles-familles.txt @@ -1,9 +1,9 @@ -A────•───Aw B────•───Bw C─────•───Cw - │ │ │ - ┼────────┬ ┬────┴───┬ ┬────┴ - │ │ │ │ │ - A2 A1────•───B1 B2────•────C1 - │ │ - ┼ ┼ - │ │ - X Y +A────•───Aw B────•───Bw C────•───Cw + │ │ │ + ┬───┴──────┬ ┬────┴───┬ ┬──────┴ + │ │ │ │ │ +A2 A1────•───B1 B2────•───C1 + │ │ + ┼ ┼ + │ │ + X Y diff --git a/frontend/src/components/galaxy/archetypes.test.js b/frontend/src/components/galaxy/archetypes.test.js index d04b221..52f31aa 100644 --- a/frontend/src/components/galaxy/archetypes.test.js +++ b/frontend/src/components/galaxy/archetypes.test.js @@ -7,19 +7,13 @@ import { checkInvariants, HARD } from '../../../../tools/layout-bench/invariants // Chaque archétype a un rendu texte validé une fois à l'œil (__snapshots__/archetypes/.txt) : il devient // le témoin. Un changement de placement qui le modifie doit être relu et le témoin mis à jour (vitest -u). -// -// Échecs connus, jusqu'à l'étape X du placement (le sang de la référence garde son enfant sous ses parents) : -// la belle-famille, plus nombreuse, revendique le couple la première et la fratrie de la référence se retrouve -// dans le désordre. Quand X est fait, ces cas passent et il faut les retirer d'ici (le test le signale). -const ECHECS_CONNUS = new Set(['belle-famille', 'trois-belles-familles']) describe('archétypes du placement', () => { for (const a of archetypes) { describe(a.slug, () => { const result = computeBlockLayout(a.graph, a.reference) const meta = [...coupleBarMeta] - const check = ECHECS_CONNUS.has(a.slug) ? it.fails : it - check('respecte les invariants', () => { + it('respecte les invariants', () => { const inv = checkInvariants(result, meta, a.graph, a.reference) expect(Object.fromEntries(HARD.map((k) => [k, inv[k]]))).toEqual(Object.fromEntries(HARD.map((k) => [k, 0]))) }) diff --git a/frontend/src/components/galaxy/blockLayout.js b/frontend/src/components/galaxy/blockLayout.js index 65cca81..327b58a 100644 --- a/frontend/src/components/galaxy/blockLayout.js +++ b/frontend/src/components/galaxy/blockLayout.js @@ -1,30 +1,59 @@ import { computeLayout, componentOrderPerGen, chainGap } from './elkLayout' +import { persons as globalPersons, unions as globalUnions, filiations as globalFiliations } from '../../data/mockData' // ============================================================ // Layout par blocs familiaux (Reingold-Tilford adapté aux couples) // // S'appuie sur computeLayout pour tout ce qui n'est pas en cause -// (normalisation des filiations, générations, chaînes de couples, +// (normalisation des filiations, rangées, chaînes de couples, // coupleBarMeta, componentOrderPerGen) et ne recalcule que le X. // -// Un bloc = une chaîne de couples (même génération) + les blocs de -// ses enfants imbriqués en dessous (contours par génération), la -// chaîne centrée au-dessus. Fratries contiguës et parents centrés -// par construction. +// Un bloc = une chaîne de couples (même rangée) + les blocs de ses +// enfants imbriqués en dessous, la chaîne centrée au-dessus. Fratries +// contiguës et parents centrés par construction. // -// Mariage entre deux familles de l'arbre : la chaîne du couple n'est -// placée qu'une fois, sous la première lignée qui la revendique (les -// grandes lignées d'abord). L'autre lignée garde une arête longue. +// Un seul arbre. La famille d'un conjoint (ses parents, sa fratrie, +// ses ancêtres) se colle à lui, au-dessus et de son côté, en poussant +// les voisins pour faire la place : jamais posée dans un trou ailleurs. +// Le conjoint est tourné vers l'extérieur de sa fratrie pour que sa +// famille ne passe pas au-dessus des frères et sœurs. // -// Un bloc racine dont la chaîne a un enfant déjà placé ailleurs -// (beaux-parents) est ancré : centré sur cet enfant, puis décalé au -// créneau libre le plus proche. +// Le sang de la référence gagne : les lignées de la personne de +// référence sont dessinées d'abord, les familles des pièces rapportées +// s'y attachent. Une famille reliée deux fois (cousins qui se marient, +// deux grandes lignées de la référence) n'est dessinée qu'une fois : +// l'autre filiation devient un renvoi si son fil croiserait une famille. // -// Benchmark et variantes écartées : tools/layout-bench/. +// L'ordre ne dépend jamais des effectifs : rangée, année de naissance, +// identifiant. Ajouter une personne ne bouge que son foyer. +// +// Banc, archétypes et témoins : tools/layout-bench/. // ============================================================ const NODE_SPACING = 30 const FAMILY_GAP = 60 +// Au-delà de cet écart entre la place idéale d'une famille de conjoint et la première place libre, on ne la colle pas +// Jamais d'îlot (Patch, 19/10) : une famille collée trop loin garde son fil, aussi long soit-il +const DETACH_PX = Infinity +// Famille reliée deux fois : au-delà de cet écart, la seconde filiation est une pastille, pas un fil +const RENVOI_FAR_PX = 1440 +// Famille de conjoint poussée plus loin que ça : ses parents reviennent juste au-dessus de leur enfant +// Famille de conjoint poussée plus loin que ça par les descendants du couple : la chaîne se décale au bord de sa rangée d'enfants +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 @@ -36,16 +65,74 @@ export function filCroiseAutreFamille(nodes, childrenOfUnion, spouseKeysOf, u, c const fratrie = [...new Set([c, ...(childrenOfUnion.get(u) || [])])] const excluded = new Set([...fratrie, ...fratrie.flatMap(k => [...(spouseKeysOf.get(k) || [])])]) const lo = Math.min(cx(u), cx(c)), hi = Math.max(cx(u), cx(c)) - return nodes.some(n => n._type === 'person' && n._generation === kid._generation && !excluded.has(n.id) + const un = byIdLocal.get(u) + const parents = new Set([un?._data?.p1Key, un?._data?.p2Key].filter(Boolean)) + return nodes.some(n => n._type === 'person' && !excluded.has(n.id) && !parents.has(n.id) + && (n._generation === kid._generation || n._generation === kid._generation - 1) && n.x + n.width > lo && n.x < hi) } -export function computeBlockLayout(graphData = null, referenceId = null) { +// Contours : par rangée, une liste d'intervalles [gauche, droite] triés et fusionnés +function addInterval(list, l, r) { + const out = [] + let nl = l, nr = r, inserted = false + for (const [a, b] of list) { + if (b < nl - 1) { out.push([a, b]); continue } + if (a > nr + 1) { if (!inserted) { out.push([nl, nr]); inserted = true } out.push([a, b]); continue } + nl = Math.min(nl, a); nr = Math.max(nr, b) + } + if (!inserted) out.push([nl, nr]) + return out +} +function mergeRows(target, source, dx) { + for (const [g, list] of source) { + let cur = target.get(g) || [] + for (const [l, r] of list) cur = addInterval(cur, l + dx, r + dx) + target.set(g, cur) + } +} +const extentOf = (rows) => { + let l = Infinity, r = -Infinity + for (const list of rows.values()) for (const [a, b] of list) { l = Math.min(l, a); r = Math.max(r, b) } + return [l, r] +} +const overlaps = (l, r, list, gap) => list.some(([a, b]) => l < b + gap && r > a - gap) + +// 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 } = {}) { + const forced = new Map() + let result = layoutOnce(graphData, referenceId, forced, familyTabs) + // 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 ») + 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 + 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) + } + return result +} + +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])) - // Chaînes de couples (composantes d'une génération) telles que computeLayout les a ordonnées + // Chaînes de couples (composantes d'une rangée) telles que computeLayout les a ordonnées const chainOf = new Map() const chains = [] for (const gen of [...componentOrderPerGen.keys()].sort((a, b) => a - b)) { @@ -72,16 +159,37 @@ export function computeBlockLayout(graphData = null, referenceId = null) { for (const [u, kids] of childrenOfUnion) kids.sort((a, b) => edgeOrder.get(`${u}>${a}`) - edgeOrder.get(`${u}>${b}`)) const hasParents = (chain) => chain.some(id => parentUnionOf.has(id)) - const descCount = new Map() - function countDesc(chain, seen = new Set()) { - if (descCount.has(chain)) return descCount.get(chain) - if (seen.has(chain)) return 0 - seen.add(chain) - let n = 0 - for (const id of chain) for (const k of (childrenOfUnion.get(id) || [])) n += 1 + countDesc(chainOf.get(k), seen) - descCount.set(chain, n) - return n + // Ligne de sang de la référence : tout ce qu'on atteint par les seuls liens parent / enfant + const unionsOfPerson = new Map() + for (const n of nodes) { + if (n._type !== 'union') continue + for (const k of [n._data?.p1Key, n._data?.p2Key]) { + if (!k) continue + if (!unionsOfPerson.has(k)) unionsOfPerson.set(k, []) + unionsOfPerson.get(k).push(n.id) + } } + const blood = new Set() + const referenceKey = result.referenceId != null ? `p-${result.referenceId}` : null + if (referenceKey && byId.has(referenceKey)) { + // On ne monte que depuis la référence et ses ancêtres (jamais depuis un descendant : son autre + // parent est une pièce rapportée), puis on descend depuis chacun d'eux + const up = [referenceKey] + blood.add(referenceKey) + while (up.length) { + const pu = parentUnionOf.get(up.shift()) + if (!pu) continue + for (const p of [byId.get(pu)?._data?.p1Key, byId.get(pu)?._data?.p2Key]) { + if (p && byId.get(p)?._type === 'person' && !blood.has(p)) { blood.add(p); up.push(p) } + } + } + const down = [...blood] + while (down.length) { + const id = down.shift() + for (const u of (unionsOfPerson.get(id) || [])) for (const k of (childrenOfUnion.get(u) || [])) if (!blood.has(k)) { blood.add(k); down.push(k) } + } + } + const isBloodChain = (chain) => chain.some(id => blood.has(id)) const gapAfter = (chain, i) => chainGap(byId.get(chain[i]), byId.get(chain[i + 1]), NODE_SPACING) const chainWidth = (chain) => chain.reduce((s, id, i) => s + byId.get(id).width + (i < chain.length - 1 ? gapAfter(chain, i) : 0), 0) @@ -92,159 +200,552 @@ export function computeBlockLayout(graphData = null, referenceId = null) { return offsets } + // La chaîne la plus haute d'une famille : on remonte de parents en parents, par le sang de la référence + // quand un membre en est (on retombe alors sur la lignée principale, déjà en cours : rien à attacher, + // la filiation sera un renvoi), sinon par le premier membre qui a des parents + function topChainOf(chain) { + let cur = chain + const seen = new Set() + while (!seen.has(cur)) { + seen.add(cur) + const m = cur.find(id => parentUnionOf.has(id) && blood.has(id)) || cur.find(id => parentUnionOf.has(id)) + if (!m) break + cur = chainOf.get(parentUnionOf.get(m)) + } + return cur + } + + // Un couple « enfant + pièce rapportée » se tourne vers l'extérieur de la fratrie : la pièce rapportée + // à gauche dans la moitié gauche, à droite dans la moitié droite, pour que sa famille se pose à côté + // Remariage (chaîne plus longue) : si l'enfant de la lignée est à un bout, il reste du côté de sa fratrie + // et toute la suite de conjoints (ex-époux compris) part de l'autre côté, vers la belle-famille. + function orient(chain, kid, side) { + if (!kid) return + if (chain.length === 3) { + const spouse = chain[0] === kid ? chain[2] : chain[0] + if (chain[1] !== chain.find(id => byId.get(id)._type === 'union') || !parentUnionOf.has(spouse)) return + if ((side < 0 && chain[0] !== spouse) || (side > 0 && chain[2] !== spouse)) chain.reverse() + return + } + const i = chain.indexOf(kid) + if (i !== 0 && i !== chain.length - 1) return + if ((side < 0 && i === 0) || (side > 0 && i === chain.length - 1)) chain.reverse() + } + const pos = new Map() const placed = new Set() - const externalEdges = new Set() // `${union}>${enfant}` : enfant placé sous une autre lignée + 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, génération par génération + // Imbrique des blocs de gauche à droite, rangée par rangée function packBlocks(blocks, gap) { const acc = new Map() const xs = [] for (const b of blocks) { let off = 0 - for (const [g, [l]] of b.rows) { + for (const [g, list] of b.rows) { const a = acc.get(g) - if (a) off = Math.max(off, a[1] + gap - l) + if (a?.length) off = Math.max(off, a[a.length - 1][1] + gap - list[0][0]) } xs.push(off) - for (const [g, [l, r]] of b.rows) { - const a = acc.get(g) - acc.set(g, a ? [Math.min(a[0], l + off), Math.max(a[1], r + off)] : [l + off, r + off]) - } + mergeRows(acc, b.rows, off) } return { xs, rows: acc } } - function buildBlock(chain) { + // Pose une famille de conjoint depuis sa place idéale (parents juste au-dessus de l'enfant), poussée du côté + // demandé : sur les rangées de l'enfant et en dessous (sa fratrie, leurs descendants), au-delà de toute + // l'étendue déjà posée, jamais sous la ligne d'une fratrie ; sur les rangées au-dessus (ses ancêtres, de + // petits couples), au premier trou libre, à côté des autres ancêtres. + function pushHybrid(blockRows, ideal, rows, side, anchorGen, gap) { + let d = ideal + for (let iter = 0; iter < 500; iter++) { + let push = 0 + for (const [g, list] of blockRows) { + const occ = rows.get(g) + if (!occ?.length) continue + if (g >= anchorGen) { + const l = list[0][0] + d, r = list[list.length - 1][1] + d + const ol = occ[0][0], or = occ[occ.length - 1][1] + if (l < or + gap && r > ol - gap) push = Math.max(push, side > 0 ? or + gap - l : r - (ol - gap)) + } else { + for (const [l0, r0] of list) for (const [ol, or] of occ) { + const l = l0 + d, r = r0 + d + if (l < or + gap && r > ol - gap) push = Math.max(push, side > 0 ? or + gap - l : r - (ol - gap)) + } + } + } + if (push <= 0) break + d += side * push + } + return d + } + + // Place fantôme d'un enfant déjà dessiné ailleurs : il garde sa place dans l'ordre de la fratrie et + // dans le centrage des parents, sans occuper de contour (c'est l'enfant réel qui l'occupe) + const ghostBlock = (kid) => { + const n = byId.get(kid) + return { ghost: true, kid, rows: new Map([[n._generation, [[0, n.width]]]]), members: new Set(), external: [], gen: n._generation, chain: [0, n.width], rel: () => null, width: n.width, place() {} } + } + + // Famille attachée à un conjoint : l'enfant à coller (ancre) doit finir au bout de sa fratrie, du côté du + // couple. Si l'ordre choisi l'en empêche, la fratrie est dessinée en miroir (du dernier au premier). + let anchor = null // { kid, end: 'left' | 'right', path: Set } : la branche du conjoint longe ce bord + const pathOf = (kid) => { + const s = new Set([chainOf.get(kid)]) + let cur = chainOf.get(parentUnionOf.get(kid)) + while (cur && !s.has(cur)) { + s.add(cur) + const m = cur.find(id => parentUnionOf.has(id) && blood.has(id)) || cur.find(id => parentUnionOf.has(id)) + cur = m ? chainOf.get(parentUnionOf.get(m)) : null + } + return s + } + const inlaws = [] // { anchorId, side } : toutes les belles-familles construites, pour la seconde passe + + // `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) { placed.add(chain) const gen = genOf(chain) - const rowW = chainWidth(chain) - const offsets = chainOffsets(chain) const childBlocks = [] const external = [] + const kidsOfChain = [] for (const id of chain) { if (byId.get(id)._type !== 'union') continue - for (const k of (childrenOfUnion.get(id) || [])) { - const kc = chainOf.get(k) - if (!placed.has(kc)) childBlocks.push(buildBlock(kc)) - else { external.push({ kid: k, unionId: id }); externalEdges.add(`${id}>${k}`) } + for (const k of (childrenOfUnion.get(id) || [])) kidsOfChain.push({ unionId: id, kid: k }) + } + // Remariage : les enfants de chaque union restent groupés dans l'ordre des unions de la chaîne, et une + // belle-famille va du côté de son union (gauche pour la première, droite pour la dernière), jamais au-delà + // des demi-frères et demi-sœurs + const unionsOfChain = chain.filter(id => byId.get(id)._type === 'union') + const sideOfUnion = (unionId) => { + if (unionsOfChain.length < 2) return 0 + return unionsOfChain.indexOf(unionId) < (unionsOfChain.length - 1) / 2 ? -1 : 1 + } + kidsOfChain.forEach(({ unionId, kid }, i) => { + const kc = chainOf.get(kid) + // 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}`) + childBlocks.push({ ...ghostBlock(kid), unionId }) + 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 }) + }) + // 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 + // naissance s'efface pour lui (choix de Patch, 19/10). + // Et un enfant dont le couple est au bord d'un large bloc de descendants va du côté où son bloc déborde + // le moins : les couples de la fratrie restent groupés sous leurs parents, le gros bloc part au-delà + // (Patch, 20/10 : « Antoine devrait être juste en dessous de ses parents ») + const offCenter = (b) => (b.chain[0] + b.chain[1]) / 2 - b.width / 2 + const byOffCenter = (list) => [...list].sort((a, b) => offCenter(b) - offCenter(a)) + const kids = unionsOfChain.flatMap(u => { + const group = childBlocks.filter(b => b.unionId === u) + return [...byOffCenter(group.filter(b => b.attachSide < 0)), ...byOffCenter(group.filter(b => !b.attachSide)), ...byOffCenter(group.filter(b => b.attachSide > 0))] + }) + // La branche qui mène au conjoint d'une autre famille va au bout, du côté de cette famille + if (anchor) { + 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) + const rows = new Map() + childBlocks.forEach((b, i) => { if (!b.ghost) mergeRows(rows, b.rows, packed.xs[i]) }) + + // Familles des conjoints de la chaîne pas encore dessinées : construites entières depuis leur ancêtre + // le plus haut. Le couple se tourne vers cette famille (conjoint du côté où elle se pose). Une famille + // 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 + 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 label = A._data?.birthName || byId.get(pcPersons[0])?._data?.lastName || A._data?.lastName || '' + const members = new Set([...extra, ...nested].filter(m => !keep.has(m))) + 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 + for (const id of [...chain]) { + if (byId.get(id)._type !== 'person') continue + const pu = parentUnionOf.get(id) + if (!pu || pu === ownerUnion) continue + const pc = chainOf.get(pu) + 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) + 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 }) + } + 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 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]) + 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) + built.push(entry) + orient(chain, kidOfChain ?? (chain.length === 3 ? (chain[0] === id ? chain[2] : chain[0]) : null), side) + } + + // Chaîne centrée sur la rangée de ses enfants directs (fantômes compris), décalée au plus près si une + // famille de conjoint occupe déjà cette rangée + const rowW = chainWidth(chain) + let offsets = chainOffsets(chain) + const kidRowL = childBlocks.length ? Math.min(...childBlocks.map((b, i) => packed.xs[i] + b.chain[0])) : 0 + const kidRowR = childBlocks.length ? Math.max(...childBlocks.map((b, i) => packed.xs[i] + b.chain[1])) : rowW + const avoidObstacles = (x) => { + const obstacles = rows.get(gen) || [] + if (!overlaps(x, x + rowW, obstacles, FAMILY_GAP)) return x + const candidates = obstacles.flatMap(([l, r]) => [r + FAMILY_GAP, l - FAMILY_GAP - rowW]) + .filter(c => !overlaps(c, c + rowW, obstacles, FAMILY_GAP)) + // De préférence une place encore au-dessus de la rangée des enfants (sinon la ligne de fratrie + // partirait de l'extérieur et croiserait les familles voisines) + const over = candidates.filter(c => c + rowW > kidRowL && c < kidRowR) + const pool = over.length ? over : candidates + return pool.length ? pool.reduce((best, c) => (Math.abs(c - x) < Math.abs(best - x) ? c : best)) : x + } + let rowX = (kidRowL + kidRowR) / 2 - rowW / 2 + if (childBlocks.length) { + // Remariage : chaque union au plus près au-dessus de ses propres enfants. Parmi les positions qui + // alignent une union sur un bord ou le centre de son groupe, on garde celle où les lignes de fratrie + // débordent le moins au-dessus des demi-frères et demi-sœurs (égalité : la plus proche du centrage). + if (unionsOfChain.length > 1) { + const groups = unionsOfChain.map(u => { + const g = childBlocks.map((b, i) => (b.unionId === u ? [packed.xs[i] + b.chain[0], packed.xs[i] + b.chain[1]] : null)).filter(Boolean) + return g.length ? [Math.min(...g.map(r => r[0])), Math.max(...g.map(r => r[1]))] : null + }) + const at = (u) => offsets.get(u) + byId.get(u).width / 2 + const cost = (x) => unionsOfChain.reduce((s, u, i) => { + if (!groups[i]) return s + const c = x + at(u) + return s + Math.max(0, c - groups[i][1]) + Math.max(0, groups[i][0] - c) + }, 0) + const centered = rowX + const candidates = [centered] + unionsOfChain.forEach((u, i) => { if (groups[i]) for (const target of [groups[i][0], (groups[i][0] + groups[i][1]) / 2, groups[i][1]]) candidates.push(target - at(u)) }) + rowX = candidates.reduce((best, x) => { + const cb = cost(best), cx = cost(x) + return cx < cb - 0.5 || (Math.abs(cx - cb) <= 0.5 && Math.abs(x - centered) < Math.abs(best - centered)) ? x : best + }) + } + rowX = avoidObstacles(rowX) + } + + // Belles-familles de la chaîne, collées au conjoint depuis leur place idéale (ses parents juste au-dessus + // de lui), poussées vers l'extérieur du bloc : sur la rangée du couple et en dessous, au-delà de tout le + // bloc (jamais sous la ligne de la fratrie des enfants) ; au-dessus, au premier trou libre. + // Elles font partie du bloc : le bloc du dessus les emporte avec l'enfant quand il l'empile au bout de + // sa fratrie. + const rowsBase = new Map([...rows].map(([g, l]) => [g, l.map(i => [...i])])) + const idealOf = (p, rx) => { + const at = p.block.rel(p.anchorId) + const anchorX = rx + offsets.get(p.anchorId) + return at == null ? anchorX - p.block.width / 2 : anchorX - at + } + // Sur un remariage où l'enfant de la lignée est à un bout, seule sa moitié est réservée : au-dessus des + // ex-conjoints, la carte ou la famille du conjoint peut se poser. + // Seule la moitié de l'enfant de la lignée est réservée pour ses parents : au-dessus du conjoint, ses propres + // parents peuvent se poser (Patch, 20/10 : « Louis et Joséphine devraient être au-dessus de Victor ») + const parentsSpan = (rx) => { + if (kidOfChain && (chain.indexOf(kidOfChain) === 0 || chain.indexOf(kidOfChain) === chain.length - 1)) { + const k = rx + offsets.get(kidOfChain) + return chain.indexOf(kidOfChain) === 0 ? [rx - FAMILY_GAP, k + byId.get(kidOfChain).width + FAMILY_GAP] : [k - FAMILY_GAP, rx + rowW + FAMILY_GAP] + } + return [rx - FAMILY_GAP, rx + rowW + FAMILY_GAP] + } + const reserveParents = (r, rx) => { if (ownerUnion) mergeRows(r, new Map([[gen - 1, [parentsSpan(rx)]]]), 0) } + // 1) Le côté de chaque famille : celui prévu (extérieur de la branche), sauf si l'autre côté la rapproche + // nettement. Le couple se retourne alors et l'enfant ira au bout de sa fratrie de ce côté-là. + { + const trial = new Map([...rowsBase].map(([g, l]) => [g, l.map(i => [...i])])) + mergeRows(trial, new Map([[gen, [[rowX, rowX + rowW]]]]), 0) + reserveParents(trial, rowX) + for (const p of built) { + if (chain.length !== 3) break + const ideal = idealOf(p, rowX) + const x = pushHybrid(p.block.rows, ideal, trial, p.side, gen, FAMILY_GAP) + const other = -p.side + const mirrored = (chain[0] === p.anchorId ? chain[2] : chain[0]) + const idealOther = ideal + (offsets.get(mirrored) - offsets.get(p.anchorId)) + const xOther = pushHybrid(p.block.rows, idealOther, trial, other, gen, FAMILY_GAP) + if (Math.abs(xOther - idealOther) + SIDE_SWITCH_PX < Math.abs(x - ideal)) { + p.side = other + orient(chain, mirrored, other) + offsets = chainOffsets(chain) + } } } - const packed = packBlocks(childBlocks, FAMILY_GAP) - - // Chaîne centrée sur la rangée de ses enfants directs - let rowX = 0 - if (childBlocks.length) { - const kidRowL = Math.min(...childBlocks.map((b, i) => packed.xs[i] + b.rows.get(b.gen)[0])) - const kidRowR = Math.max(...childBlocks.map((b, i) => packed.xs[i] + b.rows.get(b.gen)[1])) - rowX = (kidRowL + kidRowR) / 2 - rowW / 2 + // 2) Pose. Si une famille serait poussée loin par les descendants du couple, la chaîne se décale au bord + // de sa rangée d'enfants, du côté de cette famille : les parents ne sont plus au centre de leurs enfants, + // mais la belle-famille est tout près et son fil reste court. + // Au-dessus de la chaîne, la place de ses propres parents est réservée : les parents du conjoint se posent + // à côté, pas dessus (sinon le couple des parents, posé après, ne trouve plus sa place au-dessus des enfants) + const placeAll = (rx) => { + const r = new Map([...rowsBase].map(([g, l]) => [g, l.map(i => [...i])])) + mergeRows(r, new Map([[gen, [[rx, rx + rowW]]]]), 0) + reserveParents(r, rx) + const list = [] + let maxPush = 0, sideOfMax = 0 + for (const p of built) { + const ideal = idealOf(p, rx) + const x = pushHybrid(p.block.rows, ideal, r, p.side, gen, FAMILY_GAP) + if (Math.abs(x - ideal) > maxPush) { maxPush = Math.abs(x - ideal); sideOfMax = p.side } + list.push({ block: p.block, x, ideal, anchorId: p.anchorId, side: p.side }) + mergeRows(r, p.block.rows, x) + } + // Le contour rendu ne garde pas la réservation (elle ne sert qu'à pousser les belles-familles) + const out = new Map([...rowsBase].map(([g, l]) => [g, l.map(i => [...i])])) + mergeRows(out, new Map([[gen, [[rx, rx + rowW]]]]), 0) + for (const a of list) mergeRows(out, a.block.rows, a.x) + return { rows: out, list, maxPush, sideOfMax } } - - const rows = new Map([[gen, [rowX, rowX + rowW]]]) - for (const [g, [l, r]] of packed.rows) { - const a = rows.get(g) - rows.set(g, a ? [Math.min(a[0], l), Math.max(a[1], r)] : [l, r]) + const placeBest = () => { + let res = placeAll(rowX) + if (childBlocks.length && res.maxPush > PARENT_SHIFT_PX && kidRowR - kidRowL > rowW) { + const edge = avoidObstacles(res.sideOfMax < 0 ? kidRowL : kidRowR - rowW) + const alt = placeAll(edge) + if (alt.maxPush < res.maxPush) { rowX = edge; res = alt } + } + return res } - const minL = Math.min(...[...rows.values()].map(v => v[0])) - for (const [g, v] of rows) rows.set(g, [v[0] - minL, v[1] - minL]) - rowX -= minL - const kidXs = packed.xs.map(x => x - minL) + 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() + } + 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 })) + for (const a of result.list) { + 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) for (const b of childBlocks) { for (const m of b.members) members.add(m); external.push(...b.external) } + for (const a of attachments) { for (const m of a.block.members) members.add(m); external.push(...a.block.external) } + const ghosts = new Map() + childBlocks.forEach((b, i) => { if (b.ghost) ghosts.set(b.kid, packed.xs[i]) }) + + // Origine du bloc au bord gauche de son contour + const [minL] = extentOf(rows) + for (const [g, list] of rows) rows.set(g, list.map(([l, r]) => [l - minL, r - minL])) + rowX -= minL + const kidXs = packed.xs.map(x => x - minL) + for (const [k, x] of ghosts) ghosts.set(k, x - minL) + for (const a of attachments) { a.x -= minL; if (a.parents) a.parents.x -= minL } + const rel = (id) => { if (offsets.has(id)) return rowX + offsets.get(id) - for (let i = 0; i < childBlocks.length; i++) if (childBlocks[i].members.has(id)) return kidXs[i] + childBlocks[i].rel(id) + if (ghosts.has(id)) return ghosts.get(id) + for (let i = 0; i < childBlocks.length; i++) { + if (childBlocks[i].ghost) continue + const r = childBlocks[i].rel(id) + if (r != null) return kidXs[i] + r + } + for (const a of attachments) { + if (a.parents?.offsets.has(id)) return a.parents.x + a.parents.offsets.get(id) + const r = a.block.rel(id) + if (r != null) return a.x + r + } return null } return { - rows, members, external, gen, rel, - get width() { return Math.max(...[...rows.values()].map(v => v[1])) }, + rows, members, external, gen, rel, attachSide, attachBig, + chain: [rowX, rowX + rowW], + width: extentOf(rows)[1], place(x0) { for (const id of chain) pos.set(id, x0 + rowX + offsets.get(id)) childBlocks.forEach((b, i) => b.place(x0 + kidXs[i])) + attachments.forEach(a => { + a.block.place(x0 + a.x) + if (a.parents) for (const id of a.parents.chain) pos.set(id, x0 + a.parents.x + a.parents.offsets.get(id)) + }) }, } } - // 1) Racines : chaînes sans parents, grandes lignées d'abord - const roots = chains.filter(c => !hasParents(c)).sort((a, b) => countDesc(b) - countDesc(a) || genOf(a) - genOf(b)) - const free = [], anchored = [] - for (const r of roots) { - if (placed.has(r)) continue - const block = buildBlock(r) - const ownExternal = block.external.some(({ unionId }) => r.includes(unionId)) - if (ownExternal) anchored.push(block) - else free.push(block) + // Racines : chaînes sans parents, le sang de la référence d'abord, puis par rangée, année de naissance, identifiant. + // Jamais par effectifs : ajouter une personne ne change pas l'ordre des lignées. + const birthYearOf = (chain) => Math.min(...chain.map(id => { + const d = byId.get(id)._data + const raw = d?.birthYear ?? d?.birthDate + const y = raw != null ? parseInt(String(raw).slice(0, 4), 10) : NaN + return Number.isFinite(y) ? y : 9999 + })) + // La plus grande lignée d'abord : construite en premier, c'est elle qui reçoit les autres en belles-familles. + // Dans l'autre sens, une petite branche construite d'abord aspirerait toute la lignée principale comme + // « famille du conjoint » et la pousserait loin (vu le 19/10 sur un arbre réel : 249 personnes poussées). + const descCount = new Map() + const countDesc = (chain, seen = new Set()) => { + if (descCount.has(chain)) return descCount.get(chain) + if (seen.has(chain)) return 0 + seen.add(chain) + let n = chain.length + for (const id of chain) for (const k of (childrenOfUnion.get(id) || [])) n += countDesc(chainOf.get(k), seen) + descCount.set(chain, n) + return n } + const roots = chains.filter(c => !hasParents(c)).sort((a, b) => ( + (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)) + free.push(...detached) const forest = packBlocks(free, FAMILY_GAP * 2) free.forEach((b, i) => b.place(forest.xs[i])) - let cursor = Math.max(0, ...[...forest.rows.values()].map(v => v[1])) + FAMILY_GAP * 2 + 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 - // 2) Blocs ancrés : l'union concernée centrée sur son enfant déjà placé, générations profondes d'abord - const occupied = (exclude) => { - const byGen = new Map() - for (const n of nodes) { - if (!pos.has(n.id) || exclude.has(n.id)) continue - if (!byGen.has(n._generation)) byGen.set(n._generation, []) - byGen.get(n._generation).push([pos.get(n.id), pos.get(n.id) + n.width]) - } - return byGen + // 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) } - anchored.sort((a, b) => b.gen - a.gen) - for (const block of anchored) { - const occ = occupied(block.members) - const targets = block.external - .filter(({ kid }) => pos.has(kid)) - .map(({ kid, unionId }) => pos.get(kid) + byId.get(kid).width / 2 - (block.rel(unionId) + byId.get(unionId).width / 2)) - if (!targets.length) { block.place(cursor); cursor += block.width + FAMILY_GAP * 2; continue } - const ideal = targets.reduce((s, v) => s + v, 0) / targets.length - const fits = (x0) => { - block.place(x0) - for (const id of block.members) { - const n = byId.get(id); const x = pos.get(id) - for (const [s, e] of (occ.get(n._generation) || [])) { - if (x < e + FAMILY_GAP && x + n.width > s - FAMILY_GAP) return false - } - } - return true - } - let found = false - for (let d = 0; d < 40000 && !found; d += 20) { - if (fits(ideal + d)) found = true - else if (d > 0 && fits(ideal - d)) found = true - } - if (!found) { block.place(cursor); cursor += block.width + FAMILY_GAP * 2 } - } - - // Couple dont les deux conjoints ont leurs parents dans l'arbre, chacun du côté de la famille de l'autre : - // on intervertit les deux conjoints (même place, l'union reste au milieu), les deux fils ne se croisent plus. - // Répété : intervertir un couple peut remettre à l'endroit ou à l'envers celui de ses enfants. - const centerOf = (id) => pos.get(id) + byId.get(id).width / 2 - for (let pass = 0; pass < 4; pass++) { - let swapped = 0 - for (const chain of chains) { - if (chain.length !== 3 || !pos.has(chain[0])) continue - const [a, , b] = chain - const pa = parentUnionOf.get(a), pb = parentUnionOf.get(b) - if (!pa || !pb || pa === pb || !pos.has(pa) || !pos.has(pb)) continue - if ((centerOf(a) - centerOf(b)) * (centerOf(pa) - centerOf(pb)) >= 0) continue - const xa = pos.get(a), xb = pos.get(b) - pos.set(a, xb + byId.get(b).width - byId.get(a).width) - pos.set(b, xa) - swapped++ - } - if (!swapped) break - } - - // Réinjecter le X, normalisé à 0 ; Y inchangé (générations) + // 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)) for (const n of nodes) if (pos.has(n.id)) n.x = pos.get(n.id) - minX - // Arête vers un enfant placé sous l'autre lignée : renvoi (voir drawFiliations) seulement si le fil + // Arête vers un enfant dessiné sous l'autre lignée : renvoi (voir drawFiliations) seulement si le fil // croise une autre famille. Plusieurs renvois sous la même union : pastilles empilées (_renvoiIndex) - // Le fil descend de l'union puis file sur la ligne de la fratrie jusqu'à l'enfant (traceBus) : + // Le fil descend de l'union puis file sur la ligne de la fratrie jusqu'à l'enfant (traceSibship) : // il ne traverse une autre famille que si une personne de cette famille se trouve sur cette ligne, // entre l'aplomb de l'union et l'enfant. Frères et sœurs et conjoint(e)s de l'enfant ne comptent pas. const spouseKeysOf = new Map() @@ -260,11 +761,125 @@ export function computeBlockLayout(graphData = null, referenceId = null) { const renvoisByUnion = new Map() for (const e of result.edges) { const u = e.sources[0], c = e.targets[0] - if (!externalEdges.has(`${u}>${c}`) || !crossesOtherFamily(u, c)) continue + if (e._hidden) continue + if (!externalEdges.has(`${u}>${c}`) || attachedEdges.has(`${u}>${c}`)) continue + // Famille reliée deux fois à l'arbre (dessinée sous l'autre lien) : pastille si le fil croiserait une + // famille, ou s'il devrait traverser plus d'un écran + const far = Math.abs((byId.get(u).x + byId.get(u).width / 2) - (byId.get(c).x + byId.get(c).width / 2)) > RENVOI_FAR_PX + if (!far && !crossesOtherFamily(u, c)) continue e._renvoi = true e._renvoiIndex = renvoisByUnion.get(u) || 0 renvoisByUnion.set(u, e._renvoiIndex + 1) } + // Lignes de fratrie emboîtées, par rangée. Une ligne qui en contient une autre passe au-dessus d'elle. + // Deux lignes qui se chevauchent sans s'emboîter : celle dont l'union est en dehors de l'autre passe + // au-dessus (son fil n'entre dans l'autre famille qu'une fois, à l'enfant collé), sinon la plus longue. + // _railLift plus grand = plus haut ; 0 = la ligne la plus proche des enfants. + const rails = new Map() + for (const e of result.edges) { + if (e._renvoi || e._hidden) continue + const u = byId.get(e.sources[0]), k = byId.get(e.targets[0]) + if (!u || !k) continue + const ux = u.x + u.width / 2 + const r = rails.get(u.id) || { id: u.id, gen: u._generation, ux, l: ux, r: ux } + r.l = Math.min(r.l, k.x + k.width / 2); r.r = Math.max(r.r, k.x + k.width / 2) + rails.set(u.id, r) + } + const byRow = new Map() + for (const r of rails.values()) { if (!byRow.has(r.gen)) byRow.set(r.gen, []); byRow.get(r.gen).push(r) } + for (const list of byRow.values()) { + list.sort((a, b) => a.l - b.l) + const below = new Map(list.map(r => [r.id, []])) + for (let i = 0; i < list.length; i++) for (let j = i + 1; j < list.length; j++) { + const a = list[i], b = list[j] + if (b.l >= a.r) break + const aIn = a.l <= b.l && a.r >= b.r, bIn = b.l <= a.l && b.r >= a.r + let upper + if (aIn && !bIn) upper = a + else if (bIn && !aIn) upper = b + else { + const aOut = a.ux < b.l || a.ux > b.r, bOut = b.ux < a.l || b.ux > a.r + upper = aOut && !bOut ? a : bOut && !aOut ? b : (a.r - a.l >= b.r - b.l ? a : b) + } + below.get(upper.id).push(upper === a ? b.id : a.id) + } + const lift = new Map() + const visiting = new Set() + const liftOf = (id) => { + if (lift.has(id)) return lift.get(id) + if (visiting.has(id)) return 0 + visiting.add(id) + const v = below.get(id).reduce((m, o) => Math.max(m, liftOf(o) + 1), 0) + visiting.delete(id) + lift.set(id, v) + return v + } + 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: 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 } + +// ============================================================ +// Onglets de familles +// ============================================================ +// Le sous-graphe d'un ensemble de personnes : leurs unions entre elles et leurs filiations entre elles. +export function subgraphOf(personIds, graphData = null) { + const keep = new Set([...personIds].map(String)) + const persons = (graphData?.persons || globalPersons).filter(p => keep.has(String(p.id))) + const unions = (graphData?.unions || globalUnions).filter(u => keep.has(String(u.partner1Id)) && keep.has(String(u.partner2Id))) + const unionIds = new Set(unions.map(u => String(u.id))) + const filiations = (graphData?.filiations || globalFiliations).filter(f => keep.has(String(f.childId)) && (f.unionId ? unionIds.has(String(f.unionId)) : keep.has(String(f.parentId)))) + return { persons, unions, filiations } +} + +// Toutes les familles d'un arbre, de proche en proche : l'arbre de la personne de départ, puis chaque grande +// belle-famille qu'il cache (un onglet = ce bloc caché plus le conjoint charnière, posé avec lui comme +// référence), puis celles que ces onglets cachent à leur tour. Chaque personne est dans un seul onglet, sauf +// la charnière qui est des deux côtés. Ordre : celui de la découverte, la famille de départ en premier. +export function familyTabsOf(graphData = null, referenceId = null) { + const root = { key: referenceId != null ? String(referenceId) : 'root', referenceId, parentKey: null, personIds: null, label: '', count: 0 } + const tabs = [root] + const seen = new Set([root.key]) + for (let i = 0; i < tabs.length; i++) { + const tab = tabs[i] + const graph = tab.personIds ? subgraphOf(tab.personIds, graphData) : graphData + const result = computeBlockLayout(graph, tab.referenceId, { familyTabs: true }) + const fams = result.families || [] + if (fams[0]) { tab.label = fams[0].label; tab.count = fams[0].count } + for (const f of fams.slice(1)) { + if (f.drawn) continue + const key = String(f.anchorId).replace(/^p-/, '') + if (seen.has(key)) continue + seen.add(key) + tabs.push({ key, referenceId: key, parentKey: tab.key, personIds: new Set([...f.members, ...f.hinges]), hinges: f.hinges, label: f.label, count: f.count }) + } + } + // 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])) + const byLabel = new Map() + for (const tb of tabs) { if (!byLabel.has(tb.label)) byLabel.set(tb.label, []); byLabel.get(tb.label).push(tb) } + 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) { + if (tb === biggest) 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 ')}` + } + } + return tabs +} diff --git a/frontend/src/components/galaxy/elkLayout.js b/frontend/src/components/galaxy/elkLayout.js index 8d88ada..234d358 100644 --- a/frontend/src/components/galaxy/elkLayout.js +++ b/frontend/src/components/galaxy/elkLayout.js @@ -800,7 +800,7 @@ export function computeLayout(graphData = null, referenceId = null) { // --- PASS B (bottom-up) : placement initial centré sur les enfants --- const sortedGensDesc = [...componentOrderPerGen.keys()].sort((a, b) => b - a) if (sortedGensDesc.length === 0) { - return { children: allNodes, edges: allEdges, generationConflicts } + return { children: allNodes, edges: allEdges, generationConflicts, referenceId: reference ?? null } } const genMax = sortedGensDesc[0] const genMin = sortedGensDesc[sortedGensDesc.length - 1] @@ -840,7 +840,7 @@ export function computeLayout(graphData = null, referenceId = null) { node.y = node._generation * (PERSON_H + LAYER_SPACING) } - return { children: allNodes, edges: allEdges, generationConflicts } + return { children: allNodes, edges: allEdges, generationConflicts, referenceId: reference ?? null } } // ============================================================ @@ -849,7 +849,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])) + const renvoiChildren = new Set([...edges.filter(e => e._renvoi).map(e => e.targets[0]), ...children.filter(n => n._familyTab).map(n => n.id)]) 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() diff --git a/frontend/src/components/galaxy/renderers.js b/frontend/src/components/galaxy/renderers.js index 1063d40..79a81cd 100644 --- a/frontend/src/components/galaxy/renderers.js +++ b/frontend/src/components/galaxy/renderers.js @@ -107,19 +107,19 @@ const BUS_DY = 100 const SIB_R = 24 const SIB_STRAIGHT_DX = SIB_R * 2 -function busY(sourceId, sy) { - let h = 0 - for (let i = 0; i < sourceId.length; i++) h = (h * 31 + sourceId.charCodeAt(i)) | 0 - // Léger décalage par union : deux fratries voisines ne fusionnent pas leurs lignes - return sy + BUS_DY + (Math.abs(h) % 3) * 4 +// Les lignes de fratrie s'emboîtent : une famille collée au conjoint d'un enfant dessine sa ligne sous celle +// de la famille qui l'entoure. Le placement pose le niveau (_railLift, 0 = la plus proche des enfants). +const RAIL_STEP = 6 +function busY(lift, sy) { + return sy + BUS_DY - RAIL_STEP * (lift || 0) } // Une union, toute sa fratrie : un seul tracé. Tronc depuis l'union jusqu'à la barre, coins arrondis // de même rayon à chaque jonction, puis descente vers chaque enfant. Chaque enfant a sa propre trajectoire // (sur la même barre), donc le surlignage du chemin suit exactement la même géométrie. -export function traceSibship(ctx, sourceId, sourcePos, items) { +export function traceSibship(ctx, sourceId, sourcePos, items, lift = 0) { const sx = sourcePos.cx, sy = sourcePos.cy - const by = busY(sourceId, sy) + const by = busY(lift, sy) const rr = Math.min(SIB_R, by - sy - 2) ctx.beginPath() for (const { cx: ex, cy: ey } of items) { @@ -201,15 +201,18 @@ 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 = [] // Zones « + photo » : portraits vides, chez le propriétaire (cercle du nœud, rempli au dessin) export const photoPlaceholderHitAreas = [] 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 const RENVOI_CHILD_GAP = 13 -function drawRenvoiPill(ctx, { text, cx, cy, target, alpha }) { +function drawPillShape(ctx, text, cx, cy, alpha) { ctx.save() ctx.font = RENVOI_FONT const w = measureTextCached(ctx, text, 'renvoi').width + 20 @@ -228,9 +231,19 @@ function drawRenvoiPill(ctx, { text, cx, cy, target, alpha }) { ctx.textBaseline = 'middle' ctx.fillText(text, cx, cy + 0.5) ctx.restore() + return { w, h } +} + +function drawRenvoiPill(ctx, { text, cx, cy, target, alpha }) { + const { w, h } = drawPillShape(ctx, text, cx, cy, 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))) @@ -252,6 +265,7 @@ 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 } @@ -262,10 +276,13 @@ 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() edges.forEach(edge => { + if (edge._hidden) return const sourceId = edge.sources[0] const targetId = edge.targets[0] const sourcePos = posMap.get(sourceId) @@ -312,7 +329,7 @@ export function drawFiliations(ctx, edges, posMap, nodeMap, nodeRandomData, entr ctx.lineCap = 'round' const p = progress / items.length ctx.globalAlpha = Math.min(1, p * 1.5) * p - traceSibship(ctx, sourceId, sourcePos, items) + traceSibship(ctx, sourceId, sourcePos, items, nodeMap.get(sourceId)?._railLift) ctx.globalAlpha = 1 ctx.setLineDash([]) } @@ -389,7 +406,7 @@ export function drawPathHighlight(ctx, { ctx.globalAlpha = 1 ctx.globalAlpha = hp - traceSibship(ctx, edge.sources[0], sourcePos, [{ cx: targetPos.cx, cy: targetPos.cy - childR }]) + traceSibship(ctx, edge.sources[0], sourcePos, [{ cx: targetPos.cx, cy: targetPos.cy - childR }], nodeMap.get(edge.sources[0])?._railLift) ctx.globalAlpha = 1 }) } @@ -657,6 +674,16 @@ 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 + const text = `${tab.label ? t('Famille {name}', { name: tab.label }) : t('Famille')} · ${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 }) + } } // ============================================================ diff --git a/frontend/src/components/galaxy/useGalaxyInteractions.js b/frontend/src/components/galaxy/useGalaxyInteractions.js index f149fa9..6df39cb 100644 --- a/frontend/src/components/galaxy/useGalaxyInteractions.js +++ b/frontend/src/components/galaxy/useGalaxyInteractions.js @@ -1,9 +1,10 @@ import { useRef, useCallback } from 'react' import { getPersonMedias } from '../../data/mockData' import { MAX_ORBIT_MEDIAS, ORBIT_MEDIA_SIZE } from './constants' -import { nodeRadius, renvoiHitAreas, photoPlaceholderHitAreas } from './renderers' +import { nodeRadius, renvoiHitAreas, familyHitAreas, photoPlaceholderHitAreas } 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) const hitPhotoPlaceholder = (x, y) => photoPlaceholderHitAreas.find(a => Math.hypot(x - a.cx, y - a.cy) < a.r) import { getEntranceProgress } from './utils' @@ -22,7 +23,7 @@ export function useGalaxyInteractions({ canvasRef, transformRef, targetTransformRef, layoutDataRef, nodeRandomDataRef, entranceRef, hoveredNodeIdRef, hoveredMediaKeyRef, mouseWorldPosRef, - getAnimatedPos, onPersonSelect, onMediaSelect, onPhotoPlaceholderClick = null, + getAnimatedPos, onPersonSelect, onMediaSelect, onPhotoPlaceholderClick = null, onOpenFamily = null, annotationHandlers, minScale = 0.2, maxScale = 4, @@ -138,7 +139,7 @@ export function useGalaxyInteractions({ break } } - if (!isOverClickable && hitRenvoi(mouseWorldPosRef.current.x, mouseWorldPosRef.current.y)) isOverClickable = true + if (!isOverClickable && (hitRenvoi(mouseWorldPosRef.current.x, mouseWorldPosRef.current.y) || hitFamily(mouseWorldPosRef.current.x, mouseWorldPosRef.current.y))) isOverClickable = true if (!isOverClickable && hitPhotoPlaceholder(mouseWorldPosRef.current.x, mouseWorldPosRef.current.y)) isOverClickable = true hoveredNodeIdRef.current = newHoveredNode hoveredMediaKeyRef.current = newHoveredMedia @@ -284,6 +285,10 @@ 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) { @@ -295,7 +300,7 @@ export function useGalaxyInteractions({ y: rect2.height / 2 - renvoi.targetY * scale, } } - }, [canvasRef, transformRef, targetTransformRef, layoutDataRef, nodeRandomDataRef, entranceRef, getAnimatedPos, onPersonSelect, onMediaSelect, onPhotoPlaceholderClick, annotationHandlers]) + }, [canvasRef, transformRef, targetTransformRef, layoutDataRef, nodeRandomDataRef, entranceRef, getAnimatedPos, onPersonSelect, onMediaSelect, onPhotoPlaceholderClick, onOpenFamily, annotationHandlers]) return { isDragging, isZooming, zoomTimeoutRef, hasDragged, diff --git a/frontend/src/i18n/en/arbre.js b/frontend/src/i18n/en/arbre.js index 4920545..6ababa6 100644 --- a/frontend/src/i18n/en/arbre.js +++ b/frontend/src/i18n/en/arbre.js @@ -1,5 +1,11 @@ // Traductions anglaises, clé = texte français exact passé à t(). export default { + 'Famille {name}': 'The {name} family', + 'Famille': 'Family', + 'Familles': 'Families', + 'Affichage': 'Display', + 'Les grandes familles par alliance dans des onglets': 'Large in-law families in tabs', + "Une belle-famille plus large qu'un écran s'ouvre dans son propre onglet au lieu d'être dessinée dans l'arbre. Réglage de ce navigateur.": 'An in-law family wider than a screen opens in its own tab instead of being drawn in the tree. Saved in this browser.', // Arbre sur canvas (components/galaxy/renderers.js) 'Voir les parents': 'See parents', 'Voir {firstName}': 'See {firstName}', diff --git a/tools/layout-bench/defects.mjs b/tools/layout-bench/defects.mjs index 4fb95ba..a34e327 100644 --- a/tools/layout-bench/defects.mjs +++ b/tools/layout-bench/defects.mjs @@ -20,6 +20,8 @@ export function computeDefects(result, coupleBarMeta, graph, referenceId = null) if (!kidsOf.has(e.sources[0])) kidsOf.set(e.sources[0], []) kidsOf.get(e.sources[0]).push(e.targets[0]) } + const siblingsOf = new Map() + for (const [, kids] of kidsOf) for (const k of kids) siblingsOf.set(k, kids) const spousesOf = new Map() for (const c of coupleBarMeta) { if (!spousesOf.has(c.p1Key)) spousesOf.set(c.p1Key, new Set()) @@ -34,7 +36,7 @@ export function computeDefects(result, coupleBarMeta, graph, referenceId = null) const u = byId.get(uid) const ks = kids.map((k) => byId.get(k)).filter(Boolean) if (!u || !ks.length) continue - const sx = cx(u), sy = u.y + u.height / 2, by = sy + BUS_DY + busOffset(uid) + const sx = cx(u), sy = u.y + u.height / 2, by = sy + BUS_DY + (u._railLift != null ? -6 * u._railLift : busOffset(uid)) let l = sx, r = sx for (const k of ks) { const ex = cx(k) @@ -63,8 +65,12 @@ export function computeDefects(result, coupleBarMeta, graph, referenceId = null) for (const hh of households) { const h = H.find((x) => x.u === hh.union) if (!h) continue + // Un seul arbre : la fratrie du conjoint d'un enfant peut se trouver sous la ligne (famille collée), pas un étranger const ok = new Set(hh.kids) - for (const k of hh.kids) for (const s of (spousesOf.get(k) || [])) ok.add(s) + for (const k of hh.kids) for (const s of (spousesOf.get(k) || [])) { + ok.add(s) + for (const sib of (siblingsOf.get(s) || [])) { ok.add(sib); for (const ss of (spousesOf.get(sib) || [])) ok.add(ss) } + } if (nodes.some((n) => n._type === 'person' && n._generation === hh.gen + 1 && !ok.has(n.id) && cx(n) > h.a && cx(n) < h.b)) fils_sur_etranger++ } const foyers_larges = households.filter((h) => h.etendue > SCREEN).length diff --git a/tools/layout-bench/invariants.mjs b/tools/layout-bench/invariants.mjs index 9304c36..38de306 100644 --- a/tools/layout-bench/invariants.mjs +++ b/tools/layout-bench/invariants.mjs @@ -89,23 +89,37 @@ export function checkInvariants(result, coupleBarMeta, graph, referenceId = null // Fratries : contiguës (seuls les frères, sœurs et leurs conjoints entre le premier et le dernier) et dans l'ordre choisi const kidsOf = new Map() + const siblingsOf = new Map() for (const e of result.edges) { if (e._renvoi) continue if (!kidsOf.has(e.sources[0])) kidsOf.set(e.sources[0], []) kidsOf.get(e.sources[0]).push(e) } + for (const edges of kidsOf.values()) for (const e of edges) siblingsOf.set(e.targets[0], edges.map((x) => x.targets[0])) + const parentsOf = new Set(result.edges.map((e) => e.targets[0])) let fratries_coupees = 0, fratries_desordre = 0 for (const edges of kidsOf.values()) { const kids = edges.map((e) => byId.get(e.targets[0])).filter(Boolean) if (kids.length < 2) continue + // Un seul arbre : la famille du conjoint d'un enfant (sa fratrie, leurs conjoints) se colle à lui, elle peut + // donc se trouver entre deux frères. Seule une personne sans ce lien coupe la fratrie. const allowed = new Set(kids.map((k) => k.id)) - for (const k of kids) for (const s of (spousesOf.get(k.id) || [])) allowed.add(s) + for (const k of kids) for (const s of (spousesOf.get(k.id) || [])) { + allowed.add(s) + for (const sib of (siblingsOf.get(s) || [])) { allowed.add(sib); for (const ss of (spousesOf.get(sib) || [])) allowed.add(ss) } + } const lo = Math.min(...kids.map((k) => k.x)), hi = Math.max(...kids.map((k) => k.x)) const gen = kids[0]._generation if ((byGen.get(gen) || []).some((n) => n._type !== 'union' && !allowed.has(n.id) && n.x > lo && n.x < hi)) fratries_coupees++ - const byX = [...edges].sort((a, b) => cx(byId.get(a.targets[0])) - cx(byId.get(b.targets[0]))) + // Ordre choisi, de gauche à droite, ou en miroir (famille attachée à un conjoint, retournée vers lui) + // Un enfant dont le conjoint a ses parents dans l'arbre va au bout de la fratrie (sa belle-famille se colle à + // lui) : l'ordre choisi ne vaut que pour les autres + const hasInLaws = (id) => [...(spousesOf.get(id) || [])].some((s) => parentsOf.has(s)) + const byX = [...edges].filter((e) => !hasInLaws(e.targets[0])).sort((a, b) => cx(byId.get(a.targets[0])) - cx(byId.get(b.targets[0]))) const orders = byX.map((e) => e._displayOrder).filter((o) => Number.isFinite(o)) - if (orders.some((o, i) => i > 0 && o < orders[i - 1])) fratries_desordre++ + const ascending = orders.every((o, i) => i === 0 || o >= orders[i - 1]) + const descending = orders.every((o, i) => i === 0 || o <= orders[i - 1]) + if (!ascending && !descending) fratries_desordre++ } // Rangées = génération de parenté (décalage calé par famille non reliée)