Arbre : réordonner une fratrie au glisser ou aux flèches de la fiche

En mode édition, un administrateur glisse une personne à l'horizontale
parmi ses frères et soeurs pour changer leur ordre. Pour chaque place visée,
le vrai placement est recalculé : la personne soulevée suit le pointeur,
les autres branches glissent vers leur place avec un ressort, et rien ne
saute au lâcher. Appui long au doigt, Échap ou un deuxième doigt pour
annuler, mouvement réduit respecté. La fiche propose aussi deux flèches
(Déplacer à gauche, Déplacer à droite) avec la même animation. L'ordre est
appliqué tout de suite puis enregistré ; en cas d'échec l'arbre revient
en arrière et un message le dit.

Idée de départ : PR #60 de py5932 (glisser-déposer des personnes).

Co-Authored-By: py5932 <py.dequidt@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
committed 2026-10-16 19:08:15 +02:00
1 parent 8556d32bce
commit e327cc223d
9 files changed
+611 -16

No files matched your search

+36
View File
@@ -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}
/>
<ZoomControl zoomApiRef={galaxyZoomApiRef} />
{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 && (
+11
View File
@@ -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',
+308 -10
View File
@@ -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()
}}
+16
View File
@@ -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 ── */
+14 -1
View File
@@ -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 && <p className="pc-life">{lifeLine}</p>}
<div className="pc-chips">
{editMode && <span className="pz-tag pz-tag--accent pz-tag--dot">{t('Édition')}</span>}
{editMode && siblingPlace && (
<span className="pz-tag pc-order" role="group" aria-label={t('Place parmi les frères et sœurs')}>
<button type="button" className="pc-order-btn" onClick={() => onMoveSibling?.(-1)} disabled={siblingPlace.index === 0} aria-label={t('Déplacer à gauche')} title={t('Déplacer à gauche')}>
<ChevronLeft size={15} strokeWidth={2} aria-hidden="true" />
</button>
<span className="pc-order-label">{t('{n} sur {total}', { n: siblingPlace.index + 1, total: siblingPlace.count })}</span>
<button type="button" className="pc-order-btn" onClick={() => onMoveSibling?.(1)} disabled={siblingPlace.index === siblingPlace.count - 1} aria-label={t('Déplacer à droite')} title={t('Déplacer à droite')}>
<ChevronRight size={15} strokeWidth={2} aria-hidden="true" />
</button>
</span>
)}
{!editMode && ageLabel && <span className="pz-tag">{ageLabel}</span>}
{!editMode && person.profession && <span className="pz-tag">{person.profession}</span>}
</div>
+7 -5
View File
@@ -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
@@ -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))
}
@@ -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)
})
})
+6
View File
@@ -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.',
}