1+ "use client" ;
2+
3+ /**
4+ * @module core/animation
5+ *
6+ * Central animation engine for next-easytour.
7+ *
8+ * - `useFrameSequence` — state-machine-driven frame cycling
9+ * - `useEnterAnimation` — CSS enter class on mount / key change
10+ * - `useArrowDraw` — SVG stroke-dashoffset draw-on
11+ *
12+ * Design principles:
13+ * - Single timer per state (no nested setTimeout)
14+ * - All timers tracked and cleaned up on unmount/re-render
15+ * - CSS drives visuals; hooks drive timing and class names
16+ * - Trigger counter pattern for re-triggering on same step
17+ */
18+
19+ import { useEffect , useRef , useState } from "react" ;
20+
21+ // ── Types ───────────────────────────────────────────────────────────────
22+
23+ export interface FrameSequenceOptions {
24+ /** Duration per frame in ms. Default 2000. */
25+ frameDuration ?: number ;
26+ /** CSS transition duration in ms. Default 300. */
27+ transitionDuration ?: number ;
28+ /** Loop after the last frame. Default false. */
29+ loop ?: boolean ;
30+ /** Delay before the first frame enters, in ms. Default 0. */
31+ delay ?: number ;
32+ /** Whether the sequence is enabled. Default true. */
33+ enabled ?: boolean ;
34+ /** Called when the frame index changes. */
35+ onFrameChange ?: ( index : number ) => void ;
36+ }
37+
38+ export type AnimPhase = "delay" | "entering" | "visible" | "exiting" ;
39+
40+ export interface FrameSequenceState < T > {
41+ current : T ;
42+ index : number ;
43+ phase : AnimPhase ;
44+ done : boolean ;
45+ }
46+
47+ // ── useFrameSequence ────────────────────────────────────────────────────
48+
49+ /**
50+ * State machine:
51+ *
52+ * (delay) → "entering" → "visible" → "exiting" → advance index →
53+ * "entering" → "visible" → "exiting" → ...
54+ *
55+ * Each phase sets ONE timer that transitions to the next phase.
56+ * Content (the returned `current`) updates when the index advances,
57+ * which happens at the exiting→entering boundary.
58+ */
59+ export function useFrameSequence < T > (
60+ frames : T [ ] ,
61+ options : FrameSequenceOptions = { } ,
62+ ) : FrameSequenceState < T > {
63+ const {
64+ frameDuration = 2000 ,
65+ transitionDuration = 300 ,
66+ loop = false ,
67+ delay = 0 ,
68+ enabled = true ,
69+ onFrameChange,
70+ } = options ;
71+
72+ const [ index , setIndex ] = useState ( 0 ) ;
73+ const [ phase , setPhase ] = useState < AnimPhase > ( delay > 0 ? "delay" : "entering" ) ;
74+ const [ done , setDone ] = useState ( false ) ;
75+ const timerRef = useRef < ReturnType < typeof setTimeout > > ( ) ;
76+ const onFrameChangeRef = useRef ( onFrameChange ) ;
77+ onFrameChangeRef . current = onFrameChange ;
78+
79+ // Reset when frames identity changes
80+ const framesLenRef = useRef ( frames . length ) ;
81+ useEffect ( ( ) => {
82+ if ( frames . length !== framesLenRef . current ) {
83+ framesLenRef . current = frames . length ;
84+ setIndex ( 0 ) ;
85+ setPhase ( delay > 0 ? "delay" : "entering" ) ;
86+ setDone ( false ) ;
87+ }
88+ } , [ frames . length , delay ] ) ;
89+
90+ // ── State machine: one effect per phase ───────────────────────────
91+ useEffect ( ( ) => {
92+ if ( ! enabled || done ) return ;
93+ clearTimeout ( timerRef . current ) ;
94+
95+ switch ( phase ) {
96+ case "delay" :
97+ timerRef . current = setTimeout ( ( ) => setPhase ( "entering" ) , delay ) ;
98+ break ;
99+
100+ case "entering" :
101+ timerRef . current = setTimeout ( ( ) => setPhase ( "visible" ) , transitionDuration ) ;
102+ break ;
103+
104+ case "visible" :
105+ // Single-frame or disabled: stay visible forever
106+ if ( frames . length <= 1 ) break ;
107+ timerRef . current = setTimeout ( ( ) => setPhase ( "exiting" ) , frameDuration ) ;
108+ break ;
109+
110+ case "exiting" :
111+ timerRef . current = setTimeout ( ( ) => {
112+ // Advance the frame
113+ setIndex ( ( prev ) => {
114+ const next = prev + 1 ;
115+ if ( next >= frames . length ) {
116+ if ( loop ) {
117+ onFrameChangeRef . current ?.( 0 ) ;
118+ return 0 ;
119+ }
120+ setDone ( true ) ;
121+ return prev ; // stay on last
122+ }
123+ onFrameChangeRef . current ?.( next ) ;
124+ return next ;
125+ } ) ;
126+ setPhase ( "entering" ) ;
127+ } , transitionDuration ) ;
128+ break ;
129+ }
130+
131+ return ( ) => clearTimeout ( timerRef . current ) ;
132+ } , [ phase , enabled , done , frames . length , frameDuration , transitionDuration , loop , delay ] ) ;
133+
134+ const safeIndex = Math . min ( index , Math . max ( frames . length - 1 , 0 ) ) ;
135+
136+ return {
137+ current : frames [ safeIndex ] ,
138+ index : safeIndex ,
139+ phase,
140+ done,
141+ } ;
142+ }
143+
144+ // ── useEnterAnimation ───────────────────────────────────────────────────
145+
146+ /**
147+ * Returns the enter CSS class name while the animation is playing.
148+ * Re-triggers on every `key` change (including initial mount).
149+ * Uses a trigger counter so navigating away and back to the same
150+ * step still replays the animation.
151+ */
152+ export function useEnterAnimation (
153+ key : string | null ,
154+ enterClass : string ,
155+ duration : number = 250 ,
156+ ) : string {
157+ const [ trigger , setTrigger ] = useState ( 0 ) ;
158+ const [ active , setActive ] = useState ( true ) ;
159+ const prevKey = useRef < string | null > ( null ) ;
160+ const timerRef = useRef < ReturnType < typeof setTimeout > > ( ) ;
161+
162+ // Trigger on key change (including first render)
163+ useEffect ( ( ) => {
164+ if ( key !== prevKey . current ) {
165+ prevKey . current = key ;
166+ setTrigger ( ( t ) => t + 1 ) ;
167+ }
168+ } , [ key ] ) ;
169+
170+ // Run animation on trigger
171+ useEffect ( ( ) => {
172+ setActive ( true ) ;
173+ clearTimeout ( timerRef . current ) ;
174+ timerRef . current = setTimeout ( ( ) => setActive ( false ) , duration ) ;
175+ return ( ) => clearTimeout ( timerRef . current ) ;
176+ } , [ trigger , duration ] ) ;
177+
178+ return active ? enterClass : "" ;
179+ }
180+
181+ // ── useArrowDraw ────────────────────────────────────────────────────────
182+
183+ /**
184+ * SVG stroke-dashoffset draw-on animation. Uses a trigger counter
185+ * so the animation replays when:
186+ * - stepId changes
187+ * - pathLength goes from 0 to positive (SVG rendered)
188+ *
189+ * The draw works by:
190+ * 1. Set dashoffset = pathLength (path hidden)
191+ * 2. Next frame: set dashoffset = 0 with CSS transition
192+ * 3. The browser interpolates → draw-on effect
193+ */
194+ export function useArrowDraw (
195+ pathLength : number ,
196+ stepId : string | null ,
197+ enabled : boolean = true ,
198+ duration : number = 400 ,
199+ ) : { dashArray ?: number ; dashOffset : number ; transitioning : boolean } {
200+ const [ trigger , setTrigger ] = useState ( 0 ) ;
201+ const [ offset , setOffset ] = useState ( 0 ) ;
202+ const [ transitioning , setTransitioning ] = useState ( false ) ;
203+ const prevStepId = useRef ( stepId ) ;
204+ const prevPathLength = useRef ( pathLength ) ;
205+ const timerRef = useRef < ReturnType < typeof setTimeout > > ( ) ;
206+ const rafRef = useRef < number > ( 0 ) ;
207+
208+ // Trigger on step change
209+ useEffect ( ( ) => {
210+ if ( stepId !== prevStepId . current ) {
211+ prevStepId . current = stepId ;
212+ setTrigger ( ( t ) => t + 1 ) ;
213+ }
214+ } , [ stepId ] ) ;
215+
216+ // Trigger when pathLength goes from 0 to positive (first render)
217+ useEffect ( ( ) => {
218+ if ( prevPathLength . current === 0 && pathLength > 0 ) {
219+ setTrigger ( ( t ) => t + 1 ) ;
220+ }
221+ prevPathLength . current = pathLength ;
222+ } , [ pathLength ] ) ;
223+
224+ // Run draw animation on trigger
225+ useEffect ( ( ) => {
226+ if ( ! enabled || pathLength <= 0 ) return ;
227+
228+ // Step 1: set to full offset (hidden)
229+ setOffset ( pathLength ) ;
230+ setTransitioning ( false ) ;
231+
232+ // Step 2: next frame, animate to 0
233+ rafRef . current = requestAnimationFrame ( ( ) => {
234+ rafRef . current = requestAnimationFrame ( ( ) => {
235+ setTransitioning ( true ) ;
236+ setOffset ( 0 ) ;
237+ clearTimeout ( timerRef . current ) ;
238+ timerRef . current = setTimeout ( ( ) => setTransitioning ( false ) , duration ) ;
239+ } ) ;
240+ } ) ;
241+
242+ return ( ) => {
243+ cancelAnimationFrame ( rafRef . current ) ;
244+ clearTimeout ( timerRef . current ) ;
245+ } ;
246+ } , [ trigger , enabled , pathLength , duration ] ) ;
247+
248+ if ( ! enabled || pathLength <= 0 ) {
249+ return { dashOffset : 0 , transitioning : false } ;
250+ }
251+
252+ return {
253+ dashArray : pathLength ,
254+ dashOffset : offset ,
255+ transitioning,
256+ } ;
257+ }
0 commit comments