Files
swiper/components/enhanced-swipe-card.js
Aodhan Collins 5e893a0c9d Bug fixes
2025-07-31 01:38:07 +01:00

497 lines
17 KiB
JavaScript

/**
* 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 = `
<div class="loading-spinner physics-spinner"></div>
<div class="loading-text">Loading...</div>
`;
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 = `
<div class="hint-icon"></div>
<span class="hint-text">${text}</span>
`;
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 = '<div class="no-images-message enhanced-message">No more images available.</div>';
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;
}
}