/** * SwipePhysics - Advanced physics engine for realistic swipe animations * Provides spring-based animations, velocity tracking, and momentum calculations */ export class SwipePhysics { constructor(options = {}) { // Spring physics constants this.springTension = options.springTension || 300; this.springFriction = options.springFriction || 30; this.mass = options.mass || 1; // Velocity and momentum settings this.velocityThreshold = options.velocityThreshold || 0.5; this.momentumDecay = options.momentumDecay || 0.95; this.maxVelocity = options.maxVelocity || 50; // Animation settings this.dampingRatio = options.dampingRatio || 0.8; this.precision = options.precision || 0.01; this.maxDuration = options.maxDuration || 2000; // State tracking this.isAnimating = false; this.animationId = null; this.startTime = 0; // Velocity tracking this.velocityHistory = []; this.maxHistoryLength = 5; this.lastPosition = { x: 0, y: 0 }; this.lastTimestamp = 0; // Current physics state this.position = { x: 0, y: 0 }; this.velocity = { x: 0, y: 0 }; this.acceleration = { x: 0, y: 0 }; // Bind methods - remove this line as animate method doesn't exist // this.animate = this.animate.bind(this); } /** * Update position and calculate velocity */ updatePosition(x, y, timestamp = performance.now()) { if (this.lastTimestamp > 0) { const deltaTime = Math.max(timestamp - this.lastTimestamp, 1); const deltaX = x - this.lastPosition.x; const deltaY = y - this.lastPosition.y; // Calculate instantaneous velocity const velocityX = (deltaX / deltaTime) * 1000; // pixels per second const velocityY = (deltaY / deltaTime) * 1000; // Add to velocity history for smoothing this.velocityHistory.push({ x: velocityX, y: velocityY, timestamp }); // Keep history within bounds if (this.velocityHistory.length > this.maxHistoryLength) { this.velocityHistory.shift(); } // Calculate smoothed velocity this.velocity = this.getSmoothedVelocity(); // Clamp velocity to maximum this.velocity.x = Math.max(-this.maxVelocity, Math.min(this.maxVelocity, this.velocity.x)); this.velocity.y = Math.max(-this.maxVelocity, Math.min(this.maxVelocity, this.velocity.y)); } this.position.x = x; this.position.y = y; this.lastPosition.x = x; this.lastPosition.y = y; this.lastTimestamp = timestamp; } /** * Get smoothed velocity from history */ getSmoothedVelocity() { if (this.velocityHistory.length === 0) { return { x: 0, y: 0 }; } // Weight recent velocities more heavily let totalWeight = 0; let weightedVelocityX = 0; let weightedVelocityY = 0; this.velocityHistory.forEach((entry, index) => { const weight = (index + 1) / this.velocityHistory.length; totalWeight += weight; weightedVelocityX += entry.x * weight; weightedVelocityY += entry.y * weight; }); return { x: weightedVelocityX / totalWeight, y: weightedVelocityY / totalWeight }; } /** * Calculate spring force based on displacement and velocity */ calculateSpringForce(displacement, velocity) { // Hooke's law with damping: F = -kx - cv return -this.springTension * displacement - this.springFriction * velocity; } /** * Get current velocity magnitude */ getVelocityMagnitude() { return Math.sqrt(this.velocity.x * this.velocity.x + this.velocity.y * this.velocity.y); } /** * Check if swipe has enough momentum to trigger action */ hasSwipeMomentum(threshold = this.velocityThreshold) { return this.getVelocityMagnitude() > threshold; } /** * Animate spring-back to target position */ animateSpringTo(targetX, targetY, onUpdate, onComplete) { if (this.isAnimating) { cancelAnimationFrame(this.animationId); } this.isAnimating = true; this.startTime = performance.now(); const startX = this.position.x; const startY = this.position.y; const startVelX = this.velocity.x; const startVelY = this.velocity.y; const animate = (currentTime) => { const elapsed = currentTime - this.startTime; const t = elapsed / 1000; // Convert to seconds // Calculate spring physics const displacementX = this.position.x - targetX; const displacementY = this.position.y - targetY; // Apply spring forces const forceX = this.calculateSpringForce(displacementX, this.velocity.x); const forceY = this.calculateSpringForce(displacementY, this.velocity.y); // Update acceleration (F = ma, so a = F/m) this.acceleration.x = forceX / this.mass; this.acceleration.y = forceY / this.mass; // Update velocity with acceleration this.velocity.x += this.acceleration.x * (1/60); // Assume 60fps this.velocity.y += this.acceleration.y * (1/60); // Update position with velocity this.position.x += this.velocity.x * (1/60); this.position.y += this.velocity.y * (1/60); // Check if animation should stop const velocityMagnitude = this.getVelocityMagnitude(); const displacementMagnitude = Math.sqrt(displacementX * displacementX + displacementY * displacementY); const shouldStop = ( velocityMagnitude < this.precision && displacementMagnitude < this.precision ) || elapsed > this.maxDuration; if (shouldStop) { // Snap to target this.position.x = targetX; this.position.y = targetY; this.velocity.x = 0; this.velocity.y = 0; this.isAnimating = false; if (onUpdate) onUpdate(this.position.x, this.position.y, this.velocity); if (onComplete) onComplete(); } else { if (onUpdate) onUpdate(this.position.x, this.position.y, this.velocity); this.animationId = requestAnimationFrame(animate); } }; this.animationId = requestAnimationFrame(animate); } /** * Animate momentum-based exit */ animateMomentumExit(direction, onUpdate, onComplete) { if (this.isAnimating) { cancelAnimationFrame(this.animationId); } this.isAnimating = true; this.startTime = performance.now(); // Calculate exit target based on direction and momentum const exitDistance = 1000; // pixels const velocityMagnitude = this.getVelocityMagnitude(); const momentumMultiplier = Math.max(1, velocityMagnitude / 10); let targetX = this.position.x; let targetY = this.position.y; switch (direction) { case 'left': targetX = -exitDistance * momentumMultiplier; break; case 'right': targetX = exitDistance * momentumMultiplier; break; case 'up': targetY = -exitDistance * momentumMultiplier; break; case 'down': targetY = exitDistance * momentumMultiplier; break; } const animate = (currentTime) => { const elapsed = currentTime - this.startTime; const progress = Math.min(elapsed / 500, 1); // 500ms exit animation // Use easing function for smooth exit const easeOut = 1 - Math.pow(1 - progress, 3); const currentX = this.position.x + (targetX - this.position.x) * easeOut; const currentY = this.position.y + (targetY - this.position.y) * easeOut; // Apply momentum decay this.velocity.x *= this.momentumDecay; this.velocity.y *= this.momentumDecay; if (progress >= 1) { this.isAnimating = false; if (onComplete) onComplete(); } else { if (onUpdate) onUpdate(currentX, currentY, this.velocity, progress); this.animationId = requestAnimationFrame(animate); } }; this.animationId = requestAnimationFrame(animate); } /** * Stop current animation */ stopAnimation() { if (this.animationId) { cancelAnimationFrame(this.animationId); this.animationId = null; } this.isAnimating = false; } /** * Reset physics state */ reset() { this.stopAnimation(); this.position = { x: 0, y: 0 }; this.velocity = { x: 0, y: 0 }; this.acceleration = { x: 0, y: 0 }; this.velocityHistory = []; this.lastPosition = { x: 0, y: 0 }; this.lastTimestamp = 0; } /** * Get physics state for debugging */ getState() { return { position: { ...this.position }, velocity: { ...this.velocity }, acceleration: { ...this.acceleration }, velocityMagnitude: this.getVelocityMagnitude(), isAnimating: this.isAnimating }; } } /** * Visual Effects Engine for dynamic feedback during swipes */ export class SwipeVisualEffects { constructor(element, options = {}) { this.element = element; this.options = { maxBlur: options.maxBlur || 8, maxOpacity: options.maxOpacity || 0.3, colorShiftIntensity: options.colorShiftIntensity || 0.2, particleCount: options.particleCount || 20, ...options }; this.particles = []; this.setupEffects(); } setupEffects() { // Ensure element has proper CSS properties for effects this.element.style.willChange = 'transform, filter, opacity'; this.element.style.backfaceVisibility = 'hidden'; this.element.style.perspective = '1000px'; } /** * Update visual effects based on swipe progress */ updateEffects(displacement, velocity, direction) { const distance = Math.sqrt(displacement.x * displacement.x + displacement.y * displacement.y); const maxDistance = 200; // Maximum distance for full effect const intensity = Math.min(distance / maxDistance, 1); const velocityMagnitude = Math.sqrt(velocity.x * velocity.x + velocity.y * velocity.y); // Apply blur effect based on velocity const blur = Math.min(velocityMagnitude * 0.1, this.options.maxBlur); // Apply opacity shift based on distance const opacity = 1 - (intensity * this.options.maxOpacity); // Apply color temperature shift based on direction const hueShift = this.getDirectionHueShift(direction) * intensity; // Apply 3D rotation based on displacement const rotationX = (displacement.y / maxDistance) * 15; // Max 15 degrees const rotationY = (displacement.x / maxDistance) * 15; const rotationZ = (displacement.x / maxDistance) * 10; // Apply scale effect const scale = 1 - (intensity * 0.1); // Slight scale down // Combine all transforms const transform = ` translate3d(${displacement.x}px, ${displacement.y}px, 0) rotateX(${rotationX}deg) rotateY(${rotationY}deg) rotateZ(${rotationZ}deg) scale3d(${scale}, ${scale}, 1) `; const filter = ` blur(${blur}px) hue-rotate(${hueShift}deg) brightness(${1 - intensity * 0.2}) `; // Apply effects this.element.style.transform = transform; this.element.style.filter = filter; this.element.style.opacity = opacity; } /** * Get hue shift based on swipe direction */ getDirectionHueShift(direction) { const shifts = { left: -30, // Red shift for discard right: 120, // Green shift for keep up: 240, // Blue shift for favorite down: 60 // Yellow shift for review }; return shifts[direction] || 0; } /** * Create particle trail effect */ createParticleTrail(x, y, direction) { const particle = { x, y, vx: (Math.random() - 0.5) * 4, vy: (Math.random() - 0.5) * 4, life: 1, decay: 0.02, color: this.getDirectionColor(direction), size: Math.random() * 4 + 2 }; this.particles.push(particle); // Limit particle count if (this.particles.length > this.options.particleCount) { this.particles.shift(); } } /** * Get color for direction */ getDirectionColor(direction) { const colors = { left: '#e74c3c', // Red for discard right: '#2ecc71', // Green for keep up: '#3498db', // Blue for favorite down: '#f39c12' // Orange for review }; return colors[direction] || '#ffffff'; } /** * Reset all visual effects */ resetEffects() { this.element.style.transform = ''; this.element.style.filter = ''; this.element.style.opacity = ''; this.particles = []; } }