diff --git a/frontend/src/components/Galaxy.jsx b/frontend/src/components/Galaxy.jsx index 57dc865..b9a1825 100644 --- a/frontend/src/components/Galaxy.jsx +++ b/frontend/src/components/Galaxy.jsx @@ -1,6 +1,6 @@ import { memo, useEffect, useImperativeHandle, useRef, useState, useCallback } from 'react' 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 { PERSON_R, FLOAT_SPEED, FLOAT_AMPLITUDE, REPULSION_RADIUS, REPULSION_STRENGTH, MAX_ORBIT_MEDIAS, ENTRANCE_NODE_DURATION, ENTRANCE_CAMERA_DURATION, ENTRANCE_CAMERA_FROM_SCALE, ENTRANCE_REDUCED_DURATION, 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, restoreCoupleBarMeta } from './galaxy/elkLayout' import { computeBlockLayout, familyTabsOf, lineToFamily, subgraphOf, takeRootLayout } from './galaxy/blockLayout' @@ -32,7 +32,8 @@ const MAX_SCALE = 3.2 // Carte des familles : l'arbre s'efface entre ces deux échelles, les tuiles de familles apparaissent // Ouverture sur la personne de départ quand l'arbre entier serait illisible const FOCUS_MIN_READABLE_SCALE = 0.5 -const FOCUS_SCALE = 0.95 +// Assez loin pour voir la famille autour, assez près pour lire les prénoms (LOD_LABEL_FADE_IN) +const FOCUS_SCALE = 0.5 // Réordonner une fratrie : seuil avant de soulever à la souris, appui long au doigt const REORDER_MOUSE_THRESHOLD = 4 @@ -42,6 +43,8 @@ const REORDER_LONG_PRESS_MS = 320 const SHIFT_SPRING = { stiffness: 560, dampingRatio: 0.92 } const DROP_SPRING = { stiffness: 480, dampingRatio: 0.72 } const centerX = (node) => node.x + node.width / 2 +// Recul de la caméra à l'ouverture : départ immédiat, long freinage (ease-out quint) +const easeOutQuint = (t) => 1 - (1 - t) ** 5 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, reorderEnabled = false, onReorderSiblings = null, familyTabs = true, annotationViewRef = null, onAnnotationsAnchored = null, selfPersonId = null, ghosts = null, initialFamilyKey = null, onFamilyChange = null }) => { @@ -125,6 +128,9 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig const mediaHoverState = useRef(new Map()) const dragLiftProgress = useRef(0) const entranceRef = useRef(null) + // Arbre déjà ouvert dans cette page (sa première personne) : l'entrée ne se joue qu'à la première ouverture, + // un arbre modifié ou un autre onglet s'affiche net + const openedTreeRef = useRef(null) const selectedPersonIdRef = useRef(null) const selectedGlowScales = useRef(new Map()) const searchGlowScales = useRef(new Map()) @@ -678,32 +684,50 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig targetTransformRef.current = init nodeRandomDataRef.current = generateNodeRandomData(result.children, result.edges) - // Construire le schedule d'animation d'entrée - const genGroups = new Map() - result.children.forEach(node => { - const gen = node._generation ?? 0 - if (!genGroups.has(gen)) genGroups.set(gen, []) - genGroups.get(gen).push(node) - }) - genGroups.forEach(group => group.sort((a, b) => a.x - b.x)) - + // Entrée, à la première ouverture de l'arbre seulement. La caméra part serrée sur la personne de départ et + // recule jusqu'à la vue d'arrivée ; chaque personne apparaît quand elle entre dans le champ, la personne de + // départ dès la première image. On reprend la main à tout moment (le recul s'arrête au premier geste). + // Mouvements réduits : pas de recul, un fondu court. Ensuite (modification, autre onglet) : rien ne s'anime. const nodeSchedule = new Map() const coupleSchedule = new Map() const edgeSchedule = new Map() const orbitSchedule = new Map() let maxEndMs = 0 - - // Délais réduits sur les grands arbres pour que l'entrée reste courte (voir constants.js) - const genDelay = Math.min(ENTRANCE_GEN_DELAY, ENTRANCE_MAX_GEN_SPAN / Math.max(1, genGroups.size - 1)) - genGroups.forEach((nodes, gen) => { - const genStart = gen * genDelay - const nodeStagger = Math.min(ENTRANCE_NODE_STAGGER, ENTRANCE_MAX_NODE_SPAN / Math.max(1, nodes.length - 1)) - nodes.forEach((node, idx) => { - const startMs = genStart + idx * nodeStagger + Math.random() * 40 - nodeSchedule.set(node.id, { startMs, duration: ENTRANCE_NODE_DURATION }) + let camera = null + const treeKey = String(graphPersons[0]?.id ?? '') + const firstOpen = treeKey !== '' && openedTreeRef.current !== treeKey + if (treeKey) openedTreeRef.current = treeKey + if (!firstOpen) { + for (const n of result.children) nodeSchedule.set(n.id, { startMs: -Infinity, duration: 1 }) + } else if (prefersReducedMotion() || !focusNode) { + for (const n of result.children) nodeSchedule.set(n.id, { startMs: 0, duration: ENTRANCE_REDUCED_DURATION }) + maxEndMs = ENTRANCE_REDUCED_DURATION + } else { + const fx = focusNode.x + focusNode.width / 2, fy = focusNode.y + focusNode.height / 2 + const sx = fx * init.scale + init.x, sy = fy * init.scale + init.y + const at = (sc) => ({ scale: sc, x: sx - fx * sc, y: sy - fy * sc }) + camera = { from: at(Math.min(MAX_SCALE, Math.max(ENTRANCE_CAMERA_FROM_SCALE, init.scale * 1.6))), to: init, durationMs: ENTRANCE_CAMERA_DURATION } + camera.last = camera.from + // Instant où le recul fait entrer la personne dans le champ (marge de 40 px), ou null si elle reste dehors + const entersAt = (n) => { + const nx = n.x + n.width / 2 - fx, ny = n.y + n.height / 2 - fy + for (let i = 0; i <= 60; i++) { + const sc = camera.from.scale + (init.scale - camera.from.scale) * easeOutQuint(i / 60) + const px = sx + nx * sc, py = sy + ny * sc + if (px > -40 && px < cssW + 40 && py > -40 && py < cssH + 40) return (i / 60) * camera.durationMs + } + return null + } + for (const n of result.children) { + const t = n === focusNode ? 0 : entersAt(n) + if (t == null) { nodeSchedule.set(n.id, { startMs: -Infinity, duration: 1 }); continue } + const startMs = t === 0 ? 0 : Math.max(0, t - 120) + Math.random() * 40 + nodeSchedule.set(n.id, { startMs, duration: ENTRANCE_NODE_DURATION }) maxEndMs = Math.max(maxEndMs, startMs + ENTRANCE_NODE_DURATION) - }) - }) + } + transformRef.current = camera.from + targetTransformRef.current = camera.from + } coupleBarMeta.forEach(({ elkId, p1Key, p2Key }) => { const s1 = nodeSchedule.get(p1Key) @@ -741,6 +765,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig }) entranceRef.current = { + camera, startTime: -1, nodeSchedule, coupleSchedule, edgeSchedule, orbitSchedule, totalDurationMs: maxEndMs, @@ -808,6 +833,24 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig // Pendant l'entrée et le fondu des annotations qui la suit, on redessine chaque frame if (entrance && elapsed < entrance.totalDurationMs + 500) dirty = true + // Recul de la caméra à l'ouverture, abandonné dès qu'on déplace ou zoome soi-même l'arbre + const cam = entrance?.camera + if (cam) { + if (targetTransformRef.current !== cam.last) entrance.camera = null + else { + const k = easeOutQuint(Math.min(1, Math.max(0, elapsed / cam.durationMs))) + const t = { + scale: cam.from.scale + (cam.to.scale - cam.from.scale) * k, + x: cam.from.x + (cam.to.x - cam.from.x) * k, + y: cam.from.y + (cam.to.y - cam.from.y) * k, + } + transformRef.current = t + targetTransformRef.current = t + cam.last = t + dirty = true + if (k >= 1) entrance.camera = null + } + } // Lissage du transform const target = targetTransformRef.current const prev = transformRef.current diff --git a/frontend/src/components/galaxy/constants.js b/frontend/src/components/galaxy/constants.js index 7b16a6a..d07b930 100644 --- a/frontend/src/components/galaxy/constants.js +++ b/frontend/src/components/galaxy/constants.js @@ -45,14 +45,13 @@ export const REPULSION_STRENGTH = 2 export const ANGLE_VARIATION = Math.PI / 6 // --- Constantes animation d'entrée --- -// Cascade génération par génération, puis nœud par nœud de gauche à droite. Les délais unitaires -// s'appliquent aux petits arbres ; sur un grand arbre ils sont réduits pour que la cascade tienne -// dans ENTRANCE_MAX_GEN_SPAN (toutes générations) + ENTRANCE_MAX_NODE_SPAN (nœuds d'une génération). -export const ENTRANCE_GEN_DELAY = 500 -export const ENTRANCE_NODE_DURATION = 700 -export const ENTRANCE_NODE_STAGGER = 80 -export const ENTRANCE_MAX_GEN_SPAN = 1600 -export const ENTRANCE_MAX_NODE_SPAN = 700 +// À la première ouverture d'un arbre, la caméra part serrée sur la personne de départ (au moins +// ENTRANCE_CAMERA_FROM_SCALE) et recule en ENTRANCE_CAMERA_DURATION ; chaque personne apparaît en +// ENTRANCE_NODE_DURATION quand elle entre dans le champ. Mouvements réduits : fondu de ENTRANCE_REDUCED_DURATION. +export const ENTRANCE_CAMERA_DURATION = 1100 +export const ENTRANCE_CAMERA_FROM_SCALE = 1.3 +export const ENTRANCE_NODE_DURATION = 360 +export const ENTRANCE_REDUCED_DURATION = 200 export const ENTRANCE_LINE_DELAY = 200 export const ENTRANCE_LINE_DURATION = 500 export const ENTRANCE_ORBIT_DELAY = 300 diff --git a/frontend/src/components/galaxy/renderers.js b/frontend/src/components/galaxy/renderers.js index 2c1d091..4273ece 100644 --- a/frontend/src/components/galaxy/renderers.js +++ b/frontend/src/components/galaxy/renderers.js @@ -548,7 +548,8 @@ function drawPersonNode(ctx, node, pos, nodeProgress, { 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 + // Un portrait qui entre part de 92 % de sa taille, en fondu : rien n'apparaît de rien + const entranceScale = 0.92 + nodeProgress * 0.08 const personScale = entranceScale * (1 + Math.max(hs, lift) * 0.1 + lift * 0.05) * k // --- Glow sélection --- @@ -1113,7 +1114,7 @@ export function drawNodes(ctx, layoutData, posMap, entrance, entranceActive, ela ctx.fill() ctx.globalAlpha = 1 } else if (node._type === 'unknown') { - const unknownScale = 0.3 + nodeProgress * 0.7 + const unknownScale = 0.92 + nodeProgress * 0.08 ctx.save() ctx.globalAlpha = nodeProgress ctx.translate(cx, cy)