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

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 = [];
}
}