Merge pull request #219 from IliesAllali/feat/onglets-par-nom

Onglets de familles : un onglet par nom, seuil réglable au curseur
This commit is contained in:
IliesAllali authored and GitHub committed 2026-11-07 00:48:47 +01:00
commit 4544edde6f
12 files changed
+229 -376

No files matched your search

+6 -6
View File
@@ -585,9 +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 */ } }, [])
// Onglets par nom : nombre de porteurs d'un nom à partir duquel il a son onglet (8 par défaut, Patch, 07/11)
const [tabMinCount, setTabMinCountState] = useState(() => { try { const v = Number(localStorage.getItem('parenthese.tabMinCount')); return v >= 2 && v <= 30 ? v : 8 } catch { return 8 } })
const setTabMinCount = useCallback((n) => { setTabMinCountState(n); try { localStorage.setItem('parenthese.tabMinCount', String(n)) } 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
@@ -3574,7 +3574,7 @@ function App() {
reorderEnabled={canReorderSiblings}
onReorderSiblings={handleReorderSiblings}
familyTabs={familyTabs}
tabKinDepth={tabKinDepth}
tabMinCount={tabMinCount}
fullNames={fullNames}
annotationViewRef={annotationViewRef}
onAnnotationsAnchored={persistAnnotationAnchors}
@@ -3756,8 +3756,8 @@ function App() {
onClose={() => setAdminPanelVisible(false)}
familyTabs={familyTabs}
onFamilyTabsChange={setFamilyTabs}
tabKinDepth={tabKinDepth}
onTabKinDepthChange={setTabKinDepth}
tabMinCount={tabMinCount}
onTabMinCountChange={setTabMinCount}
fullNames={fullNames}
onFullNamesChange={setFullNames}
/>
+48 -17
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Download, ImagePlus, Trash2, Unplug, X } from 'lucide-react'
import './SettingsPanels.css'
import {
@@ -25,6 +25,7 @@ import { t } from '../i18n/index.js'
import { readGedcomFile } from '../utils/gedcomText.js'
import { readGedcomBundle, zipErrorMessage } from '../utils/gedcomBundle.js'
import Skeleton from './Skeleton.jsx'
import { nameTabsOf } from './galaxy/nameTabs.js'
function normalizeSlug(value) {
return String(value || '')
@@ -41,8 +42,8 @@ const TREE_ICONS = ['🌳', '🌿', '🌻', '🏡', '❤️', '🧡', '⭐', '
const AdminPanel = ({
familyTabs = true,
onFamilyTabsChange = null,
tabKinDepth = 1,
onTabKinDepthChange = null,
tabMinCount = 8,
onTabMinCountChange = null,
fullNames = false,
onFullNamesChange = null,
visible,
@@ -62,6 +63,16 @@ const AdminPanel = ({
onOpenPhotoStart,
}) => {
const [section, setSection] = useState('tree')
// Seuil des onglets : le curseur montre tout de suite les onglets qu'il crée ; l'arbre ne se recalcule qu'une
// fois le curseur posé
const [tabDraft, setTabDraft] = useState(tabMinCount)
useEffect(() => { setTabDraft(tabMinCount) }, [tabMinCount])
useEffect(() => {
if (tabDraft === tabMinCount) return undefined
const timer = setTimeout(() => onTabMinCountChange?.(tabDraft), 350)
return () => clearTimeout(timer)
}, [tabDraft, tabMinCount, onTabMinCountChange])
const tabPreview = useMemo(() => (visible && familyTabs ? nameTabsOf(null, tabDraft).slice(1) : []), [visible, familyTabs, tabDraft])
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [errorMessage, setErrorMessage] = useState('')
@@ -540,27 +551,47 @@ const AdminPanel = ({
<p className="pz-small">{t('Réglages de ce navigateur.')}</p>
<label className={`ts-choice ${familyTabs ? 'is-active' : ''}`}>
<input
type="checkbox"
type="radio"
name="familyTabs"
checked={familyTabs}
onChange={(event) => onFamilyTabsChange?.(event.target.checked)}
onChange={() => onFamilyTabsChange?.(true)}
/>
<span className="ts-choice-text">
<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>
<strong>{t('Un onglet par nom de famille')}</strong>
<small>{t("Chaque nom porté par assez de personnes reliées entre elles a son onglet, avec les conjoints. Le premier onglet montre tout 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>
))}
{familyTabs && (
<div className="ts-min-count">
<label htmlFor="settingsTabMinCount">{t('À partir de {n} personnes au même nom', { n: tabDraft })}</label>
<input
id="settingsTabMinCount"
type="range"
min={2}
max={30}
step={1}
value={tabDraft}
onChange={(event) => setTabDraft(Number(event.target.value))}
/>
<p className="ts-tab-preview" aria-live="polite">
{tabPreview.length
? tabPreview.map((tb) => <span key={tb.key} className="pz-tag">{tb.label} <small>{tb.count}</small></span>)
: t("Aucun onglet à ce seuil : tout l'arbre sur une seule page.")}
</p>
</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 ${familyTabs ? '' : 'is-active'}`}>
<input
type="radio"
name="familyTabs"
checked={!familyTabs}
onChange={() => onFamilyTabsChange?.(false)}
/>
<span className="ts-choice-text">
<strong>{t("Pas d'onglets")}</strong>
<small>{t("Tout l'arbre sur une seule page.")}</small>
</span>
</label>
<label className={`ts-choice ${fullNames ? 'is-active' : ''}`}>
<input
type="checkbox"
+43 -127
View File
@@ -2,8 +2,9 @@ import { memo, useEffect, useImperativeHandle, useRef, useState, useCallback } f
import { getPersonMedias, getParents, getChildren, getPersonUnions, persons as graphPersons, unions as graphUnions, filiations as graphFiliations } from '../data/mockData'
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, kinAround, lineToFamily, subgraphOf, takeRootLayout } from './galaxy/blockLayout'
import { computeLayout, generateNodeRandomData, coupleBarMeta } from './galaxy/elkLayout'
import { computeBlockLayout, lineToFamily, subgraphOf } from './galaxy/blockLayout'
import { nameTabsOf } from './galaxy/nameTabs'
import { hasGhosts, withGhosts } from '../utils/ghosts'
// Placement par blocs familiaux par défaut (mesures : tools/layout-bench). ?layout=sugiyama = placement Sugiyama.
@@ -77,24 +78,15 @@ 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, tabKinDepth = 1, 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, tabMinCount = 8, fullNames = false, annotationViewRef = null, onAnnotationsAnchored = null, selfPersonId = null, ghosts = null, initialFamilyKey = null, onFamilyChange = null }) => {
const canvasRef = useRef(null)
const layoutDataRef = useRef(null)
// Onglets de familles (réglage `familyTabs`) : la liste des familles de l'arbre, calculée une fois par
// version du graphe et par personne de départ, et l'onglet ouvert. Un onglet est le sous-arbre d'une
// grande belle-famille, posé avec le conjoint charnière comme référence.
const [tabs, setTabs] = useState([])
const [activeKey, setActiveKey] = useState(focusPersonId != null ? String(focusPersonId) : null)
const tabsCacheRef = useRef({ rev: -1, start: undefined, tabs: [] })
// Liste des onglets calculée dans un worker (galaxy/familyTabsWorker.js) quand l'arbre affiché est celui de la
// famille de départ : il ne dépend pas de cette liste, il s'affiche tout de suite et la barre suit. Une demande
// en cours est abandonnée (worker arrêté) dès qu'une autre part ou qu'on calcule la liste sur place.
const tabsRequestRef = useRef(null) // { rev, start, worker }
const cancelTabsRequest = useCallback(() => {
tabsRequestRef.current?.worker.terminate()
tabsRequestRef.current = null
}, [])
useEffect(() => cancelTabsRequest, [cancelTabsRequest])
const [activeKey, setActiveKey] = useState(null)
const tabsCacheRef = useRef({ rev: -1, min: null, tabs: [] })
const pendingFocusRef = useRef(null) // personne cherchée dans un autre onglet, visée après le changement
const pendingFrameRef = useRef(null) // personnes à cadrer une fois l'arbre reposé (ajout de plusieurs prénoms)
// Lien d'un onglet (?famille=<charnière>) : ouvert dès que l'arbre et ses onglets sont connus, et de nouveau si
@@ -119,31 +111,38 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
useEffect(() => {
startRefRef.current = focusPersonId
if (linkFamilyRef.current != null) pendingFamilyRef.current = linkFamilyRef.current
setActiveKey(focusPersonId != null ? String(focusPersonId) : null)
setActiveKey(null)
}, [focusPersonId])
// Onglet choisi par la personne (barre, badge, recherche) : le lien d'arrivée ne s'impose plus
// Repère du changement d'onglet (Patch, 28/10 : « on perd un peu rapidement le fil ») : la personne qui relie
// les deux familles garde sa place et son zoom à l'écran, seules les familles autour changent
const anchorRef = useRef(null)
const linkPeople = (key) => {
const list = tabsCacheRef.current.tabs
const target = list.find((tb) => tb.key === String(key)), cur = activeTabRef.current || list[0]
return [target?.referenceId, ...(target?.hinges || []), cur?.referenceId, ...(cur?.hinges || [])].filter((x) => x != null).map(String)
}
// La personne gardée : celle de l'onglet visé, visible à l'écran, la plus proche du centre (sinon rien à garder)
const rememberAnchor = (key) => {
const layout = layoutDataRef.current, t = transformRef.current
if (!layout || !t) return
const ids = linkPeople(key)
const node = layout.children.find((n) => n._type === 'person' && ids.includes(String(n._data?.id)))
anchorRef.current = node ? { id: String(node._data.id), sx: (node.x + node.width / 2) * t.scale + t.x, sy: (node.y + node.height / 2) * t.scale + t.y, scale: t.scale } : null
const layout = layoutDataRef.current, t = transformRef.current, canvas = canvasRef.current
if (!layout || !t || !canvas) return
const target = tabsCacheRef.current.tabs.find((tb) => tb.key === String(key))
const w = canvas.clientWidth, h = canvas.clientHeight
let best = null
for (const n of layout.children) {
if (n._type !== 'person' || n._line) continue
const id = String(n._data?.id)
if (target?.personIds && !target.personIds.has(id)) continue
const sx = (n.x + n.width / 2) * t.scale + t.x, sy = (n.y + n.height / 2) * t.scale + t.y
if (sx < 0 || sy < 0 || sx > w || sy > h) continue
const d = (sx - w / 2) ** 2 + (sy - h / 2) ** 2
if (!best || d < best.d) best = { id, sx, sy, d }
}
anchorRef.current = best ? { id: best.id, sx: best.sx, sy: best.sy, scale: t.scale } : null
}
const chooseTab = useCallback((key) => { rememberAnchor(key); linkFamilyRef.current = null; pendingFamilyRef.current = null; frameOnRef.current = null; setActiveKey(key) }, [])
// Survol d'un onglet : la personne par qui l'on y passe s'allume dans l'arbre affiché, sans texte (Patch : « que ça les suggère »)
// Survol d'un onglet : l'ancêtre de la branche s'allume dans l'arbre affiché, sans texte (Patch : « que ça les suggère »)
const hoverTab = (key) => {
const layout = layoutDataRef.current
if (!layout) return
const present = new Set(layout.children.filter((n) => n._type === 'person').map((n) => String(n._data?.id)))
const ids = linkPeople(key).filter((id) => present.has(id))
const ancestor = tabsCacheRef.current.tabs.find((tb) => tb.key === String(key))?.referenceId
const ids = ancestor != null && present.has(String(ancestor)) ? [String(ancestor)] : []
searchHighlightSetRef.current = new Set([...searchHighlightIdsRef.current.map((id) => `p-${id}`), ...ids.map((id) => `p-${id}`)])
needsRedrawRef.current = true
}
@@ -314,7 +313,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
unions: src.unions,
filiations: filiationsWithOrder(src.filiations, src.unions, group.source, order),
}
const result = computeBlockLayout(graph, focusPersonIdRef.current, { familyTabs: familyTabsRef.current })
const result = computeBlockLayout(graph, focusPersonIdRef.current, { familyTabs: false })
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
@@ -586,79 +585,16 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
}
}, [])
// Liste arrivée : l'arbre affiché (famille de départ) ne change pas, ses pastilles prennent le nom et le compte de
// l'onglet où elles mènent
const applyTabs = useCallback((req, list) => {
if (tabsRequestRef.current !== req) return
tabsRequestRef.current = null
tabsCacheRef.current = { rev: req.rev, start: req.start, tabs: list }
setTabs(list)
for (const n of layoutDataRef.current?.children || []) {
const target = n._familyTab && list.find((tb) => tb.key === n._familyTab.tabKey)
if (target) n._familyTab = { ...n._familyTab, label: target.label, count: target.count }
}
needsRedrawRef.current = true
}, [])
// Lance le calcul de la liste dans un worker ; faux si le navigateur ne le permet pas (on calcule alors sur place)
const requestTabs = useCallback((rev, start, referenceId) => {
cancelTabsRequest()
if (typeof Worker === 'undefined') return false
let worker
try {
worker = new Worker(new URL('./galaxy/familyTabsWorker.js', import.meta.url), { type: 'module' })
worker.postMessage({ graph: { persons: graphPersons, unions: graphUnions, filiations: graphFiliations }, referenceId })
} catch {
worker?.terminate()
return false
}
const req = { rev, start, worker }
tabsRequestRef.current = req
// Échec du worker : la liste est calculée sur place, sans toucher aux couples de l'arbre affiché
const fallback = () => {
if (tabsRequestRef.current !== req) return
const meta = coupleBarMeta
const list = familyTabsOf(null, referenceId)
restoreCoupleBarMeta(meta)
applyTabs(req, list)
}
worker.onmessage = ({ data }) => {
worker.terminate()
if (data.error) fallback()
else applyTabs(req, data.tabs)
}
worker.onerror = () => { worker.terminate(); fallback() }
return true
}, [cancelTabsRequest, applyTabs])
// Layout
useEffect(() => {
// 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
// Onglets par nom (galaxy/nameTabs.js) : la liste se lit dans les noms, une fois par version du graphe et par seuil
const cache = tabsCacheRef.current
let freshTabs = null
// Liste en attente du worker : l'arbre affiché est celui de la famille de départ
let tabsPending = false
if (USE_BLOCK_LAYOUT && familyTabs && (cache.rev !== graphRevision || cache.start !== start)) {
const pending = tabsRequestRef.current
// Dans un onglet d'une autre famille, ou à l'arrivée par le lien d'un onglet, l'arbre affiché dépend de la
// liste : elle est calculée sur place
const inOtherTab = Boolean(cache.tabs.find((tb) => tb.key === activeKey)?.personIds)
if (!inOtherTab && pendingFamilyRef.current == null) {
tabsPending = (pending && pending.rev === graphRevision && pending.start === start) || requestTabs(graphRevision, start, startRefRef.current)
}
if (!tabsPending) {
cancelTabsRequest()
freshTabs = familyTabsOf(null, startRefRef.current)
tabsCacheRef.current = { rev: graphRevision, start, tabs: freshTabs }
setTabs(freshTabs)
}
}
if (!familyTabs) {
cancelTabsRequest()
if (tabsCacheRef.current.tabs.length) { tabsCacheRef.current = { rev: -1, start: undefined, tabs: [] }; setTabs([]) }
if (familyTabs && (cache.rev !== graphRevision || cache.min !== tabMinCount)) {
const list = nameTabsOf(null, tabMinCount)
tabsCacheRef.current = { rev: graphRevision, min: tabMinCount, tabs: list }
setTabs(list)
}
if (!familyTabs && cache.tabs.length) { tabsCacheRef.current = { rev: -1, min: null, tabs: [] }; setTabs([]) }
// La famille du lien : son onglet s'il existe dans ce navigateur, sinon l'arbre entier ouvert sur sa charnière
if (pendingFamilyRef.current != null && graphPersons.length) {
const key = pendingFamilyRef.current
@@ -668,19 +604,15 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
if (key !== activeKey) { setActiveKey(key); return }
} else frameOnRef.current = key
}
const tab = familyTabs && !tabsPending ? (tabsCacheRef.current.tabs.find((tb) => tb.key === activeKey) || tabsCacheRef.current.tabs[0] || null) : null
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
// La personne de départ (ou l'ancêtre de la branche ouverte) sert de référence aux rangées
focusPersonIdRef.current = tab?.referenceId ?? startRefRef.current
// 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 line = tab?.personIds ? lineToFamily(selfPersonId ?? startRefRef.current, tab.personIds) : new Set()
// Estompés aussi : parents et enfants des porteurs qui ne portent pas le nom
for (const id of tab?.faded || []) 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)
@@ -688,30 +620,14 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
// Annotations d'avant l'ancrage (x, y seuls) : rattachées une fois à la personne la plus proche dans le
// placement de l'onglet de départ, puis enregistrées. Calculé avant l'arbre affiché (coupleBarMeta, voir plus haut)
const legacy = annotationData.some((a) => !readAnchor(a))
const departure = legacy && USE_BLOCK_LAYOUT && graph ? computeBlockLayout(null, startRefRef.current, { familyTabs }) : null
// Famille de départ sans propositions : son placement vient d'être calculé avec la liste des onglets
const kept = USE_BLOCK_LAYOUT && !graph && tab === freshTabs?.[0] ? takeRootLayout(freshTabs, focusPersonIdRef.current) : null
const result = kept || (USE_BLOCK_LAYOUT ? computeBlockLayout(graph, focusPersonIdRef.current, { familyTabs }) : computeLayout(null, focusPersonIdRef.current))
const departure = legacy && USE_BLOCK_LAYOUT && graph ? computeBlockLayout(null, startRefRef.current, { familyTabs: false }) : null
const result = USE_BLOCK_LAYOUT ? computeBlockLayout(graph, focusPersonIdRef.current, { familyTabs: false }) : computeLayout(null, focusPersonIdRef.current)
if (legacy) {
const anchored = anchorLegacy(annotationData, layoutCenters(departure || result))
for (const ann of anchored) updateAnnotationInStore(ann.id, { style: ann.style })
if (anchored.length) onAnnotationsAnchoredRef.current?.(anchored)
}
// 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 }
}
for (const n of result.children) if (n._type === 'person' && line.has(n.id.replace(/^p-/, ''))) n._line = true
// 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', back: true }
}
}
const canvas = canvasRef.current
if (!canvas) return
@@ -881,7 +797,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
pendingFrameRef.current = null
requestAnimationFrame(() => zoomApiRef?.current?.focusPersons?.(ids))
}
}, [graphRevision, activeKey, familyTabs, tabKinDepth, selfPersonId, ghosts, requestTabs, cancelTabsRequest])
}, [graphRevision, activeKey, familyTabs, tabMinCount, selfPersonId, ghosts])
// Onglet ouvert, remonté pour l'adresse de la page et le lien de partage ; null pour la famille de départ
useEffect(() => {
@@ -1369,7 +1285,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
onFocus={() => { if (!active) hoverTab(tb.key) }}
onBlur={leaveTab}
>
<span className="family-tab-name">{tb.label ? tr('Famille {name}', { name: tb.label }) : tr('Famille')}</span>
<span className="family-tab-name">{tb.all ? tr("Tout l'arbre") : tr('Famille {name}', { name: tb.label })}</span>
<span className="family-tab-count" title={tr('{n} personnes', { n: tb.count })}>{tb.count}</span>
</button>
)
+5 -1
View File
@@ -62,7 +62,6 @@
.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; }
@@ -305,3 +304,8 @@
.contrib-field-label { width: 88px; }
}
.tm-share-btn { justify-self: start; }
.ts-min-count { display: grid; gap: 8px; margin: 0 0 12px 16px; font: 400 14px var(--pz-sans); color: var(--pz-ink-2); }
.ts-min-count input[type=range] { width: 100%; max-width: 320px; accent-color: var(--pz-a500); }
.ts-tab-preview { display: flex; flex-wrap: wrap; gap: 6px; margin: 0; color: var(--pz-faint); }
.ts-tab-preview small { font-variant-numeric: tabular-nums; opacity: 0.7; }
+1 -124
View File
@@ -1,4 +1,4 @@
import { computeLayout, componentOrderPerGen, chainGap, coupleBarMeta, restoreCoupleBarMeta } from './elkLayout'
import { computeLayout, componentOrderPerGen, chainGap } from './elkLayout'
import { persons as globalPersons, unions as globalUnions, filiations as globalFiliations } from '../../data/mockData'
// ============================================================
@@ -924,126 +924,3 @@ 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
// 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 }
// La personne de départ réellement retenue (l'arbre sans personne de départ en choisit une)
if (tab === root) root.resolvedRef = result.referenceId != null ? String(result.referenceId).replace(/^p-/, '') : null
// Le placement de la famille de départ est celui que l'arbre affiche ensuite : gardé pour takeRootLayout,
// il n'est pas recalculé (plusieurs secondes sur un arbre de quelques milliers de personnes)
if (tab === root && !graphData) Object.defineProperty(tabs, 'rootLayout', { value: { result, coupleBarMeta, referenceId }, writable: true })
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)
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
// 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 »)
// 28/10 (Patch : « ptet le Joseph et Marie ») : l'onglet le plus petit prend d'abord le nom suivant le plus
// porté chez lui et pas encore pris par un autre onglet ; les prénoms de la charnière ne servent qu'en dernier recours.
const persons = graphData?.persons || globalPersons
const firstName = new Map(persons.map(p => [String(p.id), p.firstName]))
const familyName = new Map(persons.map(p => [String(p.id), (p.birthName || p.lastName || '').trim()]))
const namesByCount = (tb) => {
const n = new Map()
for (const id of tb.personIds || []) { const nom = familyName.get(String(id).replace(/^p-/, '')); if (nom) n.set(nom, (n.get(nom) || 0) + 1) }
return [...n].sort((x, y) => y[1] - x[1]).map(([nom]) => nom)
}
const byLabel = new Map()
for (const tb of tabs) { if (!byLabel.has(tb.label)) byLabel.set(tb.label, []); byLabel.get(tb.label).push(tb) }
const taken = new Set(tabs.map(tb => tb.label))
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].sort((a, b) => b.count - a.count)) {
if (tb === biggest) continue
const next = namesByCount(tb).find(nom => !taken.has(nom))
if (next) { tb.label = next; taken.add(next); 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
}
// Le placement de la famille de départ calculé par familyTabsOf (arbre du module, onglets allumés), rendu une
// seule fois : l'arbre affiché le modifie ensuite (pastilles, ligne directe, réordonnancement)
export function takeRootLayout(tabs, referenceId) {
const kept = tabs?.rootLayout
if (!kept) return null
tabs.rootLayout = null
if (String(kept.referenceId ?? '') !== String(referenceId ?? '')) return null
restoreCoupleBarMeta(kept.coupleBarMeta)
return kept.result
}
@@ -1,8 +1,5 @@
import { describe, it, expect } from 'vitest'
import { computeBlockLayout, familyTabsOf, filCroiseAutreFamille, lineToFamily, takeRootLayout } from './blockLayout'
import * as elk from './elkLayout'
import * as mockData from '../../data/mockData'
import { generate } from '../../../../tools/layout-bench/gen-fr.mjs'
import { computeBlockLayout, filCroiseAutreFamille, lineToFamily } from './blockLayout'
import { COMPACT_W, PERSON_W } from './constants'
const person = (id) => ({ id, firstName: id, lastName: '' })
@@ -171,31 +168,3 @@ describe('lineToFamily, ligne directe vers un onglet de famille', () => {
expect(drawn.has('p-oncle')).toBe(false)
})
})
describe('familyTabsOf, placement de la famille de départ gardé', () => {
it('rend le même placement et les mêmes couples qu’un calcul neuf, une seule fois', () => {
const tree = generate('desc', 5)
mockData.loadGraphData(tree.graph, { apiBaseUrl: '' })
const fresh = computeBlockLayout(null, tree.rootPersonId)
const freshMeta = JSON.stringify(elk.coupleBarMeta)
const tabs = familyTabsOf(null, tree.rootPersonId)
expect(tabs.length).toBeGreaterThan(1)
const kept = takeRootLayout(tabs, tree.rootPersonId)
expect(JSON.stringify(elk.coupleBarMeta)).toBe(freshMeta)
expect(kept.children.map((n) => [n.id, n.x, n.y])).toEqual(fresh.children.map((n) => [n.id, n.x, n.y]))
expect(takeRootLayout(tabs, tree.rootPersonId)).toBeNull()
})
})
describe('familyTabsOf, graphe passé en entier (worker)', () => {
it('rend la même liste d’onglets sans les données du module', () => {
const tree = generate('desc', 5)
mockData.loadGraphData(tree.graph, { apiBaseUrl: '' })
const graph = structuredClone({ persons: mockData.persons, unions: mockData.unions, filiations: mockData.filiations })
const expected = familyTabsOf(null, tree.rootPersonId)
mockData.replaceGraph({ persons: [], unions: [], filiations: [] })
const fromWorker = familyTabsOf(graph, tree.rootPersonId)
expect(fromWorker.length).toBeGreaterThan(1)
expect(fromWorker).toEqual(expected)
})
})
@@ -10,8 +10,6 @@ import {
// État partagé — peuplé par computeLayout, lu par renderers
// ============================================================
export let coupleBarMeta = []
// Remet les couples d'un placement déjà calculé (placement repris d'un cache, voir familyTabsOf)
export function restoreCoupleBarMeta(meta) { coupleBarMeta = meta }
export let componentOrderPerGen = new Map()
// ============================================================
@@ -1,12 +0,0 @@
// Liste des onglets de familles calculée hors du fil principal : sur un arbre de quelques milliers de personnes,
// familyTabsOf place chaque onglet pour trouver ceux qu'il cache, plusieurs secondes pendant lesquelles la page
// ne répondrait plus. Le graphe arrive avec la demande (ce module n'a pas les données de l'arbre).
import { familyTabsOf } from './blockLayout'
self.onmessage = ({ data: { id, graph, referenceId } }) => {
try {
self.postMessage({ id, tabs: familyTabsOf(graph, referenceId) })
} catch (error) {
self.postMessage({ id, error: String(error?.message || error) })
}
}
@@ -0,0 +1,76 @@
// Onglets de familles par nom (Patch, 06/11 : « si un gonz a un nom de famille il fait partie de la famille au
// même nom », puis « tout ce système faut le revisualiser de 0 et le simplifier, c'est un peu trop aléatoire »).
//
// Un onglet = les porteurs d'un même nom reliés entre eux par la filiation, dès qu'ils sont au moins `minCount`.
// Le nom est le nom de naissance s'il est connu, sinon le nom d'usage : une femme mariée reste dans la famille où
// elle est née. Deux branches homonymes sans lien font deux onglets distincts (prénom de leur ancêtre en plus).
// Dans un onglet : les porteurs et leurs conjoints ; en plus clair (`faded`), les parents et les enfants d'un
// porteur qui ne portent pas le nom (une mère d'une autre famille, les enfants d'une fille mariée).
// Le premier onglet est l'arbre entier. Rien ne dépend du placement : la liste se lit dans les noms.
import { persons as globalPersons, unions as globalUnions, filiations as globalFiliations } from '../../data/mockData'
const nameKey = (p) => (p?.birthName || p?.lastName || '').trim().toLocaleLowerCase('fr')
const birthYear = (p) => { const y = parseInt(String(p?.birthDate || '').slice(0, 4), 10); return Number.isFinite(y) ? y : Infinity }
export function nameTabsOf(graphData = null, minCount = 8) {
const persons = graphData?.persons || globalPersons
const unions = graphData?.unions || globalUnions
const filiations = graphData?.filiations || globalFiliations
const byId = new Map(persons.map((p) => [String(p.id), p]))
const unionById = new Map(unions.map((u) => [String(u.id), u]))
const add = (map, k, v) => { if (!map.has(k)) map.set(k, new Set()); map.get(k).add(v) }
const partnersOf = new Map(), parentsOf = new Map(), childrenOf = new Map()
for (const u of unions) { const a = String(u.partner1Id), b = String(u.partner2Id); add(partnersOf, a, b); add(partnersOf, b, a) }
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)] : []
for (const p of parents) { add(parentsOf, child, p); add(childrenOf, p, child) }
}
// Branches : un porteur rejoint son parent au même nom, et ses frères et sœurs au même nom (l'ancêtre porteur
// peut manquer à l'arbre)
const up = new Map()
const find = (x) => { while (up.get(x) !== x) { up.set(x, up.get(up.get(x))); x = up.get(x) } return x }
const join = (a, b) => { const ra = find(a), rb = find(b); if (ra !== rb) up.set(ra, rb) }
const key = new Map()
for (const [id, p] of byId) { const k = nameKey(p); if (k) { key.set(id, k); up.set(id, id) } }
for (const [id, k] of key) {
for (const p of parentsOf.get(id) || []) {
if (key.get(p) === k) join(id, p)
for (const s of childrenOf.get(p) || []) if (s !== id && key.get(s) === k) join(id, s)
}
}
const groups = new Map()
for (const id of key.keys()) add(groups, find(id), id)
const tabs = []
for (const bearers of groups.values()) {
if (bearers.size < minCount) continue
const members = new Set(bearers)
for (const b of bearers) for (const p of partnersOf.get(b) || []) members.add(p)
const faded = new Set()
for (const b of bearers) {
for (const p of parentsOf.get(b) || []) if (!members.has(p)) faded.add(p)
for (const c of childrenOf.get(b) || []) if (!members.has(c)) faded.add(c)
}
// Ancêtre de la branche : le porteur sans parent porteur le plus ancien ; il ouvre l'onglet et le nomme
const roots = [...bearers].filter((b) => ![...(parentsOf.get(b) || [])].some((p) => bearers.has(p)))
const root = roots.sort((a, b) => birthYear(byId.get(a)) - birthYear(byId.get(b)))[0]
// Graphie la plus portée (« Martin » plutôt que « MARTIN »)
const spell = new Map()
for (const b of bearers) { const p = byId.get(b); const s = (p.birthName || p.lastName).trim(); spell.set(s, (spell.get(s) || 0) + 1) }
const label = [...spell].sort((x, y) => y[1] - x[1])[0][0]
// Un nom porté par tout l'arbre ferait doublon avec le premier onglet
if (members.size >= persons.length) continue
tabs.push({ key: root, referenceId: root, personIds: members, faded, label, count: members.size, nameKey: key.get(root) })
}
tabs.sort((a, b) => b.count - a.count)
// Homonymes : chaque branche sauf la plus grande prend le prénom de son ancêtre
const seen = new Set()
for (const tb of tabs) {
if (seen.has(tb.nameKey)) { const first = byId.get(tb.referenceId)?.firstName; if (first) tb.label = `${tb.label} · ${first}` }
seen.add(tb.nameKey)
}
return [{ key: 'all', referenceId: null, personIds: null, all: true, label: '', count: persons.length }, ...tabs]
}
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest'
import { nameTabsOf } from './nameTabs'
// Branche Garnier : Jean et Marthe (née Roux), trois enfants Garnier, dont Paul (marié à Julie Petit, deux
// enfants Garnier) et Anne (mariée à Marc Duval, un fils Duval). Un Garnier sans lien, ailleurs.
const p = (id, firstName, lastName, birthName = null, birthDate = null) => ({ id, firstName, lastName, birthName, birthDate })
const graph = {
persons: [
p(1, 'Jean', 'Garnier', null, '1920-01-01'), p(2, 'Marthe', 'Garnier', 'Roux'), p(3, 'Paul', 'Garnier'), p(4, 'Anne', 'Duval', 'Garnier'),
p(5, 'Luc', 'Garnier'), p(6, 'Julie', 'Petit'), p(7, 'Lou', 'Garnier'), p(8, 'Léo', 'GARNIER'), p(9, 'Marc', 'Duval'), p(10, 'Hugo', 'Duval'),
p(11, 'Joseph', 'Garnier'), p(12, 'Rose', 'Roux'),
],
unions: [{ id: 'u1', partner1Id: 1, partner2Id: 2 }, { id: 'u2', partner1Id: 3, partner2Id: 6 }, { id: 'u3', partner1Id: 4, partner2Id: 9 }],
filiations: [
{ childId: 3, unionId: 'u1' }, { childId: 4, unionId: 'u1' }, { childId: 5, unionId: 'u1' },
{ childId: 7, unionId: 'u2' }, { childId: 8, unionId: 'u2' }, { childId: 10, unionId: 'u3' }, { childId: 2, parentId: 12 },
],
}
describe('nameTabsOf', () => {
const tabs = nameTabsOf(graph, 5)
const garnier = tabs.find((tb) => tb.label === 'Garnier')
it("premier onglet : l'arbre entier", () => {
expect(tabs[0]).toMatchObject({ all: true, personIds: null, count: 12 })
})
it('une branche au même nom, nom de naissance compris, casse ignorée', () => {
expect([...garnier.personIds].filter((id) => ['1', '3', '4', '5', '7', '8'].includes(id)).length).toBe(6)
})
it('avec les conjoints des porteurs', () => {
for (const id of ['2', '6', '9']) expect(garnier.personIds.has(id)).toBe(true)
})
it("en plus clair : l'enfant d'une fille mariée et le parent d'une épouse", () => {
expect([...garnier.faded].sort()).toEqual(['10'])
})
it('l homonyme sans lien reste dehors, l ancêtre ouvre l onglet', () => {
expect(garnier.personIds.has('11')).toBe(false)
expect(garnier.referenceId).toBe('1')
})
it('sous le seuil, pas d onglet', () => {
expect(nameTabsOf(graph, 7).length).toBe(1)
})
})
@@ -1,47 +0,0 @@
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'])
})
})
+7 -8
View File
@@ -6,14 +6,13 @@ export default {
'Familles': 'Families',
'Affichage': 'Display',
'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.',
'Un onglet par nom de famille': 'One tab per last name',
"Chaque nom porté par assez de personnes reliées entre elles a son onglet, avec les conjoints. Le premier onglet montre tout l'arbre.": 'Each last name carried by enough related people gets its own tab, with their spouses. The first tab shows the whole tree.',
'À partir de {n} personnes au même nom': 'From {n} people with the same name',
"Aucun onglet à ce seuil : tout l'arbre sur une seule page.": 'No tab at this threshold: the whole tree on one page.',
"Pas d'onglets": 'No tabs',
"Tout l'arbre sur une seule page.": 'The whole tree on one page.',
"Tout l'arbre": 'Whole tree',
'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)