369 lines
8.9 KiB
TypeScript
369 lines
8.9 KiB
TypeScript
import { DOCUMENT } from '@angular/common';
|
|
import {
|
|
afterNextRender,
|
|
Component,
|
|
DestroyRef,
|
|
ElementRef,
|
|
inject,
|
|
NgZone,
|
|
ViewChild,
|
|
} from '@angular/core';
|
|
import {
|
|
isBrowserPlatform,
|
|
prefersCoarsePointer,
|
|
prefersReducedMotion,
|
|
} from '../../core/platform/browser';
|
|
import { Dot } from '../../models/dot';
|
|
|
|
@Component({
|
|
selector: 'app-dot-background',
|
|
imports: [],
|
|
templateUrl: './dot-background.html',
|
|
styleUrl: './dot-background.scss',
|
|
host: {
|
|
'aria-hidden': 'true',
|
|
},
|
|
})
|
|
export class DotBackground {
|
|
@ViewChild('canvas') canvasRef!: ElementRef<HTMLCanvasElement>;
|
|
|
|
private readonly document = inject(DOCUMENT);
|
|
private readonly ngZone = inject(NgZone);
|
|
private readonly destroyRef = inject(DestroyRef);
|
|
private readonly isBrowser = isBrowserPlatform();
|
|
private readonly coarsePointer = prefersCoarsePointer();
|
|
private readonly reducedMotion = prefersReducedMotion();
|
|
|
|
private ctx: CanvasRenderingContext2D | undefined;
|
|
private dots: Dot[] = [];
|
|
private mouse = { x: -1000, y: -1000 };
|
|
private animationId = 0;
|
|
private resizeFrameId = 0;
|
|
private loopActive = false;
|
|
private tornDown = false;
|
|
private readonly teardowns: Array<() => void> = [];
|
|
|
|
private readonly INITIAL_DOT_COUNT_MIN = 6;
|
|
private readonly INITIAL_DOT_COUNT_MAX = 24;
|
|
private readonly INITIAL_DOT_COUNT_MAX_COARSE = 12;
|
|
private readonly INITIAL_DOT_AREA_DIVISOR = 130_000;
|
|
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() {
|
|
this.destroyRef.onDestroy(() => this.teardown());
|
|
|
|
afterNextRender(() => {
|
|
this.init();
|
|
});
|
|
}
|
|
|
|
private view(): Window | null {
|
|
return this.document.defaultView;
|
|
}
|
|
|
|
private init(): void {
|
|
if (this.tornDown || !this.isBrowser) {
|
|
return;
|
|
}
|
|
|
|
const view = this.view();
|
|
|
|
if (!view) {
|
|
return;
|
|
}
|
|
|
|
const canvas = this.canvasRef?.nativeElement;
|
|
|
|
if (!canvas) {
|
|
return;
|
|
}
|
|
|
|
let ctx: CanvasRenderingContext2D | null = null;
|
|
|
|
try {
|
|
ctx = canvas.getContext('2d');
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
if (!ctx) {
|
|
return;
|
|
}
|
|
|
|
this.ctx = ctx;
|
|
this.resize();
|
|
this.initDots();
|
|
|
|
this.listen(view, 'resize', this.onResize);
|
|
this.listen(this.document, 'visibilitychange', this.onVisibilityChange);
|
|
|
|
if (!this.reducedMotion && !this.coarsePointer) {
|
|
this.listen(view, 'mousemove', this.onMouseMove);
|
|
this.listen(view, 'click', this.onMouseClick);
|
|
}
|
|
|
|
if (this.reducedMotion) {
|
|
this.drawFrame();
|
|
return;
|
|
}
|
|
|
|
this.ngZone.runOutsideAngular(() => this.startLoop());
|
|
}
|
|
|
|
private listen(target: EventTarget, type: string, handler: EventListener): void {
|
|
target.addEventListener(type, handler);
|
|
this.teardowns.push(() => target.removeEventListener(type, handler));
|
|
}
|
|
|
|
private requestFrame(callback: FrameRequestCallback): number {
|
|
const view = this.view();
|
|
return view ? view.requestAnimationFrame(callback) : 0;
|
|
}
|
|
|
|
private cancelFrame(id: number): void {
|
|
const view = this.view();
|
|
|
|
if (!view || id === 0) {
|
|
return;
|
|
}
|
|
|
|
view.cancelAnimationFrame(id);
|
|
}
|
|
|
|
private startLoop(): void {
|
|
if (this.loopActive || this.reducedMotion || this.tornDown) {
|
|
return;
|
|
}
|
|
|
|
this.loopActive = true;
|
|
this.scheduleAnimate();
|
|
}
|
|
|
|
private stopLoop(): void {
|
|
this.loopActive = false;
|
|
this.cancelFrame(this.animationId);
|
|
this.animationId = 0;
|
|
}
|
|
|
|
private scheduleAnimate(): void {
|
|
this.animationId = this.requestFrame(this.animate);
|
|
}
|
|
|
|
private teardown(): void {
|
|
if (this.tornDown) {
|
|
return;
|
|
}
|
|
|
|
this.tornDown = true;
|
|
this.stopLoop();
|
|
this.cancelFrame(this.resizeFrameId);
|
|
this.resizeFrameId = 0;
|
|
|
|
for (const dispose of this.teardowns) {
|
|
dispose();
|
|
}
|
|
|
|
this.teardowns.length = 0;
|
|
}
|
|
|
|
private onResize = (): void => {
|
|
if (this.resizeFrameId !== 0) {
|
|
return;
|
|
}
|
|
|
|
this.resizeFrameId = this.requestFrame(() => {
|
|
this.resizeFrameId = 0;
|
|
this.resize();
|
|
});
|
|
};
|
|
|
|
private onVisibilityChange = (): void => {
|
|
if (this.document.hidden) {
|
|
this.stopLoop();
|
|
return;
|
|
}
|
|
|
|
if (!this.reducedMotion) {
|
|
this.ngZone.runOutsideAngular(() => this.startLoop());
|
|
}
|
|
};
|
|
|
|
private resize = (): void => {
|
|
const view = this.view();
|
|
const canvas = this.canvasRef?.nativeElement;
|
|
|
|
if (!view || !canvas) {
|
|
return;
|
|
}
|
|
|
|
const width = view.innerWidth;
|
|
const height = view.innerHeight;
|
|
|
|
const dx = width === 0 ? 0 : Math.abs(width - canvas.width) / width;
|
|
const dy = height === 0 ? 0 : Math.abs(height - canvas.height) / height;
|
|
|
|
if (!this.coarsePointer || dy > 0.2 || dx > 0.05) {
|
|
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));
|
|
}
|
|
|
|
if (this.reducedMotion && this.ctx) {
|
|
this.drawFrame();
|
|
}
|
|
}
|
|
};
|
|
|
|
private onMouseMove = (event: Event): void => {
|
|
if (!(event instanceof MouseEvent)) {
|
|
return;
|
|
}
|
|
|
|
const view = this.view();
|
|
const canvas = this.canvasRef?.nativeElement;
|
|
|
|
if (!view || !canvas) {
|
|
return;
|
|
}
|
|
|
|
this.mouse.x = (event.clientX / view.innerWidth) * canvas.width;
|
|
this.mouse.y = (event.clientY / view.innerHeight) * canvas.height;
|
|
};
|
|
|
|
private onMouseClick = (): void => {
|
|
const dot = this.spawnDot();
|
|
dot.x = this.mouse.x;
|
|
dot.y = this.mouse.y;
|
|
};
|
|
|
|
private targetDotCount(width: number, height: number): number {
|
|
const maxInitial = this.coarsePointer
|
|
? this.INITIAL_DOT_COUNT_MAX_COARSE
|
|
: this.INITIAL_DOT_COUNT_MAX;
|
|
const area = Math.max(0, width) * Math.max(0, height);
|
|
const fromArea = Math.round(area / this.INITIAL_DOT_AREA_DIVISOR);
|
|
return Math.min(maxInitial, Math.max(this.INITIAL_DOT_COUNT_MIN, fromArea));
|
|
}
|
|
|
|
private spawnDot(): Dot {
|
|
const dotId = this.ballSpawnId++;
|
|
const maxCount = this.coarsePointer ? this.MAX_DOT_COUNT_MOBILE : this.MAX_DOT_COUNT;
|
|
let dot: Dot;
|
|
|
|
if (dotId < maxCount) {
|
|
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): void {
|
|
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(): void {
|
|
const { width, height } = this.canvasRef.nativeElement;
|
|
const count = this.targetDotCount(width, height);
|
|
|
|
for (let i = 0; i < count; i++) {
|
|
this.spawnDot();
|
|
}
|
|
}
|
|
|
|
private animate = (): void => {
|
|
this.animationId = 0;
|
|
this.drawFrame();
|
|
|
|
if (this.loopActive && !this.tornDown) {
|
|
this.scheduleAnimate();
|
|
}
|
|
};
|
|
|
|
private drawFrame(): void {
|
|
const canvas = this.canvasRef?.nativeElement;
|
|
const ctx = this.ctx;
|
|
|
|
if (!canvas || !ctx) {
|
|
return;
|
|
}
|
|
|
|
const { width, height } = canvas;
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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 = ctx.createRadialGradient(dot.x, dot.y, 0, dot.x, dot.y, dot.radius);
|
|
gradient.addColorStop(0, dot.color + '40');
|
|
gradient.addColorStop(1, 'transparent');
|
|
|
|
ctx.beginPath();
|
|
ctx.arc(dot.x, dot.y, dot.radius, 0, Math.PI * 2);
|
|
ctx.fillStyle = gradient;
|
|
ctx.fill();
|
|
}
|
|
}
|
|
}
|