Merge remote-tracking branch 'origin/main' into feat/a-verifier

This commit is contained in:
IliesAllali committed 2026-10-23 15:27:25 +02:00
commit 8fe2f53b34
13 files changed
+382 -104

No files matched your search

+26 -6
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 = {}) {
@@ -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}
/>
<ZoomControl zoomApiRef={galaxyZoomApiRef} />
{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)}
/>
<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>
+30 -51
View File
@@ -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,
@@ -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({
}}
>
<input
key={textInputState.editingId || 'new'}
ref={inputRef}
className="text-input-overlay__input"
type="text"
defaultValue={textInputState.text || ''}
placeholder={t('Saisir du texte...')}
style={{
color: currentStyle.color,
fontSize: currentStyle.fontSize,
background: currentStyle.stabilo
color: style.color,
fontSize: style.fontSize,
background: style.stabilo
? 'rgba(237, 182, 164, 0.5)'
: 'rgba(255, 255, 255, 0.85)',
}}
@@ -0,0 +1,65 @@
// Ancre d'une annotation, rangée dans son style : la personne la plus proche au moment où on la pose et le
// décalage à son portrait. Chaque onglet de famille recalcule le placement : x et y ne disent plus où la
// redessiner, l'ancre si (Anaïs, 23/10 : un texte posé chez une famille réapparaissait au même endroit chez l'autre).
export function readAnchor(ann) {
const a = ann?.style?.anchor
if (!a || a.personId == null || !Number.isFinite(a.dx) || !Number.isFinite(a.dy)) return null
return { personId: String(a.personId), dx: a.dx, dy: a.dy }
}
// Centre de chaque portrait du placement, par id de personne. `offsets` : glissements en cours (fratrie réordonnée)
export function layoutCenters(layout, offsets = null) {
const centers = new Map()
for (const node of layout?.children || []) {
if (node._type !== 'person' || !node._data) continue
centers.set(String(node._data.id), {
cx: node.x + node.width / 2 + (offsets?.get(node.id)?.x || 0),
cy: node.y + node.height / 2,
})
}
return centers
}
export function nearestAnchor(x, y, centers) {
let best = null
let bestD2 = Infinity
for (const [personId, c] of centers || []) {
const dx = x - c.cx
const dy = y - c.cy
const d2 = dx * dx + dy * dy
if (d2 < bestD2) { bestD2 = d2; best = { personId, dx, dy } }
}
return best
}
// Style rattaché à la personne la plus proche de (x, y) ; les autres clés du style sont gardées
export function withAnchor(style, x, y, centers) {
const anchor = nearestAnchor(x, y, centers)
return anchor ? { ...(style || {}), anchor } : { ...(style || {}) }
}
// Annotations à dessiner dans ce placement, à leur position : portrait de la personne + décalage. Celles dont
// la personne n'est pas dessinée ici sont laissées de côté. Sans ancre (placement vide), elle reste à x, y.
export function placeAnnotations(list, centers) {
const shown = []
for (const ann of list) {
const a = readAnchor(ann)
if (!a) { shown.push(ann); continue }
const c = centers.get(a.personId)
if (c) shown.push({ ...ann, x: c.cx + a.dx, y: c.cy + a.dy })
}
return shown
}
// Annotations d'avant l'ancrage (x, y seuls) : rattachées à la personne la plus proche de leurs coordonnées
// dans `centers`, le placement de l'onglet de départ. Seules celles qui ont trouvé une personne sont rendues.
export function anchorLegacy(list, centers) {
const anchored = []
for (const ann of list) {
if (readAnchor(ann)) continue
const style = withAnchor(ann.style, ann.x, ann.y, centers)
if (style.anchor) anchored.push({ ...ann, style })
}
return anchored
}
@@ -0,0 +1,70 @@
import { describe, it, expect } from 'vitest'
import { anchorLegacy, layoutCenters, nearestAnchor, placeAnnotations, readAnchor, withAnchor } from './anchor'
// Portrait de 100 × 100 posé en (x, y) : centre en (x + 50, y + 50)
const node = (personId, x, y) => ({ 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([])
})
})
@@ -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)
@@ -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
+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}
</>
+64 -35
View File
@@ -5,6 +5,7 @@ import {
updateAnnotation as updateAnnotationInStore,
removeAnnotation as removeAnnotationFromStore,
} from '../data/mockData'
import { withAnchor } from '../components/annotations/anchor'
/**
* Hook pour gerer les annotations canvas (dessin, stickers, texte).
@@ -12,13 +13,14 @@ import {
* @param {Object} options
* @param {boolean} options.editModeActive
* @param {Function} options.updateDraft - merge data into edit draft
* @param {Object} options.viewRef - rempli par la galaxie : centres des portraits et annotations affichées
* @returns {Object} API annotations
*/
export function useAnnotations({ editModeActive, updateDraft }) {
export function useAnnotations({ editModeActive, updateDraft, viewRef = null }) {
const [activeTool, setActiveTool] = useState('pointer')
const [selectedAnnotationId, setSelectedAnnotationId] = useState(null)
const [selectedSticker, setSelectedSticker] = useState(null)
const [textInputState, setTextInputState] = useState(null) // { x, y, screenX, screenY }
const [textInputState, setTextInputState] = useState(null) // { x, y, screenX, screenY, editingId?, text?, style? }
const [revision, setRevision] = useState(0)
const [currentStyle, setCurrentStyle] = useState({
@@ -38,6 +40,19 @@ export function useAnnotations({ editModeActive, updateDraft }) {
const [isOverTrash, setIsOverTrash] = useState(false)
const dragStartPos = useRef(null)
const dragAnnotationOrigPos = useRef(null)
// Vrai si l'annotation touchée était déjà sélectionnée avant l'appui : un texte se rouvre alors pour être modifié
const pressedSelectedRef = useRef(false)
// Style rattaché à la personne la plus proche dans l'onglet affiché (voir annotations/anchor.js)
const anchoredStyle = useCallback((style, x, y) => withAnchor(style, x, y, viewRef?.current?.centers), [viewRef])
// Annotation telle que dessinée dans l'onglet affiché (position comprise)
const shownOf = useCallback((id) => (viewRef?.current?.shown || annotationData).find((a) => a.id === id) || null, [viewRef])
// Texte rouvert : la galaxie ne le dessine pas sous sa saisie
useEffect(() => {
if (viewRef?.current) viewRef.current.editingId = textInputState?.editingId ?? null
else if (viewRef) viewRef.current = { editingId: textInputState?.editingId ?? null }
}, [viewRef, textInputState])
// Track added/modified/deleted for draft
const addedIdsRef = useRef(new Set())
@@ -133,15 +148,16 @@ export function useAnnotations({ editModeActive, updateDraft }) {
x: worldX,
y: worldY,
content: JSON.stringify({ photoPath, url, w: naturalW, h: naturalH }),
style: { size },
style: anchoredStyle({ size }, worldX, worldY),
zIndex: 5,
})
}, [addAnnotation])
}, [addAnnotation, anchoredStyle])
// ---- Hit detection ----
// Seules les annotations dessinées dans l'onglet affiché se touchent, à leur position dessinée
const hitTest = useCallback((worldX, worldY) => {
// Iterate in reverse z-order (top first)
const sorted = [...annotationData].sort((a, b) => (b.zIndex || 0) - (a.zIndex || 0))
const sorted = [...(viewRef?.current?.shown || annotationData)].sort((a, b) => (b.zIndex || 0) - (a.zIndex || 0))
for (const ann of sorted) {
if (ann.type === 'text') {
const fontSize = ann.style?.fontSize || 18
@@ -187,7 +203,7 @@ export function useAnnotations({ editModeActive, updateDraft }) {
}
}
return null
}, [])
}, [viewRef])
// ---- Canvas event handlers (called from useGalaxyInteractions) ----
@@ -211,16 +227,11 @@ export function useAnnotations({ editModeActive, updateDraft }) {
const dx = worldX - dragStartPos.current.x
const dy = worldY - dragStartPos.current.y
const ann = annotationData.find((a) => a.id === draggingIdRef.current)
if (ann && ann.anchorId) {
updateAnnotation(draggingIdRef.current, {
offsetX: (dragAnnotationOrigPos.current.offsetX ?? 0) + dx,
offsetY: (dragAnnotationOrigPos.current.offsetY ?? 0) + dy,
})
} else {
updateAnnotation(draggingIdRef.current, {
x: (dragAnnotationOrigPos.current.x ?? 0) + dx,
y: (dragAnnotationOrigPos.current.y ?? 0) + dy,
})
if (ann) {
// Déplacer recalcule l'ancre : la personne la plus proche du nouvel endroit
const x = (dragAnnotationOrigPos.current.x ?? 0) + dx
const y = (dragAnnotationOrigPos.current.y ?? 0) + dy
updateAnnotation(draggingIdRef.current, { x, y, style: anchoredStyle(ann.style, x, y) })
}
// Check if over trash button in toolbar
let overTrash = false
@@ -239,7 +250,7 @@ export function useAnnotations({ editModeActive, updateDraft }) {
isOverTrashRef.current = overTrash
setIsOverTrash(overTrash)
}
}, [activeTool, updateAnnotation])
}, [activeTool, updateAnnotation, anchoredStyle])
const onCanvasMouseUp = useCallback((_worldX, _worldY) => {
// Finalize drawing
@@ -253,7 +264,7 @@ export function useAnnotations({ editModeActive, updateDraft }) {
// If a drawing annotation is already active, append this stroke to it
if (currentDrawingAnnId.current) {
const existing = annotationData.find((a) => a.id === currentDrawingAnnId.current)
const existing = shownOf(currentDrawingAnnId.current)
if (existing) {
const anchorX = existing.x
const anchorY = existing.y
@@ -292,10 +303,10 @@ export function useAnnotations({ editModeActive, updateDraft }) {
width: drawingPathRef.current.width,
}],
}),
style: {
style: anchoredStyle({
color: drawingPathRef.current.color,
brushWidth: drawingPathRef.current.width,
},
}, firstPt.x, firstPt.y),
zIndex: 0,
})
currentDrawingAnnId.current = created.id
@@ -317,7 +328,7 @@ export function useAnnotations({ editModeActive, updateDraft }) {
dragStartPos.current = null
dragAnnotationOrigPos.current = null
}
}, [activeTool, addAnnotation, deleteAnnotation])
}, [activeTool, addAnnotation, deleteAnnotation, anchoredStyle, shownOf])
const onCanvasClick = useCallback((worldX, worldY, transformRef) => {
if (activeTool === 'text') {
@@ -340,10 +351,10 @@ export function useAnnotations({ editModeActive, updateDraft }) {
x: worldX,
y: worldY,
content: selectedSticker,
style: {
style: anchoredStyle({
fontSize: 40,
rotation: (Math.random() - 0.5) * 0.25,
},
}, worldX, worldY),
zIndex: 10,
})
return true
@@ -352,6 +363,21 @@ export function useAnnotations({ editModeActive, updateDraft }) {
if (activeTool === 'pointer') {
const hit = hitTest(worldX, worldY)
if (hit) {
// Un texte déjà sélectionné se rouvre au clic pour être modifié (double clic sur ordinateur)
if (hit.type === 'text' && pressedSelectedRef.current && transformRef) {
pressedSelectedRef.current = false
const t = transformRef.current
setTextInputState({
x: hit.x,
y: hit.y,
screenX: hit.x * t.scale + t.x,
screenY: hit.y * t.scale + t.y,
editingId: hit.id,
text: hit.content,
style: hit.style,
})
return true
}
setSelectedAnnotationId(hit.id)
// Prepare for potential drag
isDraggingAnnotation.current = false
@@ -363,14 +389,15 @@ export function useAnnotations({ editModeActive, updateDraft }) {
}
return false
}, [activeTool, selectedSticker, currentStyle, addAnnotation, hitTest])
}, [activeTool, selectedSticker, addAnnotation, hitTest, anchoredStyle])
// Start drag — accepts annotationId so we can drag without prior selection
const onAnnotationDragStart = useCallback((worldX, worldY, annotationId) => {
const targetId = annotationId || selectedAnnotationId
if (activeTool === 'pointer' && targetId) {
const ann = annotationData.find((a) => a.id === targetId)
const ann = shownOf(targetId)
if (ann) {
pressedSelectedRef.current = targetId === selectedAnnotationId
setSelectedAnnotationId(targetId)
draggingIdRef.current = targetId
isDraggingAnnotation.current = true
@@ -378,17 +405,12 @@ export function useAnnotations({ editModeActive, updateDraft }) {
setIsDragging(true)
setIsOverTrash(false)
dragStartPos.current = { x: worldX, y: worldY }
dragAnnotationOrigPos.current = {
x: ann.x,
y: ann.y,
offsetX: ann.offsetX ?? 0,
offsetY: ann.offsetY ?? 0,
}
dragAnnotationOrigPos.current = { x: ann.x, y: ann.y }
return true
}
}
return false
}, [activeTool, selectedAnnotationId])
}, [activeTool, selectedAnnotationId, shownOf])
// Commit text from overlay input
const commitTextInput = useCallback((text) => {
@@ -397,21 +419,28 @@ export function useAnnotations({ editModeActive, updateDraft }) {
return
}
// Texte rouvert : seul son contenu change, il garde sa place et son style
if (textInputState.editingId) {
if (text.trim() !== textInputState.text) updateAnnotation(textInputState.editingId, { content: text.trim() })
setTextInputState(null)
return
}
addAnnotation({
type: 'text',
x: textInputState.x,
y: textInputState.y,
content: text.trim(),
style: {
style: anchoredStyle({
color: currentStyle.color,
fontSize: currentStyle.fontSize,
stabilo: currentStyle.stabilo,
},
}, textInputState.x, textInputState.y),
zIndex: 0,
})
setTextInputState(null)
}, [textInputState, currentStyle, addAnnotation])
}, [textInputState, currentStyle, addAnnotation, updateAnnotation, anchoredStyle])
const cancelTextInput = useCallback(() => {
setTextInputState(null)
+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",