- @for (skill of renderedSkills; track $index) {
+ @for (skill of skills; track skill.name) {
diff --git a/src/components/tech-stack-carousel/tech-stack-carousel.ts b/src/components/tech-stack-carousel/tech-stack-carousel.ts
index abe496d..0763e41 100644
--- a/src/components/tech-stack-carousel/tech-stack-carousel.ts
+++ b/src/components/tech-stack-carousel/tech-stack-carousel.ts
@@ -17,29 +17,8 @@ interface Skill {
})
export class TechStackCarousel implements AfterViewInit, OnDestroy {
@ViewChild('stackCarousel') private readonly stackCarousel?: ElementRef;
- 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;
- private targetAutoScrollSpeedPxPerSecond = 0;
- private isPausePendingStop = false;
- private isTouchInteracting = false;
- private isProgrammaticScrollUpdate = false;
- private lastFadeStateUpdateTime = 0;
- private readonly autoScrollSpeedPxPerSecond = 48;
- private readonly speedRampDurationMs = 420;
- private readonly speedStopThresholdPxPerSecond = 0.5;
- private readonly touchScrollResumeDelayMs = 1200;
- private readonly hoverPauseDelayMs = 300;
- private readonly fadeUpdateIntervalMs = 96;
- isAutoScrolling = false;
- showLeftFade = false;
- showRightFade = false;
+ private autoScrollIntervalId: ReturnType | null = null;
+ private readonly autoScrollIntervalMs = 5000;
readonly skills: Skill[] = [
{ name: 'Angular', category: 'Frontend', level: 'Advanced', logoUrl: '/tech-logos/angular.svg', fallbackLabel: 'NG' },
@@ -56,38 +35,12 @@ export class TechStackCarousel implements AfterViewInit, OnDestroy {
{ name: 'Flutter', category: 'Mobile', level: 'Advanced', logoUrl: '/tech-logos/flutter.svg', fallbackLabel: 'FL' },
];
- readonly renderedSkills: Skill[] = [...this.skills, ...this.skills];
-
ngAfterViewInit(): void {
- const prefersReducedMotion =
- typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
-
- if (prefersReducedMotion) {
- this.updateFadeState();
- return;
- }
-
- // Defer start to the next task to avoid NG0100 in dev mode.
- this.startAutoScrollTimeout = setTimeout(() => {
- this.startAutoScrollTimeout = null;
- this.updateFadeState();
- this.resumeAutoScroll();
- }, 0);
-
- document.addEventListener('visibilitychange', this.onVisibilityChange);
+ this.startAutoScroll();
}
ngOnDestroy(): void {
- if (this.startAutoScrollTimeout !== null) {
- clearTimeout(this.startAutoScrollTimeout);
- this.startAutoScrollTimeout = null;
- }
-
- this.clearScheduledResume();
- this.clearScheduledHoverPause();
- this.pauseAutoScroll();
- document.removeEventListener('visibilitychange', this.onVisibilityChange);
- this.isAutoScrolling = false;
+ this.stopAutoScroll();
}
scrollStack(direction: -1 | 1): void {
@@ -96,157 +49,17 @@ export class TechStackCarousel implements AfterViewInit, OnDestroy {
return;
}
+ const maxScrollLeft = Math.max(carousel.scrollWidth - carousel.clientWidth, 0);
+ if (maxScrollLeft <= 0) {
+ return;
+ }
+
+ if (direction === 1 && carousel.scrollLeft >= maxScrollLeft) {
+ carousel.scrollTo({ left: 0, behavior: 'auto' });
+ return;
+ }
+
carousel.scrollBy({ left: this.getScrollStep(carousel) * direction, behavior: 'smooth' });
- requestAnimationFrame(() => this.updateFadeState(carousel));
- }
-
- onCarouselScroll(): void {
- if (this.isProgrammaticScrollUpdate) {
- this.isProgrammaticScrollUpdate = false;
- this.updateFadeState();
- return;
- }
-
- // Keep autoscroll from fighting touch/momentum scrolling on mobile.
- this.pauseAutoScrollImmediately();
- this.scheduleResume(this.touchScrollResumeDelayMs);
- this.updateFadeState();
- }
-
- onTouchStart(): void {
- this.isTouchInteracting = true;
- this.clearScheduledResume();
- this.pauseAutoScrollImmediately();
- }
-
- onTouchEnd(): void {
- this.isTouchInteracting = false;
- this.scheduleResume(this.touchScrollResumeDelayMs);
- }
-
- onTouchCancel(): void {
- this.isTouchInteracting = false;
- this.scheduleResume(this.touchScrollResumeDelayMs);
- }
-
- onHoverStart(): void {
- 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);
- }
-
- pauseAutoScroll(): void {
- this.targetAutoScrollSpeedPxPerSecond = 0;
- this.isPausePendingStop = true;
- this.syncAutoScrollState();
-
- if (this.autoScrollFrameId === null) {
- this.currentAutoScrollSpeedPxPerSecond = 0;
- this.lastAutoScrollTime = 0;
- }
- }
-
- resumeAutoScroll(): void {
- if (document.hidden) {
- return;
- }
-
- if (this.isTouchInteracting) {
- this.scheduleResume(this.touchScrollResumeDelayMs);
- return;
- }
-
- this.isPausePendingStop = false;
- this.targetAutoScrollSpeedPxPerSecond = this.autoScrollSpeedPxPerSecond;
- this.syncAutoScrollState();
-
- if (this.autoScrollFrameId !== null) {
- return;
- }
-
- const step = (timestamp: number) => {
- const carousel = this.stackCarousel?.nativeElement;
- if (!carousel) {
- this.autoScrollFrameId = null;
- this.lastAutoScrollTime = 0;
- this.virtualScrollLeft = 0;
- this.currentAutoScrollSpeedPxPerSecond = 0;
- this.targetAutoScrollSpeedPxPerSecond = 0;
- this.isPausePendingStop = false;
- this.isAutoScrolling = false;
- return;
- }
-
- if (this.lastAutoScrollTime === 0) {
- const loopWidth = this.getLoopWidth(carousel);
- this.virtualScrollLeft = loopWidth > 0 ? carousel.scrollLeft : 0;
- }
-
- const deltaMs = this.lastAutoScrollTime > 0 ? Math.min(timestamp - this.lastAutoScrollTime, 40) : 16;
- this.lastAutoScrollTime = timestamp;
-
- this.updateAutoScrollSpeed(deltaMs);
-
- if (this.currentAutoScrollSpeedPxPerSecond > this.speedStopThresholdPxPerSecond) {
- this.autoAdvanceStack(carousel, deltaMs, this.currentAutoScrollSpeedPxPerSecond);
- }
-
- if (timestamp - this.lastFadeStateUpdateTime >= this.fadeUpdateIntervalMs) {
- this.updateFadeState(carousel);
- this.lastFadeStateUpdateTime = timestamp;
- }
-
- this.syncAutoScrollState();
-
- if (this.isPausePendingStop && this.currentAutoScrollSpeedPxPerSecond <= this.speedStopThresholdPxPerSecond) {
- this.currentAutoScrollSpeedPxPerSecond = 0;
- this.targetAutoScrollSpeedPxPerSecond = 0;
- this.lastAutoScrollTime = 0;
- this.autoScrollFrameId = null;
- this.isPausePendingStop = false;
- this.syncAutoScrollState();
- return;
- }
-
- this.autoScrollFrameId = requestAnimationFrame(step);
- };
-
- this.autoScrollFrameId = requestAnimationFrame(step);
- }
-
- onCarouselFocusOut(event: FocusEvent): void {
- const next = event.relatedTarget as Node | null;
- const shell = event.currentTarget as HTMLElement | null;
-
- if (!shell?.contains(next)) {
- this.scheduleResume(1000);
- }
}
onLogoError(event: Event): void {
@@ -255,128 +68,27 @@ export class TechStackCarousel implements AfterViewInit, OnDestroy {
wrapper?.classList.add('is-fallback');
}
- private scheduleResume(delayMs: number): void {
- const nextResumeAt = Date.now() + delayMs;
- if (nextResumeAt <= this.delayedResumeAt) {
- return;
- }
-
- this.delayedResumeAt = nextResumeAt;
-
- if (this.delayedResumeTimeout !== null) {
- clearTimeout(this.delayedResumeTimeout);
- }
-
- this.delayedResumeTimeout = setTimeout(() => {
- this.delayedResumeTimeout = null;
- this.delayedResumeAt = 0;
- this.resumeAutoScroll();
- }, delayMs);
- }
-
- private clearScheduledResume(): void {
- if (this.delayedResumeTimeout !== null) {
- clearTimeout(this.delayedResumeTimeout);
- this.delayedResumeTimeout = null;
- }
-
- 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;
- }
-
- const speedDelta = this.targetAutoScrollSpeedPxPerSecond - this.currentAutoScrollSpeedPxPerSecond;
- if (Math.abs(speedDelta) <= this.speedStopThresholdPxPerSecond) {
- this.currentAutoScrollSpeedPxPerSecond = this.targetAutoScrollSpeedPxPerSecond;
- return;
- }
-
- const maxStep = (this.autoScrollSpeedPxPerSecond * deltaMs) / this.speedRampDurationMs;
- const appliedStep = Math.sign(speedDelta) * Math.min(Math.abs(speedDelta), maxStep);
- this.currentAutoScrollSpeedPxPerSecond += appliedStep;
- }
-
- private autoAdvanceStack(carousel: HTMLElement, deltaMs: number, speedPxPerSecond: number): void {
- const loopWidth = this.getLoopWidth(carousel);
- if (loopWidth <= 0) {
- return;
- }
-
- const nextVirtualScrollLeft = this.virtualScrollLeft + (speedPxPerSecond * deltaMs) / 1000;
-
- // Normalize only when moving past the duplicated track boundary to avoid abrupt remaps on quick re-entry.
- this.virtualScrollLeft = nextVirtualScrollLeft >= loopWidth ? nextVirtualScrollLeft - loopWidth : nextVirtualScrollLeft;
-
- this.isProgrammaticScrollUpdate = true;
- carousel.scrollLeft = this.virtualScrollLeft;
- }
-
- private getLoopWidth(carousel: HTMLElement): number {
- return carousel.scrollWidth / 2;
- }
-
- private updateFadeState(carousel = this.stackCarousel?.nativeElement): void {
- if (!carousel) {
- this.showLeftFade = false;
- this.showRightFade = false;
- return;
- }
-
- const maxScroll = Math.max(carousel.scrollWidth - carousel.clientWidth, 0);
- if (maxScroll <= 1) {
- this.showLeftFade = false;
- this.showRightFade = false;
- return;
- }
-
- const left = carousel.scrollLeft;
- this.showLeftFade = left > 1;
- this.showRightFade = left < maxScroll - 1;
- }
-
private getScrollStep(carousel: HTMLElement): number {
return Math.max(carousel.clientWidth * 0.82, 260);
}
- private syncAutoScrollState(): void {
- this.isAutoScrolling =
- this.targetAutoScrollSpeedPxPerSecond > this.speedStopThresholdPxPerSecond ||
- this.currentAutoScrollSpeedPxPerSecond > this.speedStopThresholdPxPerSecond;
- }
-
- private pauseAutoScrollImmediately(): void {
- this.clearScheduledResume();
-
- if (this.autoScrollFrameId !== null) {
- cancelAnimationFrame(this.autoScrollFrameId);
- this.autoScrollFrameId = null;
- }
-
- this.isPausePendingStop = false;
- this.currentAutoScrollSpeedPxPerSecond = 0;
- this.targetAutoScrollSpeedPxPerSecond = 0;
- this.lastAutoScrollTime = 0;
- this.lastFadeStateUpdateTime = 0;
- this.syncAutoScrollState();
- }
-
- private readonly onVisibilityChange = (): void => {
- if (document.hidden) {
- this.pauseAutoScrollImmediately();
+ private startAutoScroll(): void {
+ if (this.autoScrollIntervalId !== null) {
return;
}
- this.scheduleResume(320);
- };
+ this.autoScrollIntervalId = setInterval(() => {
+ this.scrollStack(1);
+ }, this.autoScrollIntervalMs);
+ }
+
+ private stopAutoScroll(): void {
+ if (this.autoScrollIntervalId === null) {
+ return;
+ }
+
+ clearInterval(this.autoScrollIntervalId);
+ this.autoScrollIntervalId = null;
+ }
}