diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 86d5a82..571ec68 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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} /> diff --git a/frontend/src/components/AdminPanel.jsx b/frontend/src/components/AdminPanel.jsx index 988280a..d224b5d 100644 --- a/frontend/src/components/AdminPanel.jsx +++ b/frontend/src/components/AdminPanel.jsx @@ -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 = ({
diff --git a/frontend/src/components/Galaxy.jsx b/frontend/src/components/Galaxy.jsx index 38415b7..b39f6d6 100644 --- a/frontend/src/components/Galaxy.jsx +++ b/frontend/src/components/Galaxy.jsx @@ -3,7 +3,7 @@ import { getPersonMedias, getParents, getChildren, getPersonUnions, persons as g import { PERSON_R, FLOAT_SPEED, FLOAT_AMPLITUDE, REPULSION_RADIUS, REPULSION_STRENGTH, MAX_ORBIT_MEDIAS, ENTRANCE_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(() => { diff --git a/frontend/src/components/SettingsPanels.css b/frontend/src/components/SettingsPanels.css index 644e430..6fdb354 100644 --- a/frontend/src/components/SettingsPanels.css +++ b/frontend/src/components/SettingsPanels.css @@ -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; } diff --git a/frontend/src/components/galaxy/blockLayout.js b/frontend/src/components/galaxy/blockLayout.js index 88cd589..0c0466f 100644 --- a/frontend/src/components/galaxy/blockLayout.js +++ b/frontend/src/components/galaxy/blockLayout.js @@ -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 diff --git a/frontend/src/components/galaxy/nomEtProches.test.js b/frontend/src/components/galaxy/nomEtProches.test.js new file mode 100644 index 0000000..7867a16 --- /dev/null +++ b/frontend/src/components/galaxy/nomEtProches.test.js @@ -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']) + }) +}) diff --git a/frontend/src/i18n/en/arbre.js b/frontend/src/i18n/en/arbre.js index c89418e..0123a77 100644 --- a/frontend/src/i18n/en/arbre.js +++ b/frontend/src/i18n/en/arbre.js @@ -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}',