From e327cc223dbc07686372a3c67711d25d36ad99d4 Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Fri, 16 Oct 2026 19:08:05 +0200 Subject: [PATCH] =?UTF-8?q?Arbre=20:=20r=C3=A9ordonner=20une=20fratrie=20a?= =?UTF-8?q?u=20glisser=20ou=20aux=20fl=C3=A8ches=20de=20la=20fiche?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-Authored-By: Claude Opus 5.5 --- frontend/src/App.jsx | 36 ++ frontend/src/api/treeApi.js | 11 + frontend/src/components/Galaxy.jsx | 318 +++++++++++++++++- frontend/src/components/PersonCard.css | 16 + frontend/src/components/PersonCard.jsx | 15 +- frontend/src/components/galaxy/renderers.js | 12 +- .../src/components/galaxy/siblingReorder.js | 97 ++++++ .../components/galaxy/siblingReorder.test.js | 116 +++++++ frontend/src/i18n/en/fiche.js | 6 + 9 files changed, 611 insertions(+), 16 deletions(-) create mode 100644 frontend/src/components/galaxy/siblingReorder.js create mode 100644 frontend/src/components/galaxy/siblingReorder.test.js 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.', }