188 lines
5.4 KiB
TypeScript
188 lines
5.4 KiB
TypeScript
import {afterNextRender, Component, ElementRef, NgZone, OnDestroy, ViewChild} from '@angular/core';
|
|
import {Dot} from '../../models/dot';
|
|
import {DeviceDetectionService} from '../../service/device-detection-service';
|
|
|
|
@Component({
|
|
selector: 'app-dot-background',
|
|
imports: [],
|
|
templateUrl: './dot-background.html',
|
|
styleUrl: './dot-background.scss',
|
|
})
|
|
export class DotBackground implements OnDestroy {
|
|
@ViewChild('canvas') canvasRef!: ElementRef<HTMLCanvasElement>;
|
|
|
|
private ctx!: CanvasRenderingContext2D;
|
|
private dots: Dot[] = [];
|
|
private mouse = { x: -1000, y: -1000 };
|
|
private animationId = 0;
|
|
private initialized = false;
|
|
|
|
private readonly INIT_DOT_COUNT = 12;
|
|
private readonly MAX_DOT_COUNT = 100;
|
|
private readonly MAX_DOT_COUNT_MOBILE = 40;
|
|
private readonly COLORS = ['#6366f1', '#8b5cf6', '#a855f7', '#3b82f6'];
|
|
private readonly MOUSE_RADIUS = 150;
|
|
|
|
private ballSpawnId = 0;
|
|
private ballSpawnNextColor = 0;
|
|
|
|
constructor(private ngZone: NgZone, private mobileService: DeviceDetectionService) {
|
|
afterNextRender(() => {
|
|
this.init();
|
|
});
|
|
}
|
|
|
|
ngOnDestroy() {
|
|
if (!this.initialized) return;
|
|
|
|
cancelAnimationFrame(this.animationId);
|
|
window.removeEventListener('resize', this.resize);
|
|
window.removeEventListener('mousemove', this.onMouseMove);
|
|
}
|
|
|
|
private init() {
|
|
const canvas = this.canvasRef.nativeElement;
|
|
this.ctx = canvas.getContext('2d')!;
|
|
this.resize();
|
|
this.initDots();
|
|
|
|
window.addEventListener('resize', this.resize);
|
|
window.addEventListener('mousemove', this.onMouseMove);
|
|
window.addEventListener('click', this.onMouseClick);
|
|
|
|
this.initialized = true;
|
|
this.ngZone.runOutsideAngular(() => this.animate());
|
|
}
|
|
|
|
private resize = () => {
|
|
const canvas = this.canvasRef.nativeElement;
|
|
const width = window.innerWidth;
|
|
const height = window.innerHeight;
|
|
|
|
const dx = Math.abs(width - canvas.width) / width;
|
|
const dy = Math.abs(height - canvas.height) / height;
|
|
|
|
if (!this.mobileService.mobileCheck() || dy > 0.2 || dx > 0.05) {
|
|
//sync canvas size to screen size
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
|
|
for (const dot of this.dots) {
|
|
dot.x = Math.max(dot.radius, Math.min(width - dot.radius, dot.x));
|
|
dot.y = Math.max(dot.radius, Math.min(height - dot.radius, dot.y));
|
|
}
|
|
}
|
|
};
|
|
|
|
|
|
|
|
private onMouseMove = (e: MouseEvent) => {
|
|
const canvas = this.canvasRef.nativeElement;
|
|
// map real res to canvas res
|
|
this.mouse.x = e.clientX / window.innerWidth * canvas.width;
|
|
this.mouse.y = e.clientY / window.innerHeight * canvas.height;
|
|
};
|
|
|
|
private onMouseClick = () => {
|
|
const dot = this.spawnDot();
|
|
dot.x = this.mouse.x;
|
|
dot.y = this.mouse.y;
|
|
};
|
|
|
|
private spawnDot(): Dot {
|
|
const dotId = this.ballSpawnId++;
|
|
const max_count = this.mobileService.mobileCheck() ? this.MAX_DOT_COUNT_MOBILE : this.MAX_DOT_COUNT;
|
|
let dot;
|
|
if (dotId < max_count) {
|
|
dot = {
|
|
x: 0,
|
|
y: 0,
|
|
vx: 0,
|
|
vy: 0,
|
|
radius: 1,
|
|
color: "#000000",
|
|
};
|
|
|
|
this.dots.push(dot);
|
|
} else {
|
|
dot = this.dots[dotId % this.dots.length];
|
|
}
|
|
|
|
this.populateDot(dot);
|
|
|
|
return dot;
|
|
}
|
|
|
|
private populateDot(dot: Dot) {
|
|
const {width, height} = this.canvasRef.nativeElement;
|
|
|
|
dot.x = Math.random() * width;
|
|
dot.y = Math.random() * height;
|
|
dot.vx = (Math.random() - 0.5) * 0.5;
|
|
dot.vy = (Math.random() - 0.5) * 0.5;
|
|
dot.radius = Math.random() * 60 + 40;
|
|
dot.color = this.COLORS[this.ballSpawnNextColor++ % this.COLORS.length];
|
|
}
|
|
|
|
private initDots() {
|
|
for (let i = 0; i < this.INIT_DOT_COUNT; i++) {
|
|
this.spawnDot();
|
|
}
|
|
}
|
|
|
|
private animate = () => {
|
|
const canvas = this.canvasRef.nativeElement;
|
|
const { width, height } = canvas;
|
|
this.ctx.clearRect(0, 0, width, height);
|
|
|
|
for (const dot of this.dots) {
|
|
const dx = dot.x - this.mouse.x;
|
|
const dy = dot.y - this.mouse.y;
|
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
|
|
if (dist != 0 && dist < this.MOUSE_RADIUS) {
|
|
const force = (this.MOUSE_RADIUS - dist) / this.MOUSE_RADIUS;
|
|
dot.vx += (dx / dist) * force * 0.5;
|
|
dot.vy += (dy / dist) * force * 0.5;
|
|
}
|
|
|
|
dot.x += dot.vx;
|
|
dot.y += dot.vy;
|
|
|
|
const speed = Math.sqrt(dot.vx * dot.vx + dot.vy * dot.vy);
|
|
if (speed > 0.3) {
|
|
dot.vx *= 0.98;
|
|
dot.vy *= 0.98;
|
|
} else if (speed <= 0.3) {
|
|
dot.vx *= 1.02;
|
|
dot.vy *= 1.02;
|
|
} else if (speed === 0) {
|
|
dot.vx = Math.random() * 0.2 - 0.1;
|
|
dot.vy = Math.random() * 0.2 - 0.1;
|
|
}
|
|
|
|
// Bounce off edges (accounting for radius)
|
|
if (dot.x < dot.radius || dot.x > width - dot.radius) dot.vx *= -1;
|
|
if (dot.y < dot.radius || dot.y > height - dot.radius) dot.vy *= -1;
|
|
|
|
// Clamp to bounds
|
|
dot.x = Math.max(dot.radius, Math.min(width - dot.radius, dot.x));
|
|
dot.y = Math.max(dot.radius, Math.min(height - dot.radius, dot.y));
|
|
|
|
const gradient = this.ctx.createRadialGradient(
|
|
dot.x, dot.y, 0,
|
|
dot.x, dot.y, dot.radius
|
|
);
|
|
gradient.addColorStop(0, dot.color + '40');
|
|
gradient.addColorStop(1, 'transparent');
|
|
|
|
this.ctx.beginPath();
|
|
this.ctx.arc(dot.x, dot.y, dot.radius, 0, Math.PI * 2);
|
|
this.ctx.fillStyle = gradient;
|
|
this.ctx.fill();
|
|
}
|
|
|
|
this.animationId = requestAnimationFrame(this.animate);
|
|
};
|
|
}
|