mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-06 23:32:08 +00:00
Merge pull request #138 from IliesAllali/feat/onglets-ligne-directe
Onglets de familles : la ligne directe vers « C'est moi » dans chaque onglet
This commit is contained in:
9 files changed
+134
-18
No files matched your search
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user