546 lines
19 KiB
TypeScript
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);
|
|
}
|
|
}
|