From 0160c2aec60d4d0c2c3c2639cd0f9fdad111c8ed Mon Sep 17 00:00:00 2001 From: Julian Lechner Date: Mon, 27 Apr 2026 16:12:42 +0200 Subject: [PATCH] fix: performance --- scripts/generate-optimized-assets.cjs | 2 +- src/app/home/home.component.html | 14 ++- src/app/home/home.component.ts | 164 +------------------------- src/global.ts | 26 +++- src/styles.scss | 1 - 5 files changed, 33 insertions(+), 174 deletions(-) diff --git a/scripts/generate-optimized-assets.cjs b/scripts/generate-optimized-assets.cjs index 987ee66..e16e2b3 100644 --- a/scripts/generate-optimized-assets.cjs +++ b/scripts/generate-optimized-assets.cjs @@ -57,7 +57,7 @@ function getProfile(relativePath) { } if (normalizedRelativePath.startsWith('portrait/')) { - return { maxWidth: 900, quality: 82, alphaQuality: 90, variants: [128, 320, 480, 640, 900] }; + return { maxWidth: 640, quality: 82, alphaQuality: 90, variants: [128, 320, 480] }; } return { maxWidth: 1600, quality: 82, alphaQuality: 92 }; diff --git a/src/app/home/home.component.html b/src/app/home/home.component.html index bf3a6aa..8774621 100644 --- a/src/app/home/home.component.html +++ b/src/app/home/home.component.html @@ -66,11 +66,14 @@
@if (currentPortraitHighlightIndex === 0) { @@ -144,9 +147,12 @@ } diff --git a/src/app/home/home.component.ts b/src/app/home/home.component.ts index 599435a..e49be76 100644 --- a/src/app/home/home.component.ts +++ b/src/app/home/home.component.ts @@ -1,5 +1,6 @@ import type { AfterViewInit, OnDestroy, OnInit } from '@angular/core'; import { Component, inject, NgZone } from '@angular/core'; +import { NgOptimizedImage } from '@angular/common'; 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'; @@ -16,17 +17,13 @@ import { FooterComponent } from '../footer/footer.component'; @Component({ selector: 'app-home', standalone: true, - imports: [FooterComponent, FaIconComponent], + imports: [FooterComponent, FaIconComponent, NgOptimizedImage], 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 => { @@ -39,17 +36,6 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy { 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; @@ -100,7 +86,6 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy { } ngOnInit(): void { - this.preloadImageAssets(); this.initNameGradientScrollAnimation(); this.zone.runOutsideAngular((): void => { @@ -199,91 +184,6 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy { 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([ - ...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 = new Set( - 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; @@ -291,9 +191,7 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy { 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 { @@ -363,20 +261,13 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy { 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 { @@ -405,57 +296,6 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy { 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; diff --git a/src/global.ts b/src/global.ts index a007a2b..ce27e18 100644 --- a/src/global.ts +++ b/src/global.ts @@ -13,6 +13,8 @@ export interface PortraitHighlight { image: string; imageSrcset: string; imageSizes: string; + imageWidth: number; + imageHeight: number; text: string; } @@ -80,49 +82,61 @@ export const global: Global = { { image: 'assets/optimized/portrait/me.webp', imageSrcset: - 'assets/optimized/portrait/me-320.webp 320w, assets/optimized/portrait/me-480.webp 480w, assets/optimized/portrait/me-640.webp 640w, assets/optimized/portrait/me.webp 900w', + 'assets/optimized/portrait/me-320.webp 320w, assets/optimized/portrait/me-480.webp 480w, assets/optimized/portrait/me.webp 640w', imageSizes: '(max-width: 700px) calc(100vw - 2rem), (max-width: 900px) min(100vw - 6rem, 560px), 560px', + imageWidth: 640, + imageHeight: 769, text: 'ME', }, { image: 'assets/optimized/portrait/love.webp', imageSrcset: - 'assets/optimized/portrait/love-320.webp 320w, assets/optimized/portrait/love-480.webp 480w, assets/optimized/portrait/love-640.webp 640w, assets/optimized/portrait/love.webp 900w', + 'assets/optimized/portrait/love-320.webp 320w, assets/optimized/portrait/love-480.webp 480w, assets/optimized/portrait/love.webp 640w', imageSizes: '(max-width: 700px) calc(100vw - 2rem), (max-width: 900px) min(100vw - 6rem, 560px), 560px', + imageWidth: 640, + imageHeight: 710, text: 'My Love', }, { image: 'assets/optimized/portrait/scuba.webp', imageSrcset: - 'assets/optimized/portrait/scuba-320.webp 320w, assets/optimized/portrait/scuba-480.webp 480w, assets/optimized/portrait/scuba-640.webp 640w, assets/optimized/portrait/scuba.webp 900w', + 'assets/optimized/portrait/scuba-320.webp 320w, assets/optimized/portrait/scuba-480.webp 480w, assets/optimized/portrait/scuba.webp 640w', imageSizes: '(max-width: 700px) calc(100vw - 2rem), (max-width: 900px) min(100vw - 6rem, 560px), 560px', + imageWidth: 640, + imageHeight: 480, text: 'Scuba Diving', }, { image: 'assets/optimized/portrait/trains.webp', imageSrcset: - 'assets/optimized/portrait/trains-320.webp 320w, assets/optimized/portrait/trains-480.webp 480w, assets/optimized/portrait/trains-640.webp 640w, assets/optimized/portrait/trains.webp 900w', + 'assets/optimized/portrait/trains-320.webp 320w, assets/optimized/portrait/trains-480.webp 480w, assets/optimized/portrait/trains.webp 640w', imageSizes: '(max-width: 700px) calc(100vw - 2rem), (max-width: 900px) min(100vw - 6rem, 560px), 560px', + imageWidth: 640, + imageHeight: 850, text: 'Trains', }, { image: 'assets/optimized/portrait/traveling.webp', imageSrcset: - 'assets/optimized/portrait/traveling-320.webp 320w, assets/optimized/portrait/traveling-480.webp 480w, assets/optimized/portrait/traveling-640.webp 640w, assets/optimized/portrait/traveling.webp 900w', + 'assets/optimized/portrait/traveling-320.webp 320w, assets/optimized/portrait/traveling-480.webp 480w, assets/optimized/portrait/traveling.webp 640w', imageSizes: '(max-width: 700px) calc(100vw - 2rem), (max-width: 900px) min(100vw - 6rem, 560px), 560px', + imageWidth: 640, + imageHeight: 480, text: 'Traveling', }, { image: 'assets/optimized/portrait/culture.webp', imageSrcset: - 'assets/optimized/portrait/culture-320.webp 320w, assets/optimized/portrait/culture-480.webp 480w, assets/optimized/portrait/culture-640.webp 640w, assets/optimized/portrait/culture.webp 900w', + 'assets/optimized/portrait/culture-320.webp 320w, assets/optimized/portrait/culture-480.webp 480w, assets/optimized/portrait/culture.webp 640w', imageSizes: '(max-width: 700px) calc(100vw - 2rem), (max-width: 900px) min(100vw - 6rem, 560px), 560px', + imageWidth: 640, + imageHeight: 482, text: 'Culture', }, ], diff --git a/src/styles.scss b/src/styles.scss index 4bc2b59..efa4955 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -119,7 +119,6 @@ hr { grid-template-columns: minmax(0, 1.15fr) minmax(280px, 0.85fr); gap: var(--space-6); align-items: start; - min-height: var(--hero-section-min-height, auto); padding: clamp(2rem, 4vw, 4rem); }