Files
michaelschiemer/resources/js/modules/api-manager/AnimationManager.js
Michael Schiemer 55a330b223 Enable Discovery debug logging for production troubleshooting
- Add DISCOVERY_LOG_LEVEL=debug
- Add DISCOVERY_SHOW_PROGRESS=true
- Temporary changes for debugging InitializerProcessor fixes on production
2025-08-11 20:13:26 +02:00

555 lines
18 KiB
JavaScript

// 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');
}
}