497 lines
17 KiB
JavaScript
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;
|
|
}
|
|
} |