/** * Enhanced Swipe Card Component with Physics-Based Animations * Integrates SwipePhysics engine for realistic swipe interactions */ import { SwipePhysics, SwipeVisualEffects } from '../js/swipe-physics.js'; export default class EnhancedSwipeCard { constructor(options = {}) { this.container = options.container || document.querySelector('.swipe-container'); this.onSwipe = options.onSwipe || (() => {}); this.threshold = options.threshold || 100; this.velocityThreshold = options.velocityThreshold || 2; // Physics engine configuration this.physics = new SwipePhysics({ springTension: options.springTension || 280, springFriction: options.springFriction || 25, mass: options.mass || 1.2, velocityThreshold: this.velocityThreshold, momentumDecay: options.momentumDecay || 0.92, maxVelocity: options.maxVelocity || 40, dampingRatio: options.dampingRatio || 0.75 }); // State management this.state = { isDragging: false, startX: 0, startY: 0, hasMoved: false, touchStartTime: 0, currentImageInfo: null, isAnimating: false }; // DOM elements this.card = null; this.actionHints = null; this.decisionIndicators = {}; this.visualEffects = null; // Performance optimization this.rafId = null; this.lastUpdateTime = 0; this.updateThrottle = 16; // ~60fps this.init(); } init() { this.setupCard(); this.setupActionHints(); this.setupDecisionIndicators(); this.setupVisualEffects(); this.addEventListeners(); this.optimizePerformance(); console.log('Enhanced SwipeCard initialized with physics engine'); } setupCard() { this.card = this.container.querySelector('.image-card') || this.createCardElement(); // Optimize for animations this.card.style.willChange = 'transform, filter, opacity'; this.card.style.backfaceVisibility = 'hidden'; this.card.style.transformStyle = 'preserve-3d'; } createCardElement() { const card = document.createElement('div'); card.className = 'image-card enhanced-card'; card.id = 'current-card'; card.setAttribute('role', 'img'); card.setAttribute('aria-label', 'Image to be swiped'); const img = document.createElement('img'); img.src = 'data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%22400%22%20height%3D%22400%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Crect%20width%3D%22400%22%20height%3D%22400%22%20fill%3D%22%23e0e0e0%22%2F%3E%3Ctext%20x%3D%22200%22%20y%3D%22200%22%20font-size%3D%2220%22%20text-anchor%3D%22middle%22%20alignment-baseline%3D%22middle%22%20fill%3D%22%23999%22%3ELoading...%3C%2Ftext%3E%3C%2Fsvg%3E'; img.alt = 'Image'; const loadingIndicator = document.createElement('div'); loadingIndicator.className = 'loading-indicator enhanced-loading'; loadingIndicator.innerHTML = `
Loading...
`; card.appendChild(img); card.appendChild(loadingIndicator); this.container.appendChild(card); return card; } setupActionHints() { this.actionHints = { left: this.container.querySelector('.left-hint') || this.createActionHint('left', 'Discard'), right: this.container.querySelector('.right-hint') || this.createActionHint('right', 'Keep'), up: this.container.querySelector('.up-hint') || this.createActionHint('up', 'Favorite'), down: this.container.querySelector('.down-hint') || this.createActionHint('down', 'Review') }; } createActionHint(direction, text) { const hint = document.createElement('div'); hint.className = `action-hint ${direction}-hint enhanced-hint`; hint.innerHTML = `
${text} `; this.container.appendChild(hint); return hint; } setupDecisionIndicators() { // Decision indicators removed - no longer creating visual icons this.decisionIndicators = { left: null, right: null, up: null, down: null }; } setupVisualEffects() { this.visualEffects = new SwipeVisualEffects(this.card, { maxBlur: 6, maxOpacity: 0.25, colorShiftIntensity: 0.3, particleCount: 15 }); } optimizePerformance() { // Enable GPU acceleration for container this.container.style.transform = 'translateZ(0)'; this.container.style.willChange = 'transform'; // Optimize child elements const elements = this.container.querySelectorAll('.action-hint, .swipe-decision'); elements.forEach(el => { el.style.willChange = 'transform, opacity'; el.style.backfaceVisibility = 'hidden'; }); } addEventListeners() { // Bind methods to preserve context for removal this.boundHandlePointerDown = (e) => this.handlePointerDown(e.clientX, e.clientY, e.timeStamp); this.boundHandlePointerMove = (e) => this.handlePointerMove(e.clientX, e.clientY, e.timeStamp); this.boundHandlePointerUp = (e) => this.handlePointerUp(e.timeStamp); // Mouse events this.card.addEventListener('mousedown', this.boundHandlePointerDown); document.addEventListener('mousemove', this.boundHandlePointerMove); document.addEventListener('mouseup', this.boundHandlePointerUp); // Touch events this.card.addEventListener('touchstart', e => { e.preventDefault(); const touch = e.touches[0]; this.handlePointerDown(touch.clientX, touch.clientY, e.timeStamp); }, { passive: false }); this.card.addEventListener('touchmove', e => { e.preventDefault(); const touch = e.touches[0]; this.handlePointerMove(touch.clientX, touch.clientY, e.timeStamp); }, { passive: false }); this.card.addEventListener('touchend', e => { e.preventDefault(); this.handlePointerUp(e.timeStamp); }, { passive: false }); // Enhanced 3D tilt effect this.setupEnhanced3DTilt(); } setupEnhanced3DTilt() { // 3D tilt effect disabled for cleaner UX - no hover animations on image } handlePointerDown(x, y, timestamp) { if (this.state.isAnimating) return; this.state.isDragging = true; this.state.startX = x; this.state.startY = y; this.state.hasMoved = false; this.state.touchStartTime = timestamp; // Initialize physics this.physics.reset(); this.physics.updatePosition(0, 0, timestamp); // Visual feedback this.card.classList.add('swiping', 'enhanced-swiping'); this.card.style.transition = ''; this.card.style.cursor = 'grabbing'; // Start update loop this.startUpdateLoop(); } handlePointerMove(x, y, timestamp) { if (!this.state.isDragging) return; // Throttle updates for performance if (timestamp - this.lastUpdateTime < this.updateThrottle) return; this.lastUpdateTime = timestamp; const moveX = x - this.state.startX; const moveY = y - this.state.startY; if (Math.abs(moveX) > 10 || Math.abs(moveY) > 10) { this.state.hasMoved = true; } // Update physics this.physics.updatePosition(moveX, moveY, timestamp); // Apply visual effects this.updateVisualFeedback(moveX, moveY); // Update action hints this.updateActionHints(moveX, moveY); } updateVisualFeedback(moveX, moveY) { const displacement = { x: moveX, y: moveY }; const velocity = this.physics.velocity; const direction = this.getSwipeDirection(moveX, moveY); // Apply physics-based visual effects this.visualEffects.updateEffects(displacement, velocity, direction); // Create particle trail based on velocity if (this.physics.getVelocityMagnitude() > 5) { const rect = this.card.getBoundingClientRect(); this.visualEffects.createParticleTrail( rect.left + rect.width / 2 + moveX, rect.top + rect.height / 2 + moveY, direction ); } } updateActionHints(moveX, moveY) { const absX = Math.abs(moveX); const absY = Math.abs(moveY); const threshold = 60; // Hide all hints first Object.values(this.actionHints).forEach(hint => { hint.classList.remove('visible', 'enhanced-visible'); }); // Show appropriate hint with enhanced effects if (absX > threshold || absY > threshold) { let activeHint; if (absX > absY) { activeHint = moveX > 0 ? this.actionHints.right : this.actionHints.left; } else { activeHint = moveY > 0 ? this.actionHints.down : this.actionHints.up; } if (activeHint) { activeHint.classList.add('visible', 'enhanced-visible'); // Add intensity based on distance const intensity = Math.min((Math.max(absX, absY) - threshold) / 100, 1); activeHint.style.transform = `scale(${1 + intensity * 0.2})`; activeHint.style.opacity = 0.8 + intensity * 0.2; } } } handlePointerUp(timestamp) { if (!this.state.isDragging) return; this.state.isDragging = false; this.stopUpdateLoop(); // Remove visual states this.card.classList.remove('swiping', 'enhanced-swiping'); this.card.style.cursor = ''; // Hide action hints Object.values(this.actionHints).forEach(hint => { hint.classList.remove('visible', 'enhanced-visible'); hint.style.transform = ''; hint.style.opacity = ''; }); // Determine action based on physics const displacement = this.physics.position; const velocity = this.physics.velocity; const velocityMagnitude = this.physics.getVelocityMagnitude(); const absX = Math.abs(displacement.x); const absY = Math.abs(displacement.y); // Check if swipe meets threshold or has sufficient momentum const meetsThreshold = absX > this.threshold || absY > this.threshold; const hasMomentum = velocityMagnitude > this.velocityThreshold; if (this.state.hasMoved && (meetsThreshold || hasMomentum)) { // Determine direction let direction; if (absX > absY) { direction = displacement.x > 0 ? 'right' : 'left'; } else { direction = displacement.y > 0 ? 'down' : 'up'; } this.performSwipe(direction); } else { // Spring back to center this.animateSpringBack(); } } performSwipe(direction) { this.state.isAnimating = true; // Show decision indicator this.showEnhancedDecisionIndicator(direction); // Animate momentum-based exit this.physics.animateMomentumExit( direction, (x, y, velocity, progress) => { // Update card position during exit const rotation = this.getExitRotation(direction, progress); const scale = 1 - progress * 0.3; const opacity = 1 - progress; this.card.style.transform = ` translate3d(${x}px, ${y}px, 0) rotate(${rotation}deg) scale3d(${scale}, ${scale}, 1) `; this.card.style.opacity = opacity; }, () => { // Animation complete this.state.isAnimating = false; this.onSwipe(direction); this.resetCard(); } ); } animateSpringBack() { this.state.isAnimating = true; this.physics.animateSpringTo( 0, 0, (x, y, velocity) => { // Update card position during spring-back const velocityMagnitude = Math.sqrt(velocity.x * velocity.x + velocity.y * velocity.y); const wobble = Math.sin(performance.now() * 0.01) * velocityMagnitude * 0.1; this.card.style.transform = ` translate3d(${x}px, ${y}px, 0) rotate(${wobble}deg) scale3d(1, 1, 1) `; }, () => { // Spring-back complete this.state.isAnimating = false; this.visualEffects.resetEffects(); this.card.style.transform = ''; } ); } showEnhancedDecisionIndicator(direction) { // Decision indicators removed - no visual feedback needed // The swipe action will be handled directly without visual indicators console.log(`Swipe direction: ${direction}`); } getExitRotation(direction, progress) { const maxRotation = { left: -45, right: 45, up: 15, down: -15 }; return (maxRotation[direction] || 0) * progress; } getSwipeDirection(x, y) { const absX = Math.abs(x); const absY = Math.abs(y); if (absX > absY) { return x > 0 ? 'right' : 'left'; } else { return y > 0 ? 'down' : 'up'; } } startUpdateLoop() { if (this.rafId) return; const update = () => { if (this.state.isDragging) { this.rafId = requestAnimationFrame(update); } else { this.rafId = null; } }; this.rafId = requestAnimationFrame(update); } stopUpdateLoop() { if (this.rafId) { cancelAnimationFrame(this.rafId); this.rafId = null; } } setImage(imageInfo) { this.state.currentImageInfo = imageInfo; if (!imageInfo) { this.card.innerHTML = '
No more images available.
'; return; } const cardImage = this.card.querySelector('img'); if (!cardImage) return; // Enhanced image loading with fade effect const preloadImg = new Image(); preloadImg.onload = () => { cardImage.style.opacity = 0; cardImage.src = imageInfo.path; // Smooth fade-in with spring animation setTimeout(() => { cardImage.style.transition = 'opacity 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275)'; cardImage.style.opacity = 1; }, 50); }; preloadImg.src = imageInfo.path; } showLoading() { this.card.classList.add('loading', 'enhanced-loading'); } hideLoading() { this.card.classList.remove('loading', 'enhanced-loading'); } resetCard() { this.physics.reset(); this.visualEffects.resetEffects(); this.card.style.transform = ''; this.card.style.opacity = ''; this.card.style.filter = ''; this.state.hasMoved = false; this.state.isAnimating = false; } destroy() { this.stopUpdateLoop(); this.physics.stopAnimation(); // Remove event listeners if (this.boundHandlePointerDown) { this.card.removeEventListener('mousedown', this.boundHandlePointerDown); } if (this.boundHandlePointerMove) { document.removeEventListener('mousemove', this.boundHandlePointerMove); } if (this.boundHandlePointerUp) { document.removeEventListener('mouseup', this.boundHandlePointerUp); } // Clean up this.visualEffects = null; this.physics = null; this.boundHandlePointerDown = null; this.boundHandlePointerMove = null; this.boundHandlePointerUp = null; } }