This commit is contained in:
@@ -28,7 +28,6 @@ h2 {
|
||||
|
||||
.stack-carousel-shell {
|
||||
--carousel-nav-space: 4.25rem;
|
||||
--carousel-edge-fade-size: clamp(1.25rem, 3vw, 2.75rem);
|
||||
position: relative;
|
||||
width: calc(100% + (var(--carousel-nav-space) * 2));
|
||||
margin-inline: calc(var(--carousel-nav-space) * -1);
|
||||
@@ -48,15 +47,12 @@ h2 {
|
||||
overflow-x: auto;
|
||||
width: 100%;
|
||||
padding: 0.2rem;
|
||||
scroll-snap-type: none;
|
||||
scroll-snap-type: x mandatory;
|
||||
scroll-behavior: smooth;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(88, 166, 255, 0.45) rgba(33, 38, 45, 0.65);
|
||||
}
|
||||
|
||||
.stack-carousel.is-auto-scrolling {
|
||||
scroll-snap-type: none;
|
||||
}
|
||||
|
||||
.stack-slide {
|
||||
flex: 0 0 min(320px, calc(100% - 0.2rem));
|
||||
scroll-snap-align: start;
|
||||
@@ -181,7 +177,6 @@ h2 {
|
||||
@media (max-width: 760px) {
|
||||
.stack-carousel-shell {
|
||||
--carousel-nav-space: 0;
|
||||
--carousel-edge-fade-size: 0.6rem;
|
||||
width: 100%;
|
||||
margin-inline: 0;
|
||||
}
|
||||
|
||||
@@ -6,29 +6,19 @@
|
||||
<div
|
||||
class="stack-carousel-shell"
|
||||
data-disable-bg-pointer
|
||||
(mouseenter)="onHoverStart()"
|
||||
(mouseleave)="onHoverEnd()"
|
||||
(focusin)="onFocusIn()"
|
||||
(focusout)="onCarouselFocusOut($event)"
|
||||
(click)="onInteractionClick()"
|
||||
>
|
||||
<button class="carousel-btn" type="button" aria-label="Previous technologies" (click)="scrollStack(-1)">
|
||||
<span aria-hidden="true">←</span>
|
||||
</button>
|
||||
|
||||
<div class="stack-carousel-column" [class.has-left-fade]="showLeftFade" [class.has-right-fade]="showRightFade">
|
||||
<div class="stack-carousel-column">
|
||||
<div
|
||||
class="stack-carousel"
|
||||
[class.is-auto-scrolling]="isAutoScrolling"
|
||||
#stackCarousel
|
||||
(scroll)="onCarouselScroll()"
|
||||
(touchstart)="onTouchStart()"
|
||||
(touchend)="onTouchEnd()"
|
||||
(touchcancel)="onTouchCancel()"
|
||||
role="region"
|
||||
aria-label="Tech stack carousel"
|
||||
>
|
||||
@for (skill of renderedSkills; track $index) {
|
||||
@for (skill of skills; track skill.name) {
|
||||
<app-card cardClass="skill-card stack-slide">
|
||||
<div class="skill-heading">
|
||||
<span class="skill-icon" role="img" [attr.aria-label]="skill.name + ' logo'">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user