Files
Personal-Portfolio-Website/src/app/home/home.component.ts
T
2026-04-27 14:34:04 +02:00

546 lines
19 KiB
TypeScript

import type { AfterViewInit, OnDestroy, OnInit } from '@angular/core';
import { Component, inject, NgZone } from '@angular/core';
import { faArrowRight, faArrowDown, faEnvelope, faPhone } from '@fortawesome/free-solid-svg-icons';
import { faDiscord, faGithub, faLinkedin } from '@fortawesome/free-brands-svg-icons';
import type { IconDefinition } from '@fortawesome/angular-fontawesome';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { DomSanitizer } from '@angular/platform-browser';
import type { SafeUrl } from '@angular/platform-browser';
import type { Subscription } from 'rxjs';
import { interval } from 'rxjs';
import type { BioTextEntry, Global, PortraitHighlight, PortfolioProject } from '../../global';
import { global } from '../../global';
import { FooterComponent } from '../footer/footer.component';
@Component({
selector: 'app-home',
standalone: true,
imports: [FooterComponent, FaIconComponent],
templateUrl: './home.component.html',
})
export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
private readonly sanitizer = inject(DomSanitizer);
private readonly zone = inject(NgZone);
private preloadedImages: HTMLImageElement[] = [];
private tallestPortraitAspectRatio: number | null = null;
private lockedHeroSectionMinHeight: number = 0;
private scrollAnimationFrameId: number | null = null;
private heroHeightAnimationFrameId: number | null = null;
private projectEntryAnimationFrameId: number | null = null;
private aboutSectionAnimationFrameId: number | null = null;
private readonly scheduleNameGradientUpdate: () => void = (): void => {
if (this.scrollAnimationFrameId !== null || typeof window === 'undefined') {
return;
}
this.scrollAnimationFrameId = window.requestAnimationFrame((): void => {
this.scrollAnimationFrameId = null;
this.updateNameGradientProgress();
});
};
private readonly scheduleHeroSectionHeightUpdate: () => void = (): void => {
if (this.heroHeightAnimationFrameId !== null || typeof window === 'undefined') {
return;
}
this.heroHeightAnimationFrameId = window.requestAnimationFrame((): void => {
this.heroHeightAnimationFrameId = null;
this.lockedHeroSectionMinHeight = 0;
this.updateHeroSectionMinHeight();
});
};
private readonly scheduleProjectEntryRevealUpdate: () => void = (): void => {
if (this.projectEntryAnimationFrameId !== null || typeof window === 'undefined') {
return;
}
this.projectEntryAnimationFrameId = window.requestAnimationFrame((): void => {
this.projectEntryAnimationFrameId = null;
this.updateProjectEntryRevealProgress();
});
};
private readonly scheduleAboutSectionScrollAnimationUpdate: () => void = (): void => {
if (this.aboutSectionAnimationFrameId !== null || typeof window === 'undefined') {
return;
}
this.aboutSectionAnimationFrameId = window.requestAnimationFrame((): void => {
this.aboutSectionAnimationFrameId = null;
this.updateAboutSectionScrollProgress();
});
};
protected readonly global: Global = global;
protected readonly age: number = this.calculateAge(global.birthdate);
protected isLongBioShown: boolean = false;
protected isLongBioMounted: boolean = false;
protected readonly faArrowRight: IconDefinition = faArrowRight;
protected readonly faArrowDown: IconDefinition = faArrowDown;
protected readonly faEnvelope: IconDefinition = faEnvelope;
protected readonly faPhone: IconDefinition = faPhone;
readonly faDiscord: IconDefinition = faDiscord;
readonly faGithub: IconDefinition = faGithub;
readonly faLinkedin: IconDefinition = faLinkedin;
protected readonly contactSafeMail: SafeUrl;
protected readonly portraitHighlights: PortraitHighlight[] = global.portraitHighlights;
protected currentPortraitHighlightIndex: number = 0;
protected isPortraitSwitching: boolean = false;
protected currentIndex = 0;
protected readonly projects: PortfolioProject[] = global.projects;
protected readonly fallbackBioEntry: BioTextEntry = { label: 'my stack', value: '' };
private sub!: Subscription;
private bioHideTimeoutId: number | null = null;
constructor() {
this.contactSafeMail = this.sanitizer.bypassSecurityTrustUrl(`mailto:${global.contactMail}`);
}
ngOnInit(): void {
this.preloadImageAssets();
this.initNameGradientScrollAnimation();
this.zone.runOutsideAngular((): void => {
this.sub = interval(1000).subscribe((): void => {
this.zone.run((): void => {
this.currentIndex = (this.currentIndex + 1) % this.global.bioTextsList.length;
});
});
});
}
ngAfterViewInit(): void {
this.initProjectEntryRevealObserver();
this.initAboutSectionScrollAnimation();
}
ngOnDestroy(): void {
this.sub.unsubscribe();
if (this.bioHideTimeoutId !== null) {
window.clearTimeout(this.bioHideTimeoutId);
this.bioHideTimeoutId = null;
}
this.destroyNameGradientScrollAnimation();
this.destroyProjectEntryRevealObserver();
this.destroyAboutSectionScrollAnimation();
}
protected toggleBio(): void {
if (this.isLongBioShown) {
this.isLongBioShown = false;
if (typeof window !== 'undefined') {
if (this.bioHideTimeoutId !== null) {
window.clearTimeout(this.bioHideTimeoutId);
}
this.bioHideTimeoutId = window.setTimeout((): void => {
this.isLongBioMounted = false;
this.bioHideTimeoutId = null;
}, 300);
} else {
this.isLongBioMounted = false;
}
return;
}
if (this.bioHideTimeoutId !== null && typeof window !== 'undefined') {
window.clearTimeout(this.bioHideTimeoutId);
this.bioHideTimeoutId = null;
}
this.isLongBioMounted = true;
if (typeof window !== 'undefined') {
window.requestAnimationFrame((): void => {
window.requestAnimationFrame((): void => {
this.isLongBioShown = true;
});
});
} else {
this.isLongBioShown = true;
}
}
protected get currentPortraitHighlight(): PortraitHighlight | null {
return this.portraitHighlights[this.currentPortraitHighlightIndex] ?? null;
}
protected get currentBioEntry(): BioTextEntry {
return this.global.bioTextsList[this.currentIndex] ?? this.fallbackBioEntry;
}
protected showNextPortraitHighlight(): void {
if (this.portraitHighlights.length <= 1 || this.isPortraitSwitching) {
return;
}
this.isPortraitSwitching = true;
setTimeout((): void => {
this.currentPortraitHighlightIndex =
(this.currentPortraitHighlightIndex + 1) % this.portraitHighlights.length;
}, 125);
setTimeout((): void => {
this.isPortraitSwitching = false;
}, 250);
}
protected scrollToAboutSection(): void {
this.scrollToElementById('about', this.getResponsiveScrollOffset(0.035));
}
protected scrollToProjectsSection(): void {
this.scrollToElementById('projects', this.getResponsiveScrollOffset(0.035));
}
protected scrollToContactLinks(): void {
this.scrollToElementById('contact-links', this.getResponsiveScrollOffset(0.05));
}
private preloadImageAssets(): void {
if (typeof window === 'undefined') {
return;
}
const projectIcons: string[] = this.projects
.map((project: PortfolioProject): string | undefined => project.icon)
.filter((icon: string | undefined): icon is string => Boolean(icon));
const uniqueImageUrls: string[] = Array.from(
new Set<string>([
...this.portraitHighlights.map((highlight: PortraitHighlight): string => highlight.image),
...projectIcons,
]),
);
this.preloadedImages = uniqueImageUrls.map((url: string): HTMLImageElement => {
const image = new Image();
image.decoding = 'async';
image.loading = 'eager';
image.src = url;
return image;
});
this.syncHeroHeightToLargestPortraitImage();
}
private syncHeroHeightToLargestPortraitImage(): void {
if (typeof window === 'undefined') {
return;
}
const portraitImageUrls: Set<string> = new Set<string>(
this.portraitHighlights.map(
(highlight: PortraitHighlight): string =>
new URL(highlight.image, window.location.href).href,
),
);
const portraitImages: HTMLImageElement[] = this.preloadedImages.filter(
(image: HTMLImageElement): boolean => portraitImageUrls.has(image.src),
);
if (portraitImages.length === 0) {
return;
}
const finalizeAspectRatio = (): void => {
const minAspectRatio: number = portraitImages.reduce(
(minRatio: number, image: HTMLImageElement): number => {
if (image.naturalWidth === 0 || image.naturalHeight === 0) {
return minRatio;
}
const currentRatio: number = image.naturalWidth / image.naturalHeight;
return minRatio === 0 ? currentRatio : Math.min(minRatio, currentRatio);
},
0,
);
if (minAspectRatio > 0) {
this.tallestPortraitAspectRatio = minAspectRatio;
this.scheduleHeroSectionHeightUpdate();
}
};
let pendingLoads: number = portraitImages.length;
const onImageSettled = (): void => {
pendingLoads -= 1;
if (pendingLoads === 0) {
finalizeAspectRatio();
}
};
for (const image of portraitImages) {
if (image.complete) {
onImageSettled();
continue;
}
image.addEventListener('load', onImageSettled, { once: true });
image.addEventListener('error', onImageSettled, { once: true });
}
}
private initNameGradientScrollAnimation(): void {
if (typeof window === 'undefined') {
return;
}
window.addEventListener('scroll', this.scheduleNameGradientUpdate, { passive: true });
window.addEventListener('resize', this.scheduleNameGradientUpdate, { passive: true });
window.addEventListener('resize', this.scheduleHeroSectionHeightUpdate, { passive: true });
this.scheduleNameGradientUpdate();
this.scheduleHeroSectionHeightUpdate();
}
private initProjectEntryRevealObserver(): void {
if (typeof window === 'undefined') {
return;
}
window.addEventListener('scroll', this.scheduleProjectEntryRevealUpdate, { passive: true });
window.addEventListener('resize', this.scheduleProjectEntryRevealUpdate, { passive: true });
this.scheduleProjectEntryRevealUpdate();
}
private destroyProjectEntryRevealObserver(): void {
if (typeof window === 'undefined' || typeof document === 'undefined') {
return;
}
window.removeEventListener('scroll', this.scheduleProjectEntryRevealUpdate);
window.removeEventListener('resize', this.scheduleProjectEntryRevealUpdate);
if (this.projectEntryAnimationFrameId !== null) {
window.cancelAnimationFrame(this.projectEntryAnimationFrameId);
this.projectEntryAnimationFrameId = null;
}
const projectEntries: NodeListOf<HTMLElement> = document.querySelectorAll('.project-entry');
projectEntries.forEach((entry: HTMLElement): void => {
entry.style.removeProperty('--project-entry-progress');
});
}
private initAboutSectionScrollAnimation(): void {
if (typeof window === 'undefined') {
return;
}
window.addEventListener('scroll', this.scheduleAboutSectionScrollAnimationUpdate, {
passive: true,
});
window.addEventListener('resize', this.scheduleAboutSectionScrollAnimationUpdate, {
passive: true,
});
this.scheduleAboutSectionScrollAnimationUpdate();
}
private destroyAboutSectionScrollAnimation(): void {
if (typeof window === 'undefined' || typeof document === 'undefined') {
return;
}
window.removeEventListener('scroll', this.scheduleAboutSectionScrollAnimationUpdate);
window.removeEventListener('resize', this.scheduleAboutSectionScrollAnimationUpdate);
if (this.aboutSectionAnimationFrameId !== null) {
window.cancelAnimationFrame(this.aboutSectionAnimationFrameId);
this.aboutSectionAnimationFrameId = null;
}
const aboutSection: HTMLElement | null = document.getElementById('about');
aboutSection?.style.removeProperty('--about-scroll-progress');
}
private destroyNameGradientScrollAnimation(): void {
if (typeof window === 'undefined') {
return;
}
window.removeEventListener('scroll', this.scheduleNameGradientUpdate);
window.removeEventListener('resize', this.scheduleNameGradientUpdate);
window.removeEventListener('resize', this.scheduleHeroSectionHeightUpdate);
if (this.scrollAnimationFrameId !== null) {
window.cancelAnimationFrame(this.scrollAnimationFrameId);
this.scrollAnimationFrameId = null;
}
if (this.heroHeightAnimationFrameId !== null) {
window.cancelAnimationFrame(this.heroHeightAnimationFrameId);
this.heroHeightAnimationFrameId = null;
}
document.documentElement.style.removeProperty('--name-pride-progress');
document.documentElement.style.removeProperty('--hero-section-min-height');
this.lockedHeroSectionMinHeight = 0;
}
private updateNameGradientProgress(): void {
if (typeof window === 'undefined') {
return;
}
const root: HTMLElement = document.documentElement;
const portfolioEyebrow: HTMLElement | null = document.getElementById('portfolio-eyebrow');
const portfolioTriggerY: number =
portfolioEyebrow === null
? Number.POSITIVE_INFINITY
: portfolioEyebrow.getBoundingClientRect().top + window.scrollY;
const isMobileViewport: boolean = window.innerWidth <= 700;
const mobileRevealDistance: number = window.innerHeight / 25;
const triggerDistance: number = isMobileViewport
? mobileRevealDistance
: Math.max(portfolioTriggerY, 1);
const rawProgress: number = Math.min(window.scrollY / triggerDistance, 1);
const acceleratedProgress: number = Math.pow(rawProgress, isMobileViewport ? 0.48 : 0.66);
const progress: number =
window.scrollY > 0
? Math.max(acceleratedProgress, isMobileViewport ? 0.18 : 0.05)
: acceleratedProgress;
root.style.setProperty('--name-pride-progress', progress.toFixed(4));
}
private updateHeroSectionMinHeight(): void {
if (typeof window === 'undefined' || this.tallestPortraitAspectRatio === null) {
return;
}
const heroSection: HTMLElement | null = document.querySelector('.hero-section');
const heroCopy: HTMLElement | null = document.querySelector('.hero-copy');
const portraitHighlight: HTMLElement | null = document.querySelector('.portrait-highlight');
const portraitButton: HTMLElement | null = document.querySelector('.portrait-highlight-button');
if (
heroSection === null ||
heroCopy === null ||
portraitHighlight === null ||
portraitButton === null
) {
return;
}
const buttonWidth: number = portraitButton.getBoundingClientRect().width;
if (buttonWidth <= 0) {
return;
}
const maxMediaHeight: number = buttonWidth / this.tallestPortraitAspectRatio;
const currentButtonHeight: number = portraitButton.getBoundingClientRect().height;
const currentHighlightHeight: number = portraitHighlight.getBoundingClientRect().height;
const staticHighlightOverhead: number = Math.max(
currentHighlightHeight - currentButtonHeight,
0,
);
const requiredHighlightHeight: number = maxMediaHeight + staticHighlightOverhead;
const requiredContentHeight: number = Math.max(
heroCopy.getBoundingClientRect().height,
requiredHighlightHeight,
);
const heroSectionStyles: CSSStyleDeclaration = window.getComputedStyle(heroSection);
const paddingTop: number = Number.parseFloat(heroSectionStyles.paddingTop) || 0;
const paddingBottom: number = Number.parseFloat(heroSectionStyles.paddingBottom) || 0;
const requiredSectionMinHeight: number = requiredContentHeight + paddingTop + paddingBottom;
this.lockedHeroSectionMinHeight = Math.max(
this.lockedHeroSectionMinHeight,
requiredSectionMinHeight,
);
document.documentElement.style.setProperty(
'--hero-section-min-height',
`${Math.ceil(this.lockedHeroSectionMinHeight)}px`,
);
}
private updateAboutSectionScrollProgress(): void {
if (typeof window === 'undefined') {
return;
}
const aboutSection: HTMLElement | null = document.getElementById('about');
if (aboutSection === null) {
return;
}
const sectionRect: DOMRect = aboutSection.getBoundingClientRect();
const viewportHeight: number = window.innerHeight || 1;
const startY: number = viewportHeight * 0.96;
const endY: number = viewportHeight * 0.46;
const totalDistance: number = Math.max(startY - endY, 1);
const rawProgress: number = (startY - sectionRect.top) / totalDistance;
const clampedProgress: number = Math.min(Math.max(rawProgress, 0), 1);
aboutSection.style.setProperty('--about-scroll-progress', clampedProgress.toFixed(4));
}
private updateProjectEntryRevealProgress(): void {
if (typeof window === 'undefined' || typeof document === 'undefined') {
return;
}
const projectEntries: NodeListOf<HTMLElement> = document.querySelectorAll('.project-entry');
const viewportHeight: number = window.innerHeight || 1;
const startY: number = viewportHeight * 0.98;
const endY: number = viewportHeight * 0.56;
const totalDistance: number = Math.max(startY - endY, 1);
projectEntries.forEach((entry: HTMLElement): void => {
const entryRect: DOMRect = entry.getBoundingClientRect();
const rawProgress: number = (startY - entryRect.top) / totalDistance;
const clampedProgress: number = Math.min(Math.max(rawProgress, 0), 1);
entry.style.setProperty('--project-entry-progress', clampedProgress.toFixed(4));
});
}
private calculateAge(birthDateString: string): number {
const birthDate: Date = new Date(birthDateString);
const now: Date = new Date();
let age: number = now.getFullYear() - birthDate.getFullYear();
const hasBirthdayPassedThisYear: boolean =
now.getMonth() > birthDate.getMonth() ||
(now.getMonth() === birthDate.getMonth() && now.getDate() >= birthDate.getDate());
if (!hasBirthdayPassedThisYear) {
age -= 1;
}
return age;
}
private scrollToElementById(elementId: string, offsetPx: number = 0): void {
if (typeof document === 'undefined' || typeof window === 'undefined') {
return;
}
const element: HTMLElement | null = document.getElementById(elementId);
if (element === null) {
return;
}
const targetTop: number = Math.max(
element.getBoundingClientRect().top + window.scrollY - offsetPx,
0,
);
window.scrollTo({
top: targetTop,
behavior: 'smooth',
});
}
private getResponsiveScrollOffset(viewportRatio: number = 0.05): number {
if (typeof window === 'undefined') {
return 0;
}
const minOffsetPx: number = 20;
const maxOffsetPx: number = 48;
const responsiveOffsetPx: number = window.innerHeight * viewportRatio;
return Math.min(Math.max(responsiveOffsetPx, minOffsetPx), maxOffsetPx);
}
}