diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 57b551c..b733006 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -44,6 +44,7 @@ import {
uploadPersonAvatar,
updatePerson,
uploadAnnotationPhoto,
+ reorderSiblings,
} from './api/treeApi'
import { useAuth } from './hooks/useAuth'
import { useTreeAccess, getTreeTokenStorageKey, getSlugFromPathname, readTreeAccessRole, TREE_ID_FROM_ENV } from './hooks/useTreeAccess'
@@ -1154,6 +1155,37 @@ function App() {
const [contribSubmitError, setContribSubmitError] = useState('')
const [contribSuccess, setContribSuccess] = useState(false)
const [draftNotice, setDraftNotice] = useState('')
+
+ // Ordre d'une fratrie (glisser dans l'arbre ou flèches de la fiche) : appliqué tout de suite, enregistré ensuite.
+ // L'arbre a déjà glissé à la nouvelle place ; en cas d'échec il revient en arrière et on le dit.
+ const [siblingOrderRevision, setSiblingOrderRevision] = useState(0)
+ const handleReorderSiblings = useCallback(async (order) => {
+ const previous = new Map()
+ order.forEach((entry, index) => {
+ for (const filiation of filiations) {
+ if (!entry.linkIds.includes(String(filiation.id))) continue
+ previous.set(filiation, filiation.displayOrder)
+ filiation.displayOrder = index + 1
+ }
+ })
+ setSiblingOrderRevision((current) => current + 1)
+ if (isDemoMode) return
+ try {
+ await reorderSiblings(tree.treeContext.treeId, order, tree.treeContext.accessToken || auth.userAuth.token)
+ } catch (error) {
+ for (const [filiation, value] of previous) filiation.displayOrder = value
+ setSiblingOrderRevision((current) => current + 1)
+ setDraftNotice(t("L'ordre n'a pas pu être enregistré. Réessayez."))
+ throw error
+ }
+ }, [isDemoMode, tree.treeContext.treeId, tree.treeContext.accessToken, auth.userAuth.token])
+
+ const canReorderSiblings = editMode.isActive && (tree.canEditCurrentTree || isDemoMode)
+ const [siblingPlace, setSiblingPlace] = useState(null)
+ useEffect(() => {
+ const personId = tree.selectedPerson?.id
+ setSiblingPlace(personId != null && canReorderSiblings ? galaxyZoomApiRef.current?.getSiblingPlace?.(personId) ?? null : null)
+ }, [tree.selectedPerson?.id, canReorderSiblings, siblingOrderRevision, tree.graphRevision])
useEffect(() => {
if (!draftNotice) return undefined
const timer = setTimeout(() => setDraftNotice(''), 4500)
@@ -2835,6 +2867,8 @@ function App() {
focusPersonId={tree.treeContext.rootPersonId}
assetRevision={tree.assetRevision}
photoHint={tree.canEditCurrentTree}
+ reorderEnabled={canReorderSiblings}
+ onReorderSiblings={handleReorderSiblings}
/>
{tree.selectedPerson && (
@@ -2857,6 +2891,8 @@ function App() {
relationActionLoading={relationFeedback.loading}
relationActionSuccess={relationFeedback.success}
relationActionError={relationFeedback.error}
+ siblingPlace={siblingPlace}
+ onMoveSibling={(delta) => galaxyZoomApiRef.current?.moveSibling?.(tree.selectedPerson.id, delta)}
/>
)}
{tree.selectedMedia && (
diff --git a/frontend/src/api/treeApi.js b/frontend/src/api/treeApi.js
index 8172a74..e96f210 100644
--- a/frontend/src/api/treeApi.js
+++ b/frontend/src/api/treeApi.js
@@ -192,6 +192,17 @@ export async function deleteUnion(treeId, unionId, token) {
return data
}
+// Ordre d'une fratrie : chaque enfant, dans l'ordre voulu, avec ses liens vers ses parents
+export async function reorderSiblings(treeId, order, token) {
+ return apiRequest(`/trees/${encodeURIComponent(treeId)}/links/order`, {
+ method: 'PUT',
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ body: JSON.stringify({ order }),
+ })
+}
+
export async function deleteParentChildLink(treeId, linkId, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/links/${encodeURIComponent(linkId)}`, {
method: 'DELETE',
diff --git a/frontend/src/components/Galaxy.jsx b/frontend/src/components/Galaxy.jsx
index 6d69962..f1628ab 100644
--- a/frontend/src/components/Galaxy.jsx
+++ b/frontend/src/components/Galaxy.jsx
@@ -1,5 +1,5 @@
import { memo, useEffect, useImperativeHandle, useRef, useState, useCallback } from 'react'
-import { getPersonMedias, getParents, getChildren, getPersonUnions } from '../data/mockData'
+import { getPersonMedias, getParents, getChildren, getPersonUnions, persons as graphPersons, unions as graphUnions, filiations as graphFiliations } 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'
@@ -8,7 +8,8 @@ import { computeBlockLayout } 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 } from './galaxy/renderers'
+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 { useImageCache } from './galaxy/useImageCache'
import { useGalaxyInteractions } from './galaxy/useGalaxyInteractions'
@@ -29,7 +30,17 @@ const MAX_SCALE = 3.2
const FOCUS_MIN_READABLE_SCALE = 0.5
const FOCUS_SCALE = 0.95
-const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlightIds = [], graphRevision = 0, annotationHandlers = null, transformReadRef = null, zoomApiRef = null, focusPersonId = null, assetRevision = 0, photoHint = false }) => {
+// Réordonner une fratrie : seuil avant de soulever à la souris, appui long au doigt
+const REORDER_MOUSE_THRESHOLD = 4
+const REORDER_TOUCH_SLOP = 8
+const REORDER_LONG_PRESS_MS = 320
+// Les frères et sœurs s'écartent sans rebond ; la personne lâchée se pose avec un léger rebond
+const SHIFT_SPRING = { stiffness: 560, dampingRatio: 0.92 }
+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, photoHint = false, reorderEnabled = false, onReorderSiblings = null }) => {
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)
@@ -71,6 +82,18 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
const [displayLabel, setDisplayLabel] = useState('')
const displayLabelRef = useRef('')
+ // Réordonner une fratrie (mode édition, administrateurs) : décalage animé de chaque nœud vers sa place
+ // dans le placement affiché, état du geste en cours, soulèvement de la personne tenue
+ const offsetsRef = useRef(new Map())
+ const reorderRef = useRef(null)
+ const liftScalesRef = useRef(new Map())
+ const groupsCacheRef = useRef({ layout: null, groups: new Map() })
+ const lastFrameRef = useRef(0)
+ const reorderEnabledRef = useRef(reorderEnabled)
+ const onReorderRef = useRef(onReorderSiblings)
+ useEffect(() => { reorderEnabledRef.current = reorderEnabled }, [reorderEnabled])
+ useEffect(() => { onReorderRef.current = onReorderSiblings }, [onReorderSiblings])
+
// Rendu à la demande : la boucle rAF tourne mais ne redessine que si quelque chose a changé
// (transform, états lissés, entrée, souris, annotations, image chargée, rendu React, resize).
const needsRedrawRef = useRef(true)
@@ -82,7 +105,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
// Position animée d'un nœud
const getAnimatedPos = useCallback((node, time) => {
- const baseCx = node.x + node.width / 2
+ const baseCx = node.x + node.width / 2 + (offsetsRef.current.get(node.id)?.x || 0)
const baseCy = node.y + node.height / 2
const rd = nodeRandomDataRef.current.get(node.id)
if (!rd) return { cx: baseCx, cy: baseCy }
@@ -93,7 +116,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
cx += Math.sin(time * rd.floatSpeedX + rd.floatPhaseX) * rd.floatAmpX
cy += Math.sin(time * rd.floatSpeedY + rd.floatPhaseY) * rd.floatAmpY
- if (node._type === 'person' || node._type === 'unknown') {
+ if ((node._type === 'person' || node._type === 'unknown') && reorderRef.current?.phase !== 'dragging') {
const mouse = mouseWorldPos.current
const dx = cx - mouse.x
const dy = cy - mouse.y
@@ -122,6 +145,209 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
maxScale: MAX_SCALE,
})
+ // ---------- Réordonner une fratrie ----------
+ // Le glisser ne déplace une personne qu'à l'horizontale, parmi ses frères et sœurs. Pour chaque place visée,
+ // le vrai placement est recalculé : l'arbre se réarrange pendant le geste et rien ne saute au lâcher.
+ const getSiblingGroups = useCallback(() => {
+ const layout = layoutDataRef.current
+ if (groupsCacheRef.current.layout !== layout) groupsCacheRef.current = { layout, groups: buildSiblingGroups(layout) }
+ return groupsCacheRef.current.groups
+ }, [])
+
+ const canReorderNow = useCallback(() => (
+ reorderEnabledRef.current && USE_BLOCK_LAYOUT && Boolean(layoutDataRef.current) && Boolean(entranceRef.current?.finished)
+ ), [])
+
+ const worldX = useCallback((clientX) => {
+ const rect = canvasRef.current?.getBoundingClientRect()
+ const t = transformRef.current
+ return rect ? (clientX - rect.left - t.x) / t.scale : 0
+ }, [])
+
+ // Personne réordonnable sous le pointeur
+ const reorderTargetAt = useCallback((clientX, clientY) => {
+ if (!canReorderNow()) return null
+ const rect = canvasRef.current?.getBoundingClientRect()
+ if (!rect) return null
+ const t = transformRef.current
+ const wx = (clientX - rect.left - t.x) / t.scale
+ const wy = (clientY - rect.top - t.y) / t.scale
+ const groups = getSiblingGroups()
+ const now = performance.now()
+ for (const node of layoutDataRef.current.children) {
+ if (node._type !== 'person' || !groups.has(node.id)) continue
+ const pos = getAnimatedPos(node, now)
+ if (Math.hypot(pos.cx - wx, pos.cy - wy) < nodeRadius(node)) return node
+ }
+ return null
+ }, [canReorderNow, getSiblingGroups, getAnimatedPos])
+
+ // Placement complet pour un ordre de la fratrie, recalé sur la source (les parents ne bougent pas)
+ const layoutForOrder = useCallback((group, order, base) => {
+ const graph = {
+ persons: graphPersons,
+ unions: graphUnions,
+ filiations: filiationsWithOrder(graphFiliations, graphUnions, group.source, order),
+ }
+ const result = computeBlockLayout(graph)
+ const baseSource = base.children.find((n) => n.id === group.source)
+ const source = result.children.find((n) => n.id === group.source)
+ const shift = baseSource && source ? baseSource.x - source.x : 0
+ if (shift) result.children.forEach((n) => { n.x += shift })
+ return result
+ }, [])
+
+ // Remplace le placement affiché : chaque nœud garde sa position à l'écran puis glisse vers sa nouvelle place
+ const swapLayout = useCallback((next, frontId = null) => {
+ const prev = layoutDataRef.current
+ if (!prev || !next) return
+ const prevById = new Map(prev.children.map((n) => [n.id, n]))
+ const offsets = offsetsRef.current
+ const reduce = prefersReducedMotion()
+ for (const node of next.children) {
+ const old = prevById.get(node.id)
+ if (!old) continue
+ const current = offsets.get(node.id)
+ const delta = old.x + (current?.x || 0) - node.x
+ if (reduce && node.id !== frontId) { offsets.delete(node.id); continue }
+ if (Math.abs(delta) > 0.01 || current) offsets.set(node.id, { x: delta, v: current?.v || 0, spring: current?.spring || SHIFT_SPRING })
+ }
+ if (frontId) {
+ const i = next.children.findIndex((n) => n.id === frontId)
+ if (i >= 0) next.children.push(...next.children.splice(i, 1))
+ }
+ layoutDataRef.current = next
+ needsRedrawRef.current = true
+ }, [])
+
+ // Envoie l'ordre ; en cas d'échec, l'arbre revient en glissant à l'ordre précédent
+ const commitOrder = useCallback((group, order, base) => {
+ const payload = buildOrderPayload(graphFiliations, graphUnions, group.source, order)
+ const result = onReorderRef.current?.(payload)
+ if (result && typeof result.catch === 'function') {
+ result.catch(() => { if (!reorderRef.current) swapLayout(base) })
+ }
+ }, [swapLayout])
+
+ const reorderWindowListeners = useRef(null)
+
+ const endReorder = useCallback((commit) => {
+ const drag = reorderRef.current
+ if (reorderWindowListeners.current) {
+ window.removeEventListener('mousemove', reorderWindowListeners.current.move)
+ window.removeEventListener('mouseup', reorderWindowListeners.current.up)
+ reorderWindowListeners.current = null
+ }
+ if (!drag) return
+ clearTimeout(drag.timer)
+ reorderRef.current = null
+ if (drag.phase !== 'dragging') return
+ const offset = offsetsRef.current.get(drag.nodeId)
+ if (offset) offsetsRef.current.set(drag.nodeId, { ...offset, v: 0, spring: DROP_SPRING })
+ if (canvasRef.current) canvasRef.current.style.cursor = 'grab'
+ if (!commit) {
+ swapLayout(drag.base, drag.nodeId)
+ } else if (drag.index !== drag.originalIndex) {
+ commitOrder(drag.group, moveInOrder(drag.group.ids, drag.nodeId, drag.index), drag.base)
+ }
+ needsRedrawRef.current = true
+ }, [swapLayout, commitOrder])
+
+ const updateReorder = useCallback((clientX) => {
+ const drag = reorderRef.current
+ if (drag?.phase !== 'dragging') return
+ const slots = drag.slots
+ drag.targetCenter = rubberBand(worldX(clientX) - drag.grab, slots[0], slots[slots.length - 1])
+ const index = pickSlot(slots, drag.targetCenter, drag.index)
+ if (index !== drag.index) {
+ drag.index = index
+ if (!drag.layouts.has(index)) drag.layouts.set(index, layoutForOrder(drag.group, moveInOrder(drag.group.ids, drag.nodeId, index), drag.base))
+ swapLayout(drag.layouts.get(index), drag.nodeId)
+ }
+ needsRedrawRef.current = true
+ }, [worldX, layoutForOrder, swapLayout])
+
+ const startReorder = useCallback((clientX) => {
+ const press = reorderRef.current
+ const base = layoutDataRef.current
+ const group = press && getSiblingGroups().get(press.nodeId)
+ const node = group && base.children.find((n) => n.id === press.nodeId)
+ if (!node) { reorderRef.current = null; return }
+ clearTimeout(press.timer)
+ const byId = new Map(base.children.map((n) => [n.id, n]))
+ const index = group.ids.indexOf(press.nodeId)
+ const visualCenter = centerX(node) + (offsetsRef.current.get(node.id)?.x || 0)
+ reorderRef.current = {
+ phase: 'dragging',
+ nodeId: press.nodeId,
+ group,
+ base,
+ slots: group.ids.map((id) => centerX(byId.get(id))),
+ index,
+ originalIndex: index,
+ layouts: new Map([[index, base]]),
+ grab: worldX(clientX) - visualCenter,
+ targetCenter: visualCenter,
+ pointer: press.pointer,
+ }
+ swapLayout(base, press.nodeId)
+ // Le geste n'est ni un panoramique ni un clic
+ isDragging.current = false
+ hasDragged.current = true
+ hoveredNodeId.current = null
+ if (canvasRef.current) canvasRef.current.style.cursor = 'grabbing'
+ if (press.pointer === 'touch') navigator.vibrate?.(8)
+ if (press.pointer === 'mouse') {
+ const move = (e) => updateReorder(e.clientX)
+ const up = () => endReorder(true)
+ reorderWindowListeners.current = { move, up }
+ window.addEventListener('mousemove', move)
+ window.addEventListener('mouseup', up)
+ }
+ }, [getSiblingGroups, worldX, swapLayout, updateReorder, endReorder, isDragging, hasDragged])
+
+ // Appui sur une personne réordonnable : la souris attend quelques pixels, le doigt un appui long
+ const beginReorderPress = useCallback((clientX, clientY, pointer) => {
+ const node = reorderTargetAt(clientX, clientY)
+ if (!node) return false
+ const press = { phase: 'pressing', nodeId: node.id, pointer, startX: clientX, startY: clientY, lastX: clientX, timer: null }
+ if (pointer === 'touch') press.timer = setTimeout(() => { if (reorderRef.current === press) startReorder(press.lastX) }, REORDER_LONG_PRESS_MS)
+ reorderRef.current = press
+ return true
+ }, [reorderTargetAt, startReorder])
+
+ // Flèches de la fiche : même animation, une place à gauche ou à droite
+ const moveSibling = useCallback((personId, delta) => {
+ if (reorderRef.current || !canReorderNow()) return false
+ const nodeId = `p-${personId}`
+ const group = getSiblingGroups().get(nodeId)
+ if (!group) return false
+ const index = group.ids.indexOf(nodeId)
+ const target = index + delta
+ if (target < 0 || target >= group.ids.length) return false
+ const base = layoutDataRef.current
+ const order = moveInOrder(group.ids, nodeId, target)
+ swapLayout(layoutForOrder(group, order, base), nodeId)
+ commitOrder(group, order, base)
+ return true
+ }, [canReorderNow, getSiblingGroups, swapLayout, layoutForOrder, commitOrder])
+
+ const getSiblingPlace = useCallback((personId) => {
+ if (!USE_BLOCK_LAYOUT || !layoutDataRef.current) return null
+ const group = getSiblingGroups().get(`p-${personId}`)
+ return group ? { index: group.ids.indexOf(`p-${personId}`), count: group.ids.length } : null
+ }, [getSiblingGroups])
+
+ // Échap annule le glisser en cours
+ useEffect(() => {
+ const onKey = (e) => { if (e.key === 'Escape' && reorderRef.current?.phase === 'dragging') endReorder(false) }
+ window.addEventListener('keydown', onKey)
+ return () => window.removeEventListener('keydown', onKey)
+ }, [endReorder])
+
+ // Sortie du mode édition pendant un geste : on annule
+ useEffect(() => { if (!reorderEnabled && reorderRef.current) endReorder(false) }, [reorderEnabled, endReorder])
+
// Expose zoom API to parent (ZoomControl component), posée au commit et non pendant le rendu
useImperativeHandle(zoomApiRef, () => ({
zoomTo: (newScale) => {
@@ -159,9 +385,11 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
targetTransformRef.current = { scale, x: screenX - pos.cx * scale, y: screenY - pos.cy * scale }
},
getScale: () => transformRef.current.scale,
+ moveSibling,
+ getSiblingPlace,
MIN_SCALE,
MAX_SCALE,
- }), [getAnimatedPos])
+ }), [getAnimatedPos, moveSibling, getSiblingPlace])
// Zoom util (used by pinch)
const zoomAt = useCallback((factor, center) => {
@@ -325,6 +553,8 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
finished: false,
}
+ offsetsRef.current = new Map()
+ reorderRef.current = null
layoutDataRef.current = result
needsRedrawRef.current = true
setLayoutReady(true)
@@ -384,6 +614,33 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
if (next.x !== prev.x || next.y !== prev.y || next.scale !== prev.scale) dirty = true
transformRef.current = next
+ // Réordonnancement : la personne tenue suit le pointeur, les autres glissent vers leur place
+ const dt = lastFrameRef.current ? Math.min(0.034, (timestamp - lastFrameRef.current) / 1000) : 1 / 60
+ lastFrameRef.current = timestamp
+ const drag = reorderRef.current?.phase === 'dragging' ? reorderRef.current : null
+ if (offsetsRef.current.size > 0) {
+ for (const [id, state] of offsetsRef.current) {
+ if (drag && id === drag.nodeId) continue
+ const next = springStep(state, dt, state.spring)
+ if (isSettled(next)) offsetsRef.current.delete(id)
+ else offsetsRef.current.set(id, { ...next, spring: state.spring })
+ }
+ dirty = true
+ }
+ if (drag && layoutDataRef.current) {
+ const held = layoutDataRef.current.children.find((n) => n.id === drag.nodeId)
+ if (held) offsetsRef.current.set(drag.nodeId, { x: drag.targetCenter - centerX(held), v: 0, spring: DROP_SPRING })
+ dirty = true
+ }
+ for (const [id, value] of liftScalesRef.current) {
+ const target = drag && id === drag.nodeId ? 1 : 0
+ const next = approach(value, target, 0.28, 0.002)
+ if (next === 0 && target === 0) liftScalesRef.current.delete(id)
+ else liftScalesRef.current.set(id, next)
+ if (next !== value) dirty = true
+ }
+ if (drag && !liftScalesRef.current.has(drag.nodeId)) { liftScalesRef.current.set(drag.nodeId, 0); dirty = true }
+
const layoutData = layoutDataRef.current
const canvas = canvasRef.current
if (!layoutData || !canvas) { rafId = requestAnimationFrame(animate); return }
@@ -575,6 +832,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
entrance, entranceActive, elapsed,
view,
photoHint: photoHintRef.current,
+ liftScales: liftScalesRef.current,
})
drawRenvoiPills(ctx)
// Annotations dessinées APRES les noeuds pour que stickers/texte soient au-dessus
@@ -669,17 +927,45 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
ref={canvasRef}
className="galaxy-canvas"
style={{ touchAction: 'none' }}
- onMouseDown={handleMouseDown}
- onMouseMove={(e) => { needsRedrawRef.current = true; handleMouseMove(e) }}
- onMouseUp={handleMouseUp}
- onMouseLeave={(e) => { needsRedrawRef.current = true; handleMouseLeave(e) }}
+ onMouseDown={(e) => {
+ if (e.button === 0 && beginReorderPress(e.clientX, e.clientY, 'mouse')) { hasDragged.current = false; return }
+ handleMouseDown(e)
+ }}
+ onMouseMove={(e) => {
+ needsRedrawRef.current = true
+ const press = reorderRef.current
+ if (press?.phase === 'dragging') return
+ if (press?.phase === 'pressing' && press.pointer === 'mouse') {
+ if (Math.abs(e.clientX - press.startX) > REORDER_MOUSE_THRESHOLD || Math.abs(e.clientY - press.startY) > REORDER_MOUSE_THRESHOLD) {
+ startReorder(press.startX)
+ updateReorder(e.clientX)
+ }
+ return
+ }
+ handleMouseMove(e)
+ // En mode édition, une personne réordonnable se saisit
+ if (canvasRef.current && hoveredNodeId.current && reorderEnabledRef.current && getSiblingGroups().has(hoveredNodeId.current)) {
+ canvasRef.current.style.cursor = 'grab'
+ }
+ }}
+ onMouseUp={(e) => {
+ const press = reorderRef.current
+ if (press?.phase === 'pressing') reorderRef.current = null
+ if (press?.phase === 'dragging') return
+ handleMouseUp(e)
+ }}
+ onMouseLeave={(e) => { needsRedrawRef.current = true; if (reorderRef.current?.phase !== 'dragging') handleMouseLeave(e) }}
onWheel={handleWheel}
onTouchStart={(e) => {
if (e.touches.length === 1) {
const t = e.touches[0]
+ // Appui long sur une personne réordonnable ; d'ici là, le doigt fait défiler l'arbre comme d'habitude
+ beginReorderPress(t.clientX, t.clientY, 'touch')
handleMouseDown({ clientX: t.clientX, clientY: t.clientY, preventDefault: () => e.preventDefault() })
} else if (e.touches.length === 2) {
e.preventDefault()
+ // Un deuxième doigt annule le glisser d'une personne (retour à sa place, rien n'est enregistré)
+ if (reorderRef.current) endReorder(false)
// Stop any ongoing single-finger pan before starting pinch
handleMouseUp()
const [a, b] = e.touches
@@ -694,6 +980,12 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
needsRedrawRef.current = true
if (e.touches.length === 1) {
const t = e.touches[0]
+ const press = reorderRef.current
+ if (press?.phase === 'dragging') { updateReorder(t.clientX); return }
+ if (press?.phase === 'pressing') {
+ press.lastX = t.clientX
+ if (Math.hypot(t.clientX - press.startX, t.clientY - press.startY) > REORDER_TOUCH_SLOP) endReorder(false)
+ }
handleMouseMove({ clientX: t.clientX, clientY: t.clientY, preventDefault: () => e.preventDefault() })
} else if (e.touches.length === 2) {
e.preventDefault()
@@ -712,6 +1004,11 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
}
}}
onTouchEnd={(e) => {
+ if (reorderRef.current && e.touches.length === 0) {
+ const wasDragging = reorderRef.current.phase === 'dragging'
+ endReorder(true)
+ if (wasDragging) { handleMouseUp(); return }
+ }
pinchRef.current = null
if (e.touches.length === 1) {
// One finger remains after pinch: restart single-finger pan with it
@@ -726,6 +1023,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
}}
onTouchCancel={() => {
// System interruption (notification, keyboard, etc.) — reset all touch state
+ if (reorderRef.current) endReorder(false)
pinchRef.current = null
handleMouseUp()
}}
diff --git a/frontend/src/components/PersonCard.css b/frontend/src/components/PersonCard.css
index e1cd883..0ba9d29 100644
--- a/frontend/src/components/PersonCard.css
+++ b/frontend/src/components/PersonCard.css
@@ -68,6 +68,22 @@
.pc-life { margin: 0; font: 400 13.5px/1.4 var(--pz-sans); color: var(--pz-muted); }
.pc-chips { display: flex; flex-wrap: wrap; gap: 6px; }
.pc-chips:empty { display: none; }
+/* Place dans la fratrie (mode édition) : ‹ 2 sur 3 › dans une pastille */
+.pc-order { gap: 2px; padding: 2px; }
+.pc-order-label { padding: 0 3px; font-variant-numeric: tabular-nums; }
+.pc-order-btn {
+ display: inline-grid; place-items: center; width: 24px; height: 24px; padding: 0;
+ border: 0; border-radius: 999px; background: transparent; color: var(--pz-ink-2); cursor: pointer;
+ transition: transform 140ms var(--pz-ease), background-color 160ms ease, color 160ms ease;
+ -webkit-tap-highlight-color: transparent;
+}
+.pc-order-btn:active:not(:disabled) { transform: scale(.92); }
+.pc-order-btn:disabled { opacity: .35; cursor: default; }
+.pc-order-btn:focus-visible { outline: 2px solid var(--pz-a500); outline-offset: 1px; }
+@media (hover: hover) and (pointer: fine) {
+ .pc-order-btn:hover:not(:disabled) { background: var(--pz-line-soft); color: var(--pz-ink); }
+}
+@media (pointer: coarse) { .pc-order-btn { width: 32px; height: 32px; } }
.pc-name-edit { display: grid; gap: 6px; }
/* ── Champs d'édition ── */
diff --git a/frontend/src/components/PersonCard.jsx b/frontend/src/components/PersonCard.jsx
index 80e678d..bf46a12 100644
--- a/frontend/src/components/PersonCard.jsx
+++ b/frontend/src/components/PersonCard.jsx
@@ -1,5 +1,5 @@
import { useState, useEffect, useRef, forwardRef } from 'react'
-import { Save, Trash2, Plus, X, Search, Check, Play, ImagePlus } from 'lucide-react'
+import { Save, Trash2, Plus, X, Search, Check, Play, ImagePlus, ChevronLeft, ChevronRight } from 'lucide-react'
import { getParents, getChildren, getPersonUnions, getPersonMedias, filiations, persons, unions } from '../data/mockData'
import GeoMediaThumbnail from './GeoMediaThumbnail'
import './PersonCard.css'
@@ -45,6 +45,8 @@ const PersonCard = ({
relationActionLoading = false,
relationActionSuccess = '',
relationActionError = '',
+ siblingPlace = null,
+ onMoveSibling,
}) => {
// Souvenirs d'abord quand il y en a, ou quand on peut en ajouter : c'est là qu'est le bouton
const [tab, setTab] = useState(() => (getPersonMedias(person.id).length > 0 || canManageMedia ? 'medias' : 'infos'))
@@ -403,6 +405,17 @@ const PersonCard = ({
{!editMode && lifeLine &&
{lifeLine}
}
{editMode && {t('Édition')}}
+ {editMode && siblingPlace && (
+
+
+ {t('{n} sur {total}', { n: siblingPlace.index + 1, total: siblingPlace.count })}
+
+
+ )}
{!editMode && ageLabel && {ageLabel}}
{!editMode && person.profession && {person.profession}}
diff --git a/frontend/src/components/galaxy/renderers.js b/frontend/src/components/galaxy/renderers.js
index 00fcbff..68f41d2 100644
--- a/frontend/src/components/galaxy/renderers.js
+++ b/frontend/src/components/galaxy/renderers.js
@@ -426,7 +426,7 @@ function measureTextCached(ctx, text, fontKey) {
// ============================================================
function drawPersonNode(ctx, node, pos, nodeProgress, {
nodeRandomData, imageCache, hoverScales, selectedGlowScales, searchGlowScales,
- mediaHoverState, entrance, entranceActive, elapsed, view = null, photoHint = false,
+ mediaHoverState, entrance, entranceActive, elapsed, view = null, photoHint = false, liftScales = null,
}) {
const { cx, cy } = pos
const person = getPersonById(node._data.id) || node._data
@@ -498,8 +498,10 @@ function drawPersonNode(ctx, node, pos, nodeProgress, {
const k = node._compact ? COMPACT_SCALE : 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
+ const lift = liftScales?.get(node.id) || 0
const entranceScale = 0.3 + nodeProgress * 0.7
- const personScale = entranceScale * (1 + hs * 0.1) * k
+ const personScale = entranceScale * (1 + Math.max(hs, lift) * 0.1 + lift * 0.05) * k
// --- Glow sélection ---
const sg = selectedGlowScales.get(node.id) || 0
@@ -553,9 +555,9 @@ function drawPersonNode(ctx, node, pos, nodeProgress, {
ctx.rotate(frameRot)
if (withShadow) {
- const shadowBlur = 10 + hs * 14
- const shadowY = 3 + hs * 4
- const shadowAlpha = 0.15 + hs * 0.1
+ const shadowBlur = 10 + hs * 14 + lift * 16
+ const shadowY = 3 + hs * 4 + lift * 9
+ const shadowAlpha = 0.15 + hs * 0.1 + lift * 0.06
ctx.shadowColor = `rgba(42, 38, 34, ${shadowAlpha})`
ctx.shadowBlur = shadowBlur
ctx.shadowOffsetX = 0
diff --git a/frontend/src/components/galaxy/siblingReorder.js b/frontend/src/components/galaxy/siblingReorder.js
new file mode 100644
index 0000000..b551075
--- /dev/null
+++ b/frontend/src/components/galaxy/siblingReorder.js
@@ -0,0 +1,97 @@
+// Réordonner une fratrie dans l'arbre : logique pure (sans canvas ni React), testée à part.
+// Une fratrie = les enfants reliés à une même source (union `u-…` ou parent seul `vu-…`).
+
+const centerOf = (node) => node.x + node.width / 2
+
+// Toutes les fratries réordonnables d'un placement : nodeId → { source, ids } (ids triés de gauche à droite).
+// Un enfant rattaché à plusieurs sources (deux familles) n'est pas réordonnable : déplacer l'un de ses liens
+// pourrait le faire changer de famille dans le placement.
+export function buildSiblingGroups(layout) {
+ const incoming = new Map()
+ const bySource = new Map()
+ for (const edge of layout?.edges || []) {
+ const source = edge.sources[0]
+ const child = edge.targets[0]
+ incoming.set(child, (incoming.get(child) || 0) + 1)
+ if (!bySource.has(source)) bySource.set(source, [])
+ bySource.get(source).push(child)
+ }
+ const byId = new Map((layout?.children || []).map((node) => [node.id, node]))
+ const groups = new Map()
+ for (const [source, children] of bySource) {
+ if (children.length < 2) continue
+ if (children.some((id) => incoming.get(id) !== 1 || !byId.has(id))) continue
+ const ids = [...children].sort((a, b) => centerOf(byId.get(a)) - centerOf(byId.get(b)))
+ const group = { source, ids }
+ for (const id of ids) groups.set(id, group)
+ }
+ return groups
+}
+
+export function moveInOrder(ids, movedId, index) {
+ const rest = ids.filter((id) => id !== movedId)
+ rest.splice(Math.max(0, Math.min(index, rest.length)), 0, movedId)
+ return rest
+}
+
+// Place visée par la personne tenue : le nombre de milieux entre places voisines qu'elle a dépassés.
+// L'hystérésis évite les allers-retours quand on hésite pile sur une frontière.
+export function pickSlot(slotCenters, x, currentIndex, hysteresis = 10) {
+ const mids = []
+ for (let i = 0; i < slotCenters.length - 1; i += 1) mids.push((slotCenters[i] + slotCenters[i + 1]) / 2)
+ const forward = mids.filter((m) => m < x - hysteresis).length
+ const backward = mids.filter((m) => m < x + hysteresis).length
+ if (forward > currentIndex) return forward
+ if (backward < currentIndex) return backward
+ return currentIndex
+}
+
+// Au-delà des bords de la fratrie, la personne suit de moins en moins le pointeur (pas de mur brutal).
+export function rubberBand(x, min, max, reach = 90) {
+ if (x < min) return min - reach * (1 - 1 / (1 + (min - x) / reach))
+ if (x > max) return max + reach * (1 - 1 / (1 + (x - max) / reach))
+ return x
+}
+
+// Un pas de ressort amorti vers 0 (Euler semi-implicite) ; dt en secondes.
+export function springStep(state, dt, { stiffness = 520, dampingRatio = 0.86 } = {}) {
+ const damping = 2 * dampingRatio * Math.sqrt(stiffness)
+ const v = state.v + (-stiffness * state.x - damping * state.v) * dt
+ return { x: state.x + v * dt, v }
+}
+
+export const isSettled = (state) => Math.abs(state.x) < 0.15 && Math.abs(state.v) < 1.5
+
+// Les filiations qui rattachent un enfant à la source d'une fratrie
+function filiationsOf(filiations, unions, source, childId) {
+ const sameChild = (f) => String(f.childId) === String(childId)
+ if (source.startsWith('u-')) {
+ const unionId = source.slice(2)
+ const union = unions.find((u) => String(u.id) === unionId)
+ const partners = union ? [union.partner1Id, union.partner2Id].filter((id) => id != null).map(String) : []
+ const viaUnion = filiations.filter((f) => sameChild(f) && String(f.unionId) === unionId)
+ // Union reconstituée par le placement : deux liens directs vers chacun des partenaires
+ return viaUnion.length ? viaUnion : filiations.filter((f) => sameChild(f) && !f.unionId && partners.includes(String(f.parentId)))
+ }
+ const parentId = source.slice(3)
+ return filiations.filter((f) => sameChild(f) && !f.unionId && String(f.parentId) === parentId)
+}
+
+const personIdOf = (nodeId) => nodeId.slice(2)
+
+// Corps de PUT /trees/:id/links/order : chaque enfant, dans l'ordre, avec ses liens vers la source
+export function buildOrderPayload(filiations, unions, source, orderedNodeIds) {
+ return orderedNodeIds.map((nodeId) => {
+ const childId = personIdOf(nodeId)
+ return { childId, linkIds: filiationsOf(filiations, unions, source, childId).map((f) => String(f.id)) }
+ })
+}
+
+// Copie des filiations avec l'ordre de la fratrie appliqué (displayOrder 1, 2, 3…)
+export function filiationsWithOrder(filiations, unions, source, orderedNodeIds) {
+ const orderByFiliation = new Map()
+ orderedNodeIds.forEach((nodeId, index) => {
+ for (const f of filiationsOf(filiations, unions, source, personIdOf(nodeId))) orderByFiliation.set(f, index + 1)
+ })
+ return filiations.map((f) => (orderByFiliation.has(f) ? { ...f, displayOrder: orderByFiliation.get(f) } : f))
+}
diff --git a/frontend/src/components/galaxy/siblingReorder.test.js b/frontend/src/components/galaxy/siblingReorder.test.js
new file mode 100644
index 0000000..a2ac056
--- /dev/null
+++ b/frontend/src/components/galaxy/siblingReorder.test.js
@@ -0,0 +1,116 @@
+import { describe, it, expect } from 'vitest'
+import { computeBlockLayout } from './blockLayout'
+import {
+ buildSiblingGroups, moveInOrder, pickSlot, rubberBand, springStep, isSettled,
+ buildOrderPayload, filiationsWithOrder,
+} from './siblingReorder'
+
+const person = (id) => ({ id, firstName: id, lastName: '' })
+const union = (id, partner1Id, partner2Id) => ({ id, partner1Id, partner2Id, displayOrder: 1 })
+const viaUnion = (id, childId, unionId) => ({ id, childId, parentId: null, unionId, parentageType: 'biologique', displayOrder: 1 })
+const direct = (id, childId, parentId) => ({ id, childId, parentId, unionId: null, parentageType: 'biologique', displayOrder: 1 })
+
+const graph = {
+ persons: ['pa', 'ma', 'a', 'b', 'c', 'solo', 'k1', 'k2'].map(person),
+ unions: [union('u1', 'pa', 'ma')],
+ filiations: [
+ viaUnion('fa', 'a', 'u1'), viaUnion('fb', 'b', 'u1'), viaUnion('fc', 'c', 'u1'),
+ direct('fk1', 'k1', 'solo'), direct('fk2', 'k2', 'solo'),
+ ],
+}
+
+describe('buildSiblingGroups', () => {
+ it('regroupe les enfants d’une union, de gauche à droite', () => {
+ const groups = buildSiblingGroups(computeBlockLayout(graph))
+ expect(groups.get('p-b')).toEqual({ source: 'u-u1', ids: ['p-a', 'p-b', 'p-c'] })
+ expect(groups.get('p-a')).toBe(groups.get('p-c'))
+ })
+
+ it('regroupe les enfants d’un parent seul', () => {
+ const groups = buildSiblingGroups(computeBlockLayout(graph))
+ expect(groups.get('p-k1')?.source).toBe('vu-solo')
+ })
+
+ it('écarte une fratrie dont un enfant appartient à deux familles', () => {
+ const layout = computeBlockLayout(graph)
+ layout.edges.push({ id: 'extra', sources: ['vu-solo'], targets: ['p-a'] })
+ expect(buildSiblingGroups(layout).has('p-a')).toBe(false)
+ })
+
+ it('ignore les enfants uniques', () => {
+ const groups = buildSiblingGroups(computeBlockLayout({ ...graph, filiations: [viaUnion('fa', 'a', 'u1')] }))
+ expect(groups.has('p-a')).toBe(false)
+ })
+})
+
+describe('moveInOrder', () => {
+ it('déplace un élément à une place donnée', () => {
+ expect(moveInOrder(['a', 'b', 'c'], 'a', 2)).toEqual(['b', 'c', 'a'])
+ expect(moveInOrder(['a', 'b', 'c'], 'c', 0)).toEqual(['c', 'a', 'b'])
+ })
+})
+
+describe('pickSlot', () => {
+ const slots = [0, 100, 200]
+ it('change de place au milieu entre deux places', () => {
+ expect(pickSlot(slots, 49, 0, 0)).toBe(0)
+ expect(pickSlot(slots, 51, 0, 0)).toBe(1)
+ expect(pickSlot(slots, 160, 0, 0)).toBe(2)
+ })
+ it('ne bascule pas tant qu’on hésite dans la marge', () => {
+ expect(pickSlot(slots, 55, 0, 10)).toBe(0)
+ expect(pickSlot(slots, 61, 0, 10)).toBe(1)
+ expect(pickSlot(slots, 45, 1, 10)).toBe(1)
+ expect(pickSlot(slots, 39, 1, 10)).toBe(0)
+ })
+})
+
+describe('rubberBand', () => {
+ it('laisse passer entre les bords et freine au-delà', () => {
+ expect(rubberBand(50, 0, 100)).toBe(50)
+ const beyond = rubberBand(300, 0, 100)
+ expect(beyond).toBeGreaterThan(100)
+ expect(beyond).toBeLessThan(190)
+ expect(rubberBand(1000, 0, 100)).toBeGreaterThan(beyond)
+ })
+})
+
+describe('springStep', () => {
+ it('revient à 0 sans osciller longtemps', () => {
+ let state = { x: 120, v: 0 }
+ let steps = 0
+ while (!isSettled(state) && steps < 600) { state = springStep(state, 1 / 60); steps += 1 }
+ expect(isSettled(state)).toBe(true)
+ expect(steps).toBeLessThan(40)
+ })
+})
+
+describe('ordre de la fratrie dans les données', () => {
+ it('donne les liens de chaque enfant vers la source, dans l’ordre', () => {
+ expect(buildOrderPayload(graph.filiations, graph.unions, 'u-u1', ['p-c', 'p-a', 'p-b'])).toEqual([
+ { childId: 'c', linkIds: ['fc'] },
+ { childId: 'a', linkIds: ['fa'] },
+ { childId: 'b', linkIds: ['fb'] },
+ ])
+ expect(buildOrderPayload(graph.filiations, graph.unions, 'vu-solo', ['p-k2', 'p-k1'])).toEqual([
+ { childId: 'k2', linkIds: ['fk2'] },
+ { childId: 'k1', linkIds: ['fk1'] },
+ ])
+ })
+
+ it('reprend les liens directs vers les deux partenaires quand l’union est reconstituée', () => {
+ const filiations = [direct('d1', 'a', 'pa'), direct('d2', 'a', 'ma'), direct('d3', 'b', 'pa'), direct('d4', 'b', 'ma')]
+ expect(buildOrderPayload(filiations, graph.unions, 'u-u1', ['p-b', 'p-a'])).toEqual([
+ { childId: 'b', linkIds: ['d3', 'd4'] },
+ { childId: 'a', linkIds: ['d1', 'd2'] },
+ ])
+ })
+
+ it('le placement suit l’ordre appliqué aux filiations, sans toucher aux originales', () => {
+ const reordered = filiationsWithOrder(graph.filiations, graph.unions, 'u-u1', ['p-c', 'p-a', 'p-b'])
+ const { children } = computeBlockLayout({ ...graph, filiations: reordered })
+ const row = children.filter((n) => ['a', 'b', 'c'].includes(n._data?.id)).sort((l, r) => l.x - r.x).map((n) => n._data.id)
+ expect(row).toEqual(['c', 'a', 'b'])
+ expect(graph.filiations.every((f) => f.displayOrder === 1)).toBe(true)
+ })
+})
diff --git a/frontend/src/i18n/en/fiche.js b/frontend/src/i18n/en/fiche.js
index 2c1fcbe..cb56022 100644
--- a/frontend/src/i18n/en/fiche.js
+++ b/frontend/src/i18n/en/fiche.js
@@ -259,4 +259,10 @@ export default {
'En rédaction': 'In the newsroom',
'Interview archive radio': 'Archived radio interview',
'Inconnu': 'Unknown',
+ // Place dans la fratrie (fiche, mode édition)
+ 'Place parmi les frères et sœurs': 'Place among siblings',
+ 'Déplacer à gauche': 'Move left',
+ 'Déplacer à droite': 'Move right',
+ '{n} sur {total}': '{n} of {total}',
+ "L'ordre n'a pas pu être enregistré. Réessayez.": 'The order could not be saved. Please try again.',
}