export class SaturnEffect { private canvas: HTMLCanvasElement; private ctx: CanvasRenderingContext2D; private width = 0; private height = 0; // Particle storage private xyz: Float32Array | null = null; // interleaved x,y,z private types: Uint8Array | null = null; // 0 = planet, 1 = ring private count = 0; // Animation private animationId = 0; private angle = 0; private scaleFactor = 1; // Interaction private isDragging = false; private lastMouseX = 0; private lastMouseTime = 0; private mouseVelocities: number[] = []; // Speed control private readonly baseSpeed = 0.005; private currentSpeed = 0.005; private rotationDirection = 1; private readonly speedDecayRate = 0.992; private readonly minSpeedMultiplier = 1; private readonly maxSpeedMultiplier = 50; private isStopped = false; constructor(canvas: HTMLCanvasElement) { this.canvas = canvas; const ctx = canvas.getContext("2d", { alpha: true, desynchronized: false }); if (!ctx) { throw new Error("Failed to get 2D context for SaturnEffect"); } this.ctx = ctx; // Initialize size & particles this.resize(window.innerWidth, window.innerHeight); this.initParticles(); this.animate = this.animate.bind(this); this.animate(); } // External interaction handlers (accept clientX) handleMouseDown(clientX: number) { this.isDragging = true; this.lastMouseX = clientX; this.lastMouseTime = performance.now(); this.mouseVelocities = []; } handleMouseMove(clientX: number) { if (!this.isDragging) return; const now = performance.now(); const dt = now - this.lastMouseTime; if (dt > 0) { const dx = clientX - this.lastMouseX; const velocity = dx / dt; this.mouseVelocities.push(velocity); if (this.mouseVelocities.length > 5) this.mouseVelocities.shift(); // Rotate directly while dragging for immediate feedback this.angle += dx * 0.002; } this.lastMouseX = clientX; this.lastMouseTime = now; } handleMouseUp() { if (this.isDragging && this.mouseVelocities.length > 0) { this.applyFlingVelocity(); } this.isDragging = false; } handleTouchStart(clientX: number) { this.handleMouseDown(clientX); } handleTouchMove(clientX: number) { this.handleMouseMove(clientX); } handleTouchEnd() { this.handleMouseUp(); } // Resize canvas & scale (call on window resize) resize(width: number, height: number) { const dpr = window.devicePixelRatio || 1; this.width = width; this.height = height; // Update canvas pixel size and CSS size this.canvas.width = Math.max(1, Math.floor(width * dpr)); this.canvas.height = Math.max(1, Math.floor(height * dpr)); this.canvas.style.width = `${width}px`; this.canvas.style.height = `${height}px`; // Reset transform and scale for devicePixelRatio this.ctx.setTransform(1, 0, 0, 1, 0, 0); // reset this.ctx.scale(dpr, dpr); const minDim = Math.min(width, height); this.scaleFactor = Math.max(1, minDim * 0.45); } // Initialize particle arrays with reduced counts to keep performance reasonable private initParticles() { // Tuned particle counts for reasonable performance across platforms const planetCount = 1000; const ringCount = 2500; this.count = planetCount + ringCount; this.xyz = new Float32Array(this.count * 3); this.types = new Uint8Array(this.count); let idx = 0; // Planet points for (let i = 0; i < planetCount; i++, idx++) { const theta = Math.random() * Math.PI * 2; const phi = Math.acos(Math.random() * 2 - 1); const r = 1.0; this.xyz[idx * 3] = r * Math.sin(phi) * Math.cos(theta); this.xyz[idx * 3 + 1] = r * Math.sin(phi) * Math.sin(theta); this.xyz[idx * 3 + 2] = r * Math.cos(phi); this.types[idx] = 0; } // Ring points const ringInner = 1.4; const ringOuter = 2.3; for (let i = 0; i < ringCount; i++, idx++) { const angle = Math.random() * Math.PI * 2; const dist = Math.sqrt( Math.random() * (ringOuter * ringOuter - ringInner * ringInner) + ringInner * ringInner, ); this.xyz[idx * 3] = dist * Math.cos(angle); this.xyz[idx * 3 + 1] = (Math.random() - 0.5) * 0.05; this.xyz[idx * 3 + 2] = dist * Math.sin(angle); this.types[idx] = 1; } } // Map fling/velocity samples to a rotation speed and direction private applyFlingVelocity() { if (this.mouseVelocities.length === 0) return; const avg = this.mouseVelocities.reduce((a, b) => a + b, 0) / this.mouseVelocities.length; const flingThreshold = 0.3; const stopThreshold = 0.1; if (Math.abs(avg) > flingThreshold) { this.isStopped = false; const newDir = avg > 0 ? 1 : -1; if (newDir !== this.rotationDirection) this.rotationDirection = newDir; const multiplier = Math.min( this.maxSpeedMultiplier, this.minSpeedMultiplier + Math.abs(avg) * 10, ); this.currentSpeed = this.baseSpeed * multiplier; } else if (Math.abs(avg) < stopThreshold) { this.isStopped = true; this.currentSpeed = 0; } } // Main render loop private animate() { // Clear with full alpha to allow layering over background this.ctx.clearRect(0, 0, this.width, this.height); // Standard composition this.ctx.globalCompositeOperation = "source-over"; // Update rotation speed (decay) if (!this.isDragging && !this.isStopped) { if (this.currentSpeed > this.baseSpeed) { this.currentSpeed = this.baseSpeed + (this.currentSpeed - this.baseSpeed) * this.speedDecayRate; if (this.currentSpeed - this.baseSpeed < 0.00001) { this.currentSpeed = this.baseSpeed; } } this.angle += this.currentSpeed * this.rotationDirection; } // Center positions const cx = this.width * 0.6; const cy = this.height * 0.5; // Pre-calc rotations const rotationY = this.angle; const rotationX = 0.4; const rotationZ = 0.15; const sinY = Math.sin(rotationY); const cosY = Math.cos(rotationY); const sinX = Math.sin(rotationX); const cosX = Math.cos(rotationX); const sinZ = Math.sin(rotationZ); const cosZ = Math.cos(rotationZ); const fov = 1500; const scaleFactor = this.scaleFactor; if (!this.xyz || !this.types) { this.animationId = requestAnimationFrame(this.animate); return; } // Loop particles for (let i = 0; i < this.count; i++) { const x = this.xyz[i * 3]; const y = this.xyz[i * 3 + 1]; const z = this.xyz[i * 3 + 2]; // Scale to screen const px = x * scaleFactor; const py = y * scaleFactor; const pz = z * scaleFactor; // Rotate Y then X then Z const x1 = px * cosY - pz * sinY; const z1 = pz * cosY + px * sinY; const y2 = py * cosX - z1 * sinX; const z2 = z1 * cosX + py * sinX; const x3 = x1 * cosZ - y2 * sinZ; const y3 = y2 * cosZ + x1 * sinZ; const z3 = z2; const scale = fov / (fov + z3); if (z3 > -fov) { const x2d = cx + x3 * scale; const y2d = cy + y3 * scale; const type = this.types[i]; const sizeBase = type === 0 ? 2.4 : 1.5; const size = sizeBase * scale; let alpha = scale * scale * scale; if (alpha > 1) alpha = 1; if (alpha < 0.15) continue; if (type === 0) { // Planet: warm-ish this.ctx.fillStyle = `rgba(255, 240, 220, ${alpha})`; } else { // Ring: cool-ish this.ctx.fillStyle = `rgba(220, 240, 255, ${alpha})`; } // Render as small rectangles (faster than arc) this.ctx.fillRect(x2d, y2d, size, size); } } this.animationId = requestAnimationFrame(this.animate); } // Stop animations and release resources destroy() { if (this.animationId) { cancelAnimationFrame(this.animationId); this.animationId = 0; } // Intentionally do not null out arrays to allow reuse if desired. } }