Bug fixes
This commit is contained in:
497
components/enhanced-swipe-card.js
Normal file
497
components/enhanced-swipe-card.js
Normal file
@@ -0,0 +1,497 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user