Onglets de familles : la ligne directe vers « C'est moi » dans chaque onglet

Dans l'onglet d'une autre famille, la personne commune (celle que le visiteur
désigne par « C'est moi », sinon la personne de départ de l'arbre) reste visible,
reliée par sa ligne directe : le plus court chemin de parenté jusqu'à la famille,
avec les deux parents à chaque pas, sans fratries autour. Ces portraits sont
estompés et un peu réduits.

Un visiteur sans compte peut maintenant dire qui il est dans l'arbre (menu du
visiteur), choix gardé dans son navigateur pour cet arbre.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
IliesAllaliandClaude Opus 5.5 committed 2026-10-23 14:49:46 +02:00
1 parent dd0eb93965
commit 03f172b59b
9 files changed
+134 -18

No files matched your search

+7 -5
View File
@@ -132,8 +132,9 @@ function generateSecurePassword() {
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')
}
// Sans compte (visiteur par mot de passe), le choix reste dans ce navigateur pour cet arbre
function getAccountSelfPersonStorageKey(userEmail, treeId) {
return `account_self_person_${userEmail || 'unknown'}_${treeId || 'none'}`
return `account_self_person_${userEmail || 'visiteur'}_${treeId || 'none'}`
}
function buildContributionChangesFromDraft(draft = {}) {
@@ -804,7 +805,7 @@ function App() {
useEffect(() => {
const treeId = tree.treeContext.treeId
if (!treeId || !accountEmail) {
if (!treeId) {
setLinkedSelfPersonId('')
return
}
@@ -818,7 +819,7 @@ function App() {
}, [accountEmail, tree.treeContext.treeId])
useEffect(() => {
if (!linkedSelfPersonId || !tree.treeContext.treeId || !accountEmail) {
if (!linkedSelfPersonId || !tree.treeContext.treeId) {
return
}
@@ -2922,7 +2923,7 @@ function App() {
const handleSelectSelfPerson = (personId) => {
const treeId = tree.treeContext.treeId
if (!treeId || !accountEmail) {
if (!treeId) {
return
}
@@ -3202,6 +3203,7 @@ function App() {
reorderEnabled={canReorderSiblings}
onReorderSiblings={handleReorderSiblings}
familyTabs={familyTabs}
selfPersonId={linkedSelfPersonId || null}
/>
<ZoomControl zoomApiRef={galaxyZoomApiRef} />
{tree.selectedPerson && (
@@ -3315,7 +3317,7 @@ function App() {
selectedPersonId={linkedSelfPersonId}
people={persons}
onSelectPerson={handleSelectSelfPerson}
onManageAccount={() => handleUseAccount('login')}
onManageAccount={accountEmail ? () => handleUseAccount('login') : undefined}
onClose={() => setAccountPanelVisible(false)}
/>
<AdminPanel
+2 -2
View File
@@ -34,13 +34,13 @@ const AccountPanel = ({
const displayName = linkedPerson
? `${linkedPerson.firstName || ''} ${linkedPerson.lastName || ''}`.trim()
: userDisplayName || t('Mon compte')
: userDisplayName || (userEmail ? t('Mon compte') : t('Qui êtes-vous dans cet arbre ?'))
return (
<div className="pz-overlay account-overlay account-overlay--open" onClick={onClose}>
<div className="pz-modal account-card" role="dialog" aria-modal="true" aria-labelledby="accountPanelTitle" onClick={(event) => event.stopPropagation()}>
<div className="pz-modal-head">
<p className="pz-eyebrow">{t('Mon compte')}</p>
<p className="pz-eyebrow">{userEmail ? t('Mon compte') : t('Dans cet arbre')}</p>
<h1 id="accountPanelTitle" className="pz-title">{displayName}</h1>
{userEmail && <p className="pz-sub">{userEmail}</p>}
</div>
+8 -4
View File
@@ -3,7 +3,7 @@ import { getPersonMedias, getParents, getChildren, getPersonUnions, persons as g
import { PERSON_R, FLOAT_SPEED, FLOAT_AMPLITUDE, REPULSION_RADIUS, REPULSION_STRENGTH, MAX_ORBIT_MEDIAS, ENTRANCE_GEN_DELAY, ENTRANCE_NODE_DURATION, ENTRANCE_NODE_STAGGER, ENTRANCE_MAX_GEN_SPAN, ENTRANCE_MAX_NODE_SPAN, ENTRANCE_LINE_DELAY, ENTRANCE_LINE_DURATION, ENTRANCE_ORBIT_DELAY, ENTRANCE_ORBIT_DURATION, ENTRANCE_ORBIT_STAGGER } from './galaxy/constants'
import { easeOutCubic } from './galaxy/utils'
import { computeLayout, generateNodeRandomData, coupleBarMeta } from './galaxy/elkLayout'
import { computeBlockLayout, familyTabsOf, subgraphOf } from './galaxy/blockLayout'
import { computeBlockLayout, familyTabsOf, lineToFamily, subgraphOf } from './galaxy/blockLayout'
// Placement par blocs familiaux par défaut (mesures : tools/layout-bench). ?layout=sugiyama = placement Sugiyama.
const USE_BLOCK_LAYOUT = typeof window === 'undefined' || new URLSearchParams(window.location.search).get('layout') !== 'sugiyama'
@@ -42,7 +42,7 @@ const DROP_SPRING = { stiffness: 480, dampingRatio: 0.72 }
const centerX = (node) => node.x + node.width / 2
const prefersReducedMotion = () => typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlightIds = [], graphRevision = 0, annotationHandlers = null, transformReadRef = null, zoomApiRef = null, focusPersonId = null, assetRevision = 0, reorderEnabled = false, onReorderSiblings = null, familyTabs = true }) => {
const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlightIds = [], graphRevision = 0, annotationHandlers = null, transformReadRef = null, zoomApiRef = null, focusPersonId = null, assetRevision = 0, reorderEnabled = false, onReorderSiblings = null, familyTabs = true, selfPersonId = 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
@@ -479,13 +479,17 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
activeTabRef.current = tab
// La personne de départ (ou la charnière de l'onglet) sert de référence aux rangées
focusPersonIdRef.current = tab ? tab.referenceId : startRefRef.current
const graph = tab?.personIds ? subgraphOf(tab.personIds) : null
// 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()
const graph = tab?.personIds ? subgraphOf(new Set([...tab.personIds, ...line])) : null
const result = USE_BLOCK_LAYOUT ? computeBlockLayout(graph, focusPersonIdRef.current, { familyTabs }) : computeLayout(null, focusPersonIdRef.current)
// Les pastilles portent le nom de l'onglet où elles mènent (prénoms ajoutés aux homonymes)
for (const n of result.children) {
const target = n._familyTab && tabsCacheRef.current.tabs.find((tb) => tb.key === n._familyTab.tabKey)
if (target) n._familyTab = { ...n._familyTab, label: target.label, count: target.count }
}
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) {
@@ -630,7 +634,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
pendingFocusRef.current = null
requestAnimationFrame(() => zoomApiRef?.current?.focusPerson?.(id))
}
}, [graphRevision, activeKey, familyTabs])
}, [graphRevision, activeKey, familyTabs, selfPersonId])
// Sync selectedPersonId
useEffect(() => {
@@ -844,6 +844,61 @@ export function subgraphOf(personIds, graphData = null) {
return { persons, unions, filiations }
}
// La ligne directe d'une personne vers une famille (Patch, 21/10 : « je veux me voir tout le temps ») : le plus
// court chemin de parenté (parents, enfants, conjoints) de `selfId` jusqu'au premier membre de `memberIds`,
// sans fratries autour. Chaque pas d'un enfant à un parent garde ses deux parents (le fil part de leur union,
// et sans l'autre parent le placement dessine un conjoint « Inconnu »).
// Rend les personnes à ajouter (ni `selfId` déjà membre, ni chemin : ensemble vide).
export function lineToFamily(selfId, memberIds, graphData = null) {
const members = new Set([...memberIds].map(String))
const self = selfId != null ? String(selfId) : null
if (self == null || members.has(self)) return new Set()
const persons = graphData?.persons || globalPersons
if (!persons.some(p => String(p.id) === self)) return new Set()
const unions = graphData?.unions || globalUnions
const filiations = graphData?.filiations || globalFiliations
const unionById = new Map(unions.map(u => [String(u.id), u]))
// Voisins : [voisin, personnes que ce pas fait dessiner en plus]
const next = new Map()
const link = (a, b, extra = []) => { if (!next.has(a)) next.set(a, []); next.get(a).push([b, extra]) }
for (const u of unions) {
const a = String(u.partner1Id), b = String(u.partner2Id)
link(a, b); link(b, a)
}
const parentsOf = new Map()
for (const f of filiations) {
const child = String(f.childId)
const u = f.unionId ? unionById.get(String(f.unionId)) : null
const parents = u ? [String(u.partner1Id), String(u.partner2Id)] : f.parentId != null ? [String(f.parentId)] : []
if (!parentsOf.has(child)) parentsOf.set(child, new Set())
for (const p of parents) parentsOf.get(child).add(p)
}
for (const [child, parents] of parentsOf) {
for (const p of parents) {
const couple = [...parents].filter(q => q !== p)
link(child, p, couple); link(p, child, couple)
}
}
const from = new Map([[self, null]])
const queue = [self]
let hit = null
for (let i = 0; i < queue.length && hit == null; i++) {
for (const [nb, extra] of next.get(queue[i]) || []) {
if (from.has(nb)) continue
from.set(nb, { prev: queue[i], extra })
if (members.has(nb)) { hit = nb; break }
queue.push(nb)
}
}
const line = new Set()
for (let id = hit; id != null && from.get(id); id = from.get(id).prev) {
for (const e of from.get(id).extra) if (!members.has(e)) line.add(e)
const prev = from.get(id).prev
if (!members.has(prev)) line.add(prev)
}
return line
}
// 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
@@ -858,6 +913,8 @@ export function familyTabsOf(graphData = null, referenceId = null) {
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
for (const f of fams.slice(1)) {
if (f.drawn) continue
const key = String(f.anchorId).replace(/^p-/, '')
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { computeBlockLayout, filCroiseAutreFamille } from './blockLayout'
import { computeBlockLayout, filCroiseAutreFamille, lineToFamily } from './blockLayout'
import { COMPACT_W, PERSON_W } from './constants'
const person = (id) => ({ id, firstName: id, lastName: '' })
@@ -134,3 +134,37 @@ describe('computeBlockLayout, couple tourné vers les familles de chacun', () =>
expect(Math.sign(x('p-marc') - x('p-nadia'))).toBe(Math.sign(x('u-uLH') - x('u-uPO')))
})
})
describe('lineToFamily, ligne directe vers un onglet de famille', () => {
// gp + gm → mere (+ pere) → moi ; mere a un frère (oncle) ; gm vient de la famille B (b1 + b2 → gm, b1 frère bx)
const graph = {
persons: ['gp', 'gm', 'mere', 'pere', 'oncle', 'moi', 'b1', 'b2', 'bx'].map(person),
unions: [union('u1', 'gp', 'gm'), union('u2', 'pere', 'mere'), union('u3', 'b1', 'b2')],
filiations: [fil('f1', 'mere', 'u1'), fil('f2', 'oncle', 'u1'), fil('f3', 'moi', 'u2'), fil('f4', 'gm', 'u3'), fil('f5', 'bx', 'u3')],
}
it("rend le chemin jusqu'au premier membre, avec les deux parents à chaque pas, sans fratrie", () => {
const line = lineToFamily('moi', ['gm', 'b1', 'b2', 'bx'], graph)
expect([...line].sort()).toEqual(['gp', 'mere', 'moi', 'pere'])
})
it('rend un ensemble vide si la personne est déjà membre ou inconnue', () => {
expect(lineToFamily('gm', ['gm', 'b1'], graph).size).toBe(0)
expect(lineToFamily('personne', ['gm'], graph).size).toBe(0)
expect(lineToFamily(null, ['gm'], graph).size).toBe(0)
})
it('se dessine en entier avec la famille', () => {
const members = ['gm', 'b1', 'b2', 'bx']
const line = lineToFamily('moi', members, graph)
const keep = new Set([...members, ...line])
const sub = {
persons: graph.persons.filter(p => keep.has(p.id)),
unions: graph.unions.filter(u => keep.has(u.partner1Id) && keep.has(u.partner2Id)),
filiations: graph.filiations.filter(f => keep.has(f.childId)),
}
const drawn = new Set(computeBlockLayout(sub, 'gm').children.filter(n => n._type === 'person').map(n => n.id))
for (const id of line) expect(drawn.has(`p-${id}`)).toBe(true)
expect(drawn.has('p-oncle')).toBe(false)
})
})
@@ -14,6 +14,9 @@ export const UNKNOWN_R = 18
// Il ne prend pas un emplacement de nœud : union de largeur nulle, écart négatif vers le partenaire.
export const COMPACT_SCALE = 0.6
export const COMPACT_R = PERSON_R * COMPACT_SCALE
// Ligne directe vers la personne commune, dans l'onglet d'une autre famille : en retrait
export const LINE_SCALE = 0.82
export const LINE_ALPHA = 0.55
export const COMPACT_W = 40
export const COMPACT_JOIN_GAP = -18
export const MAX_ORBIT_MEDIAS = 3
+7 -5
View File
@@ -5,7 +5,7 @@ import { cropShape, cropViewport, isLandscapeRect, resolveFrameKind } from '../.
// le Map global utilisé plus bas)
import { Map as mapIcon, MapPin as mapPinIcon, Route as routeIcon } from '@lucide/icons'
import {
PERSON_R, UNKNOWN_R, UNION_W, UNION_H, COMPACT_R, COMPACT_SCALE,
PERSON_R, UNKNOWN_R, UNION_W, UNION_H, COMPACT_R, COMPACT_SCALE, LINE_SCALE, LINE_ALPHA,
MAX_ORBIT_MEDIAS, ORBIT_MEDIA_SIZE,
COLORS,
LOD_SHADOW_MIN_SCALE, LOD_DETAIL_MIN_SCALE, LOD_LABEL_FADE_IN, LOD_LABEL_FADE_OUT, CULL_MARGIN,
@@ -531,7 +531,9 @@ function drawPersonNode(ctx, node, pos, nodeProgress, {
}
// --- Hover scale + entrance scale (conjoint isolé : en petit) ---
const k = node._compact ? COMPACT_SCALE : 1
// Ligne directe venue d'une autre famille (onglets) : un peu plus petite et estompée
const k = node._compact ? COMPACT_SCALE : node._line ? LINE_SCALE : 1
const dim = node._line ? LINE_ALPHA : 1
const R = PERSON_R * k
const hs = hoverScales.get(node.id) || 0
// Personne soulevée pour changer sa place dans la fratrie : un peu plus grande, ombre plus portée
@@ -573,7 +575,7 @@ function drawPersonNode(ctx, node, pos, nodeProgress, {
// --- Halo glow ---
if (withDetail) {
const glowR = R + (12 + hs * 6) * k
const glowAlpha = (person.isAlive ? 0.15 + hs * 0.1 : 0.08 + hs * 0.06) * nodeProgress
const glowAlpha = (person.isAlive ? 0.15 + hs * 0.1 : 0.08 + hs * 0.06) * nodeProgress * dim
const glow = ctx.createRadialGradient(cx, cy, R * 0.6, cx, cy, glowR)
glow.addColorStop(0, `rgba(147, 64, 42, ${glowAlpha})`)
glow.addColorStop(1, 'rgba(147, 64, 42, 0)')
@@ -585,7 +587,7 @@ function drawPersonNode(ctx, node, pos, nodeProgress, {
// --- Cadre photo ---
ctx.save()
ctx.globalAlpha = nodeProgress
ctx.globalAlpha = nodeProgress * dim
ctx.translate(cx, cy)
ctx.scale(personScale, personScale)
ctx.rotate(frameRot)
@@ -608,7 +610,7 @@ function drawPersonNode(ctx, node, pos, nodeProgress, {
const labelRot = rd?.labelRotation || 0
const labelOffsetY = (PERSON_R + 24) * personScale
ctx.save()
ctx.globalAlpha = nodeProgress * labelAlpha
ctx.globalAlpha = nodeProgress * labelAlpha * dim
ctx.translate(cx, cy + labelOffsetY)
ctx.rotate(labelRot)
// Libellés du petit portrait : un peu réduits, pas autant que le portrait pour rester lisibles
+13 -1
View File
@@ -1,4 +1,4 @@
import { Check, ChevronRight, Globe, HelpCircle, LogIn, LogOut, Plus, Settings, Share2, TreeDeciduous, UserPlus } from 'lucide-react'
import { Check, ChevronRight, Globe, HelpCircle, LogIn, LogOut, Plus, Settings, Share2, TreeDeciduous, User, UserPlus } from 'lucide-react'
import { t } from '../../i18n/index.js'
import LanguageSwitch from './LanguageSwitch'
import { NavMenu, NavMenuGroup, NavMenuItem, NavMenuSep } from './NavMenu'
@@ -60,6 +60,18 @@ function AvatarMenu({
<>
<NavMenuItem icon={<LogIn {...ICON} />} label={t('Se connecter')} onSelect={() => { onClose(); onLogin?.() }} />
{onRegister && <NavMenuItem icon={<UserPlus {...ICON} />} label={t('Créer mon arbre')} onSelect={() => { onClose(); onRegister() }} />}
{/* Visiteur d'un arbre partagé : il dit qui il est, cette personne le suit dans chaque onglet de famille */}
{activeTreeId && onAccountClick && (
<>
<NavMenuSep />
<NavMenuItem
icon={<User {...ICON} />}
label={linkedPersonName || t('Qui êtes-vous dans cet arbre ?')}
onSelect={() => { onClose(); onAccountClick() }}
right={<ChevronRight size={16} aria-hidden="true" className="navm-chevron" />}
/>
</>
)}
<NavMenuSep />
{helpRow}
</>
+2
View File
@@ -77,6 +77,8 @@ export default {
"Supprimer définitivement": "Delete permanently",
// Panneau « Mon compte » (AccountPanel)
"Mon compte": "My account",
"Dans cet arbre": "In this tree",
"Qui êtes-vous dans cet arbre ?": "Who are you in this tree?",
"Vous apparaissez dans cet arbre sous ce nom et avec cette photo.": "This is the name and photo you appear with in this tree.",
"Dites-nous qui vous êtes dans cet arbre, votre nom et votre photo viendront de votre fiche.": "Tell us who you are in this tree. Your name and photo will come from your profile.",
"Je suis cette personne": "I'm this person",