Lots of technical improvements
Build, Test & Push Frontend / quality-check (pull_request) Failing after 41s
Build, Test & Push Frontend / docker (pull_request) Has been skipped

This commit is contained in:
Andreas Dahm committed 2026-06-02 15:48:31 +02:00
1 parent 102cdfcb52
commit a26443af8f
26 files changed
+1864 -430

No files matched your search

+7 -2
View File
@@ -1,9 +1,10 @@
import {ApplicationConfig, inject, provideAppInitializer, provideBrowserGlobalErrorListeners, provideZonelessChangeDetection} from '@angular/core';
import {ApplicationConfig, inject, isDevMode, provideAppInitializer, provideBrowserGlobalErrorListeners, provideZonelessChangeDetection} from '@angular/core';
import { provideRouter, withInMemoryScrolling } from '@angular/router';
import { routes } from './app.routes';
import {provideAnimations} from '@angular/platform-browser/animations';
import {provideHttpClient} from '@angular/common/http';
import {provideServiceWorker} from '@angular/service-worker';
import {provideTranslateService} from '@ngx-translate/core';
import {provideTranslateHttpLoader} from '@ngx-translate/http-loader';
import {LocalStoreConstants} from './constants/LocalStoreConstants';
@@ -47,11 +48,15 @@ export const appConfig: ApplicationConfig = {
sanitizer.bypassSecurityTrustResourceUrl('assets/logos/linkedIn.svg')
);
}),
provideServiceWorker('ngsw-worker.js', {
enabled: !isDevMode(),
registrationStrategy: 'registerWhenStable:30000'
}),
]
};
function getInitialLang(): string {
const saved = localStorage.getItem(LocalStoreConstants.LANGUAGE_KEY);
const saved = typeof localStorage !== 'undefined' ? localStorage.getItem(LocalStoreConstants.LANGUAGE_KEY) : null;
if (saved) return saved;
const nav = typeof navigator !== 'undefined' ? navigator.language?.toLowerCase() : 'en';
return nav?.startsWith('de') ? 'de' : 'en';
+16 -16
View File
@@ -2,20 +2,20 @@ import { Routes } from '@angular/router';
import {RouterConstants} from './constants/RouterConstants';
export const routes: Routes = [
{ path: '', loadComponent: () => import('./pages/about/about.component').then(m => m.AboutComponent) },
{ path: RouterConstants.ABOUT.PATH, loadComponent: () => import('./pages/about/about.component').then(m => m.AboutComponent) },
{ path: RouterConstants.PROJECTS.PATH, loadComponent: () => import('./pages/projects/projects.component').then(m => m.ProjectsComponent) },
{ path: RouterConstants.ALGORITHMS.PATH, loadComponent: () => import('./pages/algorithms/algorithms.component').then(m => m.AlgorithmsComponent) },
{ path: RouterConstants.PATHFINDING.PATH, loadComponent: () => import('./pages/algorithms/pathfinding/pathfinding.component').then(m => m.PathfindingComponent) },
{ path: RouterConstants.SORTING.PATH, loadComponent: () => import('./pages/algorithms/sorting/sorting.component').then(m => m.SortingComponent) },
{ path: RouterConstants.IMPRINT.PATH, loadComponent: () => import('./pages/imprint/imprint.component').then(m => m.ImprintComponent) },
{ path: RouterConstants.GOL.PATH, loadComponent: () => import('./pages/algorithms/conway-gol/conway-gol.component').then(m => m.ConwayGolComponent) },
{ path: RouterConstants.LABYRINTH.PATH, loadComponent: () => import('./pages/algorithms/pathfinding/labyrinth/labyrinth.component').then(m => m.LabyrinthComponent) },
{ path: RouterConstants.FRACTAL.PATH, loadComponent: () => import('./pages/algorithms/fractal/fractal.component').then(m => m.FractalComponent) },
{ path: RouterConstants.FRACTAL3d.PATH, loadComponent: () => import('./pages/algorithms/fractal3d/fractal3d.component').then(m => m.Fractal3dComponent) },
{ path: RouterConstants.PENDULUM.PATH, loadComponent: () => import('./pages/algorithms/pendulum/pendulum.component').then(m => m.default) },
{ path: RouterConstants.CLOTH.PATH, loadComponent: () => import('./pages/algorithms/cloth/cloth.component').then(m => m.ClothComponent) },
{ path: RouterConstants.FOUR_COLOR.PATH, loadComponent: () => import('./pages/algorithms/four-color/four-color.component').then(m => m.FourColorComponent) },
{ path: RouterConstants.STOPWATCH.PATH, loadComponent: () => import('./pages/stopwatch/stopwatch.component').then(m => m.StopwatchComponent) },
{ path: '', loadComponent: () => import('./pages/about/about.component').then(m => m.AboutComponent), data: { seo: 'ABOUT' } },
{ path: RouterConstants.ABOUT.PATH, loadComponent: () => import('./pages/about/about.component').then(m => m.AboutComponent), data: { seo: 'ABOUT' } },
{ path: RouterConstants.PROJECTS.PATH, loadComponent: () => import('./pages/projects/projects.component').then(m => m.ProjectsComponent), data: { seo: 'PROJECTS' } },
{ path: RouterConstants.ALGORITHMS.PATH, loadComponent: () => import('./pages/algorithms/algorithms.component').then(m => m.AlgorithmsComponent), data: { seo: 'ALGORITHMS' } },
{ path: RouterConstants.PATHFINDING.PATH, loadComponent: () => import('./pages/algorithms/pathfinding/pathfinding.component').then(m => m.PathfindingComponent), data: { seo: 'ALGORITHMS' } },
{ path: RouterConstants.SORTING.PATH, loadComponent: () => import('./pages/algorithms/sorting/sorting.component').then(m => m.SortingComponent), data: { seo: 'ALGORITHMS' } },
{ path: RouterConstants.IMPRINT.PATH, loadComponent: () => import('./pages/imprint/imprint.component').then(m => m.ImprintComponent), data: { seo: 'IMPRINT' } },
{ path: RouterConstants.GOL.PATH, loadComponent: () => import('./pages/algorithms/conway-gol/conway-gol.component').then(m => m.ConwayGolComponent), data: { seo: 'ALGORITHMS' } },
{ path: RouterConstants.LABYRINTH.PATH, loadComponent: () => import('./pages/algorithms/pathfinding/labyrinth/labyrinth.component').then(m => m.LabyrinthComponent), data: { seo: 'ALGORITHMS' } },
{ path: RouterConstants.FRACTAL.PATH, loadComponent: () => import('./pages/algorithms/fractal/fractal.component').then(m => m.FractalComponent), data: { seo: 'ALGORITHMS' } },
{ path: RouterConstants.FRACTAL3d.PATH, loadComponent: () => import('./pages/algorithms/fractal3d/fractal3d.component').then(m => m.Fractal3dComponent), data: { seo: 'ALGORITHMS' } },
{ path: RouterConstants.PENDULUM.PATH, loadComponent: () => import('./pages/algorithms/pendulum/pendulum.component').then(m => m.default), data: { seo: 'ALGORITHMS' } },
{ path: RouterConstants.CLOTH.PATH, loadComponent: () => import('./pages/algorithms/cloth/cloth.component').then(m => m.ClothComponent), data: { seo: 'ALGORITHMS' } },
{ path: RouterConstants.FOUR_COLOR.PATH, loadComponent: () => import('./pages/algorithms/four-color/four-color.component').then(m => m.FourColorComponent), data: { seo: 'ALGORITHMS' } },
{ path: RouterConstants.STOPWATCH.PATH, loadComponent: () => import('./pages/stopwatch/stopwatch.component').then(m => m.StopwatchComponent), data: { seo: 'STOPWATCH' } },
{ path: '**', loadComponent: () => import('./pages/not-found/not-found.component').then(m => m.NotFoundComponent), data: { seo: 'NOT_FOUND' } },
];
+7 -1
View File
@@ -1,8 +1,9 @@
import { Component } from '@angular/core';
import { Component, inject } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import {TopbarComponent} from '../topbar/topbar.component';
import {TranslatePipe} from '@ngx-translate/core';
import {ParticleBackgroundComponent} from '../../shared/components/particles-background/particles-background.component';
import {SeoService} from '../../service/seo.service';
@Component({
@@ -12,5 +13,10 @@ import {ParticleBackgroundComponent} from '../../shared/components/particles-bac
styleUrl: './app.component.scss'
})
export class AppComponent {
private readonly seo = inject(SeoService);
currentYear = new Date().getFullYear();
constructor() {
this.seo.init();
}
}
@@ -0,0 +1,121 @@
import { PathfindingService } from './pathfinding.service';
import { Node } from '../pathfinding.models';
function createNode(row: number, col: number): Node {
return {
row,
col,
isStart: false,
isEnd: false,
isWall: false,
isVisited: false,
isPath: false,
nodeData: Infinity,
linkedNode: null,
fScore: Infinity,
hScore: Infinity,
};
}
function createGrid(rows: number, cols: number): Node[][] {
const grid: Node[][] = [];
for (let row = 0; row < rows; row++) {
const currentRow: Node[] = [];
for (let col = 0; col < cols; col++) {
currentRow.push(createNode(row, col));
}
grid.push(currentRow);
}
return grid;
}
describe('PathfindingService', () => {
let service: PathfindingService;
beforeEach(() => {
service = new PathfindingService();
});
it('should be created', () => {
expect(service).toBeTruthy();
});
describe('getUnvisitedNeighbors', () => {
it('returns the four orthogonal neighbors for an inner node', () => {
const grid = createGrid(3, 3);
const neighbors = service.getUnvisitedNeighbors(grid[1][1], grid);
expect(neighbors.length).toBe(4);
});
it('excludes walls and already visited nodes', () => {
const grid = createGrid(3, 3);
grid[0][1].isWall = true;
grid[1][0].isVisited = true;
const neighbors = service.getUnvisitedNeighbors(grid[1][1], grid);
expect(neighbors.length).toBe(2);
});
it('returns only in-bounds neighbors for a corner node', () => {
const grid = createGrid(3, 3);
const neighbors = service.getUnvisitedNeighbors(grid[0][0], grid);
expect(neighbors.length).toBe(2);
});
});
describe('getNodesInShortestPath', () => {
it('reconstructs the path by following linkedNode references', () => {
const grid = createGrid(1, 3);
grid[0][1].linkedNode = grid[0][0];
grid[0][2].linkedNode = grid[0][1];
const path = service.getNodesInShortestPath(grid[0][2]);
expect(path).toEqual([grid[0][0], grid[0][1], grid[0][2]]);
});
});
describe('dijkstra', () => {
it('finds the shortest path on an open grid', () => {
const grid = createGrid(5, 5);
const start = grid[0][0];
const end = grid[4][4];
const { visitedNodesInOrder, nodesInShortestPathOrder } = service.dijkstra(grid, start, end);
expect(visitedNodesInOrder).toContain(end);
// Manhattan distance 8 => 9 nodes including both endpoints.
expect(nodesInShortestPathOrder.length).toBe(9);
expect(nodesInShortestPathOrder[0]).toBe(start);
expect(nodesInShortestPathOrder[nodesInShortestPathOrder.length - 1]).toBe(end);
});
it('returns an empty path when the end node is walled off', () => {
const grid = createGrid(3, 3);
const start = grid[0][0];
const end = grid[2][2];
grid[1][2].isWall = true;
grid[2][1].isWall = true;
const { nodesInShortestPathOrder } = service.dijkstra(grid, start, end);
expect(nodesInShortestPathOrder).toEqual([]);
});
});
describe('aStar', () => {
it('reaches the end and returns a connected path on an open grid', () => {
const grid = createGrid(5, 5);
const start = grid[0][0];
const end = grid[4][4];
const { visitedNodesInOrder, nodesInShortestPathOrder } = service.aStar(grid, start, end);
expect(visitedNodesInOrder).toContain(end);
expect(nodesInShortestPathOrder.length).toBeGreaterThan(0);
expect(nodesInShortestPathOrder[0]).toBe(start);
expect(nodesInShortestPathOrder[nodesInShortestPathOrder.length - 1]).toBe(end);
});
});
});
@@ -0,0 +1,57 @@
import { SortingService } from './sorting.service';
import { SortData, SortSnapshot } from '../sorting.models';
function toSortData(values: number[]): SortData[] {
return values.map((value) => ({ value, state: 'unsorted' }));
}
function finalValues(snapshots: SortSnapshot[]): number[] {
const lastSnapshot = snapshots[snapshots.length - 1];
return lastSnapshot.array.map((item) => item.value);
}
function isAscending(values: number[]): boolean {
for (let i = 1; i < values.length; i++) {
if (values[i - 1] > values[i]) {
return false;
}
}
return true;
}
describe('SortingService', () => {
let service: SortingService;
const unsorted = [5, 2, 9, 1, 5, 6, 3, 8, 7, 4];
const expectedSorted = [...unsorted].sort((a, b) => a - b);
beforeEach(() => {
service = new SortingService();
});
it('should be created', () => {
expect(service).toBeTruthy();
});
for (const algorithm of [
'bubbleSort',
'cocktailSort',
'quickSort',
'heapSort',
'timSort',
] as const) {
it(`${algorithm} should produce a fully sorted final snapshot`, () => {
const snapshots = service[algorithm](toSortData(unsorted));
const result = finalValues(snapshots);
expect(result).toEqual(expectedSorted);
expect(isAscending(result)).toBeTrue();
});
it(`${algorithm} should not mutate the input array`, () => {
const input = toSortData(unsorted);
service[algorithm](input);
expect(input.map((item) => item.value)).toEqual(unsorted);
});
}
});
@@ -0,0 +1,10 @@
<section class="grid gap-fluid-md max-w-app mx-4 mt-auto">
<mat-card class="p-fluid-md text-center">
<h1 class="m-0 mb-2 text-[clamp(3rem,12vw,6rem)] font-bold opacity-80">404</h1>
<h2 class="m-0 mb-4 text-[clamp(1rem,3vw,1.2rem)] font-semibold">{{ 'NOT_FOUND.TITLE' | translate }}</h2>
<p class="opacity-80 mb-6">{{ 'NOT_FOUND.MESSAGE' | translate }}</p>
<a mat-flat-button color="primary" [routerLink]="RouterConstants.ABOUT.LINK">
{{ 'NOT_FOUND.BACK_HOME' | translate }}
</a>
</mat-card>
</section>
@@ -0,0 +1,15 @@
import { Component } from '@angular/core';
import { RouterLink } from '@angular/router';
import { MatCard } from '@angular/material/card';
import { MatButton } from '@angular/material/button';
import { TranslatePipe } from '@ngx-translate/core';
import { RouterConstants } from '../../constants/RouterConstants';
@Component({
selector: 'app-not-found',
imports: [RouterLink, MatCard, MatButton, TranslatePipe],
templateUrl: './not-found.component.html',
})
export class NotFoundComponent {
protected readonly RouterConstants = RouterConstants;
}
@@ -0,0 +1,57 @@
import { GpuCapabilityService } from './gpu-capability.service';
describe('GpuCapabilityService', () => {
let service: GpuCapabilityService;
const originalGpu = (navigator as unknown as { gpu: unknown }).gpu;
function setGpu(value: unknown): void {
Object.defineProperty(navigator, 'gpu', { value, configurable: true });
}
beforeEach(() => {
service = new GpuCapabilityService();
});
afterEach(() => {
setGpu(originalGpu);
});
it('reports "webgpu" when an adapter is available', async () => {
setGpu({ requestAdapter: () => Promise.resolve({}) });
const tier = await service.detect();
expect(tier).toBe('webgpu');
expect(service.tier()).toBe('webgpu');
});
it('falls back to "webgl" when WebGPU is missing but a WebGL2 context exists', async () => {
setGpu(undefined);
const fakeCanvas = { getContext: () => ({}) } as unknown as HTMLCanvasElement;
spyOn(document, 'createElement').and.returnValue(fakeCanvas);
const tier = await service.detect();
expect(tier).toBe('webgl');
});
it('reports "none" when neither WebGPU nor WebGL is available', async () => {
setGpu(undefined);
const fakeCanvas = { getContext: () => null } as unknown as HTMLCanvasElement;
spyOn(document, 'createElement').and.returnValue(fakeCanvas);
const tier = await service.detect();
expect(tier).toBe('none');
});
it('caches the detected tier and probes only once', async () => {
setGpu({ requestAdapter: () => Promise.resolve({}) });
const first = await service.detect();
setGpu(undefined);
const second = await service.detect();
expect(second).toBe(first);
});
});
+5 -1
View File
@@ -31,7 +31,7 @@ export class GpuCapabilityService {
}
private async isWebGpuAvailable(): Promise<boolean> {
if (!navigator.gpu) {
if (typeof navigator === 'undefined' || !navigator.gpu) {
return false;
}
@@ -44,6 +44,10 @@ export class GpuCapabilityService {
}
private isWebGlAvailable(): boolean {
if (typeof document === 'undefined') {
return false;
}
try {
const canvas = document.createElement('canvas');
const context = canvas.getContext('webgl2');
+43
View File
@@ -0,0 +1,43 @@
import { TestBed } from '@angular/core/testing';
import { TranslateService } from '@ngx-translate/core';
import { LanguageService } from './language.service';
import { LocalStoreConstants } from '../constants/LocalStoreConstants';
describe('LanguageService', () => {
let translateSpy: jasmine.SpyObj<TranslateService>;
function createService(): LanguageService {
translateSpy = jasmine.createSpyObj<TranslateService>('TranslateService', ['use']);
TestBed.configureTestingModule({
providers: [{ provide: TranslateService, useValue: translateSpy }],
});
return TestBed.inject(LanguageService);
}
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
localStorage.clear();
});
it('uses the stored language as the initial value', () => {
localStorage.setItem(LocalStoreConstants.LANGUAGE_KEY, 'de');
const service = createService();
expect(service.lang()).toBe('de');
expect(translateSpy.use).toHaveBeenCalledWith('de');
});
it('use() updates the signal, the translate service and localStorage', () => {
localStorage.setItem(LocalStoreConstants.LANGUAGE_KEY, 'en');
const service = createService();
service.use('de');
expect(service.lang()).toBe('de');
expect(translateSpy.use).toHaveBeenCalledWith('de');
expect(localStorage.getItem(LocalStoreConstants.LANGUAGE_KEY)).toBe('de');
});
});
+2 -1
View File
@@ -26,7 +26,8 @@ export class LanguageService {
const stored = localStorage.getItem(LocalStoreConstants.LANGUAGE_KEY) as Lang | null;
if (stored === 'de' || stored === 'en') return stored;
} catch (e) { void e; }
const browser = (navigator.language || 'en').toLowerCase();
const browserLang = typeof navigator !== 'undefined' ? navigator.language : 'en';
const browser = (browserLang || 'en').toLowerCase();
return browser.startsWith('de') ? 'de' : 'en';
}
}
+78
View File
@@ -0,0 +1,78 @@
import { Injectable, inject } from '@angular/core';
import { DOCUMENT } from '@angular/common';
import { Title, Meta } from '@angular/platform-browser';
import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { filter } from 'rxjs';
const SITE_URL = 'https://andreas-dahm.eu';
const DEFAULT_SEO_KEY = 'DEFAULT';
/**
* Keeps the document title and SEO/social meta tags in sync with the active
* route and the selected language. Each route declares its translation key via
* `data: { seo: 'ABOUT' }`; the matching `SEO.<key>` i18n entry provides the
* localized title and description.
*/
@Injectable({ providedIn: 'root' })
export class SeoService {
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
private readonly title = inject(Title);
private readonly meta = inject(Meta);
private readonly translate = inject(TranslateService);
private readonly document = inject(DOCUMENT);
private currentSeoKey = DEFAULT_SEO_KEY;
init(): void {
this.router.events
.pipe(filter((event) => event instanceof NavigationEnd))
.subscribe(() => {
this.currentSeoKey = this.resolveSeoKey();
this.applyTags();
});
// Re-translate the tags when the user switches language.
this.translate.onLangChange.subscribe(() => this.applyTags());
}
private resolveSeoKey(): string {
let active = this.route;
while (active.firstChild) {
active = active.firstChild;
}
const seoKey = active.snapshot.data['seo'];
return typeof seoKey === 'string' ? seoKey : DEFAULT_SEO_KEY;
}
private applyTags(): void {
const titleKey = `SEO.${this.currentSeoKey}.TITLE`;
const descriptionKey = `SEO.${this.currentSeoKey}.DESCRIPTION`;
this.translate.get([titleKey, descriptionKey]).subscribe((translations) => {
const title = translations[titleKey];
const description = translations[descriptionKey];
const url = SITE_URL + this.router.url.split('?')[0];
this.title.setTitle(title);
this.meta.updateTag({ name: 'description', content: description });
this.meta.updateTag({ property: 'og:title', content: title });
this.meta.updateTag({ property: 'og:description', content: description });
this.meta.updateTag({ property: 'og:url', content: url });
this.meta.updateTag({ name: 'twitter:title', content: title });
this.meta.updateTag({ name: 'twitter:description', content: description });
this.updateCanonical(url);
});
}
private updateCanonical(url: string): void {
let link = this.document.querySelector<HTMLLinkElement>('link[rel="canonical"]');
if (!link) {
link = this.document.createElement('link');
link.setAttribute('rel', 'canonical');
this.document.head.appendChild(link);
}
link.setAttribute('href', url);
}
}
+46
View File
@@ -0,0 +1,46 @@
import { TestBed } from '@angular/core/testing';
import { ThemeService } from './theme.service';
import { LocalStoreConstants } from '../constants/LocalStoreConstants';
describe('ThemeService', () => {
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
localStorage.clear();
});
function createService(): ThemeService {
TestBed.configureTestingModule({});
return TestBed.inject(ThemeService);
}
it('uses the stored theme as the initial value', () => {
localStorage.setItem(LocalStoreConstants.THEME_KEY, 'dark');
const service = createService();
expect(service.theme()).toBe('dark');
});
it('toggle switches between light and dark', () => {
localStorage.setItem(LocalStoreConstants.THEME_KEY, 'light');
const service = createService();
service.toggle();
expect(service.theme()).toBe('dark');
service.toggle();
expect(service.theme()).toBe('light');
});
it('setTheme persists the selection to localStorage', () => {
localStorage.setItem(LocalStoreConstants.THEME_KEY, 'light');
const service = createService();
service.setTheme('dark');
TestBed.tick();
expect(localStorage.getItem(LocalStoreConstants.THEME_KEY)).toBe('dark');
});
});
@@ -1,4 +1,5 @@
import {AfterViewInit, Component, ElementRef, HostListener, inject, NgZone, OnDestroy, ViewChild} from '@angular/core';
import {AfterViewInit, Component, ElementRef, HostListener, inject, NgZone, OnDestroy, PLATFORM_ID, ViewChild} from '@angular/core';
import {isPlatformBrowser} from '@angular/common';
@Component({
selector: 'app-particles-background',
@@ -10,6 +11,7 @@ export class ParticleBackgroundComponent implements AfterViewInit, OnDestroy {
@ViewChild('canvas', { static: true }) canvasRef!: ElementRef<HTMLCanvasElement>;
private readonly ngZone = inject(NgZone);
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
private ctx!: CanvasRenderingContext2D;
private particles: any[] = [];
@@ -21,6 +23,11 @@ export class ParticleBackgroundComponent implements AfterViewInit, OnDestroy {
private readonly particleSpeed = 0.8;
ngAfterViewInit(): void {
// Canvas animation relies on browser-only APIs; skip it during SSR/prerender.
if (!this.isBrowser) {
return;
}
const canvas = this.canvasRef.nativeElement;
this.ctx = canvas.getContext('2d')!;
@@ -38,6 +45,10 @@ export class ParticleBackgroundComponent implements AfterViewInit, OnDestroy {
@HostListener('window:resize')
resizeCanvas(): void {
if (!this.isBrowser) {
return;
}
const canvas = this.canvasRef.nativeElement;
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;