import { Component, OnInit, OnDestroy, ElementRef, ViewChild, AfterViewInit, } from '@angular/core'; import { CommonModule } from '@angular/common'; import * as THREE from 'three'; @Component({ selector: 'app-particle-background', standalone: true, imports: [CommonModule], template: ` `, styles: [ ` canvas { display: block; z-index: 0; position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; } `, ], }) export class ParticleBackgroundComponent implements OnInit, AfterViewInit, OnDestroy { @ViewChild('canvas', { static: false }) canvasRef!: ElementRef; private scene!: THREE.Scene; private camera!: THREE.PerspectiveCamera; private renderer!: THREE.WebGLRenderer; private particles!: THREE.Points; private animationId: number | null = null; private particleCount = 500; private mouseX = 0; private mouseY = 0; private mouseWorldX = 0; private mouseWorldY = 0; private particleVelocities: Float32Array | null = null; private mouseDownTime = 0; private isMouseDown = false; private clickWorldX = 0; private clickWorldY = 0; private repulsionWaves: Array<{ x: number; y: number; radius: number; maxRadius: number; opacity: number; time: number; }> = []; private waveObjects: THREE.Mesh[] = []; ngOnInit(): void {} ngAfterViewInit(): void { this.initThree(); this.createParticles(); this.animate(); } ngOnDestroy(): void { if (this.animationId !== null) { cancelAnimationFrame(this.animationId); } // Clean up wave objects this.waveObjects.forEach((wave) => { this.scene.remove(wave); wave.geometry.dispose(); (wave.material as THREE.Material).dispose(); }); this.waveObjects = []; if (this.renderer) { this.renderer.dispose(); } } private initThree(): void { const canvas = this.canvasRef.nativeElement; const width = window.innerWidth; const height = window.innerHeight; // Scene this.scene = new THREE.Scene(); // Camera this.camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000); this.camera.position.z = 5; // Renderer this.renderer = new THREE.WebGLRenderer({ canvas: canvas, alpha: true, antialias: true, }); this.renderer.setSize(width, height); this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); window.addEventListener('resize', () => this.onWindowResize()); this.addMouseInteraction(); } private screenToWorld(x: number, y: number): { x: number; y: number } { // Convert screen coordinates to normalized device coordinates const mouseX = (x / window.innerWidth) * 2 - 1; const mouseY = -(y / window.innerHeight) * 2 + 1; // Create a vector in normalized device coordinates const vector = new THREE.Vector3(mouseX, mouseY, 0.5); // Unproject to world coordinates vector.unproject(this.camera); // Calculate direction from camera const dir = vector.sub(this.camera.position).normalize(); // Find intersection with z=0 plane (where particles are) const distance = -this.camera.position.z / dir.z; const pos = this.camera.position.clone().add(dir.multiplyScalar(distance)); return { x: pos.x, y: pos.y }; } private addMouseInteraction(): void { let lastMouseEvent: MouseEvent | null = null; window.addEventListener('mousemove', (event) => { lastMouseEvent = event; this.mouseX = (event.clientX / window.innerWidth) * 2 - 1; this.mouseY = -(event.clientY / window.innerHeight) * 2 + 1; const worldPos = this.screenToWorld(event.clientX, event.clientY); this.mouseWorldX = worldPos.x; this.mouseWorldY = worldPos.y; }); window.addEventListener('mousedown', (event) => { this.isMouseDown = true; this.mouseDownTime = Date.now(); }); window.addEventListener('mouseup', (event) => { if (this.isMouseDown) { // Calculate world position from event coordinates directly const worldPos = this.screenToWorld(event.clientX, event.clientY); this.clickWorldX = worldPos.x; this.clickWorldY = worldPos.y; const holdDuration = Date.now() - this.mouseDownTime; this.applyRepulsion(holdDuration); this.isMouseDown = false; } }); // Handle mouse leaving window window.addEventListener('mouseleave', () => { if (this.isMouseDown && lastMouseEvent) { // Use last known mouse position with proper conversion const worldPos = this.screenToWorld(lastMouseEvent.clientX, lastMouseEvent.clientY); this.clickWorldX = worldPos.x; this.clickWorldY = worldPos.y; const holdDuration = Date.now() - this.mouseDownTime; this.applyRepulsion(holdDuration); this.isMouseDown = false; } }); } private applyRepulsion(holdDuration: number): void { if (!this.particleVelocities) return; const positions = this.particles.geometry.attributes['position'].array as Float32Array; const velocities = this.particleVelocities; // Calculate power based on hold duration with limits const minHoldTime = 50; // Minimum 50ms to register const maxHoldTime = 1000; // Maximum 1 second for full power const clampedHold = Math.max(minHoldTime, Math.min(holdDuration, maxHoldTime)); const normalizedHold = (clampedHold - minHoldTime) / (maxHoldTime - minHoldTime); // Strength limits const baseStrength = 0.15; const maxStrength = 0.8; const repulsionStrength = Math.min( baseStrength + (maxStrength - baseStrength) * normalizedHold, maxStrength ); // Radius limits const minRadius = 2.5; const maxRadius = 5; const repulsionRadius = Math.min( minRadius + (maxRadius - minRadius) * normalizedHold, maxRadius ); // Add visual repulsion wave - scale based on hold duration const waveOpacity = 0.3 + normalizedHold * 0.3; // 0.3 to 0.6 based on hold const waveMaxRadius = repulsionRadius * (1.2 + normalizedHold * 0.5); // 1.2x to 1.7x based on hold this.repulsionWaves.push({ x: this.clickWorldX, y: this.clickWorldY, radius: 0, maxRadius: waveMaxRadius, opacity: waveOpacity, time: Date.now(), }); for (let i = 0; i < positions.length; i += 3) { const x = positions[i]; const y = positions[i + 1]; const z = positions[i + 2]; // Calculate distance from click point const dx = x - this.clickWorldX; const dy = y - this.clickWorldY; const distance = Math.sqrt(dx * dx + dy * dy); // Apply repulsion if within radius if (distance < repulsionRadius && distance > 0.1) { const normalizedDx = dx / distance; const normalizedDy = dy / distance; const force = (1 - distance / repulsionRadius) * repulsionStrength; // Add force to velocity (physics-based) velocities[i] += normalizedDx * force; velocities[i + 1] += normalizedDy * force; velocities[i + 2] += (Math.random() - 0.5) * force * 0.4; // Z-axis movement } } } private createParticles(): void { const geometry = new THREE.BufferGeometry(); const positions = new Float32Array(this.particleCount * 3); const colors = new Float32Array(this.particleCount * 3); const color1 = new THREE.Color(0x3b82f6); // Blue const color2 = new THREE.Color(0x8b5cf6); // Purple const color3 = new THREE.Color(0xec4899); // Pink for (let i = 0; i < this.particleCount; i++) { const i3 = i * 3; // Position positions[i3] = (Math.random() - 0.5) * 20; positions[i3 + 1] = (Math.random() - 0.5) * 20; positions[i3 + 2] = (Math.random() - 0.5) * 20; // Color const colorChoice = Math.random(); let color: THREE.Color; if (colorChoice < 0.33) { color = color1; } else if (colorChoice < 0.66) { color = color2; } else { color = color3; } colors[i3] = color.r; colors[i3 + 1] = color.g; colors[i3 + 2] = color.b; } geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3)); // Initialize velocities for physics this.particleVelocities = new Float32Array(this.particleCount * 3); for (let i = 0; i < this.particleVelocities.length; i++) { this.particleVelocities[i] = 0; } const material = new THREE.PointsMaterial({ size: 0.08, vertexColors: true, transparent: true, opacity: 0.3, blending: THREE.AdditiveBlending, }); this.particles = new THREE.Points(geometry, material); this.scene.add(this.particles); } private animate = (): void => { this.animationId = requestAnimationFrame(this.animate); if (this.particles) { // Much slower rotation this.particles.rotation.x += 0.0001; this.particles.rotation.y += 0.0002; // Mouse interaction - subtle rotation this.particles.rotation.x += this.mouseY * 0.0001; this.particles.rotation.y += this.mouseX * 0.00015; // Physics-based movement with ease-out const positions = this.particles.geometry.attributes['position'].array as Float32Array; const velocities = this.particleVelocities!; const damping = 0.92; // Ease-out damping (lower = faster decay) const maxVelocity = 0.5; // Increased for stronger repulsion effects // Continuous mouse repulsion (weaker, always active) const mouseRepulsionRadius = 2; const mouseRepulsionStrength = 0.0003; // Much weaker than click repulsion for (let i = 0; i < positions.length; i += 3) { const x = positions[i]; const y = positions[i + 1]; const z = positions[i + 2]; // Calculate distance from mouse const dx = x - this.mouseWorldX; const dy = y - this.mouseWorldY; const distance = Math.sqrt(dx * dx + dy * dy); // Apply continuous mouse repulsion if within radius if (distance < mouseRepulsionRadius && distance > 0.1) { const normalizedDx = dx / distance; const normalizedDy = dy / distance; const force = (1 - distance / mouseRepulsionRadius) * mouseRepulsionStrength; // Add weak force to velocity velocities[i] += normalizedDx * force; velocities[i + 1] += normalizedDy * force; velocities[i + 2] += (Math.random() - 0.5) * force * 0.2; } // Apply damping (ease-out effect) velocities[i] *= damping; velocities[i + 1] *= damping; velocities[i + 2] *= damping; // Limit max velocity const vx = Math.max(-maxVelocity, Math.min(maxVelocity, velocities[i])); const vy = Math.max(-maxVelocity, Math.min(maxVelocity, velocities[i + 1])); const vz = Math.max(-maxVelocity, Math.min(maxVelocity, velocities[i + 2])); velocities[i] = vx; velocities[i + 1] = vy; velocities[i + 2] = vz; // Update position based on velocity positions[i] += velocities[i]; positions[i + 1] += velocities[i + 1]; positions[i + 2] += velocities[i + 2]; // Slower vertical drift positions[i + 1] += 0.001; // Wrap around boundaries if (positions[i] > 10) positions[i] = -10; if (positions[i] < -10) positions[i] = 10; if (positions[i + 1] > 10) positions[i + 1] = -10; if (positions[i + 1] < -10) positions[i + 1] = 10; if (positions[i + 2] > 10) positions[i + 2] = -10; if (positions[i + 2] < -10) positions[i + 2] = 10; } this.particles.geometry.attributes['position'].needsUpdate = true; } // Update and render repulsion waves this.updateRepulsionWaves(); // Camera follows mouse slightly this.camera.position.x += (this.mouseX * 0.3 - this.camera.position.x) * 0.03; this.camera.position.y += (this.mouseY * 0.3 - this.camera.position.y) * 0.03; this.camera.lookAt(0, 0, 0); this.renderer.render(this.scene, this.camera); }; private updateRepulsionWaves(): void { const now = Date.now(); const waveDuration = 1500; // Longer, softer animation duration // Update existing waves for (let i = this.repulsionWaves.length - 1; i >= 0; i--) { const wave = this.repulsionWaves[i]; const elapsed = now - wave.time; const progress = Math.min(elapsed / waveDuration, 1); if (progress >= 1) { // Remove expired waves if (this.waveObjects[i]) { this.scene.remove(this.waveObjects[i]); this.waveObjects[i].geometry.dispose(); (this.waveObjects[i].material as THREE.Material).dispose(); } this.waveObjects.splice(i, 1); this.repulsionWaves.splice(i, 1); continue; } // Smooth easing function for softer expansion const easeOut = 1 - Math.pow(1 - progress, 3); // Expand radius with easing wave.radius = wave.maxRadius * easeOut; // Softer fade out - start from initial opacity const initialOpacity = wave.opacity; wave.opacity = initialOpacity * (1 - progress * progress); // Quadratic fade for softer effect // Create or update wave object if (!this.waveObjects[i]) { this.createWaveObject(wave, i); } else { this.updateWaveObject(wave, i); } } } private createWaveObject(wave: { x: number; y: number; radius: number; maxRadius: number; opacity: number }, index: number): void { const geometry = new THREE.RingGeometry(0, 0.05, 32); const material = new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: wave.opacity * 0.5, // Softer initial opacity side: THREE.DoubleSide, blending: THREE.AdditiveBlending, }); const waveMesh = new THREE.Mesh(geometry, material); waveMesh.position.set(wave.x, wave.y, 0); this.scene.add(waveMesh); this.waveObjects[index] = waveMesh; } private updateWaveObject(wave: { x: number; y: number; radius: number; maxRadius: number; opacity: number }, index: number): void { const waveMesh = this.waveObjects[index]; if (!waveMesh) return; const material = waveMesh.material as THREE.MeshBasicMaterial; const progress = wave.radius / wave.maxRadius; // Update geometry for expanding ring - thinner ring for softer look waveMesh.geometry.dispose(); const innerRadius = Math.max(0, wave.radius * 0.85); // Thinner ring (85% instead of 70%) waveMesh.geometry = new THREE.RingGeometry(innerRadius, wave.radius, 32); // Softer opacity material.opacity = wave.opacity * 0.6; // Additional softening multiplier // Softer color variation (subtle blue to purple gradient) const hue = (progress * 40 + 240) % 360; // Slower color transition material.color.setHSL(hue / 360, 0.5, 0.8); // Lower saturation, higher lightness for softer look } private onWindowResize(): void { const width = window.innerWidth; const height = window.innerHeight; this.camera.aspect = width / height; this.camera.updateProjectionMatrix(); this.renderer.setSize(width, height); } }