// modules/api-manager/AnimationManager.js import { Logger } from '../../core/logger.js'; /** * Web Animations API Manager - High-performance animations with timeline control */ export class AnimationManager { constructor(config = {}) { this.config = config; this.activeAnimations = new Map(); this.animationGroups = new Map(); this.defaultEasing = config.easing || 'ease-out'; this.defaultDuration = config.duration || 300; // Check for Web Animations API support this.supported = 'animate' in Element.prototype; if (!this.supported) { Logger.warn('[AnimationManager] Web Animations API not supported, using fallbacks'); } else { Logger.info('[AnimationManager] Initialized with Web Animations API support'); } } /** * Animate element using Web Animations API */ animate(element, keyframes, options = {}) { if (!element || !keyframes) { Logger.warn('[AnimationManager] Missing element or keyframes'); return null; } const animationOptions = { duration: this.defaultDuration, easing: this.defaultEasing, fill: 'forwards', ...options }; const animationId = this.generateId(); if (this.supported) { const animation = element.animate(keyframes, animationOptions); // Enhanced animation object const enhancedAnimation = this.enhanceAnimation(animation, animationId, { element, keyframes, options: animationOptions }); this.activeAnimations.set(animationId, enhancedAnimation); // Auto-cleanup on finish animation.addEventListener('finish', () => { this.activeAnimations.delete(animationId); }); Logger.info(`[AnimationManager] Animation started: ${animationId}`); return enhancedAnimation; } else { return this.fallbackAnimate(element, keyframes, animationOptions, animationId); } } /** * Create keyframe animations */ keyframes(keyframeSet) { // Convert CSS-like keyframes to Web Animations format if (Array.isArray(keyframeSet)) { return keyframeSet; } // Handle object format: { '0%': {...}, '50%': {...}, '100%': {...} } if (typeof keyframeSet === 'object') { const frames = []; const sortedKeys = Object.keys(keyframeSet).sort((a, b) => { const aPercent = parseFloat(a.replace('%', '')); const bPercent = parseFloat(b.replace('%', '')); return aPercent - bPercent; }); sortedKeys.forEach(key => { const offset = parseFloat(key.replace('%', '')) / 100; frames.push({ ...keyframeSet[key], offset }); }); return frames; } return keyframeSet; } /** * Pre-defined animation effects */ effects = { // Entrance animations fadeIn: (duration = 300) => ([ { opacity: 0 }, { opacity: 1 } ]), slideInLeft: (distance = '100px', duration = 300) => ([ { transform: `translateX(-${distance})`, opacity: 0 }, { transform: 'translateX(0)', opacity: 1 } ]), slideInRight: (distance = '100px', duration = 300) => ([ { transform: `translateX(${distance})`, opacity: 0 }, { transform: 'translateX(0)', opacity: 1 } ]), slideInUp: (distance = '100px', duration = 300) => ([ { transform: `translateY(${distance})`, opacity: 0 }, { transform: 'translateY(0)', opacity: 1 } ]), slideInDown: (distance = '100px', duration = 300) => ([ { transform: `translateY(-${distance})`, opacity: 0 }, { transform: 'translateY(0)', opacity: 1 } ]), scaleIn: (scale = 0.8, duration = 300) => ([ { transform: `scale(${scale})`, opacity: 0 }, { transform: 'scale(1)', opacity: 1 } ]), rotateIn: (rotation = '-180deg', duration = 600) => ([ { transform: `rotate(${rotation})`, opacity: 0 }, { transform: 'rotate(0deg)', opacity: 1 } ]), // Exit animations fadeOut: (duration = 300) => ([ { opacity: 1 }, { opacity: 0 } ]), slideOutLeft: (distance = '100px', duration = 300) => ([ { transform: 'translateX(0)', opacity: 1 }, { transform: `translateX(-${distance})`, opacity: 0 } ]), slideOutRight: (distance = '100px', duration = 300) => ([ { transform: 'translateX(0)', opacity: 1 }, { transform: `translateX(${distance})`, opacity: 0 } ]), scaleOut: (scale = 0.8, duration = 300) => ([ { transform: 'scale(1)', opacity: 1 }, { transform: `scale(${scale})`, opacity: 0 } ]), // Attention seekers bounce: (intensity = '20px', duration = 600) => ([ { transform: 'translateY(0)' }, { transform: `translateY(-${intensity})`, offset: 0.25 }, { transform: 'translateY(0)', offset: 0.5 }, { transform: `translateY(-${intensity})`, offset: 0.75 }, { transform: 'translateY(0)' } ]), pulse: (scale = 1.1, duration = 600) => ([ { transform: 'scale(1)' }, { transform: `scale(${scale})`, offset: 0.5 }, { transform: 'scale(1)' } ]), shake: (distance = '10px', duration = 600) => ([ { transform: 'translateX(0)' }, { transform: `translateX(-${distance})`, offset: 0.1 }, { transform: `translateX(${distance})`, offset: 0.2 }, { transform: `translateX(-${distance})`, offset: 0.3 }, { transform: `translateX(${distance})`, offset: 0.4 }, { transform: `translateX(-${distance})`, offset: 0.5 }, { transform: `translateX(${distance})`, offset: 0.6 }, { transform: `translateX(-${distance})`, offset: 0.7 }, { transform: `translateX(${distance})`, offset: 0.8 }, { transform: `translateX(-${distance})`, offset: 0.9 }, { transform: 'translateX(0)' } ]), rubberBand: (duration = 1000) => ([ { transform: 'scale(1)' }, { transform: 'scale(1.25, 0.75)', offset: 0.3 }, { transform: 'scale(0.75, 1.25)', offset: 0.4 }, { transform: 'scale(1.15, 0.85)', offset: 0.5 }, { transform: 'scale(0.95, 1.05)', offset: 0.65 }, { transform: 'scale(1.05, 0.95)', offset: 0.75 }, { transform: 'scale(1)' } ]), // Loading animations spin: (duration = 1000) => ([ { transform: 'rotate(0deg)' }, { transform: 'rotate(360deg)' } ]), heartbeat: (scale = 1.3, duration = 1000) => ([ { transform: 'scale(1)' }, { transform: `scale(${scale})`, offset: 0.14 }, { transform: 'scale(1)', offset: 0.28 }, { transform: `scale(${scale})`, offset: 0.42 }, { transform: 'scale(1)', offset: 0.70 } ]) }; /** * Predefined easing functions */ easings = { linear: 'linear', ease: 'ease', easeIn: 'ease-in', easeOut: 'ease-out', easeInOut: 'ease-in-out', // Custom cubic-bezier easings easeInQuad: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)', easeOutQuad: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)', easeInOutQuad: 'cubic-bezier(0.455, 0.03, 0.515, 0.955)', easeInCubic: 'cubic-bezier(0.32, 0, 0.67, 0)', easeOutCubic: 'cubic-bezier(0.33, 1, 0.68, 1)', easeInOutCubic: 'cubic-bezier(0.65, 0, 0.35, 1)', easeInQuart: 'cubic-bezier(0.5, 0, 0.75, 0)', easeOutQuart: 'cubic-bezier(0.25, 1, 0.5, 1)', easeInOutQuart: 'cubic-bezier(0.76, 0, 0.24, 1)', // Bouncy easings easeOutBack: 'cubic-bezier(0.34, 1.56, 0.64, 1)', easeInBack: 'cubic-bezier(0.36, 0, 0.66, -0.56)', easeInOutBack: 'cubic-bezier(0.68, -0.6, 0.32, 1.6)' }; /** * Quick effect methods */ fadeIn(element, options = {}) { return this.animate(element, this.effects.fadeIn(), { duration: 300, easing: this.easings.easeOut, ...options }); } fadeOut(element, options = {}) { return this.animate(element, this.effects.fadeOut(), { duration: 300, easing: this.easings.easeIn, ...options }); } slideIn(element, direction = 'up', options = {}) { const effectMap = { up: 'slideInUp', down: 'slideInDown', left: 'slideInLeft', right: 'slideInRight' }; return this.animate(element, this.effects[effectMap[direction]](), { duration: 400, easing: this.easings.easeOutBack, ...options }); } bounce(element, options = {}) { return this.animate(element, this.effects.bounce(), { duration: 600, easing: this.easings.easeInOut, ...options }); } pulse(element, options = {}) { return this.animate(element, this.effects.pulse(), { duration: 600, easing: this.easings.easeInOut, iterations: Infinity, ...options }); } /** * Animation groups - animate multiple elements together */ group(animations, options = {}) { const groupId = this.generateId('group'); const animationPromises = []; animations.forEach(({ element, keyframes, animationOptions = {} }) => { const animation = this.animate(element, keyframes, { ...animationOptions, ...options }); if (animation && animation.finished) { animationPromises.push(animation.finished); } }); const groupController = { id: groupId, finished: Promise.all(animationPromises), play: () => { animations.forEach(anim => { if (anim.animation) anim.animation.play(); }); }, pause: () => { animations.forEach(anim => { if (anim.animation) anim.animation.pause(); }); }, reverse: () => { animations.forEach(anim => { if (anim.animation) anim.animation.reverse(); }); }, cancel: () => { animations.forEach(anim => { if (anim.animation) anim.animation.cancel(); }); } }; this.animationGroups.set(groupId, groupController); return groupController; } /** * Staggered animations - animate elements with delay */ stagger(elements, keyframes, options = {}) { const staggerDelay = options.staggerDelay || 100; const animations = []; elements.forEach((element, index) => { const delay = index * staggerDelay; const animation = this.animate(element, keyframes, { ...options, delay }); animations.push({ element, animation }); }); return this.group(animations.map(({ element, animation }) => ({ element, keyframes, animation }))); } /** * Timeline animations - sequence animations */ timeline(sequence) { const timelineId = this.generateId('timeline'); let currentTime = 0; const animations = []; sequence.forEach(step => { const delay = step.at !== undefined ? step.at : currentTime; const animation = this.animate(step.element, step.keyframes, { ...step.options, delay }); animations.push(animation); if (step.at === undefined) { currentTime += step.options?.duration || this.defaultDuration; } }); return { id: timelineId, animations, finished: Promise.all(animations.map(a => a.finished)), play: () => animations.forEach(a => a.play()), pause: () => animations.forEach(a => a.pause()), reverse: () => animations.forEach(a => a.reverse()), cancel: () => animations.forEach(a => a.cancel()) }; } /** * Scroll-triggered animations */ onScroll(element, keyframes, options = {}) { const scrollTrigger = options.trigger || element; const startOffset = options.start || 0; const endOffset = options.end || 1; const observer = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { const progress = Math.min(Math.max( (entry.intersectionRatio - startOffset) / (endOffset - startOffset), 0 ), 1); if (progress >= 0) { this.animate(element, keyframes, { ...options, duration: options.duration || this.defaultDuration }); } } }); }, { threshold: this.createThresholdArray(20) }); observer.observe(scrollTrigger); return { observer, disconnect: () => observer.disconnect() }; } // Helper methods enhanceAnimation(animation, id, metadata) { return { id, animation, metadata, // Enhanced controls play: () => animation.play(), pause: () => animation.pause(), reverse: () => animation.reverse(), cancel: () => animation.cancel(), finish: () => animation.finish(), // Properties get currentTime() { return animation.currentTime; }, set currentTime(time) { animation.currentTime = time; }, get playbackRate() { return animation.playbackRate; }, set playbackRate(rate) { animation.playbackRate = rate; }, get playState() { return animation.playState; }, get finished() { return animation.finished; }, // Enhanced methods seek(progress) { animation.currentTime = animation.effect.getTiming().duration * progress; }, setProgress(progress) { this.seek(Math.max(0, Math.min(1, progress))); }, onFinish(callback) { animation.addEventListener('finish', callback); }, onCancel(callback) { animation.addEventListener('cancel', callback); } }; } fallbackAnimate(element, keyframes, options, id) { Logger.info('[AnimationManager] Using CSS fallback animation'); // Simple CSS transition fallback const finalFrame = keyframes[keyframes.length - 1]; element.style.transition = `all ${options.duration}ms ${options.easing}`; // Apply final styles Object.assign(element.style, finalFrame); // Return promise-like object return { id, finished: new Promise(resolve => setTimeout(resolve, options.duration)), play: () => {}, pause: () => {}, cancel: () => { element.style.transition = ''; } }; } generateId(prefix = 'anim') { return `${prefix}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; } createThresholdArray(steps) { const thresholds = []; for (let i = 0; i <= steps; i++) { thresholds.push(i / steps); } return thresholds; } /** * Get all active animations */ getActiveAnimations() { return Array.from(this.activeAnimations.entries()).map(([id, animation]) => ({ id, playState: animation.playState, currentTime: animation.currentTime, metadata: animation.metadata })); } /** * Cancel all active animations */ cancelAll() { this.activeAnimations.forEach(animation => { animation.cancel(); }); this.activeAnimations.clear(); Logger.info('[AnimationManager] All animations cancelled'); } /** * Pause all active animations */ pauseAll() { this.activeAnimations.forEach(animation => { animation.pause(); }); Logger.info('[AnimationManager] All animations paused'); } /** * Resume all paused animations */ resumeAll() { this.activeAnimations.forEach(animation => { if (animation.playState === 'paused') { animation.play(); } }); Logger.info('[AnimationManager] All animations resumed'); } }