diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index d400ebd..869221b 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 = {}) { @@ -805,7 +806,7 @@ function App() { useEffect(() => { const treeId = tree.treeContext.treeId - if (!treeId || !accountEmail) { + if (!treeId) { setLinkedSelfPersonId('') return } @@ -819,7 +820,7 @@ function App() { }, [accountEmail, tree.treeContext.treeId]) useEffect(() => { - if (!linkedSelfPersonId || !tree.treeContext.treeId || !accountEmail) { + if (!linkedSelfPersonId || !tree.treeContext.treeId) { return } @@ -834,12 +835,28 @@ function App() { } }, [accountEmail, linkedSelfPersonId, tree.graphRevision, tree.treeContext.treeId]) - // Annotations canvas (dessin, stickers, texte) + // Annotations canvas (dessin, stickers, texte). La galaxie remplit annotationViewRef à chaque image : + // centres des portraits de l'onglet affiché et annotations dessinées, pour poser et toucher au bon endroit + const annotationViewRef = useRef(null) const annot = useAnnotations({ editModeActive: editMode.isActive, updateDraft: editMode.updateDraft, + viewRef: annotationViewRef, }) + // Anciennes annotations rattachées à leur personne par la galaxie : enregistrées une fois, style seul + // (les autres champs ne sont pas envoyés). Sans droit d'administration, l'ancre reste calculée à chaque ouverture. + const persistAnnotationAnchors = useCallback((anchored) => { + const token = auth.userAuth.token + const treeId = tree.treeContext.treeId + if (isDemoMode || !userRole.isAdmin || !token || !treeId) return + const update = anchored + .filter((ann) => !String(ann.id).startsWith('local-')) + .map((ann) => ({ id: ann.id, style: ann.style })) + if (!update.length) return + batchAnnotations(treeId, { update }, token).catch((err) => console.error('Failed to save annotation anchors:', err)) + }, [isDemoMode, userRole.isAdmin, auth.userAuth.token, tree.treeContext.treeId]) + // Quitter la fiche (changer de personne, la fermer, sortir du mode modification, envoyer, ouvrir un // souvenir) : ses changements sont enregistrés d'abord. Faux si ça échoue : la fiche reste ouverte // avec le message d'erreur, la saisie n'est pas jetée. En démo rien n'est persisté : on ne sauvegarde pas. @@ -2926,7 +2943,7 @@ function App() { const handleSelectSelfPerson = (personId) => { const treeId = tree.treeContext.treeId - if (!treeId || !accountEmail) { + if (!treeId) { return } @@ -3206,6 +3223,9 @@ function App() { reorderEnabled={canReorderSiblings} onReorderSiblings={handleReorderSiblings} familyTabs={familyTabs} + annotationViewRef={annotationViewRef} + onAnnotationsAnchored={persistAnnotationAnchors} + selfPersonId={linkedSelfPersonId || null} /> {tree.selectedPerson && ( @@ -3319,7 +3339,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..c85398b 100644 --- a/frontend/src/components/Galaxy.jsx +++ b/frontend/src/components/Galaxy.jsx @@ -3,14 +3,15 @@ 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' import { findPathBFS } from './galaxy/pathfinding' import { drawBackground, drawCoupleLinks, drawFiliations, drawAnnotations, drawPathHighlight, drawNodes, drawRenvoiPills, nodeRadius } from './galaxy/renderers' import { buildSiblingGroups, moveInOrder, pickSlot, rubberBand, springStep, isSettled, buildOrderPayload, filiationsWithOrder } from './galaxy/siblingReorder' -import { annotations as annotationData } from '../data/mockData' +import { annotations as annotationData, updateAnnotation as updateAnnotationInStore } from '../data/mockData' +import { anchorLegacy, layoutCenters, placeAnnotations, readAnchor } from './annotations/anchor' import { useImageCache } from './galaxy/useImageCache' import { useGalaxyInteractions } from './galaxy/useGalaxyInteractions' import { t as tr } from '../i18n/index.js' @@ -42,7 +43,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, annotationViewRef = null, onAnnotationsAnchored = null, 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 @@ -55,6 +56,8 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig const activeTabRef = useRef(null) const familyTabsRef = useRef(familyTabs) useEffect(() => { familyTabsRef.current = familyTabs }, [familyTabs]) + const onAnnotationsAnchoredRef = useRef(onAnnotationsAnchored) + useEffect(() => { onAnnotationsAnchoredRef.current = onAnnotationsAnchored }, [onAnnotationsAnchored]) const startRefRef = useRef(focusPersonId) useEffect(() => { startRefRef.current = focusPersonId; setActiveKey(focusPersonId != null ? String(focusPersonId) : null) }, [focusPersonId]) // Lu au calcul du cadrage et par le réordonnancement : la référence de l'arbre affiché @@ -479,13 +482,26 @@ 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 + // 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 const result = USE_BLOCK_LAYOUT ? computeBlockLayout(graph, focusPersonIdRef.current, { familyTabs }) : 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) { @@ -630,7 +646,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(() => { @@ -908,51 +924,14 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig liftScales: liftScalesRef.current, }) drawRenvoiPills(ctx) - // Annotations dessinées APRES les noeuds pour que stickers/texte soient au-dessus - // Annotations : ancrer aux personnes proches pour qu'elles suivent le layout - const personPosMap = new Map() - layoutData.children.forEach(node => { - if (node._type === 'person') { - const pos = posMap.get(node.id) - if (pos) personPosMap.set(node._data.id, pos) - } - }) - const anchoredAnnotations = annotationData.map((ann) => { - let anchorId = ann.anchorId || null - let offsetX = ann.offsetX ?? 0 - let offsetY = ann.offsetY ?? 0 - - if (!anchorId) { - // Trouver la personne la plus proche et stocker l'offset - let nearestId = null - let nearestD2 = Infinity - for (const [pid, pos] of personPosMap.entries()) { - const dx = ann.x - pos.cx - const dy = ann.y - pos.cy - const d2 = dx * dx + dy * dy - if (d2 < nearestD2) { - nearestD2 = d2 - nearestId = pid - offsetX = dx - offsetY = dy - } - } - if (nearestId) { - anchorId = nearestId - ann.anchorId = nearestId - ann.offsetX = offsetX - ann.offsetY = offsetY - } - } - - const base = anchorId ? personPosMap.get(anchorId) : null - const drawX = base ? base.cx + offsetX : ann.x - const drawY = base ? base.cy + offsetY : ann.y - // Keep underlying data in sync so hitTest/drag use the drawn position - ann.x = drawX - ann.y = drawY - return { ...ann, x: drawX, y: drawY } - }) + // Annotations dessinées APRES les noeuds pour que stickers/texte soient au-dessus. + // Chacune suit le portrait de sa personne dans le placement affiché, absente si la personne n'y est pas. + // Les centres et les positions servent aussi à poser, toucher et déplacer (useAnnotations) + // Un texte rouvert pour être modifié n'est pas dessiné sous sa saisie (editingId, posé par useAnnotations) + const annotationCenters = layoutCenters(layoutData, offsetsRef.current) + const editingId = annotationViewRef?.current?.editingId ?? null + const anchoredAnnotations = placeAnnotations(annotationData, annotationCenters) + if (annotationViewRef) annotationViewRef.current = { editingId, centers: annotationCenters, shown: anchoredAnnotations } // Afficher les annotations en dernier, après l'animation d'entrée const stickerPreview = annotationHandlers?.activeTool === 'sticker' && annotationHandlers?.selectedSticker @@ -963,7 +942,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig if (entranceAlpha > 0) { drawAnnotations( ctx, - anchoredAnnotations, + editingId ? anchoredAnnotations.filter((a) => a.id !== editingId) : anchoredAnnotations, annotationHandlers?.selectedAnnotationId ?? null, annotationHandlers?.drawingPathRef?.current ?? null, stickerPreview, diff --git a/frontend/src/components/annotations/TextInputOverlay.jsx b/frontend/src/components/annotations/TextInputOverlay.jsx index 2b88228..3ea2384 100644 --- a/frontend/src/components/annotations/TextInputOverlay.jsx +++ b/frontend/src/components/annotations/TextInputOverlay.jsx @@ -18,6 +18,9 @@ export default function TextInputOverlay({ if (!textInputState) return null + // Texte déjà posé et rouvert : sa saisie et son style à lui + const style = textInputState.style || currentStyle + const handleKeyDown = (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() @@ -46,14 +49,16 @@ export default function TextInputOverlay({ }} > ({ id: `p-${personId}`, _type: 'person', _data: { id: personId }, x, y, width: 100, height: 100 }) + +// Deux onglets d'un même arbre : a et b dans la famille Combro, c et d dans la famille des Bois, +// b est la charnière (dessinée des deux côtés, ailleurs à chaque fois) +const combro = { children: [node('a', 0, 0), node('b', 300, 0), { id: 'u-1', _type: 'union', x: 200, y: 40, width: 10, height: 10 }] } +const desBois = { children: [node('b', 900, 500), node('c', 1200, 500), node('d', 1500, 500)] } + +describe('ancrage des annotations', () => { + it('rattache à la personne la plus proche, avec le décalage à son portrait', () => { + expect(nearestAnchor(30, -40, layoutCenters(combro))).toEqual({ personId: 'a', dx: -20, dy: -90 }) + expect(nearestAnchor(330, 60, layoutCenters(combro))).toEqual({ personId: 'b', dx: -20, dy: 10 }) + expect(nearestAnchor(0, 0, new Map())).toBeNull() + }) + + it('ne compte que les personnes et suit le glissement d’un portrait en cours', () => { + const centers = layoutCenters(combro, new Map([['p-a', { x: 40 }]])) + expect([...centers.keys()]).toEqual(['a', 'b']) + expect(centers.get('a')).toEqual({ cx: 90, cy: 50 }) + }) + + it('garde les autres clés du style', () => { + const style = withAnchor({ color: '#93402A', fontSize: 18 }, 30, -40, layoutCenters(combro)) + expect(style).toEqual({ color: '#93402A', fontSize: 18, anchor: { personId: 'a', dx: -20, dy: -90 } }) + }) + + it('n’affiche un texte que dans l’onglet où sa personne est dessinée', () => { + const texte = { id: 't1', type: 'text', x: 30, y: -40, content: 'Combro', style: { anchor: { personId: 'a', dx: -20, dy: -90 } } } + expect(placeAnnotations([texte], layoutCenters(combro))).toMatchObject([{ id: 't1', x: 30, y: -40 }]) + expect(placeAnnotations([texte], layoutCenters(desBois))).toEqual([]) + }) + + it('suit sa personne quand le placement change (charnière des deux côtés)', () => { + const texte = { id: 't2', type: 'text', x: 0, y: 0, content: 'b', style: { anchor: { personId: 'b', dx: -20, dy: 10 } } } + expect(placeAnnotations([texte], layoutCenters(combro))[0]).toMatchObject({ x: 330, y: 60 }) + expect(placeAnnotations([texte], layoutCenters(desBois))[0]).toMatchObject({ x: 930, y: 560 }) + }) + + it('laisse à ses coordonnées une annotation sans ancre', () => { + const libre = { id: 't3', type: 'text', x: 5, y: 6, content: 'x', style: {} } + expect(placeAnnotations([libre], layoutCenters(desBois))).toEqual([libre]) + }) + + it('refuse une ancre incomplète', () => { + expect(readAnchor({ style: { anchor: { personId: 'a', dx: 1 } } })).toBeNull() + expect(readAnchor({ style: { anchor: { personId: 7, dx: 1, dy: 2 } } })).toEqual({ personId: '7', dx: 1, dy: 2 }) + }) +}) + +describe('ancrage à la volée des anciennes annotations', () => { + it('rattache dans le placement de l’onglet de départ, sans toucher au reste', () => { + const ancienne = { id: 'old', type: 'sticker', x: 330, y: 60, content: '⭐', style: { fontSize: 40, rotation: 0.1 }, zIndex: 10 } + const [ancree] = anchorLegacy([ancienne], layoutCenters(combro)) + expect(ancree).toEqual({ ...ancienne, style: { fontSize: 40, rotation: 0.1, anchor: { personId: 'b', dx: -20, dy: 10 } } }) + // Puis elle s'affiche au même point dans l'onglet de départ + expect(placeAnnotations([ancree], layoutCenters(combro))[0]).toMatchObject({ x: 330, y: 60 }) + }) + + it('ne repasse pas sur une annotation déjà ancrée', () => { + const ancree = { id: 'a1', type: 'text', x: 0, y: 0, content: 'x', style: { anchor: { personId: 'a', dx: 0, dy: 0 } } } + expect(anchorLegacy([ancree], layoutCenters(desBois))).toEqual([]) + }) + + it('n’ancre rien dans un placement sans personne', () => { + expect(anchorLegacy([{ id: 'x', x: 0, y: 0, style: {} }], new Map())).toEqual([]) + }) +}) 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 4c1fe6a..40a726f 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) @@ -610,7 +612,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={