427 lines
14 KiB
JavaScript
427 lines
14 KiB
JavaScript
/**
|
|
* 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 = [];
|
|
}
|
|
} |