import { afterNextRender, ChangeDetectionStrategy, Component, effect, ElementRef, inject, input, NgZone, OnDestroy, viewChild, } from '@angular/core'; import { CommonModule } from '@angular/common'; import { SettingsService } from '../../services/settings.service'; @Component({ selector: 'brand-accent', standalone: true, imports: [CommonModule], templateUrl: './brand-accent.html', styleUrl: './brand-accent.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class BrandAccent implements OnDestroy { canvasRef = viewChild>('canvasElement'); private ctx!: CanvasRenderingContext2D; private settingsService = inject(SettingsService); drawGrid = input(true); private canvasNativeSize: [number, number] = [0, 0]; private bgGradient!: CanvasGradient; private gridGradient!: CanvasGradient; private readonly offscreenCanvas!: OffscreenCanvas; private readonly offscreenCtx!: OffscreenCanvasRenderingContext2D; private resizeObserver?: ResizeObserver; private intersectionObserver?: IntersectionObserver; private rendererScale!: number; private animation: { id?: number; startTime: number; progress: number; gridDimension: [number, number]; duration: number; gridSquareSize: number; } = { startTime: 0, progress: 0, gridDimension: [0, 0], duration: 30, gridSquareSize: 100, }; constructor(private ngZone: NgZone) { if (typeof document !== 'undefined') { this.offscreenCanvas = new OffscreenCanvas(0, 0); this.offscreenCtx = this.offscreenCanvas.getContext('2d', { colorSpace: 'srgb' })!; } effect(() => { // Trigger cache refresh when dark mode changes const isDark = this.settingsService.darkMode(); if (this.ctx) { this.cacheGradients(isDark); this.cacheBackground(isDark); } }); afterNextRender(() => { const nativeElement = this.canvasRef()?.nativeElement; if (nativeElement) { this.ctx = nativeElement.getContext('2d', { colorSpace: 'srgb' })!; this.ngZone.runOutsideAngular(() => { this.resizeObserver = new ResizeObserver((entries) => { for (let entry of entries) { const { inlineSize: width, blockSize: height } = entry.borderBoxSize[0]; this.resize(width, height); } }); this.resizeObserver.observe(nativeElement); this.intersectionObserver = new IntersectionObserver( (entries) => { if (entries[0].isIntersecting) { if (!this.animation.id) this.animate(performance.now()); } else if (this.animation.id) { cancelAnimationFrame(this.animation.id); this.animation.id = undefined; } }, { rootMargin: '200px' }, ); this.intersectionObserver.observe(nativeElement); // Safe Initialization const rect = nativeElement.getBoundingClientRect(); this.resize(rect.width, rect.height); this.animation.startTime = performance.now(); this.draw(); // this.animate(performance.now()); }); } }); } private resize(width: number, height: number) { const canvas = this.canvasRef()?.nativeElement; if (canvas) { const dpr = window.devicePixelRatio || 1; this.rendererScale = dpr; canvas.width = width * dpr; canvas.height = height * dpr; if (this.offscreenCanvas) { this.offscreenCanvas.width = width; this.offscreenCanvas.height = height; } this.canvasNativeSize = [width, height]; this.ctx.scale(dpr, dpr); this.updateAnimationInfo(); this.animate(performance.now()); } } private updateAnimationInfo() { this.animation.gridDimension = [ Math.trunc(this.canvasNativeSize[0] / this.animation.gridSquareSize) + 3, Math.trunc(this.canvasNativeSize[1] / this.animation.gridSquareSize) + 3, ]; const isDark = this.settingsService.darkMode(); this.cacheGradients(isDark); this.cacheBackground(isDark); } private cacheGradients(isDark: boolean) { let w = this.canvasNativeSize[0]; let h = this.canvasNativeSize[1]; if (w === 0 || h === 0) return; if (!isDark) { this.bgGradient = this.ctx.createLinearGradient(0, 0, w, h); this.bgGradient.addColorStop(0, 'oklch(1 0 0 / 0.6)'); this.bgGradient.addColorStop(1, 'oklch(0.7938 0.1292 47.77 / 0.6)'); } this.gridGradient = this.ctx.createLinearGradient(0, 0, w, h); if (isDark) { // Dark mode: subtle light grid this.gridGradient.addColorStop(0, 'rgba(255, 255, 255, 0)'); this.gridGradient.addColorStop(1, 'rgba(255, 255, 255, 0.4)'); } else { // Light mode: dark grid this.gridGradient.addColorStop(0, 'rgba(0, 0, 0, 0)'); this.gridGradient.addColorStop(1, 'rgba(0, 0, 0, 0.2)'); } } private cacheBackground(isDark: boolean) { if (!this.offscreenCtx) return; const w = this.canvasNativeSize[0]; const h = this.canvasNativeSize[1]; if (w === 0 || h === 0) return; this.offscreenCtx.clearRect(0, 0, w, h); this.offscreenCtx.globalCompositeOperation = 'source-over'; if (!isDark) { this.offscreenCtx.fillStyle = this.bgGradient; this.offscreenCtx.fillRect(0, 0, w, h); } else { this.offscreenCtx.fillStyle = 'oklch(0.1776 0 0)'; this.offscreenCtx.fillRect(0, 0, w, h); const ellipseDimension = [Math.max(100, w * 0.9421875), Math.max(200 / 3, h * (203 / 120))]; const ellipseX = w / 2; const ellipseY = h * 0.5763888888 + ellipseDimension[1] / 2; const blurScale = Math.min(780, Math.min(ellipseDimension[0], ellipseDimension[1]) * 0.6) / this.rendererScale; this.offscreenCtx.filter = `blur(${blurScale}px)`; this.offscreenCtx.beginPath(); this.offscreenCtx.ellipse( ellipseX, ellipseY, ellipseDimension[0] / 2, ellipseDimension[1] / 2, 0, 0, 2 * Math.PI, ); this.offscreenCtx.fillStyle = 'oklch(0.2547 0.0913 29.02)'; this.offscreenCtx.fill(); this.offscreenCtx.filter = 'none'; } } private animate = (currentTime: number) => { this.animation.id = requestAnimationFrame(this.animate); this.update(currentTime); this.draw(); }; private update(currentTime: number) { let elapsedTime: number; elapsedTime = (currentTime - this.animation.startTime) / 1000; const animationTime = elapsedTime % this.animation.duration; this.animation.progress = animationTime / this.animation.duration; } private draw() { this.ctx.clearRect(0, 0, this.canvasNativeSize[0], this.canvasNativeSize[1]); const isDark = this.settingsService.darkMode(); if (this.offscreenCanvas) { this.ctx.globalCompositeOperation = 'source-over'; this.ctx.imageSmoothingEnabled = false; this.ctx.drawImage(this.offscreenCanvas, 0, 0); // this.ctx.drawImage(this.offscreenCanvas, 0, 0, this.canvasNativeSize[0], this.canvasNativeSize[1]); } if (!this.drawGrid()) return; if (!isDark) { this.ctx.globalCompositeOperation = 'multiply'; } else { this.ctx.globalCompositeOperation = 'screen'; } this.ctx.beginPath(); this.ctx.lineWidth = 2; this.ctx.strokeStyle = this.gridGradient; this.ctx.lineCap = 'round'; const lineRenderMargin = this.animation.gridSquareSize * 2; const xOffset = (this.canvasNativeSize[0] - this.animation.gridDimension[0] * this.animation.gridSquareSize) / 2; const yOffset = (this.canvasNativeSize[1] - this.animation.gridDimension[1] * this.animation.gridSquareSize) / 2; const movingOffset = -(this.animation.gridSquareSize * this.animation.progress); for (let y = 0; y < this.animation.gridDimension[1]; y++) { this.ctx.moveTo( -lineRenderMargin, y * this.animation.gridSquareSize + yOffset + movingOffset, ); this.ctx.lineTo( this.canvasNativeSize[0] + lineRenderMargin, y * this.animation.gridSquareSize + yOffset + movingOffset, ); } for (let x = 0; x < this.animation.gridDimension[0]; x++) { this.ctx.moveTo( x * this.animation.gridSquareSize + xOffset + movingOffset, -lineRenderMargin, ); this.ctx.lineTo( x * this.animation.gridSquareSize + xOffset + movingOffset, this.canvasNativeSize[1] + lineRenderMargin, ); } this.ctx.stroke(); } ngOnDestroy() { if (this.animation.id) cancelAnimationFrame(this.animation.id); this.resizeObserver?.disconnect(); this.intersectionObserver?.disconnect(); } }