/**
* @fileoverview Advanced animation sequencing and timeline controls
* @module AnimationSequencer
*/
/**
* Animation keyframe class
*/
export class Keyframe {
constructor(time, properties = {}, easing = 'linear') {
this.time = time;
this.properties = properties;
this.easing = easing;
this.id = Math.random().toString(36).substr(2, 9);
}
/**
* Interpolate between this keyframe and another
* @param {Keyframe} nextKeyframe - Next keyframe
* @param {number} progress - Progress (0-1)
* @returns {Object} Interpolated properties
*/
interpolate(nextKeyframe, progress) {
const easedProgress = this.applyEasing(progress, this.easing);
const result = {};
// Interpolate all properties
Object.keys(this.properties).forEach(key => {
const startValue = this.properties[key];
const endValue = nextKeyframe.properties[key];
if (typeof startValue === 'number' && typeof endValue === 'number') {
result[key] = startValue + (endValue - startValue) * easedProgress;
} else if (Array.isArray(startValue) && Array.isArray(endValue)) {
result[key] = startValue.map((start, index) =>
start + (endValue[index] - start) * easedProgress
);
} else if (typeof startValue === 'string' && startValue.startsWith('#')) {
// Color interpolation
result[key] = this.interpolateColor(startValue, endValue, easedProgress);
} else {
// Default to start value for non-interpolatable types
result[key] = easedProgress < 0.5 ? startValue : endValue;
}
});
return result;
}
/**
* Apply easing function
* @param {number} t - Time parameter (0-1)
* @param {string} easingType - Easing type
* @returns {number} Eased value
*/
applyEasing(t, easingType) {
switch (easingType) {
case 'linear': return t;
case 'easeIn': return t * t;
case 'easeOut': return 1 - (1 - t) * (1 - t);
case 'easeInOut': return t < 0.5 ? 2 * t * t : 1 - 2 * (1 - t) * (1 - t);
case 'easeInCubic': return t * t * t;
case 'easeOutCubic': return 1 - Math.pow(1 - t, 3);
case 'easeInOutCubic': return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
case 'bounce': return this.bounceEasing(t);
case 'elastic': return this.elasticEasing(t);
default: return t;
}
}
/**
* Bounce easing function
* @param {number} t - Time parameter
* @returns {number} Bounced value
*/
bounceEasing(t) {
const n1 = 7.5625;
const d1 = 2.75;
if (t < 1 / d1) {
return n1 * t * t;
} else if (t < 2 / d1) {
return n1 * (t -= 1.5 / d1) * t + 0.75;
} else if (t < 2.5 / d1) {
return n1 * (t -= 2.25 / d1) * t + 0.9375;
} else {
return n1 * (t -= 2.625 / d1) * t + 0.984375;
}
}
/**
* Elastic easing function
* @param {number} t - Time parameter
* @returns {number} Elastic value
*/
elasticEasing(t) {
const c4 = (2 * Math.PI) / 3;
return t === 0 ? 0 : t === 1 ? 1 : -Math.pow(2, 10 * t - 10) * Math.sin((t * 10 - 10.75) * c4);
}
/**
* Interpolate between two colors
* @param {string} color1 - Start color (hex)
* @param {string} color2 - End color (hex)
* @param {number} progress - Progress (0-1)
* @returns {string} Interpolated color
*/
interpolateColor(color1, color2, progress) {
const rgb1 = this.hexToRgb(color1);
const rgb2 = this.hexToRgb(color2);
const r = Math.round(rgb1.r + (rgb2.r - rgb1.r) * progress);
const g = Math.round(rgb1.g + (rgb2.g - rgb1.g) * progress);
const b = Math.round(rgb1.b + (rgb2.b - rgb1.b) * progress);
return this.rgbToHex(r, g, b);
}
/**
* Convert hex to RGB
* @param {string} hex - Hex color
* @returns {Object} RGB object
*/
hexToRgb(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16)
} : { r: 0, g: 0, b: 0 };
}
/**
* Convert RGB to hex
* @param {number} r - Red component
* @param {number} g - Green component
* @param {number} b - Blue component
* @returns {string} Hex color
*/
rgbToHex(r, g, b) {
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
}
}
/**
* Animation track for organizing keyframes
*/
export class AnimationTrack {
constructor(name, target = null) {
this.name = name;
this.target = target;
this.keyframes = [];
this.enabled = true;
this.muted = false;
this.solo = false;
this.volume = 1.0;
}
/**
* Add keyframe to track
* @param {Keyframe} keyframe - Keyframe to add
*/
addKeyframe(keyframe) {
this.keyframes.push(keyframe);
this.keyframes.sort((a, b) => a.time - b.time);
}
/**
* Remove keyframe from track
* @param {string} keyframeId - Keyframe ID to remove
*/
removeKeyframe(keyframeId) {
this.keyframes = this.keyframes.filter(kf => kf.id !== keyframeId);
}
/**
* Get interpolated properties at given time
* @param {number} time - Current time
* @returns {Object} Interpolated properties
*/
getPropertiesAtTime(time) {
if (!this.enabled || this.muted) return {};
if (this.keyframes.length === 0) return {};
// Find surrounding keyframes
let prevKeyframe = null;
let nextKeyframe = null;
for (let i = 0; i < this.keyframes.length; i++) {
const keyframe = this.keyframes[i];
if (keyframe.time <= time) {
prevKeyframe = keyframe;
} else {
nextKeyframe = keyframe;
break;
}
}
// If only one keyframe or time is before first keyframe
if (!prevKeyframe) {
return this.keyframes[0].properties;
}
// If time is after last keyframe
if (!nextKeyframe) {
return prevKeyframe.properties;
}
// Interpolate between keyframes
const duration = nextKeyframe.time - prevKeyframe.time;
const progress = duration > 0 ? (time - prevKeyframe.time) / duration : 0;
const interpolated = prevKeyframe.interpolate(nextKeyframe, progress);
// Apply volume scaling for numeric properties
if (this.volume !== 1.0) {
Object.keys(interpolated).forEach(key => {
if (typeof interpolated[key] === 'number') {
interpolated[key] *= this.volume;
}
});
}
return interpolated;
}
/**
* Get all keyframes in time range
* @param {number} startTime - Start time
* @param {number} endTime - End time
* @returns {Array} Keyframes in range
*/
getKeyframesInRange(startTime, endTime) {
return this.keyframes.filter(kf => kf.time >= startTime && kf.time <= endTime);
}
/**
* Duplicate track
* @returns {AnimationTrack} Duplicated track
*/
duplicate() {
const newTrack = new AnimationTrack(`${this.name} Copy`, this.target);
newTrack.keyframes = this.keyframes.map(kf =>
new Keyframe(kf.time, { ...kf.properties }, kf.easing)
);
newTrack.enabled = this.enabled;
newTrack.volume = this.volume;
return newTrack;
}
}
/**
* Advanced animation sequencer with timeline controls
*/
export class AnimationSequencer {
constructor() {
this.tracks = new Map();
this.currentTime = 0;
this.duration = 10000; // 10 seconds default
this.isPlaying = false;
this.isPaused = false;
this.loop = false;
this.playbackSpeed = 1.0;
this.startTime = null;
this.pauseTime = 0;
this.lastUpdateTime = 0;
this.markers = new Map();
this.regions = new Map();
this.onTimeUpdate = null;
this.onPlayStateChange = null;
this.onTrackUpdate = null;
this.animationFrame = null;
}
/**
* Add animation track
* @param {AnimationTrack} track - Track to add
*/
addTrack(track) {
this.tracks.set(track.name, track);
this.notifyTrackUpdate();
}
/**
* Remove animation track
* @param {string} trackName - Track name to remove
*/
removeTrack(trackName) {
this.tracks.delete(trackName);
this.notifyTrackUpdate();
}
/**
* Get track by name
* @param {string} trackName - Track name
* @returns {AnimationTrack} Track instance
*/
getTrack(trackName) {
return this.tracks.get(trackName);
}
/**
* Create new track with keyframes
* @param {string} name - Track name
* @param {Array} keyframeData - Array of keyframe data
* @param {*} target - Target object
* @returns {AnimationTrack} Created track
*/
createTrack(name, keyframeData = [], target = null) {
const track = new AnimationTrack(name, target);
keyframeData.forEach(data => {
const keyframe = new Keyframe(data.time, data.properties, data.easing);
track.addKeyframe(keyframe);
});
this.addTrack(track);
return track;
}
/**
* Start playback
*/
play() {
if (this.isPlaying) return;
this.isPlaying = true;
this.isPaused = false;
this.startTime = performance.now() - this.currentTime / this.playbackSpeed;
this.update();
this.notifyPlayStateChange();
}
/**
* Pause playback
*/
pause() {
if (!this.isPlaying || this.isPaused) return;
this.isPaused = true;
this.pauseTime = this.currentTime;
if (this.animationFrame) {
cancelAnimationFrame(this.animationFrame);
this.animationFrame = null;
}
this.notifyPlayStateChange();
}
/**
* Stop playback and reset
*/
stop() {
this.isPlaying = false;
this.isPaused = false;
this.currentTime = 0;
this.pauseTime = 0;
if (this.animationFrame) {
cancelAnimationFrame(this.animationFrame);
this.animationFrame = null;
}
this.notifyPlayStateChange();
this.notifyTimeUpdate();
}
/**
* Seek to specific time
* @param {number} time - Time to seek to
*/
seekTo(time) {
this.currentTime = Math.max(0, Math.min(time, this.duration));
if (this.isPlaying && !this.isPaused) {
this.startTime = performance.now() - this.currentTime / this.playbackSpeed;
} else {
this.pauseTime = this.currentTime;
}
this.notifyTimeUpdate();
}
/**
* Set playback speed
* @param {number} speed - Playback speed multiplier
*/
setPlaybackSpeed(speed) {
const wasPlaying = this.isPlaying && !this.isPaused;
this.playbackSpeed = Math.max(0.1, Math.min(4.0, speed));
if (wasPlaying) {
this.startTime = performance.now() - this.currentTime / this.playbackSpeed;
}
}
/**
* Set loop mode
* @param {boolean} enabled - Loop enabled
*/
setLoop(enabled) {
this.loop = enabled;
}
/**
* Set sequence duration
* @param {number} duration - Duration in milliseconds
*/
setDuration(duration) {
this.duration = Math.max(1000, duration);
if (this.currentTime > this.duration) {
this.seekTo(this.duration);
}
}
/**
* Update animation frame
*/
update() {
if (!this.isPlaying || this.isPaused) return;
const now = performance.now();
this.currentTime = (now - this.startTime) * this.playbackSpeed;
// Check for end of sequence
if (this.currentTime >= this.duration) {
if (this.loop) {
this.seekTo(0);
} else {
this.stop();
return;
}
}
this.notifyTimeUpdate();
// Schedule next frame
this.animationFrame = requestAnimationFrame(() => this.update());
}
/**
* Get current state of all tracks
* @returns {Object} Current animation state
*/
getCurrentState() {
const state = {};
this.tracks.forEach((track, name) => {
if (track.enabled && !track.muted) {
state[name] = track.getPropertiesAtTime(this.currentTime);
}
});
return state;
}
/**
* Add time marker
* @param {string} name - Marker name
* @param {number} time - Marker time
* @param {string} color - Marker color
*/
addMarker(name, time, color = '#ff0000') {
this.markers.set(name, { time, color });
}
/**
* Remove time marker
* @param {string} name - Marker name
*/
removeMarker(name) {
this.markers.delete(name);
}
/**
* Add time region
* @param {string} name - Region name
* @param {number} startTime - Start time
* @param {number} endTime - End time
* @param {string} color - Region color
*/
addRegion(name, startTime, endTime, color = '#0080ff40') {
this.regions.set(name, { startTime, endTime, color });
}
/**
* Remove time region
* @param {string} name - Region name
*/
removeRegion(name) {
this.regions.delete(name);
}
/**
* Export sequence data
* @returns {Object} Sequence data
*/
export() {
const tracksData = {};
this.tracks.forEach((track, name) => {
tracksData[name] = {
enabled: track.enabled,
muted: track.muted,
volume: track.volume,
keyframes: track.keyframes.map(kf => ({
time: kf.time,
properties: kf.properties,
easing: kf.easing
}))
};
});
return {
version: '2.0',
duration: this.duration,
tracks: tracksData,
markers: Object.fromEntries(this.markers),
regions: Object.fromEntries(this.regions)
};
}
/**
* Import sequence data
* @param {Object} data - Sequence data
*/
import(data) {
this.stop();
this.tracks.clear();
this.markers.clear();
this.regions.clear();
this.duration = data.duration || 10000;
// Import tracks
Object.entries(data.tracks || {}).forEach(([name, trackData]) => {
const track = new AnimationTrack(name);
track.enabled = trackData.enabled !== false;
track.muted = trackData.muted || false;
track.volume = trackData.volume || 1.0;
trackData.keyframes.forEach(kfData => {
const keyframe = new Keyframe(kfData.time, kfData.properties, kfData.easing);
track.addKeyframe(keyframe);
});
this.addTrack(track);
});
// Import markers
Object.entries(data.markers || {}).forEach(([name, marker]) => {
this.markers.set(name, marker);
});
// Import regions
Object.entries(data.regions || {}).forEach(([name, region]) => {
this.regions.set(name, region);
});
}
/**
* Generate animation preview
* @param {number} samples - Number of time samples
* @returns {Array} Preview data
*/
generatePreview(samples = 100) {
const preview = [];
const timeStep = this.duration / samples;
for (let i = 0; i <= samples; i++) {
const time = i * timeStep;
const state = {};
this.tracks.forEach((track, name) => {
state[name] = track.getPropertiesAtTime(time);
});
preview.push({ time, state });
}
return preview;
}
/**
* Notify time update listeners
*/
notifyTimeUpdate() {
if (this.onTimeUpdate) {
this.onTimeUpdate(this.currentTime, this.duration);
}
}
/**
* Notify play state change listeners
*/
notifyPlayStateChange() {
if (this.onPlayStateChange) {
this.onPlayStateChange(this.isPlaying, this.isPaused);
}
}
/**
* Notify track update listeners
*/
notifyTrackUpdate() {
if (this.onTrackUpdate) {
this.onTrackUpdate(Array.from(this.tracks.values()));
}
}
/**
* Batch operations for performance
* @param {Function} operations - Operations to batch
*/
batch(operations) {
const wasNotifying = this.onTrackUpdate !== null;
if (wasNotifying) {
const originalCallback = this.onTrackUpdate;
this.onTrackUpdate = null;
operations();
this.onTrackUpdate = originalCallback;
this.notifyTrackUpdate();
} else {
operations();
}
}
}
/**
* Preset animation templates
*/
export class AnimationPresets {
/**
* Create fade in/out animation
* @param {number} duration - Animation duration
* @returns {Object} Track data
*/
static fadeInOut(duration = 5000) {
return {
opacity: [
{ time: 0, properties: { opacity: 0 }, easing: 'easeIn' },
{ time: duration * 0.3, properties: { opacity: 1 }, easing: 'easeOut' },
{ time: duration * 0.7, properties: { opacity: 1 }, easing: 'easeIn' },
{ time: duration, properties: { opacity: 0 }, easing: 'easeOut' }
]
};
}
/**
* Create bounce animation
* @param {number} duration - Animation duration
* @returns {Object} Track data
*/
static bounce(duration = 3000) {
return {
transform: [
{ time: 0, properties: { y: 0, scale: 1 }, easing: 'easeOut' },
{ time: duration * 0.25, properties: { y: -50, scale: 1.1 }, easing: 'bounce' },
{ time: duration * 0.5, properties: { y: 0, scale: 1 }, easing: 'easeOut' },
{ time: duration * 0.75, properties: { y: -25, scale: 1.05 }, easing: 'bounce' },
{ time: duration, properties: { y: 0, scale: 1 }, easing: 'easeOut' }
]
};
}
/**
* Create color cycle animation
* @param {Array} colors - Array of colors to cycle through
* @param {number} duration - Animation duration
* @returns {Object} Track data
*/
static colorCycle(colors = ['#ff0000', '#00ff00', '#0000ff'], duration = 6000) {
const keyframes = [];
const timeStep = duration / colors.length;
colors.forEach((color, index) => {
keyframes.push({
time: index * timeStep,
properties: { color },
easing: 'easeInOut'
});
});
// Return to first color
keyframes.push({
time: duration,
properties: { color: colors[0] },
easing: 'easeInOut'
});
return { color: keyframes };
}
/**
* Create pulsing animation
* @param {number} duration - Animation duration
* @param {number} pulseCount - Number of pulses
* @returns {Object} Track data
*/
static pulse(duration = 4000, pulseCount = 2) {
const keyframes = [];
const pulseInterval = duration / pulseCount;
for (let i = 0; i < pulseCount; i++) {
const startTime = i * pulseInterval;
const midTime = startTime + pulseInterval * 0.5;
const endTime = startTime + pulseInterval;
keyframes.push(
{ time: startTime, properties: { scale: 1, opacity: 0.8 }, easing: 'easeOut' },
{ time: midTime, properties: { scale: 1.3, opacity: 1 }, easing: 'easeIn' },
{ time: endTime, properties: { scale: 1, opacity: 0.8 }, easing: 'easeOut' }
);
}
return { pulse: keyframes };
}
/**
* Create spiral animation
* @param {number} duration - Animation duration
* @param {number} radius - Spiral radius
* @param {number} turns - Number of turns
* @returns {Object} Track data
*/
static spiral(duration = 8000, radius = 100, turns = 3) {
const keyframes = [];
const samples = 50;
const timeStep = duration / samples;
for (let i = 0; i <= samples; i++) {
const progress = i / samples;
const angle = progress * turns * Math.PI * 2;
const currentRadius = radius * (1 - progress * 0.8);
const x = Math.cos(angle) * currentRadius;
const y = Math.sin(angle) * currentRadius;
keyframes.push({
time: i * timeStep,
properties: { x, y, rotation: angle * 180 / Math.PI },
easing: 'linear'
});
}
return { spiral: keyframes };
}
}
// Export singleton instance
export const animationSequencer = new AnimationSequencer();
Source