mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-12 16:16:09 +00:00
Ouverture d'un arbre : la caméra recule de la personne de départ vers sa famille
À la première ouverture d'un arbre, la caméra part serrée sur la personne de départ et recule en 1,1 s (ease-out quint) 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. Le premier geste (glisser, molette, recherche) arrête le recul. - Un grand arbre s'ouvre à 0,5 au lieu de 0,95 : on voit la famille autour, les prénoms restent lisibles. - Ensuite (arbre modifié, autre onglet de famille), rien ne s'anime : l'arbre s'affiche net. Avant, la cascade se rejouait à chaque modification. - Un portrait qui entre part de 92 % de sa taille en fondu, en 360 ms (30 % en 700 ms avant). - Mouvements réduits : pas de recul, un fondu de 200 ms. Avant, la cascade partait des générations les plus anciennes : sur un grand arbre ouvert sur la personne de départ, l'écran restait vide près de 3 s. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0f98b8b507
commit
8ef87c7163
3 files changed
+75
-32
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user