From 94ca7defb27a08558b8afe6d2b6b5551711ee683 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=BB=A8=ED=8A=B8=EB=A1=A4DELTA?= Date: Thu, 9 Apr 2026 16:10:45 +0900 Subject: [PATCH] Optimize Brand Accent Component By Caching Dark Mode Ellipse --- .../components/brand-accent/brand-accent.ts | 130 +++++++++++++----- 1 file changed, 93 insertions(+), 37 deletions(-) diff --git a/src/app/components/brand-accent/brand-accent.ts b/src/app/components/brand-accent/brand-accent.ts index 8adc7e3..0a067bf 100644 --- a/src/app/components/brand-accent/brand-accent.ts +++ b/src/app/components/brand-accent/brand-accent.ts @@ -27,9 +27,14 @@ export class BrandAccent implements OnDestroy { private bgGradient!: CanvasGradient; private gridGradient!: CanvasGradient; + private offscreenCanvas!: OffscreenCanvas; + private offscreenCtx!: OffscreenCanvasRenderingContext2D; + private resizeObserver?: ResizeObserver; private intersectionObserver?: IntersectionObserver; + private rendererScale!: number; + private animation: { id?: number; startTime: number; @@ -48,19 +53,30 @@ export class BrandAccent implements OnDestroy { }; constructor(private ngZone: NgZone) { + if (typeof document !== 'undefined') { + this.offscreenCanvas = new OffscreenCanvas(0, 0); + this.offscreenCtx = this.offscreenCanvas.getContext('2d', { alpha: false, colorSpace: 'srgb' })!; + } + effect(() => { // Trigger cache refresh when dark mode changes - this.settingsService.darkMode(); + const isDark = this.settingsService.darkMode(); if (this.ctx) { - this.cacheGradients(); + this.cacheGradients(isDark); + this.cacheBackground(isDark); } }); afterNextRender(() => { - this.ctx = this.canvasRef.nativeElement.getContext('2d')!; + this.ctx = this.canvasRef.nativeElement.getContext('2d', { colorSpace: 'srgb' })!; this.ngZone.runOutsideAngular(() => { - this.resizeObserver = new ResizeObserver(() => this.resize()); + 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(this.canvasRef.nativeElement); this.intersectionObserver = new IntersectionObserver( @@ -71,11 +87,15 @@ export class BrandAccent implements OnDestroy { cancelAnimationFrame(this.animation.id); this.animation.id = undefined; } - }, { rootMargin: '200px' } + }, + { rootMargin: '200px' }, ); this.intersectionObserver.observe(this.canvasRef.nativeElement); - this.resize(); // Safe Initialization + // Safe Initialization + const rect = this.canvasRef.nativeElement.getBoundingClientRect(); + this.resize(rect.width, rect.height); + this.animation.startTime = performance.now(); this.draw(); @@ -84,13 +104,19 @@ export class BrandAccent implements OnDestroy { }); } - private resize() { + private resize(width: number, height: number) { const canvas = this.canvasRef.nativeElement; const dpr = window.devicePixelRatio || 1; - const rect = canvas.getBoundingClientRect(); - canvas.width = rect.width * dpr; - canvas.height = rect.height * dpr; + this.rendererScale = dpr; + + canvas.width = width * dpr; + canvas.height = height * dpr; + + if (this.offscreenCanvas) { + this.offscreenCanvas.width = width; + this.offscreenCanvas.height = height; + } this.ctx.scale(dpr, dpr); this.updateAnimationInfo(); @@ -109,13 +135,16 @@ export class BrandAccent implements OnDestroy { Math.trunc(this.canvasNativeSize[1] / this.animation.gridSquareSize) + 3, ]; - this.cacheGradients(); + const isDark = this.settingsService.darkMode(); + + this.cacheGradients(isDark); + this.cacheBackground(isDark); } - private cacheGradients() { + private cacheGradients(isDark: boolean) { let w = this.canvasNativeSize[0]; let h = this.canvasNativeSize[1]; - const isDark = this.settingsService.darkMode(); + if (w === 0 || h === 0) return; if (!isDark) { this.bgGradient = this.ctx.createLinearGradient(0, 0, w, h); @@ -135,6 +164,47 @@ export class BrandAccent implements OnDestroy { } } + 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); @@ -162,33 +232,18 @@ export class BrandAccent implements OnDestroy { this.ctx.clearRect(0, 0, this.canvasNativeSize[0], this.canvasNativeSize[1]); const isDark = this.settingsService.darkMode(); - if (!isDark) { + if (this.offscreenCanvas) { this.ctx.globalCompositeOperation = 'source-over'; - this.ctx.fillStyle = this.bgGradient; - this.ctx.fillRect(0, 0, this.canvasNativeSize[0], this.canvasNativeSize[1]); + + 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 (!isDark) { this.ctx.globalCompositeOperation = 'multiply'; } else { - this.ctx.globalCompositeOperation = 'source-over'; - this.ctx.fillStyle = 'oklch(0.1776 0 0)'; - this.ctx.fillRect(0, 0, this.canvasNativeSize[0], this.canvasNativeSize[1]); - - const ellipseDimension = [ - Math.max(100, this.canvasNativeSize[0] * 0.9421875), - Math.max(200 / 3, this.canvasNativeSize[1] * (203 / 120)), - ]; - - const ellipseX = this.canvasNativeSize[0] / 2; - const ellipseY = this.canvasNativeSize[1] * 0.5763888888 + ellipseDimension[1] / 2; - - this.ctx.filter = `blur(${Math.min(780, Math.min(ellipseDimension[0], ellipseDimension[1]) * 0.6)}px)`; - - this.ctx.beginPath(); - this.ctx.ellipse(ellipseX, ellipseY, ellipseDimension[0] / 2, ellipseDimension[1] / 2, 0, 0, 2 * Math.PI); - this.ctx.fillStyle = 'oklch(0.2547 0.0913 29.02)'; - this.ctx.fill(); - - this.ctx.filter = 'none'; - this.ctx.globalCompositeOperation = 'screen'; } @@ -205,6 +260,7 @@ export class BrandAccent implements OnDestroy { 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++) {