From 03f172b59bed1e8f9864a0f8569dd35d11f6e9ab Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Fri, 23 Oct 2026 14:49:46 +0200 Subject: [PATCH] =?UTF-8?q?Onglets=20de=20familles=20:=20la=20ligne=20dire?= =?UTF-8?q?cte=20vers=20=C2=AB=20C'est=20moi=20=C2=BB=20dans=20chaque=20on?= =?UTF-8?q?glet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/src/App.jsx | 12 ++-- frontend/src/components/AccountPanel.jsx | 4 +- frontend/src/components/Galaxy.jsx | 12 ++-- frontend/src/components/galaxy/blockLayout.js | 57 +++++++++++++++++++ .../src/components/galaxy/blockLayout.test.js | 36 +++++++++++- frontend/src/components/galaxy/constants.js | 3 + frontend/src/components/galaxy/renderers.js | 12 ++-- frontend/src/components/navbar/AvatarMenu.jsx | 14 ++++- frontend/src/i18n/en/compte.js | 2 + 9 files changed, 134 insertions(+), 18 deletions(-) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 6c18939..91a095c 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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} /> {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)} />
event.stopPropagation()}>
-

{t('Mon compte')}

+

{userEmail ? t('Mon compte') : t('Dans cet arbre')}

{displayName}

{userEmail &&

{userEmail}

}
diff --git a/frontend/src/components/Galaxy.jsx b/frontend/src/components/Galaxy.jsx index d14f9a7..b951718 100644 --- a/frontend/src/components/Galaxy.jsx +++ b/frontend/src/components/Galaxy.jsx @@ -3,7 +3,7 @@ import { getPersonMedias, getParents, getChildren, getPersonUnions, persons as g import { PERSON_R, FLOAT_SPEED, FLOAT_AMPLITUDE, REPULSION_RADIUS, REPULSION_STRENGTH, MAX_ORBIT_MEDIAS, ENTRANCE_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(() => { diff --git a/frontend/src/components/galaxy/blockLayout.js b/frontend/src/components/galaxy/blockLayout.js index 327b58a..55f6c65 100644 --- a/frontend/src/components/galaxy/blockLayout.js +++ b/frontend/src/components/galaxy/blockLayout.js @@ -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-/, '') diff --git a/frontend/src/components/galaxy/blockLayout.test.js b/frontend/src/components/galaxy/blockLayout.test.js index 1cc18b1..2f310e3 100644 --- a/frontend/src/components/galaxy/blockLayout.test.js +++ b/frontend/src/components/galaxy/blockLayout.test.js @@ -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) + }) +}) diff --git a/frontend/src/components/galaxy/constants.js b/frontend/src/components/galaxy/constants.js index 5a90fdc..7b16a6a 100644 --- a/frontend/src/components/galaxy/constants.js +++ b/frontend/src/components/galaxy/constants.js @@ -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 diff --git a/frontend/src/components/galaxy/renderers.js b/frontend/src/components/galaxy/renderers.js index a43583b..173b30d 100644 --- a/frontend/src/components/galaxy/renderers.js +++ b/frontend/src/components/galaxy/renderers.js @@ -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 diff --git a/frontend/src/components/navbar/AvatarMenu.jsx b/frontend/src/components/navbar/AvatarMenu.jsx index 8bea988..acd6759 100644 --- a/frontend/src/components/navbar/AvatarMenu.jsx +++ b/frontend/src/components/navbar/AvatarMenu.jsx @@ -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({ <> } label={t('Se connecter')} onSelect={() => { onClose(); onLogin?.() }} /> {onRegister && } 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 && ( + <> + + } + label={linkedPersonName || t('Qui êtes-vous dans cet arbre ?')} + onSelect={() => { onClose(); onAccountClick() }} + right={