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; 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(); } } }