Merge pull request #116 from IliesAllali/feat/placement-un-seul-arbre

Placement : un seul arbre, grandes belles-familles en onglets
This commit is contained in:
IliesAllali authored and GitHub committed 2026-10-20 13:59:31 +02:00
commit 5f115322e2
19 files changed
+1076 -224

No files matched your search

+6
View File
@@ -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}
/>
<ZoomControl zoomApiRef={galaxyZoomApiRef} />
{tree.canEditCurrentTree && (
@@ -3296,6 +3300,8 @@ function App() {
onGedcomImported={handleGedcomImported}
onOpenPhotoStart={() => { setAdminPanelVisible(false); openPhotoStart() }}
onClose={() => setAdminPanelVisible(false)}
familyTabs={familyTabs}
onFamilyTabsChange={setFamilyTabs}
/>
<PhotoStartPanel
visible={photoStart.visible}
+18
View File
@@ -33,6 +33,8 @@ function normalizeSlug(value) {
}
const AdminPanel = ({
familyTabs = true,
onFamilyTabsChange = null,
visible,
treeId,
authToken,
@@ -475,6 +477,22 @@ const AdminPanel = ({
</span>
</label>
</fieldset>
<fieldset className="ts-policy">
<legend className="pz-eyebrow">{t('Affichage')}</legend>
<label className={`ts-choice ${familyTabs ? 'is-active' : ''}`}>
<input
type="checkbox"
name="familyTabs"
checked={familyTabs}
onChange={(event) => onFamilyTabsChange?.(event.target.checked)}
/>
<span className="ts-choice-text">
<strong>{t('Les grandes familles par alliance dans des onglets')}</strong>
<small>{t("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.")}</small>
</span>
</label>
</fieldset>
</form>
)}
+68
View File
@@ -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; }
}
+107 -15
View File
@@ -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 && (
<div className="path-label-overlay">{displayLabel}</div>
)}
{familyTabs && tabs.length > 1 && (
<nav ref={tabsBarRef} onScroll={updateTabsEdges} className={`family-tabs${annotationHandlers ? ' family-tabs--lowered' : ''}${tabsEdges}`} aria-label={tr('Familles')}>
{tabs.map((tb) => {
const active = tb.key === (tabs.some((x) => x.key === activeKey) ? activeKey : tabs[0].key)
return (
<button
key={tb.key}
type="button"
className={`family-tab${active ? ' is-active' : ''}`}
aria-current={active ? 'page' : undefined}
onClick={() => setActiveKey(tb.key)}
>
<span className="family-tab-name">{tb.label ? tr('Famille {name}', { name: tb.label }) : tr('Famille')}</span>
<span className="family-tab-count">{tb.count}</span>
</button>
)
})}
</nav>
)}
</>
)
}
@@ -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
@@ -10,12 +10,12 @@ A2────•───A2w
│
┬────┴
│
A3────•───A3w B0────•───B0w
│ │
┬────┴ ┬──────────────┼
│ │ │
X────•────Y S
│
┼
│
K
A3────•───A3w B0────•───B0w
│ │
┴─┬ ┬──────┴────┬
│ │ │
X────•────Y S
│
┼
│
K
@@ -3,11 +3,12 @@
┬──────────────┴───┬
│ │
P1────•───P1w P2────•───P2w
│ │
┬────┴ ┬─────────────┴
│ │
│
┬────┴
│
C1────•───C2
│
┼
│
K
renvoi : P2+P2w → C2
@@ -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
@@ -1,6 +1,6 @@
Pierre──•──Odile Louis──•─Helene
│ │
┬────────┼────────┬────────┬ ┬────────┬───┴────┬────────┬────────┬
Pierre───•──Odile Louis──•─Helene
│ │
┬────────┬────┴───┬────────┬ ┬────────┬───┴────┬────────┬────────┬
│ │ │ │ │ │ │ │ │
Luc Paul Mireil Nadia──•──Marc Claire Julien Sophie Eric
│
@@ -1,5 +1,5 @@
W1────•────H────•───W2
│ │
┬───┴────┬ ┴───┬
│ │ │
A B C
┬────┴───┬ ┴──┬
│ │ │
A B C
@@ -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
@@ -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/<slug>.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])))
})
+743 -128
View File
@@ -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<chaîne> } : 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
}
+3 -3
View File
@@ -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()
+37 -10
View File
@@ -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 })
}
}
// ============================================================
@@ -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,
+6
View File
@@ -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}',
+8 -2
View File
@@ -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
+17 -3
View File
@@ -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)