import { AfterViewInit, Component, ElementRef, OnDestroy, ViewChild } from '@angular/core'; type PointerState = { x: number; y: number; active: boolean; pressed: boolean; }; type Particle = { x: number; y: number; vx: number; vy: number; size: number; alpha: number; ageMs: number; lifetimeMs: number; respawnDelayMs: number; }; class ParticleEngine { private readonly connectionDistanceSq = 19600; private readonly densityRadiusSq = 4900; private readonly densityFadeStart = 6; private readonly densityFadeRange = 10; private readonly maxDensityFade = 0.75; private readonly minLifetimeMs = 9000; private readonly maxLifetimeMs = 24000; private readonly minRespawnDelayMs = 140; private readonly maxRespawnDelayMs = 900; private readonly particles: Particle[] = []; private readonly localDensity: number[] = []; private readonly lifeAlpha: number[] = []; private width = 0; private height = 0; private dpr = 1; private pointer: PointerState = { x: 0, y: 0, active: false, pressed: false }; constructor(private readonly ctx: CanvasRenderingContext2D, particleCount: number) { for (let i = 0; i < particleCount; i += 1) { const particle: Particle = { x: Math.random(), y: Math.random(), vx: (Math.random() - 0.5) * 0.24, vy: (Math.random() - 0.5) * 0.24, size: 1.2 + Math.random() * 2.8, alpha: 0.5 + Math.random() * 0.45, ageMs: 0, lifetimeMs: this.randomLifetimeMs(), respawnDelayMs: 0, }; this.resetParticle(particle, false); this.particles.push(particle); } } resize(width: number, height: number, dpr: number): void { this.width = width; this.height = height; this.dpr = dpr; } setPointer(pointer: PointerState): void { this.pointer = pointer; } renderFrame(deltaMs: number): void { const clampedDeltaMs = Math.min(deltaMs, 40); const step = clampedDeltaMs / 16.6667; this.update(step, clampedDeltaMs); this.draw(); } renderStatic(): void { this.draw(); } private update(step: number, deltaMs: number): void { if (this.width === 0 || this.height === 0) { return; } const influenceRadius = Math.min(this.width, this.height) * 0.3; const influenceRadiusSq = influenceRadius * influenceRadius; for (const particle of this.particles) { if (particle.respawnDelayMs > 0) { particle.respawnDelayMs = Math.max(0, particle.respawnDelayMs - deltaMs); if (particle.respawnDelayMs === 0) { this.resetParticle(particle, false); } continue; } particle.ageMs += deltaMs; if (particle.ageMs >= particle.lifetimeMs) { particle.respawnDelayMs = this.randomRespawnDelayMs(); continue; } const px = particle.x * this.width; const py = particle.y * this.height; if (this.pointer.active) { const dx = px - this.pointer.x; const dy = py - this.pointer.y; const distanceSq = dx * dx + dy * dy; if (distanceSq > 1 && distanceSq < influenceRadiusSq) { const distance = Math.sqrt(distanceSq); const normalized = 1 - distance / influenceRadius; const directionBoost = this.pointer.pressed ? -2.2 : 1.35; const force = normalized * 0.033 * directionBoost; particle.vx += (dx / distance) * force * step; particle.vy += (dy / distance) * force * step; } } particle.vx *= 0.978; particle.vy *= 0.978; particle.x += (particle.vx * step) / this.width; particle.y += (particle.vy * step) / this.height; if (particle.x < 0 || particle.x > 1) { particle.vx *= -0.95; particle.x = Math.min(1, Math.max(0, particle.x)); } if (particle.y < 0 || particle.y > 1) { particle.vy *= -0.95; particle.y = Math.min(1, Math.max(0, particle.y)); } } } private draw(): void { if (this.width === 0 || this.height === 0) { return; } this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); this.ctx.clearRect(0, 0, this.width, this.height); const gradient = this.ctx.createRadialGradient( this.width * 0.2, this.height * 0.1, 0, this.width * 0.5, this.height * 0.5, Math.max(this.width, this.height) ); gradient.addColorStop(0, 'rgba(88, 166, 255, 0.16)'); gradient.addColorStop(0.4, 'rgba(88, 166, 255, 0.2)'); gradient.addColorStop(0.7, 'rgba(147, 102, 255, 0.12)'); gradient.addColorStop(1, 'rgba(13, 17, 23, 0)'); this.ctx.fillStyle = gradient; this.ctx.fillRect(0, 0, this.width, this.height); if (this.localDensity.length !== this.particles.length) { this.localDensity.length = this.particles.length; } if (this.lifeAlpha.length !== this.particles.length) { this.lifeAlpha.length = this.particles.length; } this.localDensity.fill(0); for (let i = 0; i < this.particles.length; i += 1) { this.lifeAlpha[i] = this.getLifeAlpha(this.particles[i]); } this.ctx.lineWidth = 1; for (let i = 0; i < this.particles.length; i += 1) { const a = this.particles[i]; const lifeA = this.lifeAlpha[i]; if (lifeA <= 0.01) { continue; } for (let j = i + 1; j < this.particles.length; j += 1) { const b = this.particles[j]; const lifeB = this.lifeAlpha[j]; if (lifeB <= 0.01) { continue; } const ax = a.x * this.width; const ay = a.y * this.height; const bx = b.x * this.width; const by = b.y * this.height; const dx = ax - bx; const dy = ay - by; const distSq = dx * dx + dy * dy; if (distSq < this.densityRadiusSq) { this.localDensity[i] += 1; this.localDensity[j] += 1; } if (distSq < this.connectionDistanceSq) { const alpha = 1 - distSq / this.connectionDistanceSq; this.ctx.strokeStyle = `rgba(106, 194, 255, ${alpha * 0.38 * Math.min(lifeA, lifeB)})`; this.ctx.beginPath(); this.ctx.moveTo(ax, ay); this.ctx.lineTo(bx, by); this.ctx.stroke(); } } } for (let i = 0; i < this.particles.length; i += 1) { const particle = this.particles[i]; const x = particle.x * this.width; const y = particle.y * this.height; const crowding = Math.min( Math.max((this.localDensity[i] - this.densityFadeStart) / this.densityFadeRange, 0), 1 ); const effectiveAlpha = Math.max( particle.alpha * this.lifeAlpha[i] * (1 - crowding * this.maxDensityFade), 0.02 ); if (effectiveAlpha <= 0.021) { continue; } this.ctx.beginPath(); this.ctx.arc(x, y, particle.size, 0, Math.PI * 2); this.ctx.fillStyle = `rgba(222, 244, 255, ${effectiveAlpha})`; this.ctx.fill(); } } private randomLifetimeMs(): number { return this.minLifetimeMs + Math.random() * (this.maxLifetimeMs - this.minLifetimeMs); } private randomRespawnDelayMs(): number { return this.minRespawnDelayMs + Math.random() * (this.maxRespawnDelayMs - this.minRespawnDelayMs); } private resetParticle(particle: Particle, withDelay: boolean): void { particle.x = Math.random(); particle.y = Math.random(); particle.vx = (Math.random() - 0.5) * 0.24; particle.vy = (Math.random() - 0.5) * 0.24; particle.size = 1.2 + Math.random() * 2.8; particle.alpha = 0.5 + Math.random() * 0.45; particle.ageMs = 0; particle.lifetimeMs = this.randomLifetimeMs(); particle.respawnDelayMs = withDelay ? this.randomRespawnDelayMs() : 0; } private getLifeAlpha(particle: Particle): number { if (particle.respawnDelayMs > 0 || particle.lifetimeMs <= 0) { return 0; } const progress = Math.min(Math.max(particle.ageMs / particle.lifetimeMs, 0), 1); const fadeIn = Math.min(progress / 0.25, 1); const fadeOut = Math.min((1 - progress) / 0.32, 1); return Math.min(fadeIn, fadeOut, 1); } } @Component({ selector: 'app-interactive-background', standalone: true, templateUrl: './interactive-background.html', styleUrl: './interactive-background.css' }) export class InteractiveBackground implements AfterViewInit, OnDestroy { @ViewChild('canvas', { static: true }) private readonly canvasRef!: ElementRef; isCursorVisible = false; isPointerDown = false; cursorTransform = 'translate3d(-9999px, -9999px, 0)'; private particleEngine: ParticleEngine | null = null; private destroyCallbacks: Array<() => void> = []; private frameId: number | null = null; private lastFrameTime = 0; private pointerX = 0; private pointerY = 0; private pointerActive = false; private activeTouchId: number | null = null; private reducedMotion = false; private mediaQuery!: MediaQueryList; ngAfterViewInit(): void { const canvas = this.canvasRef.nativeElement; const context = canvas.getContext('2d'); this.mediaQuery = this.getMediaQuery('(prefers-reduced-motion: reduce)'); this.reducedMotion = this.mediaQuery.matches; if (context) { const area = window.innerWidth * window.innerHeight; const dynamicCount = Math.round(Math.min(170, Math.max(80, area / 14500))); const particleCount = this.reducedMotion ? 42 : dynamicCount; this.particleEngine = new ParticleEngine(context, particleCount); this.resizeCanvas(); this.renderInitialFrame(); if (!this.reducedMotion) { this.startAnimation(); } } this.isCursorVisible = this.getMediaQuery('(pointer: fine)').matches; const onResize = () => this.resizeCanvas(); const supportsPointerEvents = 'PointerEvent' in window; const onPointerMove = (event: PointerEvent) => this.handlePointerMove(event); const onPointerLeave = () => this.handlePointerLeave(); const onPointerDown = (event: PointerEvent) => this.handlePointerDown(event); const onPointerUp = (event: PointerEvent) => this.handlePointerUp(event); const onPointerCancel = () => this.handlePointerLeave(); const onMouseMove = (event: MouseEvent) => this.handleMouseMove(event); const onMouseLeave = () => this.handlePointerLeave(); const onMouseDown = (event: MouseEvent) => this.handleMouseDown(event); const onMouseUp = () => this.handleMouseUp(); const onTouchStart = (event: TouchEvent) => this.handleTouchStart(event); const onTouchMove = (event: TouchEvent) => this.handleTouchMove(event); const onTouchEnd = (event: TouchEvent) => this.handleTouchEnd(event); const onTouchCancel = () => this.handlePointerLeave(); const onMotionChange = (event: MediaQueryListEvent) => this.handleMotionPreferenceChange(event.matches); window.addEventListener('resize', onResize); if (supportsPointerEvents) { window.addEventListener('pointermove', onPointerMove, { passive: true }); window.addEventListener('pointerleave', onPointerLeave); window.addEventListener('pointerdown', onPointerDown, { passive: true }); window.addEventListener('pointerup', onPointerUp, { passive: true }); window.addEventListener('pointercancel', onPointerCancel, { passive: true }); } else { window.addEventListener('mousemove', onMouseMove, { passive: true }); window.addEventListener('mouseleave', onMouseLeave); window.addEventListener('mousedown', onMouseDown, { passive: true }); window.addEventListener('mouseup', onMouseUp, { passive: true }); window.addEventListener('touchstart', onTouchStart, { passive: true }); window.addEventListener('touchmove', onTouchMove, { passive: true }); window.addEventListener('touchend', onTouchEnd, { passive: true }); window.addEventListener('touchcancel', onTouchCancel, { passive: true }); } this.mediaQuery.addEventListener('change', onMotionChange); this.destroyCallbacks = [ () => window.removeEventListener('resize', onResize), () => window.removeEventListener('pointermove', onPointerMove), () => window.removeEventListener('pointerleave', onPointerLeave), () => window.removeEventListener('pointerdown', onPointerDown), () => window.removeEventListener('pointerup', onPointerUp), () => window.removeEventListener('pointercancel', onPointerCancel), () => window.removeEventListener('mousemove', onMouseMove), () => window.removeEventListener('mouseleave', onMouseLeave), () => window.removeEventListener('mousedown', onMouseDown), () => window.removeEventListener('mouseup', onMouseUp), () => window.removeEventListener('touchstart', onTouchStart), () => window.removeEventListener('touchmove', onTouchMove), () => window.removeEventListener('touchend', onTouchEnd), () => window.removeEventListener('touchcancel', onTouchCancel), () => this.mediaQuery.removeEventListener('change', onMotionChange), ]; } ngOnDestroy(): void { this.stopAnimation(); for (const callback of this.destroyCallbacks) { callback(); } this.destroyCallbacks = []; } private resizeCanvas(): void { if (!this.particleEngine) { return; } const canvas = this.canvasRef.nativeElement; const width = window.innerWidth; const height = window.innerHeight; const dpr = Math.min(window.devicePixelRatio || 1, 2); canvas.width = Math.floor(width * dpr); canvas.height = Math.floor(height * dpr); canvas.style.width = `${width}px`; canvas.style.height = `${height}px`; this.particleEngine.resize(width, height, dpr); this.renderInitialFrame(); } private startAnimation(): void { if (this.frameId !== null) { return; } this.lastFrameTime = performance.now(); const step = (now: number) => { if (!this.particleEngine) { return; } const delta = now - this.lastFrameTime; this.lastFrameTime = now; this.syncPointer(); this.particleEngine.renderFrame(delta); this.frameId = requestAnimationFrame(step); }; this.frameId = requestAnimationFrame(step); } private stopAnimation(): void { if (this.frameId !== null) { cancelAnimationFrame(this.frameId); this.frameId = null; } } private handlePointerMove(event: PointerEvent): void { if (event.pointerType === 'touch' && this.activeTouchId !== null && event.pointerId !== this.activeTouchId) { return; } this.updatePointer(event.clientX, event.clientY, true); } private handlePointerDown(event: PointerEvent): void { if (event.pointerType === 'touch') { this.activeTouchId = event.pointerId; } this.isPointerDown = true; this.updatePointer(event.clientX, event.clientY, true); } private handlePointerUp(event: PointerEvent): void { this.isPointerDown = false; if (event.pointerType === 'touch') { if (this.activeTouchId !== null && event.pointerId !== this.activeTouchId) { return; } this.activeTouchId = null; this.pointerActive = false; return; } this.updatePointer(event.clientX, event.clientY, true); } private handleMouseMove(event: MouseEvent): void { this.updatePointer(event.clientX, event.clientY, true); } private handleMouseDown(event: MouseEvent): void { this.isPointerDown = true; this.updatePointer(event.clientX, event.clientY, true); } private handleMouseUp(): void { this.isPointerDown = false; } private handleTouchStart(event: TouchEvent): void { const touch = event.changedTouches.item(0); if (!touch) { return; } this.activeTouchId = touch.identifier; this.isPointerDown = true; this.updatePointer(touch.clientX, touch.clientY, true); } private handleTouchMove(event: TouchEvent): void { if (this.activeTouchId === null) { return; } const touch = this.findTouch(event.touches, this.activeTouchId); if (!touch) { return; } this.updatePointer(touch.clientX, touch.clientY, true); } private handleTouchEnd(event: TouchEvent): void { if (this.activeTouchId === null) { return; } const touch = this.findTouch(event.changedTouches, this.activeTouchId); if (!touch) { return; } this.isPointerDown = false; this.activeTouchId = null; this.pointerActive = false; } private handlePointerLeave(): void { this.pointerActive = false; this.isPointerDown = false; this.activeTouchId = null; this.cursorTransform = 'translate3d(-9999px, -9999px, 0)'; } private updatePointer(x: number, y: number, active: boolean): void { this.pointerX = x; this.pointerY = y; this.pointerActive = active; this.cursorTransform = `translate3d(${x}px, ${y}px, 0)`; } private findTouch(touches: TouchList, identifier: number): Touch | null { for (let index = 0; index < touches.length; index += 1) { const touch = touches.item(index); if (touch && touch.identifier === identifier) { return touch; } } return null; } private syncPointer(): void { this.particleEngine?.setPointer({ x: this.pointerX, y: this.pointerY, active: this.pointerActive, pressed: this.isPointerDown, }); } private handleMotionPreferenceChange(reducedMotion: boolean): void { this.reducedMotion = reducedMotion; if (this.reducedMotion) { this.stopAnimation(); this.syncPointer(); this.particleEngine?.renderStatic(); return; } this.startAnimation(); } private renderInitialFrame(): void { this.syncPointer(); this.particleEngine?.renderStatic(); } private getMediaQuery(query: string): MediaQueryList { if (typeof window.matchMedia === 'function') { return window.matchMedia(query); } return { matches: false, media: query, onchange: null, addListener: () => undefined, removeListener: () => undefined, addEventListener: () => undefined, removeEventListener: () => undefined, dispatchEvent: () => false, }; } }