Retour au placement automatique : abandon du placement libre

Le mainteneur préfère un réordonnancement des frères et soeurs au placement
libre (coordonnées enregistrées). Ce commit annule le glisser libre, ses
corrections, la migration des coordonnées et le bouton de remise à zéro ;
le réordonnancement arrive dans les commits suivants.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
IliesAllaliandClaude Opus 5.5 committed 2026-10-16 18:46:28 +02:00
1 parent 8fa2808b5d
commit 8574dfed9e
29 files changed
+26 -860

No files matched your search

+1 -1
View File
@@ -8,7 +8,7 @@ Site et démo : [parenthese.io](https://parenthese.io) · [English version](READ
## Ce que fait l'application
- Un arbre par famille : personnes, unions, liens parent-enfant, mise en page automatique réagençable par glisser-déposer en mode édition.
- Un arbre par famille : personnes, unions, liens parent-enfant, mise en page automatique.
- Des médias sur chaque personne : photos, vidéos (fichier ou lien YouTube), enregistrements audio pour garder une voix, documents, traces GPS.
- Des annotations libres sur l'arbre : dessins, stickers, textes, photos posées où l'on veut.
- Un partage par lien : chaque arbre a une adresse `/arbre/<nom>` protégée par un mot de passe de partage, qui permet de regarder et de proposer des ajouts.
+1 -1
View File
@@ -13,7 +13,7 @@ Website: [parenthese.io/en](https://parenthese.io/en/) · Demo, no account neede
## What it does
- One tree per family: people, couples, parent-child links, laid out automatically and rearrangeable by dragging people in edit mode.
- One tree per family: people, couples, parent-child links, laid out automatically.
- Memories on each person: photos, videos (a file or a YouTube link), audio recordings to keep a voice, documents, GPS tracks.
- Free annotations on the tree: drawings, stickers, text, photos placed anywhere.
- Sharing by link: each tree has an address `/arbre/<name>` protected by a sharing password. With it, relatives can look around and suggest additions.
-53
View File
@@ -228,14 +228,6 @@ components:
deathDate:
type: string
nullable: true
layoutX:
type: number
nullable: true
description: Manual tree-layout X coordinate for the person.
layoutY:
type: number
nullable: true
description: Manual tree-layout Y coordinate for the person.
sex:
type: string
nullable: true
@@ -353,16 +345,6 @@ components:
deathDate:
type: string
nullable: true
layoutX:
type: number
nullable: true
minimum: -1000000
maximum: 1000000
layoutY:
type: number
nullable: true
minimum: -1000000
maximum: 1000000
sex:
type: string
nullable: true
@@ -1373,41 +1355,6 @@ paths:
schema:
$ref: '#/components/schemas/ErrorResponse'
/trees/{id}/layout/reset:
post:
tags: [Trees]
summary: Reset manual person positions so the tree is laid out automatically again (admin)
security:
- BearerAuth: []
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
'200':
description: Positions cleared
content:
application/json:
schema:
type: object
properties:
personsReset:
type: integer
'401':
description: Authentication required
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'403':
description: Forbidden (admin only)
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/trees/{id}/unions:
post:
tags: [Trees]
@@ -1,3 +0,0 @@
ALTER TABLE "persons"
ADD COLUMN "layoutX" DOUBLE PRECISION,
ADD COLUMN "layoutY" DOUBLE PRECISION;
-2
View File
@@ -198,8 +198,6 @@ model Person {
birthName String?
birthDate DateTime?
deathDate DateTime?
layoutX Float?
layoutY Float?
birthPlace String?
profession String?
region String?
-13
View File
@@ -10,11 +10,8 @@ import { removeStoredFile } from '../lib/media-files.js'
import { syncPersonMediaOrder } from '../lib/media-order.js'
import type { Actor, MembershipRole } from '../types/auth.js'
import { LAYOUT_COORDINATE_LIMIT } from '../utils/graph-payload.js'
import { findParentChildValidationError, findUnionValidationError } from '../utils/relationship-guards.js'
const layoutCoordinateSchema = z.number().finite().min(-LAYOUT_COORDINATE_LIMIT).max(LAYOUT_COORDINATE_LIMIT)
const isoDateRegex = /^\d{4}-\d{2}-\d{2}$/
const treeIdParamSchema = z.object({
@@ -89,8 +86,6 @@ const personUpdateSchema = z
birthName: z.string().max(120).optional().nullable(),
birthDate: z.string().regex(isoDateRegex).optional().nullable(),
deathDate: z.string().regex(isoDateRegex).optional().nullable(),
layoutX: layoutCoordinateSchema.optional().nullable(),
layoutY: layoutCoordinateSchema.optional().nullable(),
birthPlace: z.string().max(200).optional().nullable(),
profession: z.string().max(200).optional().nullable(),
region: z.string().max(120).optional().nullable(),
@@ -407,8 +402,6 @@ async function applyContributionChange(
birthName: parsed.data.birthName !== undefined ? toTrimmedStringOrNull(parsed.data.birthName) : undefined,
birthDate: parsed.data.birthDate !== undefined ? toDateOrNull(parsed.data.birthDate) : undefined,
deathDate: parsed.data.deathDate !== undefined ? toDateOrNull(parsed.data.deathDate) : undefined,
layoutX: parsed.data.layoutX,
layoutY: parsed.data.layoutY,
birthPlace: parsed.data.birthPlace !== undefined ? toTrimmedStringOrNull(parsed.data.birthPlace) : undefined,
profession: parsed.data.profession !== undefined ? toTrimmedStringOrNull(parsed.data.profession) : undefined,
region: parsed.data.region !== undefined ? toTrimmedStringOrNull(parsed.data.region) : undefined,
@@ -784,8 +777,6 @@ async function applyContributionChange(
}
const SNAPSHOT_ENTITY_TYPES = new Set(['person', 'union', 'parent_child_link'])
// Champs qui ne créent jamais de conflit de relecture (positions dans l'arbre, déplacées à la main)
const NON_CONFLICTING_KEYS = new Set(['layoutX', 'layoutY'])
// État actuel d'une personne, union ou lien, tel qu'on le montre à la relecture. null : introuvable dans l'arbre.
async function snapshotEntity(
@@ -803,8 +794,6 @@ async function snapshotEntity(
birthName: true,
birthDate: true,
deathDate: true,
layoutX: true,
layoutY: true,
birthPlace: true,
profession: true,
region: true,
@@ -858,8 +847,6 @@ async function detectConflict(
const snap = await snapshotEntity(tx, treeId, change.entityType, change.entityId)
if (!snap) return true
for (const key of Object.keys(before)) {
// La place dans l'arbre bouge à chaque glisser du propriétaire : ce n'est pas une modification concurrente
if (NON_CONFLICTING_KEYS.has(key)) continue
if (key in snap && String(snap[key] ?? '') !== String(before[key] ?? '')) return true
}
return false
-64
View File
@@ -13,11 +13,8 @@ import { decryptSharePassword, encryptSharePassword } from '../lib/share-passwor
import { purgeTrees } from '../lib/tree-purge.js'
import { reserveTreeUnlockAttempt, serializePasswordCheck } from '../lib/unlock-guard.js'
import type { MembershipRole } from '../types/auth.js'
import { LAYOUT_COORDINATE_LIMIT } from '../utils/graph-payload.js'
import { findParentChildValidationError, findUnionValidationError } from '../utils/relationship-guards.js'
const layoutCoordinateSchema = z.number().finite().min(-LAYOUT_COORDINATE_LIMIT).max(LAYOUT_COORDINATE_LIMIT)
const MAX_TREES_PER_ACCOUNT = 10
const treeIdParamSchema = z.object({
@@ -104,8 +101,6 @@ const patchPersonSchema = z
birthName: z.string().max(120).optional().nullable(),
birthDate: z.string().regex(isoDateRegex).optional().nullable(),
deathDate: z.string().regex(isoDateRegex).optional().nullable(),
layoutX: layoutCoordinateSchema.optional().nullable(),
layoutY: layoutCoordinateSchema.optional().nullable(),
birthPlace: z.string().max(200).optional().nullable(),
profession: z.string().max(200).optional().nullable(),
region: z.string().max(120).optional().nullable(),
@@ -1307,8 +1302,6 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
birthName?: string | null
birthDate?: Date | null
deathDate?: Date | null
layoutX?: number | null
layoutY?: number | null
birthPlace?: string | null
profession?: string | null
region?: string | null
@@ -1338,14 +1331,6 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
data.deathDate = toDateOrNull(payload.data.deathDate)
}
if (payload.data.layoutX !== undefined) {
data.layoutX = payload.data.layoutX
}
if (payload.data.layoutY !== undefined) {
data.layoutY = payload.data.layoutY
}
if (payload.data.birthPlace !== undefined) {
data.birthPlace = toTrimmedStringOrNull(payload.data.birthPlace)
}
@@ -1410,55 +1395,6 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
return reply.send({ person })
})
// Rétablit la disposition automatique : efface la place manuelle de toutes les personnes de l'arbre
app.post('/trees/:id/layout/reset', async (request, reply) => {
const user = requireUser(request, reply)
if (!user) {
return
}
const params = treeIdParamSchema.safeParse(request.params)
if (!params.success) {
return reply.code(400).send({ error: 'invalid_tree_id' })
}
const membership = await getAdminMembership(app, user.userId, params.data.id)
if (!hasActiveAdminMembership(membership)) {
return reply.code(403).send({ error: 'forbidden' })
}
const count = await app.prisma.$transaction(async (tx) => {
const result = await tx.person.updateMany({
where: {
treeId: params.data.id,
deletedAt: null,
OR: [{ layoutX: { not: null } }, { layoutY: { not: null } }],
},
data: {
layoutX: null,
layoutY: null,
updatedBy: user.userId,
},
})
await tx.auditLog.create({
data: {
treeId: params.data.id,
actorType: 'user',
actorId: user.userId,
action: 'tree_layout_reset',
entityType: 'tree',
entityId: params.data.id,
payloadJson: { personsReset: result.count },
},
})
return result.count
})
return reply.send({ personsReset: count })
})
app.delete('/trees/:id/persons/:personId', async (request, reply) => {
const user = requireUser(request, reply)
if (!user) {
-7
View File
@@ -2,9 +2,6 @@ import type { Annotation, MediaItem, ParentChildLink, Person, Union } from '@pri
import { sanitizeAnnotationContentForRead } from '../lib/annotation-content.js'
// Espace des coordonnées de disposition manuelle (unités du graphe). Au-delà, rien de réel dans un arbre.
export const LAYOUT_COORDINATE_LIMIT = 1_000_000
export type GraphPayload = {
persons: Array<{
id: string
@@ -13,8 +10,6 @@ export type GraphPayload = {
birthName: string | null
birthDate: string | null
deathDate: string | null
layoutX: number | null
layoutY: number | null
birthPlace: string | null
profession: string | null
region: string | null
@@ -88,8 +83,6 @@ export function buildGraphPayload(
birthName: person.birthName,
birthDate: formatDate(person.birthDate),
deathDate: formatDate(person.deathDate),
layoutX: person.layoutX ?? null,
layoutY: person.layoutY ?? null,
birthPlace: person.birthPlace,
profession: person.profession,
region: person.region,
+1 -138
View File
@@ -456,15 +456,11 @@ describe('API security and authz', () => {
payload: {
firstName: 'Alice',
notes: 'Mise a jour',
layoutX: 321.5,
layoutY: 654.25,
},
})
expect(patchResponse.statusCode).toBe(200)
expect(prismaMock.person.updateMany).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({ layoutX: 321.5, layoutY: 654.25 }),
}))
expect(prismaMock.person.updateMany).toHaveBeenCalled()
prismaMock.union.updateMany.mockResolvedValueOnce({ count: 2 })
prismaMock.parentChildLink.updateMany.mockResolvedValueOnce({ count: 3 })
@@ -541,88 +537,6 @@ describe('API security and authz', () => {
await app.close()
})
it('refuses a manual layout coordinate outside the tree space', async () => {
const app = createApp()
const token = await createUserToken(app)
const response = await app.inject({
method: 'PATCH',
url: '/trees/tree-1/persons/person-1',
headers: { authorization: `Bearer ${token}` },
payload: { layoutX: 2_000_000, layoutY: 10 },
})
expect(response.statusCode).toBe(400)
expect(prismaMock.person.updateMany).not.toHaveBeenCalled()
await app.close()
})
it('forbids a member from moving a person by hand', async () => {
const app = createApp()
const token = await createUserToken(app)
prismaMock.treeMembership.findUnique.mockResolvedValue({
treeId: 'tree-1',
userId: 'user-1',
role: 'member',
tree: { deletedAt: null, rootPersonId: null },
})
const response = await app.inject({
method: 'PATCH',
url: '/trees/tree-1/persons/person-1',
headers: { authorization: `Bearer ${token}` },
payload: { layoutX: 120, layoutY: 240 },
})
expect(response.statusCode).toBe(403)
expect(prismaMock.person.updateMany).not.toHaveBeenCalled()
await app.close()
})
it('lets the owner put every person back to the automatic layout, with an audit entry', async () => {
const app = createApp()
const token = await createUserToken(app)
prismaMock.person.updateMany.mockResolvedValueOnce({ count: 4 })
const response = await app.inject({
method: 'POST',
url: '/trees/tree-1/layout/reset',
headers: { authorization: `Bearer ${token}` },
})
expect(response.statusCode).toBe(200)
expect(response.json()).toEqual({ personsReset: 4 })
expect(prismaMock.person.updateMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({ treeId: 'tree-1', deletedAt: null }),
data: expect.objectContaining({ layoutX: null, layoutY: null }),
}))
expect(prismaMock.auditLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({ treeId: 'tree-1', action: 'tree_layout_reset', payloadJson: { personsReset: 4 } }),
})
await app.close()
})
it('forbids a member from resetting the automatic layout', async () => {
const app = createApp()
const token = await createUserToken(app)
prismaMock.treeMembership.findUnique.mockResolvedValue({
treeId: 'tree-1',
userId: 'user-1',
role: 'member',
tree: { deletedAt: null, rootPersonId: null },
})
const response = await app.inject({
method: 'POST',
url: '/trees/tree-1/layout/reset',
headers: { authorization: `Bearer ${token}` },
})
expect(response.statusCode).toBe(403)
expect(prismaMock.person.updateMany).not.toHaveBeenCalled()
await app.close()
})
it('allows contributor access token to submit a pending contribution session', async () => {
const app = createApp()
await app.ready()
@@ -1012,57 +926,6 @@ describe('API security and authz', () => {
await app.close()
})
it('does not flag a pending person change as a conflict when the owner only moved that person', async () => {
const app = createApp()
const token = await createUserToken(app)
prismaMock.contributionSession.findFirst.mockResolvedValue({
id: 'session-1',
treeId: 'tree-1',
status: 'pending',
changes: [
{
id: 'change-1',
entityType: 'person',
action: 'update',
entityId: 'person-1',
beforeJson: { firstName: 'Alice', layoutX: null, layoutY: null },
afterJson: { firstName: 'Alicia' },
conflictState: 'none',
},
],
})
// Le propriétaire a glissé la personne depuis l'envoi : seule la place a changé
prismaMock.person.findFirst.mockResolvedValue({
firstName: 'Alice',
lastName: 'Martin',
birthName: null,
birthDate: null,
deathDate: null,
layoutX: 300,
layoutY: 400,
birthPlace: null,
profession: null,
region: null,
nationality: null,
sex: null,
notes: null,
})
const response = await app.inject({
method: 'POST',
url: '/trees/tree-1/contributions/sessions/session-1/review',
headers: { authorization: `Bearer ${token}` },
payload: { decision: 'approved' },
})
expect(response.statusCode).toBe(200)
expect(prismaMock.contributionChange.update).not.toHaveBeenCalledWith(expect.objectContaining({
data: { conflictState: 'needs_review' },
}))
await app.close()
})
it('allows admin to list pending contribution sessions', async () => {
const app = createApp()
const token = await createUserToken(app)
+2 -4
View File
@@ -545,7 +545,7 @@ describe('Journey 4 — Admin reviews a contribution session', () => {
action: 'update',
entityId: 'person-1',
beforeJson: { firstName: 'Pierre' },
afterJson: { notes: 'Surnomme Pierrot', layoutX: 234.5, layoutY: 678.25 },
afterJson: { notes: 'Surnomme Pierrot' },
conflictState: 'none',
},
],
@@ -581,9 +581,7 @@ describe('Journey 4 — Admin reviews a contribution session', () => {
// Both changes should be applied
expect(prismaMock.person.create).toHaveBeenCalled()
expect(prismaMock.person.updateMany).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({ layoutX: 234.5, layoutY: 678.25 }),
}))
expect(prismaMock.person.updateMany).toHaveBeenCalled()
// Audit log for review
expect(prismaMock.auditLog.create).toHaveBeenCalledWith(
-4
View File
@@ -15,8 +15,6 @@ describe('buildGraphPayload', () => {
birthName: 'Durand',
birthDate: new Date('1994-06-02T00:00:00.000Z'),
deathDate: null,
layoutX: 432.5,
layoutY: -19.25,
sex: null,
notes: null,
avatarPath: 't-1/p-2/avatar.jpg',
@@ -98,8 +96,6 @@ describe('buildGraphPayload', () => {
expect(graph.persons[0].id).toBe('p-2')
expect(graph.persons[0].birthDate).toBe('1994-06-02')
expect(graph.persons[0].birthName).toBe('Durand')
expect(graph.persons[0].layoutX).toBe(432.5)
expect(graph.persons[0].layoutY).toBe(-19.25)
expect(graph.persons[0].avatarUrlPath).toBe('/trees/t-1/persons/p-2/avatar')
expect(graph.persons[1].avatarUrlPath).toBeNull()
expect(graph.unions[0].id).toBe('u-1')
+2 -69
View File
@@ -19,7 +19,6 @@ import EditConfirmModal from './components/EditConfirmModal'
import DraftRestoreBanner from './components/DraftRestoreBanner'
import AddPersonPanel from './components/AddPersonPanel'
import ContributionSessionModal from './components/ContributionSessionModal'
import { buildManualPositionOverrides } from './components/galaxy/layoutPositions'
import { ContextualNavbar } from './components/navbar'
import ZoomControl from './components/ZoomControl'
import { ExpandableSearch } from './components/search'
@@ -284,8 +283,6 @@ function buildContributionChangesFromDraft(draft = {}) {
if (Object.prototype.hasOwnProperty.call(source, 'region')) after.region = (source.region || '').trim() || null
if (Object.prototype.hasOwnProperty.call(source, 'nationality')) after.nationality = (source.nationality || '').trim() || null
if (Object.prototype.hasOwnProperty.call(source, 'note')) after.notes = source.note || null
if (Object.prototype.hasOwnProperty.call(source, 'layoutX')) after.layoutX = source.layoutX
if (Object.prototype.hasOwnProperty.call(source, 'layoutY')) after.layoutY = source.layoutY
if (Object.prototype.hasOwnProperty.call(source, 'isAlive') && source.isAlive === true) after.deathDate = null
if (Object.keys(after).length > 0) {
@@ -628,23 +625,6 @@ function App() {
treeId: tree.treeContext.treeId,
canEdit: canEditInCurrentMode,
})
// draftRevision : la valeur lue par restoreDraft (une ref) ne change pas, il faut un signal qui bouge à chaque écriture
const manualPositionOverrides = useMemo(() => {
if (!editMode.hasDraft) return {}
return buildManualPositionOverrides(editMode.restoreDraft().modifiedPersons)
}, [editMode.hasDraft, editMode.draftRevision, editMode.restoreDraft])
const handlePersonPositionChange = useCallback((personId, position) => {
const draft = editMode.restoreDraft()
editMode.updateDraft({
modifiedPersons: {
...(draft.modifiedPersons || {}),
[String(personId)]: {
...(draft.modifiedPersons?.[String(personId)] || {}),
...position,
},
},
})
}, [editMode.restoreDraft, editMode.updateDraft])
const localEntityCounterRef = useRef(0)
const makeLocalId = useCallback((prefix) => {
@@ -1179,12 +1159,6 @@ function App() {
const timer = setTimeout(() => setDraftNotice(''), 4500)
return () => clearTimeout(timer)
}, [draftNotice])
const [editSaveError, setEditSaveError] = useState('')
useEffect(() => {
if (!editSaveError) return undefined
const timer = setTimeout(() => setEditSaveError(''), 6000)
return () => clearTimeout(timer)
}, [editSaveError])
// Soumission des modifications
const handleEditSubmit = useCallback(async () => {
@@ -1202,7 +1176,6 @@ function App() {
const treeId = tree.treeContext.treeId
const draft = editMode.restoreDraft()
let failedPersonSaves = 0
if (token && treeId) {
// Appliquer les modifications de personnes depuis le draft
const modifiedPersons = draft.modifiedPersons || {}
@@ -1219,14 +1192,11 @@ function App() {
if (Object.prototype.hasOwnProperty.call(rawChanges, 'nationality')) payload.nationality = (rawChanges.nationality || '').trim() || null
if (Object.prototype.hasOwnProperty.call(rawChanges, 'isAlive') && rawChanges.isAlive === true) payload.deathDate = null
if (Object.prototype.hasOwnProperty.call(rawChanges, 'note')) payload.notes = rawChanges.note || null
if (Object.prototype.hasOwnProperty.call(rawChanges, 'layoutX')) payload.layoutX = rawChanges.layoutX
if (Object.prototype.hasOwnProperty.call(rawChanges, 'layoutY')) payload.layoutY = rawChanges.layoutY
if (Object.keys(payload).length > 0) {
try {
await updatePerson(treeId, personId, payload, token)
} catch (err) {
failedPersonSaves += 1
console.error('Failed to update person:', personId, err)
}
}
@@ -1274,11 +1244,6 @@ function App() {
}
await tree.refreshCurrentTreeGraph({ relayout: false })
if (failedPersonSaves > 0) {
// Brouillon gardé : rien n'est perdu, l'admin peut réessayer
setEditSaveError(t("Certaines modifications n'ont pas pu être enregistrées. Vérifiez puis réessayez."))
return
}
editMode.clearDraft()
editMode.deactivate()
} else {
@@ -1322,16 +1287,6 @@ function App() {
return
}
// Comme un nom modifié, la place envoyée reste à l'écran jusqu'à la relecture
for (const [personId, changes] of Object.entries(draft.modifiedPersons || {})) {
if (!Number.isFinite(changes?.layoutX) || !Number.isFinite(changes?.layoutY)) continue
const localPerson = persons.find((candidate) => String(candidate.id) === String(personId))
if (localPerson) {
localPerson.layoutX = changes.layoutX
localPerson.layoutY = changes.layoutY
}
}
editMode.clearDraft()
editMode.deactivate()
setShowContribModal(false)
@@ -2555,22 +2510,6 @@ function App() {
}
}
// Admin : efface les positions manuelles de l'arbre, y compris celles restées dans son brouillon
const handleResetTreeLayout = useCallback(async () => {
const draft = editMode.restoreDraft()
if (draft.modifiedPersons) {
const modifiedPersons = {}
for (const [personId, changes] of Object.entries(draft.modifiedPersons)) {
const rest = { ...changes }
delete rest.layoutX
delete rest.layoutY
if (Object.keys(rest).length > 0) modifiedPersons[personId] = rest
}
editMode.updateDraft({ modifiedPersons })
}
await refreshTreeAndKeepSelection(null, { relayout: true })
}, [editMode.restoreDraft, editMode.updateDraft, refreshTreeAndKeepSelection])
const handleGedcomImported = useCallback(async (result, treeIdOverride = null) => {
await refreshTreeAndKeepSelection(null, { relayout: true })
const refs = Array.isArray(result?.photoRefs) ? result.photoRefs : []
@@ -2842,9 +2781,6 @@ function App() {
{draftNotice && !contribSuccess && !showContribModal && (
<div className="contrib-success-toast" role="status">{draftNotice}</div>
)}
{editSaveError && (
<div className="contrib-success-toast" role="alert">{editSaveError}</div>
)}
{contribSuccess && (
<div className="contrib-success-toast" role="status">
{t("Merci, c'est envoyé ! Ça apparaîtra dans l'arbre une fois relu.")}
@@ -2890,9 +2826,6 @@ function App() {
<Galaxy
onPersonSelect={handlePersonSelect}
onMediaSelect={handleMediaSelectFresh}
onPersonPositionChange={handlePersonPositionChange}
editModeActive={editMode.isActive}
manualPositionOverrides={manualPositionOverrides}
selectedPersonId={tree.selectedPerson?.id || null}
searchHighlightIds={search.matchedPersonIds}
graphRevision={tree.graphRevision}
@@ -2984,8 +2917,6 @@ function App() {
onGedcomImported={handleGedcomImported}
onOpenPhotoStart={() => { setAdminPanelVisible(false); openPhotoStart() }}
onClose={() => setAdminPanelVisible(false)}
hasManualPositions={persons.some((person) => Number.isFinite(person.layoutX) && Number.isFinite(person.layoutY))}
onLayoutReset={handleResetTreeLayout}
/>
<PhotoStartPanel
visible={photoStart.visible}
@@ -3037,3 +2968,5 @@ function App() {
export default App
-10
View File
@@ -122,16 +122,6 @@ export async function deleteTree(treeId, token) {
return data
}
// Efface les positions manuelles de toutes les personnes (admin) : la disposition redevient automatique
export async function resetTreeLayout(treeId, token) {
return apiRequest(`/trees/${encodeURIComponent(treeId)}/layout/reset`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
},
})
}
export async function createPerson(treeId, payload, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/persons`, {
method: 'POST',
+1 -59
View File
@@ -7,7 +7,6 @@ import {
fetchTreeSettings,
getTree,
importGedcom,
resetTreeLayout,
updateTree,
updateTreeSettings,
} from '../api/treeApi'
@@ -32,8 +31,6 @@ const AdminPanel = ({
onTreeDeleted,
onGedcomImported,
onOpenPhotoStart,
hasManualPositions = false,
onLayoutReset,
}) => {
const [section, setSection] = useState('tree')
const [loading, setLoading] = useState(false)
@@ -50,7 +47,6 @@ const AdminPanel = ({
const [showDeleteModal, setShowDeleteModal] = useState(false)
const [deleteConfirmName, setDeleteConfirmName] = useState('')
const [showLayoutResetModal, setShowLayoutResetModal] = useState(false)
const [gedcomFile, setGedcomFile] = useState(null)
const [importingGedcom, setImportingGedcom] = useState(false)
const gedcomInputRef = useRef(null)
@@ -67,7 +63,6 @@ const AdminPanel = ({
setSection('tree')
setShowDeleteModal(false)
setDeleteConfirmName('')
setShowLayoutResetModal(false)
setGedcomFile(null)
setImportingGedcom(false)
@@ -235,25 +230,6 @@ const AdminPanel = ({
}
}
const handleResetLayout = async () => {
if (!treeId || !authToken || saving) return
setSaving(true)
setErrorMessage('')
setSuccessMessage('')
try {
await resetTreeLayout(treeId, authToken)
await onLayoutReset?.()
setShowLayoutResetModal(false)
setSuccessMessage(t('La disposition automatique est rétablie.'))
} catch {
setErrorMessage(t('Impossible de rétablir la disposition.'))
} finally {
setSaving(false)
}
}
const handleDeleteTree = async () => {
if (!treeId || !authToken || saving || !tree) return
if (deleteConfirmName.trim() !== tree.name) {
@@ -283,7 +259,7 @@ const AdminPanel = ({
<div
className="pz-overlay tree-settings-overlay"
onClick={() => {
if (!showDeleteModal && !showLayoutResetModal) onClose?.()
if (!showDeleteModal) onClose?.()
}}
>
<div
@@ -452,20 +428,6 @@ const AdminPanel = ({
{section === 'danger' && (
<div className="ts-panel">
{hasManualPositions && (
<div className="ts-row">
<div className="ts-row-text">
<strong>{t("Disposition de l'arbre")}</strong>
<p className="pz-small">{t("Les personnes déplacées à la main reprennent la place que leur donne l'arbre.")}</p>
</div>
<div className="ts-row-actions">
<button type="button" className="pz-btn pz-btn--secondary pz-btn--sm" onClick={() => setShowLayoutResetModal(true)} disabled={saving}>
{t('Rétablir la disposition automatique')}
</button>
</div>
</div>
)}
<div className="ts-danger">
<div className="ts-row-text">
<strong>{t('Supprimer cet arbre')}</strong>
@@ -483,26 +445,6 @@ const AdminPanel = ({
</div>
</div>
{showLayoutResetModal && (
<div className="pz-overlay ts-delete-overlay" onClick={(event) => { event.stopPropagation(); if (!saving) setShowLayoutResetModal(false) }}>
<div className="pz-modal settings-delete-modal" role="dialog" aria-modal="true" aria-labelledby="treeLayoutResetTitle" onClick={(event) => event.stopPropagation()}>
<div className="pz-modal-head">
<p className="pz-eyebrow">{t('Dernière vérification')}</p>
<h3 id="treeLayoutResetTitle" className="pz-title">{t('Rétablir la disposition automatique ?')}</h3>
<p className="pz-sub">{t('Les déplacements faits à la main seront effacés pour toute la famille.')}</p>
</div>
<div className="pz-modal-actions">
<button type="button" className="pz-btn pz-btn--ghost" onClick={() => setShowLayoutResetModal(false)} disabled={saving}>
{t('Annuler')}
</button>
<button type="button" className="pz-btn pz-btn--danger danger-action" onClick={handleResetLayout} disabled={saving}>
<PzBusy busy={saving} busyLabel={t('Rétablissement en cours')}>{t('Rétablir')}</PzBusy>
</button>
</div>
</div>
</div>
)}
{showDeleteModal && (
<div className="pz-overlay ts-delete-overlay" onClick={(event) => { event.stopPropagation(); if (!saving) setShowDeleteModal(false) }}>
<div className="pz-modal settings-delete-modal" role="dialog" aria-modal="true" aria-labelledby="treeDeleteTitle" onClick={(event) => event.stopPropagation()}>
@@ -5,7 +5,6 @@ import { persons as treePersons } from '../data/mockData'
import { Check, ChevronDown, Copy, RefreshCw, X } from 'lucide-react'
import './SettingsPanels.css'
import PzBusy from './PzBusy.jsx'
import { describePositionChange } from './galaxy/layoutPositions.js'
import { HELP_URL } from '../utils/siteLinks.js'
import { t, dateLocale } from '../i18n/index.js'
@@ -131,7 +130,7 @@ function getFieldRows(change) {
if (!after || typeof after !== 'object') return []
const before = (change.beforeJson && typeof change.beforeJson === 'object') ? change.beforeJson : null
const rows = Object.entries(after)
return Object.entries(after)
.filter(([key]) => PERSON_FIELD_LABELS[key])
.map(([key, newVal]) => ({
key,
@@ -143,8 +142,6 @@ function getFieldRows(change) {
if (oldVal !== undefined) return true
return newVal !== null && newVal !== undefined && newVal !== ''
})
const layoutRow = describePositionChange(before, after)
return layoutRow ? [...rows, layoutRow] : rows
}
function getAnnotationPreview(change, treeId) {
+6 -34
View File
@@ -1,5 +1,5 @@
import { memo, useEffect, useImperativeHandle, useRef, useState, useCallback } from 'react'
import { getPersonById, getPersonMedias, getParents, getChildren, getPersonUnions } from '../data/mockData'
import { getPersonMedias, getParents, getChildren, getPersonUnions } from '../data/mockData'
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'
@@ -12,11 +12,6 @@ import { drawBackground, drawCoupleLinks, drawFiliations, drawAnnotations, drawP
import { annotations as annotationData } from '../data/mockData'
import { useImageCache } from './galaxy/useImageCache'
import { useGalaxyInteractions } from './galaxy/useGalaxyInteractions'
import {
applyManualPersonPositions,
captureAutomaticPersonPositions,
positionUnionJunctions,
} from './galaxy/layoutPositions'
import './Galaxy.css'
// Assez bas pour cadrer un grand arbre entier à l'ouverture
@@ -34,7 +29,7 @@ const MAX_SCALE = 3.2
const FOCUS_MIN_READABLE_SCALE = 0.5
const FOCUS_SCALE = 0.95
const Galaxy = ({ onPersonSelect, onMediaSelect, onPersonPositionChange, editModeActive = false, manualPositionOverrides = {}, selectedPersonId, searchHighlightIds = [], graphRevision = 0, annotationHandlers = null, transformReadRef = null, zoomApiRef = null, focusPersonId = null, assetRevision = 0, photoHint = false }) => {
const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlightIds = [], graphRevision = 0, annotationHandlers = null, transformReadRef = null, zoomApiRef = null, focusPersonId = null, assetRevision = 0, photoHint = false }) => {
const canvasRef = useRef(null)
// Lu au calcul du cadrage seulement : changer de personne de départ ne recadre pas une vue en cours
const focusPersonIdRef = useRef(focusPersonId)
@@ -43,10 +38,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, onPersonPositionChange, editMod
// Propriétaire : un portrait vide survolé montre « + photo »
const photoHintRef = useRef(photoHint)
useEffect(() => { photoHintRef.current = photoHint }, [photoHint])
const manualPositionOverridesRef = useRef(manualPositionOverrides)
useEffect(() => { manualPositionOverridesRef.current = manualPositionOverrides }, [manualPositionOverrides])
const layoutDataRef = useRef(null)
const automaticPersonPositionsRef = useRef(new Map())
const [layoutReady, setLayoutReady] = useState(false)
const transformRef = useRef({ x: 0, y: 0, scale: 1 })
@@ -92,7 +84,6 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, onPersonPositionChange, editMod
const getAnimatedPos = useCallback((node, time) => {
const baseCx = node.x + node.width / 2
const baseCy = node.y + node.height / 2
if (node._manualPosition) return { cx: baseCx, cy: baseCy }
const rd = nodeRandomDataRef.current.get(node.id)
if (!rd) return { cx: baseCx, cy: baseCy }
@@ -119,14 +110,13 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, onPersonPositionChange, editMod
const {
isDragging, isZooming, zoomTimeoutRef, hasDragged,
handleMouseDown, handleMouseMove, handleMouseUp, cancelPersonDrag,
handleMouseDown, handleMouseMove, handleMouseUp,
handleMouseLeave, handleWheel, handleClick,
} = useGalaxyInteractions({
canvasRef, transformRef, targetTransformRef,
layoutDataRef, nodeRandomDataRef, entranceRef,
hoveredNodeIdRef: hoveredNodeId, hoveredMediaKeyRef: hoveredMediaKey, mouseWorldPosRef: mouseWorldPos,
getAnimatedPos, onPersonSelect, onMediaSelect,
onPersonPositionChange, editModeActive,
annotationHandlers,
minScale: MIN_SCALE,
maxScale: MAX_SCALE,
@@ -211,12 +201,6 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, onPersonPositionChange, editMod
// Layout
useEffect(() => {
const result = USE_BLOCK_LAYOUT ? computeBlockLayout() : computeLayout()
automaticPersonPositionsRef.current = captureAutomaticPersonPositions(result)
applyManualPersonPositions(result, {
draftPositions: manualPositionOverridesRef.current,
getPersonById,
automaticPositions: automaticPersonPositionsRef.current,
})
const canvas = canvasRef.current
if (!canvas) return
@@ -346,15 +330,6 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, onPersonPositionChange, editMod
setLayoutReady(true)
}, [graphRevision])
useEffect(() => {
applyManualPersonPositions(layoutDataRef.current, {
draftPositions: manualPositionOverrides,
getPersonById,
automaticPositions: automaticPersonPositionsRef.current,
})
needsRedrawRef.current = true
}, [graphRevision, manualPositionOverrides])
// Sync selectedPersonId
useEffect(() => {
selectedPersonIdRef.current = selectedPersonId
@@ -419,7 +394,6 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, onPersonPositionChange, editMod
layoutData.children.forEach(node => {
posMap.set(node.id, getAnimatedPos(node, timestamp))
})
positionUnionJunctions(coupleBarMeta, posMap)
const nodeMap = new Map()
layoutData.children.forEach(n => nodeMap.set(n.id, n))
@@ -706,8 +680,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, onPersonPositionChange, editMod
handleMouseDown({ clientX: t.clientX, clientY: t.clientY, preventDefault: () => e.preventDefault() })
} else if (e.touches.length === 2) {
e.preventDefault()
// Un second doigt annule le glisser d'une personne (rien n'est enregistré) puis arrête le pan
cancelPersonDrag()
// Stop any ongoing single-finger pan before starting pinch
handleMouseUp()
const [a, b] = e.touches
const dx = a.clientX - b.clientX
@@ -745,16 +718,15 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, onPersonPositionChange, editMod
// and mark hasDragged=true so the lift doesn't fire an accidental click
const t = e.touches[0]
handleMouseUp()
handleMouseDown({ clientX: t.clientX, clientY: t.clientY, preventDefault: () => e.preventDefault() }, { panOnly: true })
handleMouseDown({ clientX: t.clientX, clientY: t.clientY, preventDefault: () => e.preventDefault() })
hasDragged.current = true
} else {
handleMouseUp()
}
}}
onTouchCancel={() => {
// System interruption (notification, keyboard, etc.) — reset all touch state, sans enregistrer de position
// System interruption (notification, keyboard, etc.) — reset all touch state
pinchRef.current = null
cancelPersonDrag()
handleMouseUp()
}}
onClick={handleClick}
@@ -210,9 +210,7 @@ export function computeBlockLayout(graphData = null) {
const renvoisByUnion = new Map()
for (const e of result.edges) {
const u = e.sources[0], c = e.targets[0]
if (!externalEdges.has(`${u}>${c}`)) continue
e._renvoiEligible = true
if (Math.abs(centerX(u) - centerX(c)) <= RENVOI_MIN_DX) continue
if (!externalEdges.has(`${u}>${c}`) || Math.abs(centerX(u) - centerX(c)) <= RENVOI_MIN_DX) continue
e._renvoi = true
e._renvoiIndex = renvoisByUnion.get(u) || 0
renvoisByUnion.set(u, e._renvoiIndex + 1)
@@ -1,84 +0,0 @@
import { t } from '../../i18n/index.js'
function readLayoutPosition(person) {
if (!Number.isFinite(person?.layoutX) || !Number.isFinite(person?.layoutY)) return null
return { x: person.layoutX, y: person.layoutY }
}
export function captureAutomaticPersonPositions(layoutData) {
return new Map(
layoutData.children
.filter((node) => node._type === 'person')
.map((node) => [node.id, { x: node.x, y: node.y }]),
)
}
export function applyManualPersonPositions(
layoutData,
{ draftPositions = {}, getPersonById, automaticPositions = new Map() } = {},
) {
if (!layoutData) return
for (const node of layoutData.children) {
if (node._type !== 'person') continue
const personId = String(node._data.id)
const draftPosition = readLayoutPosition(draftPositions[personId])
const person = getPersonById?.(node._data.id) || node._data
const position = draftPosition || readLayoutPosition(person)
if (position) {
node.x = position.x - node.width / 2
node.y = position.y - node.height / 2
node._manualPosition = true
continue
}
const automaticPosition = automaticPositions.get(node.id)
if (automaticPosition) {
node.x = automaticPosition.x
node.y = automaticPosition.y
}
node._manualPosition = false
}
}
// Positions manuelles en attente dans le brouillon, par id de personne
export function buildManualPositionOverrides(modifiedPersons = {}) {
const positions = {}
for (const [personId, changes] of Object.entries(modifiedPersons || {})) {
if (Number.isFinite(changes?.layoutX) && Number.isFinite(changes?.layoutY)) {
positions[String(personId)] = { layoutX: changes.layoutX, layoutY: changes.layoutY }
}
}
return positions
}
// Ligne « Position dans l'arbre » d'une contribution en relecture. Jamais de coordonnées brutes.
export function describePositionChange(before, after) {
if (!after || !Object.prototype.hasOwnProperty.call(after, 'layoutX')) return null
const stateLabel = (value) => (value === null || value === undefined ? t('Replacée automatiquement') : t('Déplacée'))
const newLabel = stateLabel(after.layoutX)
const hasBefore = Boolean(before) && Object.prototype.hasOwnProperty.call(before, 'layoutX')
const oldLabel = hasBefore ? (before.layoutX === null || before.layoutX === undefined ? t('Automatique') : t('Déplacée')) : undefined
return {
key: 'layout',
label: t("Position dans l'arbre"),
// Déjà déplacée avant, déplacée encore : le libellé ne change pas, on n'affiche qu'une valeur
oldVal: oldLabel === newLabel ? undefined : oldLabel,
newVal: newLabel,
}
}
export function positionUnionJunctions(coupleBarMeta, positionById) {
for (const { elkId, p1Key, p2Key } of coupleBarMeta) {
const partner1 = positionById.get(p1Key)
const partner2 = positionById.get(p2Key)
if (!partner1 || !partner2) continue
positionById.set(elkId, {
cx: (partner1.cx + partner2.cx) / 2,
cy: (partner1.cy + partner2.cy) / 2,
})
}
}
@@ -1,137 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
applyManualPersonPositions,
buildManualPositionOverrides,
captureAutomaticPersonPositions,
describePositionChange,
positionUnionJunctions,
} from './layoutPositions'
const makeLayout = () => ({
children: [
{ id: 'p-1', _type: 'person', _data: { id: '1', layoutX: 300, layoutY: 400 }, x: 10, y: 20, width: 80, height: 80 },
{ id: 'p-2', _type: 'person', _data: { id: '2' }, x: 110, y: 120, width: 80, height: 80 },
{ id: 'u-1', _type: 'union', _data: { id: 'u-1' }, x: 50, y: 70, width: 20, height: 20 },
],
edges: [{ id: 'edge-1', sources: ['p-1'], targets: ['p-2'] }],
})
describe('manual person layout positions', () => {
it('uses draft coordinates ahead of persisted coordinates and leaves relationships intact', () => {
const layout = makeLayout()
const automaticPositions = captureAutomaticPersonPositions(layout)
applyManualPersonPositions(layout, {
draftPositions: { 1: { layoutX: 500, layoutY: 600 } },
automaticPositions,
})
expect(layout.children[0]).toMatchObject({ x: 460, y: 560, _manualPosition: true })
expect(layout.edges).toEqual([{ id: 'edge-1', sources: ['p-1'], targets: ['p-2'] }])
expect(layout.children[2].x).toBe(50)
})
it('restores automatic coordinates when draft positions are discarded', () => {
const layout = makeLayout()
layout.children[0]._data.layoutX = null
layout.children[0]._data.layoutY = null
const automaticPositions = captureAutomaticPersonPositions(layout)
layout.children[0].x = 900
layout.children[0].y = 900
applyManualPersonPositions(layout, { automaticPositions })
expect(layout.children[0]).toMatchObject({ x: 10, y: 20, _manualPosition: false })
})
it('ignores incomplete coordinates and falls back to saved person coordinates', () => {
const layout = makeLayout()
applyManualPersonPositions(layout, {
draftPositions: { 1: { layoutX: 500 } },
automaticPositions: captureAutomaticPersonPositions(layout),
})
expect(layout.children[0]).toMatchObject({ x: 260, y: 360, _manualPosition: true })
})
})
describe('dynamic union junction positions', () => {
it('keeps the junction centered between partners after their positions change', () => {
const positions = new Map([
['p-1', { cx: 400, cy: 250 }],
['p-2', { cx: 700, cy: 350 }],
['u-1', { cx: 100, cy: 100 }],
])
positionUnionJunctions(
[{ elkId: 'u-1', p1Key: 'p-1', p2Key: 'p-2' }],
positions,
)
expect(positions.get('u-1')).toEqual({ cx: 550, cy: 300 })
})
it('leaves a junction unchanged if either partner position is unavailable', () => {
const originalJunction = { cx: 100, cy: 100 }
const positions = new Map([
['p-1', { cx: 400, cy: 250 }],
['u-1', originalJunction],
])
positionUnionJunctions(
[{ elkId: 'u-1', p1Key: 'p-1', p2Key: 'p-2' }],
positions,
)
expect(positions.get('u-1')).toBe(originalJunction)
})
})
describe('positions manuelles du brouillon', () => {
it('reprend toutes les personnes déplacées dans le brouillon, pas seulement la dernière', () => {
const draft = {
'1': { layoutX: 10, layoutY: 20 },
'2': { layoutX: 30, layoutY: 40, firstName: 'Hugo' },
}
expect(buildManualPositionOverrides(draft)).toEqual({
'1': { layoutX: 10, layoutY: 20 },
'2': { layoutX: 30, layoutY: 40 },
})
})
it('ignore les personnes sans position complète', () => {
expect(buildManualPositionOverrides({ 3: { layoutX: 5 }, 4: { firstName: 'Anne' } })).toEqual({})
expect(buildManualPositionOverrides(undefined)).toEqual({})
})
})
describe("ligne « Position dans l'arbre » en relecture", () => {
it('dit déplacée ou replacée automatiquement, sans coordonnée brute', () => {
expect(describePositionChange(null, { layoutX: 320.5, layoutY: 110 })).toEqual({
key: 'layout',
label: "Position dans l'arbre",
oldVal: undefined,
newVal: 'Déplacée',
})
expect(describePositionChange({ layoutX: 320, layoutY: 110 }, { layoutX: null, layoutY: null })).toMatchObject({
oldVal: 'Déplacée',
newVal: 'Replacée automatiquement',
})
})
it('montre le passage automatique vers déplacée, et rien si la ligne ne bouge pas de libellé', () => {
expect(describePositionChange({ layoutX: null, layoutY: null }, { layoutX: 1, layoutY: 2 })).toMatchObject({
oldVal: 'Automatique',
newVal: 'Déplacée',
})
expect(describePositionChange({ layoutX: 1, layoutY: 2 }, { layoutX: 3, layoutY: 4 })).toMatchObject({
oldVal: undefined,
newVal: 'Déplacée',
})
})
it('ne concerne pas une contribution qui ne touche pas la position', () => {
expect(describePositionChange({ firstName: 'Anne' }, { firstName: 'Ana' })).toBeNull()
})
})
+2 -14
View File
@@ -7,7 +7,6 @@ import {
PERSON_R, UNKNOWN_R, UNION_W, UNION_H, COMPACT_R, COMPACT_SCALE,
MAX_ORBIT_MEDIAS, ORBIT_MEDIA_SIZE,
COLORS,
RENVOI_MIN_DX,
LOD_SHADOW_MIN_SCALE, LOD_DETAIL_MIN_SCALE, LOD_LABEL_FADE_IN, LOD_LABEL_FADE_OUT, CULL_MARGIN,
} from './constants'
import { getEntranceProgress, drawPartialBezier } from './utils'
@@ -102,11 +101,6 @@ const BUS_TURN = 56
// En dessous de cet écart union → enfant, pas de virage
const BUS_STRAIGHT_DX = 30
export function shouldRenderRenvoi(edge, sourcePos, targetPos) {
if (!edge._renvoiEligible) return Boolean(edge._renvoi)
return Math.abs(sourcePos.cx - targetPos.cx) > RENVOI_MIN_DX
}
function busY(sourceId, sy) {
let h = 0
for (let i = 0; i < sourceId.length; i++) h = (h * 31 + sourceId.charCodeAt(i)) | 0
@@ -257,7 +251,6 @@ export function drawFiliations(ctx, edges, posMap, nodeMap, nodeRandomData, entr
renvoiHitAreas.length = 0
renvoiPills.length = 0
if (!edges) return
const renvoiIndexBySource = new Map()
edges.forEach(edge => {
const sourceId = edge.sources[0]
@@ -294,13 +287,8 @@ export function drawFiliations(ctx, edges, posMap, nodeMap, nodeRandomData, entr
ctx.globalAlpha = Math.min(1, edgeProgress * 1.5)
const childR = nodeRadius(targetNode)
const isRenvoi = shouldRenderRenvoi(edge, sourcePos, targetPos)
if (isRenvoi) {
const index = edge._renvoiEligible
? (renvoiIndexBySource.get(sourceId) || 0)
: (edge._renvoiIndex || 0)
if (edge._renvoiEligible) renvoiIndexBySource.set(sourceId, index + 1)
drawRenvoi(ctx, { ...edge, _renvoiIndex: index }, sourcePos, targetPos, targetNode, childR, view)
if (edge._renvoi) {
drawRenvoi(ctx, edge, sourcePos, targetPos, targetNode, childR, view)
ctx.globalAlpha = 1
ctx.setLineDash([])
return
@@ -1,26 +0,0 @@
import { describe, expect, it } from 'vitest'
import { RENVOI_MIN_DX } from './constants'
import { shouldRenderRenvoi } from './renderers'
describe('renvoi rendering with manual positions', () => {
it('replaces an eligible renvoi with a direct link once the nodes are close enough', () => {
expect(shouldRenderRenvoi(
{ _renvoi: true, _renvoiEligible: true },
{ cx: 0, cy: 0 },
{ cx: RENVOI_MIN_DX, cy: 200 },
)).toBe(false)
})
it('retains a renvoi for an eligible edge that is still far away', () => {
expect(shouldRenderRenvoi(
{ _renvoiEligible: true },
{ cx: 0, cy: 0 },
{ cx: RENVOI_MIN_DX + 1, cy: 200 },
)).toBe(true)
})
it('preserves the precomputed behavior for non-eligible edges', () => {
expect(shouldRenderRenvoi({ _renvoi: true }, { cx: 0 }, { cx: 0 })).toBe(true)
expect(shouldRenderRenvoi({}, { cx: 0 }, { cx: 1000 })).toBe(false)
})
})
@@ -1,4 +1,4 @@
import { useRef, useCallback, useEffect } from 'react'
import { useRef, useCallback } from 'react'
import { getPersonMedias } from '../../data/mockData'
import { MAX_ORBIT_MEDIAS, ORBIT_MEDIA_SIZE } from './constants'
import { nodeRadius, renvoiHitAreas } from './renderers'
@@ -8,7 +8,6 @@ import { getEntranceProgress } from './utils'
const CURSOR_GRAB = 'default'
const CURSOR_GRABBING = 'grabbing'
const CURSOR_NODE_GRAB = 'grab'
const CURSOR_POINTER = 'pointer'
const CURSOR_CROSSHAIR = 'crosshair'
@@ -23,7 +22,6 @@ export function useGalaxyInteractions({
layoutDataRef, nodeRandomDataRef, entranceRef,
hoveredNodeIdRef, hoveredMediaKeyRef, mouseWorldPosRef,
getAnimatedPos, onPersonSelect, onMediaSelect,
onPersonPositionChange, editModeActive = false,
annotationHandlers,
minScale = 0.2,
maxScale = 4,
@@ -34,11 +32,6 @@ export function useGalaxyInteractions({
const lastMouse = useRef({ x: 0, y: 0 })
const hasDragged = useRef(false)
const annotationDragging = useRef(false)
const draggedPersonRef = useRef(null)
const handleMouseUpRef = useRef(null)
const handleGlobalMouseUp = useCallback(() => {
handleMouseUpRef.current?.()
}, [])
const getWorldPos = useCallback((e) => {
const rect = canvasRef.current?.getBoundingClientRect()
@@ -50,8 +43,7 @@ export function useGalaxyInteractions({
}
}, [canvasRef, transformRef])
// panOnly : un doigt qui reste après un pincement ne saisit pas de personne, il ne fait que déplacer l'arbre
const handleMouseDown = useCallback((e, { panOnly = false } = {}) => {
const handleMouseDown = useCallback((e) => {
const ah = annotationHandlers
const worldPos = getWorldPos(e)
@@ -73,41 +65,11 @@ export function useGalaxyInteractions({
}
}
if (editModeActive && !panOnly && (!ah || ah.activeTool === 'pointer') && worldPos && layoutDataRef.current) {
const now = performance.now()
for (let index = layoutDataRef.current.children.length - 1; index >= 0; index -= 1) {
const node = layoutDataRef.current.children[index]
if (node._type !== 'person') continue
const pos = getAnimatedPos(node, now)
if (Math.hypot(pos.cx - worldPos.x, pos.cy - worldPos.y) >= nodeRadius(node)) continue
draggedPersonRef.current = {
node,
offsetX: worldPos.x - pos.cx,
offsetY: worldPos.y - pos.cy,
startClientX: e.clientX,
startClientY: e.clientY,
// Place d'origine, pour un glisser annulé (second doigt, interruption)
originX: node.x,
originY: node.y,
originManual: node._manualPosition,
}
hoveredNodeIdRef.current = node.id
hasDragged.current = false
lastMouse.current = { x: e.clientX, y: e.clientY }
setCanvasCursor(canvasRef, CURSOR_GRABBING)
if (e.nativeEvent?.type === 'mousedown' && typeof window !== 'undefined' && handleMouseUpRef.current) {
window.addEventListener('mouseup', handleGlobalMouseUp)
}
return
}
}
isDragging.current = true
hasDragged.current = false
lastMouse.current = { x: e.clientX, y: e.clientY }
setCanvasCursor(canvasRef, CURSOR_GRABBING)
}, [canvasRef, annotationHandlers, getWorldPos, editModeActive, layoutDataRef, getAnimatedPos, hoveredNodeIdRef, handleGlobalMouseUp])
}, [canvasRef, annotationHandlers, getWorldPos])
const handleMouseMove = useCallback((e) => {
const rect = canvasRef.current?.getBoundingClientRect()
@@ -121,19 +83,6 @@ export function useGalaxyInteractions({
const ah = annotationHandlers
if (draggedPersonRef.current) {
const drag = draggedPersonRef.current
if (!hasDragged.current && Math.hypot(e.clientX - drag.startClientX, e.clientY - drag.startClientY) <= 2) return
hasDragged.current = true
drag.node.x = mouseWorldPosRef.current.x - drag.offsetX - drag.node.width / 2
drag.node.y = mouseWorldPosRef.current.y - drag.offsetY - drag.node.height / 2
drag.node._manualPosition = true
hoveredNodeIdRef.current = drag.node.id
lastMouse.current = { x: e.clientX, y: e.clientY }
setCanvasCursor(canvasRef, CURSOR_GRABBING)
return
}
// Drawing tool: accumulate points
if (ah && ah.activeTool === 'drawing' && ah.drawingPathRef.current) {
ah.onCanvasMouseMove(mouseWorldPosRef.current.x, mouseWorldPosRef.current.y)
@@ -193,9 +142,7 @@ export function useGalaxyInteractions({
hoveredMediaKeyRef.current = newHoveredMedia
if (!ah || ah.activeTool === 'pointer') {
const cursor = editModeActive && newHoveredNode
? CURSOR_NODE_GRAB
: isOverClickable ? CURSOR_POINTER : CURSOR_GRAB
const cursor = isOverClickable ? CURSOR_POINTER : CURSOR_GRAB
setCanvasCursor(canvasRef, cursor)
}
}
@@ -210,7 +157,7 @@ export function useGalaxyInteractions({
x: targetTransformRef.current.x + dx,
y: targetTransformRef.current.y + dy,
}
}, [canvasRef, transformRef, targetTransformRef, layoutDataRef, nodeRandomDataRef, entranceRef, hoveredNodeIdRef, hoveredMediaKeyRef, mouseWorldPosRef, getAnimatedPos, annotationHandlers, editModeActive])
}, [canvasRef, transformRef, targetTransformRef, layoutDataRef, nodeRandomDataRef, entranceRef, hoveredNodeIdRef, hoveredMediaKeyRef, mouseWorldPosRef, getAnimatedPos, annotationHandlers])
const handleMouseUp = useCallback(() => {
const ah = annotationHandlers
@@ -230,58 +177,15 @@ export function useGalaxyInteractions({
return
}
if (draggedPersonRef.current) {
const { node } = draggedPersonRef.current
draggedPersonRef.current = null
if (typeof window !== 'undefined') {
window.removeEventListener('mouseup', handleGlobalMouseUp)
}
if (hasDragged.current) {
onPersonPositionChange?.(node._data.id, {
layoutX: node.x + node.width / 2,
layoutY: node.y + node.height / 2,
})
}
hoveredNodeIdRef.current = node.id
setCanvasCursor(canvasRef, editModeActive ? CURSOR_NODE_GRAB : CURSOR_POINTER)
return
}
isDragging.current = false
const isOverClickable = hoveredNodeIdRef.current || hoveredMediaKeyRef.current
setCanvasCursor(canvasRef, isOverClickable ? CURSOR_POINTER : CURSOR_GRAB)
}, [canvasRef, hoveredNodeIdRef, hoveredMediaKeyRef, mouseWorldPosRef, annotationHandlers, onPersonPositionChange, editModeActive, handleGlobalMouseUp])
useEffect(() => {
handleMouseUpRef.current = handleMouseUp
}, [handleMouseUp])
useEffect(() => () => {
if (typeof window !== 'undefined') {
window.removeEventListener('mouseup', handleGlobalMouseUp)
}
}, [handleGlobalMouseUp])
// Annule le glisser d'une personne sans rien enregistrer : la personne revient à sa place d'avant
const cancelPersonDrag = useCallback(() => {
const drag = draggedPersonRef.current
if (!drag) return
draggedPersonRef.current = null
const { node, originX, originY, originManual } = drag
node.x = originX
node.y = originY
node._manualPosition = originManual
if (typeof window !== 'undefined') {
window.removeEventListener('mouseup', handleGlobalMouseUp)
}
hoveredNodeIdRef.current = node.id
setCanvasCursor(canvasRef, editModeActive ? CURSOR_NODE_GRAB : CURSOR_POINTER)
}, [canvasRef, hoveredNodeIdRef, editModeActive, handleGlobalMouseUp])
}, [canvasRef, hoveredNodeIdRef, hoveredMediaKeyRef, mouseWorldPosRef, annotationHandlers])
const handleMouseLeave = useCallback(() => {
isDragging.current = false
annotationDragging.current = false
setCanvasCursor(canvasRef, draggedPersonRef.current ? CURSOR_GRABBING : CURSOR_GRAB)
setCanvasCursor(canvasRef, CURSOR_GRAB)
mouseWorldPosRef.current = { x: -9999, y: -9999 }
hoveredNodeIdRef.current = null
hoveredMediaKeyRef.current = null
@@ -387,6 +291,6 @@ export function useGalaxyInteractions({
return {
isDragging, isZooming, zoomTimeoutRef, hasDragged,
handleMouseDown, handleMouseMove, handleMouseUp,
handleMouseLeave, handleWheel, handleClick, cancelPersonDrag,
handleMouseLeave, handleWheel, handleClick,
}
}
-2
View File
@@ -100,8 +100,6 @@ function mapRemotePersonToLocal(person, current = null) {
return {
id: person?.id ?? current?.id,
layoutX: Number.isFinite(person?.layoutX) ? person.layoutX : null,
layoutY: Number.isFinite(person?.layoutY) ? person.layoutY : null,
firstName: person?.firstName || current?.firstName || t('Inconnu'),
lastName: person?.lastName || current?.lastName || '',
birthName: person?.birthName ?? current?.birthName ?? null,
-3
View File
@@ -13,8 +13,6 @@ describe('loadGraphData avatar mapping', () => {
lastName: 'Martin',
birthDate: null,
deathDate: null,
layoutX: 420.5,
layoutY: 275.25,
notes: null,
avatarUrlPath: '/trees/t-1/persons/p-1/avatar',
},
@@ -48,7 +46,6 @@ describe('loadGraphData avatar mapping', () => {
)
expect(getPersonById('p-1')?.photo).toBe('/api/trees/t-1/persons/p-1/avatar?token=abc')
expect(getPersonById('p-1')).toMatchObject({ layoutX: 420.5, layoutY: 275.25 })
expect(getPersonById('p-2')?.photo).toBeNull()
expect(getPersonMedias('p-2')[0]?.source).toBe('Album familial')
})
-5
View File
@@ -21,8 +21,6 @@ export function useEditMode({ treeId, canEdit }) {
const [isActive, setIsActive] = useState(false)
const [hasDraft, setHasDraft] = useState(false)
const [draftTimestamp, setDraftTimestamp] = useState(null)
// Change à chaque écriture du brouillon : les valeurs dérivées (positions manuelles) s'y recalculent
const [draftRevision, setDraftRevision] = useState(0)
const autoSaveTimerRef = useRef(null)
const draftDataRef = useRef({})
@@ -153,7 +151,6 @@ export function useEditMode({ treeId, canEdit }) {
}
// Considérer qu'il y a désormais un draft actif (permet le bouton "Mettre à jour l'arbre")
setHasDraft(true)
setDraftRevision((current) => current + 1)
}, [])
/**
@@ -167,7 +164,6 @@ export function useEditMode({ treeId, canEdit }) {
draftDataRef.current = {}
setHasDraft(false)
setDraftTimestamp(null)
setDraftRevision((current) => current + 1)
} catch (err) {
console.error('Failed to clear draft:', err)
}
@@ -185,7 +181,6 @@ export function useEditMode({ treeId, canEdit }) {
isActive,
hasDraft,
draftTimestamp,
draftRevision,
// Actions
activate,
-1
View File
@@ -97,5 +97,4 @@ export default {
'Ressource introuvable.': 'Not found.',
'Conflit détecté (email ou slug déjà utilisé).': 'Conflict detected (email or slug already in use).',
"Erreur backend. Vérifiez que l'API et la base sont lancées.": 'Server error. Check that the API and database are running.',
"Certaines modifications n'ont pas pu être enregistrées. Vérifiez puis réessayez.": "Some changes couldn't be saved. Check them and try again.",
}
-9
View File
@@ -123,15 +123,6 @@ export default {
"Dernière vérification": "Final check",
"Supprimer « {name} »": "Delete “{name}”",
"Tapez le nom exact de l'arbre pour confirmer.": "Type the exact name of the tree to confirm.",
"Disposition de l'arbre": "Tree layout",
"Les personnes déplacées à la main reprennent la place que leur donne l'arbre.": "People moved by hand go back to the place the tree gives them.",
"Rétablir la disposition automatique": "Restore the automatic layout",
"Rétablir la disposition automatique ?": "Restore the automatic layout?",
"Les déplacements faits à la main seront effacés pour toute la famille.": "Positions moved by hand will be erased for the whole family.",
"Rétablir": "Restore",
"Rétablissement en cours": "Restoring",
"La disposition automatique est rétablie.": "The automatic layout is restored.",
"Impossible de rétablir la disposition.": "Couldn't restore the layout.",
// Onglet Visites (TreeVisitsSection)
"téléphone": "phone",
"tablette": "tablet",
-4
View File
@@ -32,10 +32,6 @@ export default {
'Date de décès': 'Date of death',
'Lieu de naissance': 'Birthplace',
'Métier': 'Occupation',
"Position dans l'arbre": "Position in the tree",
'Déplacée': 'Moved',
'Replacée automatiquement': 'Placed automatically again',
'Automatique': 'Automatic',
'Nationalité': 'Nationality',
'Région': 'Region',
'Notes': 'Notes',
-2
View File
@@ -46,8 +46,6 @@ export function adaptGraphForGalaxy(apiGraph, _treeId) {
// Adapter les personnes
const adaptedPersons = persons.map((p) => ({
id: p.id,
layoutX: Number.isFinite(p.layoutX) ? p.layoutX : null,
layoutY: Number.isFinite(p.layoutY) ? p.layoutY : null,
firstName: p.firstName || '',
lastName: p.lastName || '',
birthName: p.birthName || null,