Docker, API proxy, Drone CI, .env
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,468 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user