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.', }