diff --git a/src/components/interactive-background/interactive-background.ts b/src/components/interactive-background/interactive-background.ts index 0d9b199..da15546 100644 --- a/src/components/interactive-background/interactive-background.ts +++ b/src/components/interactive-background/interactive-background.ts @@ -290,7 +290,14 @@ export class InteractiveBackground implements AfterViewInit, OnDestroy { private reducedMotion = false; private lowMotionMobile = false; private mediaQuery!: MediaQueryList; + private resizeFrameId: number | null = null; + private resizeSettleTimeout: ReturnType | null = null; + private lastCanvasWidth = 0; + private lastCanvasHeight = 0; + private lastCanvasDpr = 0; private readonly pointerExcludeSelector = '[data-disable-bg-pointer]'; + private readonly resizeSettleDelayMs = 140; + private readonly resizeNoiseThresholdPx = 2; ngAfterViewInit(): void { const canvas = this.canvasRef.nativeElement; @@ -306,14 +313,15 @@ export class InteractiveBackground implements AfterViewInit, OnDestroy { const mobileCount = Math.round(Math.min(54, Math.max(28, dynamicCount * 0.4))); const particleCount = this.reducedMotion ? 42 : (this.lowMotionMobile ? mobileCount : dynamicCount); this.particleEngine = new ParticleEngine(context, particleCount); - this.resizeCanvas(); + this.resizeCanvas(true); this.renderInitialFrame(); if (!this.reducedMotion) { this.startAnimation(); } } - const onResize = () => this.resizeCanvas(); + const visualViewport = window.visualViewport; + const onResize = () => this.scheduleCanvasResize(); const supportsPointerEvents = 'PointerEvent' in window; const onPointerMove = (event: PointerEvent) => this.handlePointerMove(event); const onPointerLeave = () => this.handlePointerLeave(); @@ -333,7 +341,9 @@ export class InteractiveBackground implements AfterViewInit, OnDestroy { const onMotionChange = (event: MediaQueryListEvent) => this.handleMotionPreferenceChange(event.matches); - window.addEventListener('resize', onResize); + window.addEventListener('resize', onResize, { passive: true }); + visualViewport?.addEventListener('resize', onResize, { passive: true }); + visualViewport?.addEventListener('scroll', onResize, { passive: true }); if (supportsPointerEvents) { window.addEventListener('pointermove', onPointerMove, { passive: true }); @@ -356,6 +366,8 @@ export class InteractiveBackground implements AfterViewInit, OnDestroy { this.destroyCallbacks = [ () => window.removeEventListener('resize', onResize), + () => visualViewport?.removeEventListener('resize', onResize), + () => visualViewport?.removeEventListener('scroll', onResize), () => window.removeEventListener('pointermove', onPointerMove), () => window.removeEventListener('pointerleave', onPointerLeave), () => window.removeEventListener('pointerdown', onPointerDown), @@ -375,22 +387,61 @@ export class InteractiveBackground implements AfterViewInit, OnDestroy { ngOnDestroy(): void { this.stopAnimation(); + if (this.resizeFrameId !== null) { + cancelAnimationFrame(this.resizeFrameId); + this.resizeFrameId = null; + } + if (this.resizeSettleTimeout !== null) { + clearTimeout(this.resizeSettleTimeout); + this.resizeSettleTimeout = null; + } for (const callback of this.destroyCallbacks) { callback(); } this.destroyCallbacks = []; } - private resizeCanvas(): void { + private scheduleCanvasResize(): void { + if (this.resizeFrameId === null) { + this.resizeFrameId = requestAnimationFrame(() => { + this.resizeFrameId = null; + this.resizeCanvas(false); + }); + } + + if (this.resizeSettleTimeout !== null) { + clearTimeout(this.resizeSettleTimeout); + } + + this.resizeSettleTimeout = setTimeout(() => { + this.resizeSettleTimeout = null; + this.resizeCanvas(true); + }, this.resizeSettleDelayMs); + } + + private resizeCanvas(force: boolean): void { if (!this.particleEngine) { return; } const canvas = this.canvasRef.nativeElement; - const width = window.innerWidth; - const height = window.innerHeight; + const viewport = this.getViewportSize(); + const width = Math.max(1, Math.round(viewport.width)); + const height = Math.max(1, Math.round(viewport.height)); const dpr = Math.min(window.devicePixelRatio || 1, 2); + const widthChanged = Math.abs(width - this.lastCanvasWidth) > this.resizeNoiseThresholdPx; + const heightChanged = Math.abs(height - this.lastCanvasHeight) > this.resizeNoiseThresholdPx; + const dprChanged = Math.abs(dpr - this.lastCanvasDpr) > 0.01; + + if (!force && !widthChanged && !heightChanged && !dprChanged) { + return; + } + + this.lastCanvasWidth = width; + this.lastCanvasHeight = height; + this.lastCanvasDpr = dpr; + canvas.width = Math.floor(width * dpr); canvas.height = Math.floor(height * dpr); canvas.style.width = `${width}px`; @@ -400,6 +451,21 @@ export class InteractiveBackground implements AfterViewInit, OnDestroy { this.renderInitialFrame(); } + private getViewportSize(): { width: number; height: number } { + const visualViewport = window.visualViewport; + if (visualViewport) { + return { + width: visualViewport.width, + height: visualViewport.height, + }; + } + + return { + width: window.innerWidth, + height: window.innerHeight, + }; + } + private startAnimation(): void { if (this.frameId !== null) { return;