469 lines
15 KiB
TypeScript
469 lines
15 KiB
TypeScript
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: `
|
|
<canvas #canvas class="fixed inset-0 w-full h-full pointer-events-none"></canvas>
|
|
`,
|
|
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<HTMLCanvasElement>;
|
|
|
|
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);
|
|
}
|
|
}
|
|
|