simplified carousel
All checks were successful
publish.yml / publish (push) Successful in 1m4s

This commit is contained in:
2026-04-24 00:08:08 +02:00
parent f7eceda442
commit 7108f22e8b
3 changed files with 33 additions and 336 deletions

View File

@@ -17,29 +17,8 @@ interface Skill {
})
export class TechStackCarousel implements AfterViewInit, OnDestroy {
@ViewChild('stackCarousel') private readonly stackCarousel?: ElementRef<HTMLElement>;
private autoScrollFrameId: number | null = null;
private startAutoScrollTimeout: ReturnType<typeof setTimeout> | null = null;
private delayedResumeTimeout: ReturnType<typeof setTimeout> | null = null;
private delayedHoverPauseTimeout: ReturnType<typeof setTimeout> | 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<typeof setInterval> | 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;
}
}