From 1a00b77511495029ce81d07a9e93f7cbc12a2e7f Mon Sep 17 00:00:00 2001 From: Tim Kainz Date: Thu, 23 Apr 2026 23:45:04 +0200 Subject: [PATCH] add hover delay to carousel and remove dynamic background --- .../interactive-background.ts | 699 +----------------- .../tech-stack-carousel.html | 2 +- .../tech-stack-carousel.ts | 31 +- 3 files changed, 62 insertions(+), 670 deletions(-) diff --git a/src/components/interactive-background/interactive-background.ts b/src/components/interactive-background/interactive-background.ts index da15546..0002ee0 100644 --- a/src/components/interactive-background/interactive-background.ts +++ b/src/components/interactive-background/interactive-background.ts @@ -1,272 +1,4 @@ -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); - } -} +import { AfterViewInit, Component, ElementRef, HostListener, ViewChild } from '@angular/core'; @Component({ selector: 'app-interactive-background', @@ -274,425 +6,56 @@ class ParticleEngine { templateUrl: './interactive-background.html', styleUrl: './interactive-background.css' }) -export class InteractiveBackground implements AfterViewInit, OnDestroy { +export class InteractiveBackground implements AfterViewInit { @ViewChild('canvas', { static: true }) private readonly canvasRef!: ElementRef; - - isPointerDown = false; - - 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 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; + private ctx: CanvasRenderingContext2D | null = null; ngAfterViewInit(): void { - const canvas = this.canvasRef.nativeElement; - const context = canvas.getContext('2d'); - - this.mediaQuery = this.getMediaQuery('(prefers-reduced-motion: reduce)'); - this.lowMotionMobile = this.getMediaQuery('(max-width: 760px), (pointer: coarse) and (hover: none)').matches; - 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 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(true); - this.renderInitialFrame(); - if (!this.reducedMotion) { - this.startAnimation(); - } - } - - const visualViewport = window.visualViewport; - const onResize = () => this.scheduleCanvasResize(); - 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, { passive: true }); - visualViewport?.addEventListener('resize', onResize, { passive: true }); - visualViewport?.addEventListener('scroll', onResize, { passive: true }); - - 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), - () => visualViewport?.removeEventListener('resize', onResize), - () => visualViewport?.removeEventListener('scroll', 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), - ]; + this.ctx = this.canvasRef.nativeElement.getContext('2d'); + this.resizeAndDraw(); } - 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 = []; + @HostListener('window:resize') + onResize(): void { + this.resizeAndDraw(); } - 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) { + private resizeAndDraw(): void { + const ctx = this.ctx; + if (!ctx) { return; } + const width = Math.max(1, window.innerWidth); + const height = Math.max(1, window.innerHeight); const canvas = this.canvasRef.nativeElement; - 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) { + if (canvas.width === width && canvas.height === height) { return; } - this.lastCanvasWidth = width; - this.lastCanvasHeight = height; - this.lastCanvasDpr = dpr; - - canvas.width = Math.floor(width * dpr); - canvas.height = Math.floor(height * dpr); + canvas.width = width; + canvas.height = height; canvas.style.width = `${width}px`; canvas.style.height = `${height}px`; - this.particleEngine.resize(width, height, dpr); - this.renderInitialFrame(); + const gradient = ctx.createRadialGradient( + width * 0.24, + height * 0.12, + 0, + width * 0.5, + height * 0.5, + Math.max(width, height) + ); + gradient.addColorStop(0, 'rgba(88, 166, 255, 0.14)'); + gradient.addColorStop(0.42, 'rgba(88, 166, 255, 0.18)'); + gradient.addColorStop(0.72, 'rgba(147, 102, 255, 0.1)'); + gradient.addColorStop(1, 'rgba(13, 17, 23, 0)'); + ctx.clearRect(0, 0, width, height); + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, width, height); } - 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; - } - - 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 (this.isPointerExcluded(event.target)) { - this.handlePointerLeave(); - return; - } - - 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 (this.isPointerExcluded(event.target)) { - this.handlePointerLeave(); - return; - } - - if (event.pointerType === 'touch') { - this.activeTouchId = event.pointerId; - } - - this.isPointerDown = true; - this.updatePointer(event.clientX, event.clientY, true); - } - - private handlePointerUp(event: PointerEvent): void { - if (this.isPointerExcluded(event.target)) { - this.handlePointerLeave(); - return; - } - - 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 { - if (this.isPointerExcluded(event.target)) { - this.handlePointerLeave(); - return; - } - - this.updatePointer(event.clientX, event.clientY, true); - } - - private handleMouseDown(event: MouseEvent): void { - if (this.isPointerExcluded(event.target)) { - this.handlePointerLeave(); - return; - } - - this.isPointerDown = true; - this.updatePointer(event.clientX, event.clientY, true); - } - - private handleMouseUp(): void { - this.isPointerDown = false; - } - - private handleTouchStart(event: TouchEvent): void { - if (this.isPointerExcluded(event.target)) { - this.handlePointerLeave(); - return; - } - - 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.isPointerExcluded(event.target)) { - this.handlePointerLeave(); - return; - } - - 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.isPointerExcluded(event.target)) { - this.handlePointerLeave(); - return; - } - - 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; - } - - private updatePointer(x: number, y: number, active: boolean): void { - this.pointerX = x; - this.pointerY = y; - this.pointerActive = active; - } - - 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 isPointerExcluded(target: EventTarget | null): boolean { - if (!(target instanceof Element)) { - return false; - } - - return target.closest(this.pointerExcludeSelector) !== 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, - }; - } } diff --git a/src/components/tech-stack-carousel/tech-stack-carousel.html b/src/components/tech-stack-carousel/tech-stack-carousel.html index b9a5eb1..1528b4d 100644 --- a/src/components/tech-stack-carousel/tech-stack-carousel.html +++ b/src/components/tech-stack-carousel/tech-stack-carousel.html @@ -8,7 +8,7 @@ data-disable-bg-pointer (mouseenter)="onHoverStart()" (mouseleave)="onHoverEnd()" - (focusin)="onHoverStart()" + (focusin)="onFocusIn()" (focusout)="onCarouselFocusOut($event)" (click)="onInteractionClick()" > diff --git a/src/components/tech-stack-carousel/tech-stack-carousel.ts b/src/components/tech-stack-carousel/tech-stack-carousel.ts index b9e1e47..a569fbb 100644 --- a/src/components/tech-stack-carousel/tech-stack-carousel.ts +++ b/src/components/tech-stack-carousel/tech-stack-carousel.ts @@ -20,7 +20,9 @@ export class TechStackCarousel implements AfterViewInit, OnDestroy { private autoScrollFrameId: number | null = null; private startAutoScrollTimeout: ReturnType | null = null; private delayedResumeTimeout: ReturnType | null = null; + private delayedHoverPauseTimeout: ReturnType | null = null; private delayedResumeAt = 0; + private isHoveringCarousel = false; private lastAutoScrollTime = 0; private virtualScrollLeft = 0; private currentAutoScrollSpeedPxPerSecond = 0; @@ -32,6 +34,7 @@ export class TechStackCarousel implements AfterViewInit, OnDestroy { private readonly speedRampDurationMs = 420; private readonly speedStopThresholdPxPerSecond = 0.5; private readonly touchScrollResumeDelayMs = 1200; + private readonly hoverPauseDelayMs = 300; isAutoScrolling = false; showLeftFade = false; showRightFade = false; @@ -77,6 +80,7 @@ export class TechStackCarousel implements AfterViewInit, OnDestroy { } this.clearScheduledResume(); + this.clearScheduledHoverPause(); this.pauseAutoScroll(); this.isAutoScrolling = false; } @@ -121,14 +125,32 @@ export class TechStackCarousel implements AfterViewInit, OnDestroy { } onHoverStart(): void { - this.pauseAutoScroll(); + this.isHoveringCarousel = true; this.clearScheduledResume(); + this.clearScheduledHoverPause(); + + this.delayedHoverPauseTimeout = setTimeout(() => { + this.delayedHoverPauseTimeout = null; + + if (!this.isHoveringCarousel) { + return; + } + + this.pauseAutoScroll(); + }, this.hoverPauseDelayMs); } onHoverEnd(): void { + this.isHoveringCarousel = false; + this.clearScheduledHoverPause(); this.scheduleResume(1000); } + onFocusIn(): void { + this.pauseAutoScroll(); + this.clearScheduledResume(); + } + onInteractionClick(): void { this.pauseAutoScroll(); this.scheduleResume(2000); @@ -248,6 +270,13 @@ export class TechStackCarousel implements AfterViewInit, OnDestroy { this.delayedResumeAt = 0; } + private clearScheduledHoverPause(): void { + if (this.delayedHoverPauseTimeout !== null) { + clearTimeout(this.delayedHoverPauseTimeout); + this.delayedHoverPauseTimeout = null; + } + } + private updateAutoScrollSpeed(deltaMs: number): void { if (deltaMs <= 0) { return;