onglets : les porteurs du nom dans leur famille, proches de l'autre côté réglables

Un enfant d'un membre qui porte le nom d'un onglet entre dans cet onglet (nom de naissance d'abord,
homonymes sans lien laissés dehors), avec son couple ; l'autre parent est estompé. Nouveau réglage
d'affichage : proches de l'autre côté à 0, 1, 2 ou 3 liens, 1 par défaut. Libellés des réglages
d'affichage réécrits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
IliesAllaliandClaude Opus 5.5 committed 2026-11-06 18:12:21 +01:00
1 parent 09b2acf4fe
commit 642b6020c3
7 files changed
+177 -25

No files matched your search

+6
View File
@@ -585,6 +585,9 @@ function App() {
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 */ } }, [])
// Nom de famille sous le prénom dans l'arbre, celui de naissance s'il est connu (#168). Réglage de ce navigateur.
// Proches des autres familles dessinés, estompés, autour d'un onglet : 0 (ma ligne seulement) à 3 liens
const [tabKinDepth, setTabKinDepthState] = useState(() => { try { const v = localStorage.getItem('parenthese.tabKinDepth'); return ['0', '1', '2', '3'].includes(v) ? Number(v) : 1 } catch { return 1 } })
const setTabKinDepth = useCallback((d) => { setTabKinDepthState(d); try { localStorage.setItem('parenthese.tabKinDepth', String(d)) } catch { /* navigateur sans stockage */ } }, [])
const [fullNames, setFullNamesState] = useState(() => { try { return localStorage.getItem('parenthese.fullNames') === 'on' } catch { return false } })
const setFullNames = useCallback((on) => { setFullNamesState(on); try { localStorage.setItem('parenthese.fullNames', 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
@@ -3571,6 +3574,7 @@ function App() {
reorderEnabled={canReorderSiblings}
onReorderSiblings={handleReorderSiblings}
familyTabs={familyTabs}
tabKinDepth={tabKinDepth}
fullNames={fullNames}
annotationViewRef={annotationViewRef}
onAnnotationsAnchored={persistAnnotationAnchors}
@@ -3752,6 +3756,8 @@ function App() {
onClose={() => setAdminPanelVisible(false)}
familyTabs={familyTabs}
onFamilyTabsChange={setFamilyTabs}
tabKinDepth={tabKinDepth}
onTabKinDepthChange={setTabKinDepth}
fullNames={fullNames}
onFullNamesChange={setFullNames}
/>
+18 -4
View File
@@ -41,6 +41,8 @@ const TREE_ICONS = ['🌳', '🌿', '🌻', '🏡', '❤️', '🧡', '⭐', '
const AdminPanel = ({
familyTabs = true,
onFamilyTabsChange = null,
tabKinDepth = 1,
onTabKinDepthChange = null,
fullNames = false,
onFullNamesChange = null,
visible,
@@ -535,6 +537,7 @@ const AdminPanel = ({
<fieldset className="ts-policy">
<legend className="pz-eyebrow">{t('Affichage')}</legend>
<p className="pz-small">{t('Réglages de ce navigateur.')}</p>
<label className={`ts-choice ${familyTabs ? 'is-active' : ''}`}>
<input
type="checkbox"
@@ -543,10 +546,21 @@ const AdminPanel = ({
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>
<strong>{t('Séparer les belles-familles en onglets')}</strong>
<small>{t("Quand la famille d'un conjoint est trop large pour l'écran, elle s'ouvre dans un onglet en haut de l'arbre.")}</small>
</span>
</label>
<div className="pz-field">
<span className="pz-label" id="settingsTabKinLabel">{t("Dans un onglet, montrer aussi les proches de l'autre côté")}</span>
<div className="ts-icons" role="radiogroup" aria-labelledby="settingsTabKinLabel">
{[[0, t('Ma ligne seulement')], [1, t('1 lien')], [2, t('2 liens')], [3, t('3 liens')]].map(([d, text]) => (
<button key={d} type="button" role="radio" aria-checked={tabKinDepth === d} disabled={!familyTabs} className="ts-icon ts-icon--letter" onClick={() => onTabKinDepthChange?.(d)}>
{text}
</button>
))}
</div>
<p className="pz-hint">{t('Un lien : un parent, un enfant ou un conjoint. Ils apparaissent en plus clair ; votre propre ligne reste toujours visible.')}</p>
</div>
<label className={`ts-choice ${fullNames ? 'is-active' : ''}`}>
<input
type="checkbox"
@@ -555,8 +569,8 @@ const AdminPanel = ({
onChange={(event) => onFullNamesChange?.(event.target.checked)}
/>
<span className="ts-choice-text">
<strong>{t("Le nom de famille sous le prénom")}</strong>
<small>{t("Le nom de naissance quand il est connu, sinon le nom d'usage. Réglage de ce navigateur.")}</small>
<strong>{t('Afficher le nom de famille')}</strong>
<small>{t("Sous chaque prénom. Le nom de naissance s'il est connu.")}</small>
</span>
</label>
</fieldset>
+9 -3
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_NODE_DURATION, ENTRANCE_CAMERA_DURATION, ENTRANCE_CAMERA_FROM_SCALE, ENTRANCE_REDUCED_DURATION, 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, restoreCoupleBarMeta } from './galaxy/elkLayout'
import { computeBlockLayout, familyTabsOf, lineToFamily, subgraphOf, takeRootLayout } from './galaxy/blockLayout'
import { computeBlockLayout, familyTabsOf, kinAround, lineToFamily, subgraphOf, takeRootLayout } from './galaxy/blockLayout'
import { hasGhosts, withGhosts } from '../utils/ghosts'
// Placement par blocs familiaux par défaut (mesures : tools/layout-bench). ?layout=sugiyama = placement Sugiyama.
@@ -77,7 +77,7 @@ function barTabs(tabs, activeKey) {
})
}
const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlightIds = [], graphRevision = 0, annotationHandlers = null, transformReadRef = null, zoomApiRef = null, focusPersonId = null, assetRevision = 0, reorderEnabled = false, onReorderSiblings = null, familyTabs = true, fullNames = false, annotationViewRef = null, onAnnotationsAnchored = null, selfPersonId = null, ghosts = null, initialFamilyKey = null, onFamilyChange = null }) => {
const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlightIds = [], graphRevision = 0, annotationHandlers = null, transformReadRef = null, zoomApiRef = null, focusPersonId = null, assetRevision = 0, reorderEnabled = false, onReorderSiblings = null, familyTabs = true, tabKinDepth = 1, fullNames = false, annotationViewRef = null, onAnnotationsAnchored = null, selfPersonId = null, ghosts = null, initialFamilyKey = null, onFamilyChange = null }) => {
const canvasRef = useRef(null)
const layoutDataRef = useRef(null)
// Onglets de familles (réglage `familyTabs`) : la liste des familles de l'arbre, calculée une fois par
@@ -675,6 +675,12 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
// Dans l'onglet d'une autre famille, la personne commune (« C'est moi », sinon la personne de départ) reste
// là, reliée par sa ligne directe (Patch, 21/10 : « je veux me voir tout le temps »)
const line = tab?.personIds ? lineToFamily(selfPersonId ?? startRefRef.current ?? tabsCacheRef.current.tabs[0]?.resolvedRef, tab.personIds) : new Set()
// Autour, estompés aussi : l'autre parent des porteurs du nom venus d'ailleurs, et les proches de l'autre côté
// jusqu'à la profondeur choisie dans les réglages (Patch, 06/11)
if (tab?.personIds) {
for (const id of tab.faded || []) line.add(id)
for (const id of kinAround(tab.personIds, tabKinDepth)) line.add(id)
}
const tabGraph = tab?.personIds ? subgraphOf(new Set([...tab.personIds, ...line])) : null
const graph = hasGhosts(ghosts)
? withGhosts(tabGraph || { persons: graphPersons, unions: graphUnions, filiations: graphFiliations }, ghosts, !tabGraph)
@@ -875,7 +881,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
pendingFrameRef.current = null
requestAnimationFrame(() => zoomApiRef?.current?.focusPersons?.(ids))
}
}, [graphRevision, activeKey, familyTabs, selfPersonId, ghosts, requestTabs, cancelTabsRequest])
}, [graphRevision, activeKey, familyTabs, tabKinDepth, selfPersonId, ghosts, requestTabs, cancelTabsRequest])
// Onglet ouvert, remonté pour l'adresse de la page et le lien de partage ; null pour la famille de départ
useEffect(() => {
@@ -62,6 +62,7 @@
.ts-icon--letter { width: auto; padding: 0 12px; border-radius: 999px; font: 500 13px var(--pz-sans); color: var(--pz-ink-2); }
.ts-icon[aria-checked="true"] { border-color: var(--pz-a500); background: var(--pz-a50); box-shadow: 0 0 0 3px var(--pz-a100); }
.ts-icon:focus-visible { outline: 2px solid var(--pz-a500); outline-offset: 2px; }
.ts-icon:disabled { opacity: 0.5; cursor: default; }
/* Cinq onglets : ils rétrécissent (texte sur deux lignes) au lieu de déborder du panneau sur un petit téléphone */
.ts-tabs .pz-tab { min-width: 0; }
+85 -14
View File
@@ -854,34 +854,35 @@ export function subgraphOf(personIds, graphData = null) {
return { persons, unions, filiations }
}
// La ligne directe d'une personne vers une famille (Patch, 21/10 : « je veux me voir tout le temps ») : le plus
// court chemin de parenté (parents, enfants, conjoints) de `selfId` jusqu'au premier membre de `memberIds`,
// sans fratries autour. Chaque pas d'un enfant à un parent garde ses deux parents (le fil part de leur union,
// et sans l'autre parent le placement dessine un conjoint « Inconnu »).
// Rend les personnes à ajouter (ni `selfId` déjà membre, ni chemin : ensemble vide).
export function lineToFamily(selfId, memberIds, graphData = null) {
const members = new Set([...memberIds].map(String))
const self = selfId != null ? String(selfId) : null
if (self == null || members.has(self)) return new Set()
const persons = graphData?.persons || globalPersons
if (!persons.some(p => String(p.id) === self)) return new Set()
// La parenté d'un arbre : pour chaque personne, ses voisins (conjoints, parents, enfants), chacun avec les personnes
// que ce pas fait dessiner en plus. Chaque pas entre un enfant et un parent garde l'autre parent (le fil part de
// leur union, et sans l'autre parent le placement dessine un conjoint « Inconnu »).
function kinshipOf(graphData = null) {
const unions = graphData?.unions || globalUnions
const filiations = graphData?.filiations || globalFiliations
const unionById = new Map(unions.map(u => [String(u.id), u]))
// Voisins : [voisin, personnes que ce pas fait dessiner en plus]
const next = new Map()
const link = (a, b, extra = []) => { if (!next.has(a)) next.set(a, []); next.get(a).push([b, extra]) }
const partnersOf = new Map()
for (const u of unions) {
const a = String(u.partner1Id), b = String(u.partner2Id)
link(a, b); link(b, a)
if (!partnersOf.has(a)) partnersOf.set(a, new Set())
if (!partnersOf.has(b)) partnersOf.set(b, new Set())
partnersOf.get(a).add(b); partnersOf.get(b).add(a)
}
const parentsOf = new Map()
const childrenOf = new Map()
for (const f of filiations) {
const child = String(f.childId)
const u = f.unionId ? unionById.get(String(f.unionId)) : null
const parents = u ? [String(u.partner1Id), String(u.partner2Id)] : f.parentId != null ? [String(f.parentId)] : []
if (!parentsOf.has(child)) parentsOf.set(child, new Set())
for (const p of parents) parentsOf.get(child).add(p)
for (const p of parents) {
parentsOf.get(child).add(p)
if (!childrenOf.has(p)) childrenOf.set(p, new Set())
childrenOf.get(p).add(child)
}
}
for (const [child, parents] of parentsOf) {
for (const p of parents) {
@@ -889,6 +890,20 @@ export function lineToFamily(selfId, memberIds, graphData = null) {
link(child, p, couple); link(p, child, couple)
}
}
return { next, parentsOf, childrenOf, partnersOf }
}
// La ligne directe d'une personne vers une famille (Patch, 21/10 : « je veux me voir tout le temps ») : le plus
// court chemin de parenté (parents, enfants, conjoints) de `selfId` jusqu'au premier membre de `memberIds`,
// sans fratries autour, chaque pas d'un enfant à un parent gardant ses deux parents.
// Rend les personnes à ajouter (ni `selfId` déjà membre, ni chemin : ensemble vide).
export function lineToFamily(selfId, memberIds, graphData = null) {
const members = new Set([...memberIds].map(String))
const self = selfId != null ? String(selfId) : null
if (self == null || members.has(self)) return new Set()
const persons = graphData?.persons || globalPersons
if (!persons.some(p => String(p.id) === self)) return new Set()
const { next } = kinshipOf(graphData)
const from = new Map([[self, null]])
const queue = [self]
let hit = null
@@ -909,6 +924,60 @@ export function lineToFamily(selfId, memberIds, graphData = null) {
return line
}
// Les porteurs du nom d'une famille (Patch, 06/11 : « si un gonz a un nom de famille il fait partie de la famille
// au même nom ») : un enfant d'un membre qui porte le nom de l'onglet en devient membre, puis ses enfants au même
// nom, de génération en génération. Nom de naissance s'il est connu, sinon nom d'usage (une femme mariée notée au
// nom de son mari reste chez elle). Seuls les enfants d'un membre comptent : un homonyme sans lien reste dehors.
// Le couple d'un porteur est dessiné entier (`added` prend ses conjoints) ; l'autre parent d'un porteur ajouté,
// sans le nom, est rendu à part (`coParents`) pour être estompé, comme un proche de l'autre côté.
const nomCle = (s) => (s || '').trim().toLocaleLowerCase('fr')
export function bearersOfName(memberIds, name, graphData = null) {
const added = new Set()
const coParents = new Set()
const target = nomCle(name)
if (!target) return { added, coParents }
const persons = graphData?.persons || globalPersons
const nameOf = new Map(persons.map(p => [String(p.id), nomCle(p.birthName || p.lastName)]))
const { parentsOf, childrenOf, partnersOf } = kinshipOf(graphData)
const members = new Set([...memberIds].map(String))
const bearers = []
const queue = [...members]
for (let i = 0; i < queue.length; i++) {
for (const c of childrenOf.get(queue[i]) || []) {
if (members.has(c) || added.has(c) || nameOf.get(c) !== target) continue
added.add(c); bearers.push(c); queue.push(c)
}
}
const inTab = (id) => members.has(id) || added.has(id)
for (const b of bearers) for (const p of partnersOf.get(b) || []) if (!inTab(p)) added.add(p)
for (const b of bearers) for (const p of parentsOf.get(b) || []) if (!inTab(p)) coParents.add(p)
return { added, coParents }
}
// Les proches de l'autre côté d'un onglet (réglage « Proches des autres familles ») : les personnes à `depth` pas
// de parenté au plus d'un membre, chaque pas d'un enfant à un parent gardant l'autre parent. Estompées à l'écran.
export function kinAround(memberIds, depth, graphData = null) {
const around = new Set()
if (!(depth > 0)) return around
const members = new Set([...memberIds].map(String))
const { next } = kinshipOf(graphData)
const dist = new Map([...members].map(id => [id, 0]))
const queue = [...members]
for (let i = 0; i < queue.length; i++) {
const d = dist.get(queue[i])
if (d >= depth) continue
for (const [nb, extra] of next.get(queue[i]) || []) {
for (const e of extra) if (!members.has(e)) around.add(e)
if (dist.has(nb)) continue
dist.set(nb, d + 1)
around.add(nb)
queue.push(nb)
}
}
for (const id of members) around.delete(id)
return around
}
// 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
@@ -933,7 +1002,9 @@ export function familyTabsOf(graphData = null, referenceId = null) {
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 })
const base = new Set([...f.members, ...f.hinges])
const { added, coParents } = bearersOfName(base, f.label, graphData)
tabs.push({ key, referenceId: key, parentKey: tab.key, personIds: new Set([...base, ...added]), faded: coParents, hinges: f.hinges, label: f.label, count: f.count + added.size })
}
}
// Deux onglets au même nom (homonymes, ou une branche sortie de sa propre lignée) : sauf le plus grand, chacun
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import { bearersOfName, kinAround } from './blockLayout'
// Henri Garnier (membre de l'onglet Garnier) et Odette, née Morel, notée au nom de son mari.
// Leurs enfants : Paul Garnier (marié à Julie Petit, une fille Lou Garnier) et Anne, au nom de sa mère.
// Un Garnier sans lien, ailleurs dans l'arbre.
const p = (id, firstName, lastName, birthName = null) => ({ id, firstName, lastName, birthName })
const graph = {
persons: [
p(1, 'Henri', 'Garnier'), p(2, 'Odette', 'Garnier', 'Morel'), p(3, 'Paul', 'Garnier'), p(4, 'Anne', 'Morel'),
p(5, 'Julie', 'Petit'), p(6, 'Lou', 'Garnier'), p(7, 'Jean', 'Garnier'), p(8, 'Marc', 'Morel'),
],
unions: [{ id: 'u1', partner1Id: 1, partner2Id: 2 }, { id: 'u2', partner1Id: 3, partner2Id: 5 }],
filiations: [
{ childId: 3, unionId: 'u1' }, { childId: 4, unionId: 'u1' }, { childId: 6, unionId: 'u2' }, { childId: 2, parentId: 8 },
],
}
describe('bearersOfName', () => {
const { added, coParents } = bearersOfName(['1'], 'Garnier', graph)
it('prend les enfants au nom, de génération en génération, avec leurs conjoints', () => {
expect([...added].sort()).toEqual(['3', '5', '6'])
})
it("laisse dehors l'enfant au nom de sa mère, l'homonyme sans lien, et la mère notée au nom du mari", () => {
expect(added.has('4')).toBe(false)
expect(added.has('7')).toBe(false)
expect(added.has('2')).toBe(false)
})
it("rend l'autre parent à estomper", () => {
expect([...coParents]).toEqual(['2'])
})
it('ignore la casse du nom', () => {
expect(bearersOfName(['1'], 'GARNIER', graph).added.has('3')).toBe(true)
})
})
describe('kinAround', () => {
it('ma ligne seulement : personne autour', () => {
expect(kinAround(['1'], 0, graph).size).toBe(0)
})
it('1 lien : conjoint et enfants, et l\'autre parent', () => {
expect([...kinAround(['1'], 1, graph)].sort()).toEqual(['2', '3', '4'])
})
it('2 liens : petits-enfants, conjoints des enfants, parents du conjoint', () => {
expect([...kinAround(['1'], 2, graph)].sort()).toEqual(['2', '3', '4', '5', '6', '8'])
})
})
+11 -4
View File
@@ -5,10 +5,17 @@ export default {
'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.',
'Le nom de famille sous le prénom': 'Last name under the first name',
"Le nom de naissance quand il est connu, sinon le nom d'usage. Réglage de ce navigateur.": 'The maiden name when it is known, otherwise the current last name. Saved in this browser.',
'Réglages de ce navigateur.': 'Saved in this browser.',
'Séparer les belles-familles en onglets': 'Put in-law families in tabs',
"Quand la famille d'un conjoint est trop large pour l'écran, elle s'ouvre dans un onglet en haut de l'arbre.": "When a spouse's family is too wide for the screen, it opens in a tab at the top of the tree.",
"Dans un onglet, montrer aussi les proches de l'autre côté": 'In a tab, also show relatives from the other side',
'Ma ligne seulement': 'Only my line',
'1 lien': '1 link',
'2 liens': '2 links',
'3 liens': '3 links',
'Un lien : un parent, un enfant ou un conjoint. Ils apparaissent en plus clair ; votre propre ligne reste toujours visible.': 'A link is a parent, a child or a spouse. They appear lighter; your own line always stays visible.',
'Afficher le nom de famille': 'Show the last name',
"Sous chaque prénom. Le nom de naissance s'il est connu.": 'Under each first name. The maiden name when it is known.',
// Arbre sur canvas (components/galaxy/renderers.js)
'Voir les parents': 'See parents',
'Voir {firstName}': 'See {firstName}',