mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-10 07:52:13 +00:00
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:
12 files changed
+229
-376
No files matched your search
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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,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'])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user