Docker, API proxy, Drone CI, .env

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Rafał Miczek
2026-02-13 19:58:25 +01:00
co-authored by Cursor
commit d5d4883917
76 changed files with 15170 additions and 0 deletions
@@ -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);
}
}