Compare commits

..

3 Commits

Author SHA1 Message Date
3f1093e3c8 Merge pull request 'Lots of technical improvements' (#36) from feat/technical-improvements into main
All checks were successful
Build, Test & Push Frontend / quality-check (push) Successful in 1m37s
Build, Test & Push Frontend / docker (push) Successful in 1m4s
Reviewed-on: #36
2026-06-02 15:59:05 +02:00
Andreas Dahm
1087fc40e0 Try to fix test
All checks were successful
Build, Test & Push Frontend / quality-check (pull_request) Successful in 1m45s
Build, Test & Push Frontend / docker (pull_request) Has been skipped
2026-06-02 15:56:15 +02:00
Andreas Dahm
a26443af8f Lots of technical improvements
Some checks failed
Build, Test & Push Frontend / quality-check (pull_request) Failing after 41s
Build, Test & Push Frontend / docker (pull_request) Has been skipped
2026-06-02 15:48:31 +02:00
28 changed files with 2386 additions and 438 deletions

View File

@@ -52,21 +52,24 @@ jobs:
- name: Lint & Type Check
run: npm run lint --if-present
# 2. Unit Tests (Logik) Not necessary, because atm no tests written
#- name: Unit Tests
# run: npx ng test --watch=false --browsers=ChromeHeadless
# 2. i18n key sync (en/de must share the same keys)
- name: i18n Key Check
run: npm run i18n:check
# 3. Build Production (necessary for lighthouse)
# 3. Unit Tests (Logic) — browser/launcher configured in karma.conf.js
- name: Unit Tests
run: npx ng test --watch=false
# 4. Build Production (necessary for lighthouse)
- name: Build Production
run: npx ng build --configuration production
# 4. Lighthouse Audit (Performance & SEO)
- name: Install Puppeteer
run: npm install puppeteer --no-save
# 5. Lighthouse Audit (Performance & SEO)
# Puppeteer is a devDependency, so `npm ci` already installed it and
# downloaded Chrome. executablePath() is async in Puppeteer v25, so await it.
- name: Lighthouse CI
run: |
CHROME_PATH=$(node -e 'console.log(require("puppeteer").executablePath())')
CHROME_PATH=$(node -e 'require("puppeteer").executablePath().then(p => console.log(p))')
export CHROME_PATH=$CHROME_PATH
npx lhci autorun

View File

@@ -1,6 +1,8 @@
# Build
FROM node:22-alpine AS build
WORKDIR /app
# The production build never runs tests, so skip Puppeteer's Chrome download.
ENV PUPPETEER_SKIP_DOWNLOAD=1
COPY package*.json ./
RUN npm ci
COPY . .

View File

@@ -29,6 +29,7 @@
"inlineStyleLanguage": "scss",
"assets": [
"src/assets/favicon.ico",
"src/manifest.webmanifest",
{
"glob": "**/*",
"input": "public"
@@ -59,7 +60,8 @@
"maximumError": "8kB"
}
],
"outputHashing": "all"
"outputHashing": "all",
"serviceWorker": "ngsw-config.json"
},
"development": {
"optimization": false,
@@ -87,6 +89,7 @@
"test": {
"builder": "@angular/build:karma",
"options": {
"karmaConfig": "karma.conf.js",
"tsConfig": "tsconfig.spec.json",
"inlineStyleLanguage": "scss",
"assets": [

43
karma.conf.js Normal file
View File

@@ -0,0 +1,43 @@
const os = require('os');
const path = require('path');
const { computeExecutablePath, Browser } = require('@puppeteer/browsers');
const puppeteer = require('puppeteer');
// Resolve the Chrome that Puppeteer downloaded so tests run without a
// system-installed browser, both locally and in CI containers.
// Puppeteer v25 made executablePath() async, which a sync karma.conf cannot
// await — so we compute the path synchronously via @puppeteer/browsers instead.
const buildId = puppeteer.PUPPETEER_REVISIONS.chrome;
const cacheDir = process.env.PUPPETEER_CACHE_DIR || path.join(os.homedir(), '.cache', 'puppeteer');
process.env.CHROME_BIN = computeExecutablePath({ browser: Browser.CHROME, buildId, cacheDir });
module.exports = function (config) {
config.set({
basePath: '',
frameworks: ['jasmine'],
plugins: [
require('karma-jasmine'),
require('karma-chrome-launcher'),
require('karma-jasmine-html-reporter'),
require('karma-coverage'),
],
jasmineHtmlReporter: {
suppressAll: true,
},
coverageReporter: {
dir: path.join(__dirname, 'coverage', 'playground-frontend'),
subdir: '.',
reporters: [{ type: 'html' }, { type: 'text-summary' }],
},
reporters: ['progress', 'kjhtml'],
browsers: ['ChromeHeadlessNoSandbox'],
customLaunchers: {
ChromeHeadlessNoSandbox: {
base: 'ChromeHeadless',
// CI containers run as root with no sandbox; /dev/shm is often too small.
flags: ['--no-sandbox', '--headless', '--disable-gpu', '--disable-dev-shm-usage'],
},
},
restartOnFileChange: true,
});
};

View File

@@ -11,6 +11,30 @@ server {
try_files $uri =404;
}
# Service worker control files must never be cached hard, otherwise the
# PWA can no longer pick up new deployments. Exact matches win over the
# generic *.js rule below.
location = /ngsw-worker.js {
add_header Cache-Control "no-cache, max-age=0, must-revalidate";
try_files $uri =404;
}
location = /ngsw.json {
add_header Cache-Control "no-cache, max-age=0, must-revalidate";
try_files $uri =404;
}
location = /safety-worker.js {
add_header Cache-Control "no-cache, max-age=0, must-revalidate";
try_files $uri =404;
}
location = /worker-basic.min.js {
add_header Cache-Control "no-cache, max-age=0, must-revalidate";
try_files $uri =404;
}
location = /manifest.webmanifest {
add_header Cache-Control "no-cache, max-age=0, must-revalidate";
try_files $uri =404;
}
# Angular Bundles cache hard
location ~* \.(?:js|css)$ {
add_header Cache-Control "public, max-age=31536000, immutable";

41
ngsw-config.json Normal file
View File

@@ -0,0 +1,41 @@
{
"$schema": "./node_modules/@angular/service-worker/config/schema.json",
"index": "/index.html",
"assetGroups": [
{
"name": "app",
"installMode": "prefetch",
"resources": {
"files": [
"/favicon.ico",
"/index.html",
"/manifest.webmanifest",
"/*.css",
"/*.js"
]
}
},
{
"name": "assets",
"installMode": "lazy",
"updateMode": "prefetch",
"resources": {
"files": [
"/assets/**",
"/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)"
]
}
}
],
"dataGroups": [
{
"name": "i18n",
"urls": ["/assets/i18n/**"],
"cacheConfig": {
"maxSize": 10,
"maxAge": "7d",
"strategy": "freshness"
}
}
]
}

1987
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,8 @@
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test",
"lint": "ng lint"
"lint": "ng lint",
"i18n:check": "node scripts/check-i18n.mjs"
},
"private": true,
"dependencies": {
@@ -21,10 +22,10 @@
"@angular/material": "~21.2.6",
"@angular/platform-browser": "~21.2.9",
"@angular/router": "~21.2.9",
"@angular/service-worker": "21.2.9",
"@babylonjs/core": "^8.54.1",
"@ngx-translate/core": "^17.0.0",
"@ngx-translate/http-loader": "^17.0.0",
"inquirer": "^13.3.0",
"rxjs": "~7.8.2",
"swiper": "~12.1.0",
"tslib": "~2.8.1"
@@ -39,6 +40,12 @@
"angular-eslint": "21.3.1",
"eslint": "^10.0.3",
"jasmine-core": "~6.1.0",
"karma": "^6.4.4",
"karma-chrome-launcher": "^3.2.0",
"karma-coverage": "^2.2.1",
"karma-jasmine": "^5.1.0",
"karma-jasmine-html-reporter": "^2.2.0",
"puppeteer": "^25.1.0",
"tailwindcss": "^3.4.19",
"typescript": "~5.9.3",
"typescript-eslint": "8.58.2"

65
scripts/check-i18n.mjs Normal file
View File

@@ -0,0 +1,65 @@
#!/usr/bin/env node
// Verifies that all i18n translation files share the exact same set of keys.
// Exits with a non-zero status (and prints the differences) if any key is
// present in one language but missing in another, so CI can catch drift.
import { readFileSync, readdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const scriptDir = dirname(fileURLToPath(import.meta.url));
const i18nDir = join(scriptDir, '..', 'src', 'assets', 'i18n');
function collectLeafKeys(value, prefix, keys) {
for (const key of Object.keys(value)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
const child = value[key];
const isNestedObject = child !== null && typeof child === 'object' && !Array.isArray(child);
if (isNestedObject) {
collectLeafKeys(child, fullKey, keys);
} else {
keys.add(fullKey);
}
}
return keys;
}
function loadKeys(fileName) {
const content = readFileSync(join(i18nDir, fileName), 'utf8');
const parsed = JSON.parse(content);
return collectLeafKeys(parsed, '', new Set());
}
const files = readdirSync(i18nDir).filter((name) => name.endsWith('.json'));
if (files.length < 2) {
console.log('i18n check: nothing to compare (fewer than two language files).');
process.exit(0);
}
const keysByFile = new Map(files.map((file) => [file, loadKeys(file)]));
const allKeys = new Set();
for (const keys of keysByFile.values()) {
for (const key of keys) {
allKeys.add(key);
}
}
let hasError = false;
for (const [file, keys] of keysByFile) {
const missing = [...allKeys].filter((key) => !keys.has(key)).sort();
if (missing.length > 0) {
hasError = true;
console.error(`\n[${file}] missing ${missing.length} key(s):`);
for (const key of missing) {
console.error(` - ${key}`);
}
}
}
if (hasError) {
console.error('\ni18n check failed: translation files are out of sync.');
process.exit(1);
}
console.log(`i18n check passed: ${files.join(', ')} share ${allKeys.size} keys.`);

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';

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' } },
];

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();
}
}

View File

@@ -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);
});
});
});

View File

@@ -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);
});
}
});

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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);
});
});

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');

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');
});
});

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';
}
}

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);
}
}

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');
});
});

View File

@@ -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;

View File

@@ -601,5 +601,40 @@
"INTERVAL_MINUTES": "Minuten",
"INTERVAL_SECONDS": "Sekunden",
"INTERVAL_HINT": "Spielt einen kurzen Ton ab, sobald die verstrichene Zeit ein Vielfaches des Intervalls überschreitet."
},
"NOT_FOUND": {
"TITLE": "Seite nicht gefunden",
"MESSAGE": "Die gesuchte Seite existiert nicht oder wurde verschoben.",
"BACK_HOME": "Zurück zum Start"
},
"SEO": {
"DEFAULT": {
"TITLE": "Andreas Dahm Playground",
"DESCRIPTION": "Portfolio und interaktiver Playground von Andreas Dahm, Senior Software Developer: Algorithmus-Visualisierungen, GPU-Simulationen und persönliche Projekte."
},
"ABOUT": {
"TITLE": "Über mich Andreas Dahm",
"DESCRIPTION": "Andreas Dahm, Senior Software Developer und Architekt aus München: Erfahrung, Skills und Projekte in Full-Stack-Entwicklung, 3D-Simulation und Algorithmen."
},
"PROJECTS": {
"TITLE": "Projekte Andreas Dahm",
"DESCRIPTION": "Ausgewählte Projekte von Andreas Dahm: ein Steam-Release, selbst gehostete Infrastruktur, Game Jams und eine wissenschaftliche Diplomarbeit."
},
"ALGORITHMS": {
"TITLE": "Algorithmen Andreas Dahm",
"DESCRIPTION": "Interaktive Visualisierungen von Algorithmen und Simulationen: Pathfinding, Sortierung, Fraktale, Stoff- und Pendelphysik direkt im Browser."
},
"IMPRINT": {
"TITLE": "Impressum Andreas Dahm",
"DESCRIPTION": "Rechtliche Informationen und Kontaktdaten zur Website Andreas Dahm Playground."
},
"STOPWATCH": {
"TITLE": "Stoppuhr Andreas Dahm",
"DESCRIPTION": "Eine einfache Online-Stoppuhr mit konfigurierbaren Intervall-Signaltönen."
},
"NOT_FOUND": {
"TITLE": "Seite nicht gefunden Andreas Dahm",
"DESCRIPTION": "Die angeforderte Seite konnte nicht gefunden werden."
}
}
}

View File

@@ -380,6 +380,7 @@
"EXPLANATION": {
"TITLE": "Algorithms",
"BUBBLE_SORT_EXPLANATION": "repeatedly compares adjacent elements and swaps them if they are in the wrong order. The largest element \"bubbles\" to the end of the list like an air bubble. Advantage: Extremely simple to understand and implement; detects already sorted lists very quickly. Disadvantage: Very inefficient for large lists (runtime O(n²)). Rarely used in practice.",
"COCKTAIL_SORT_EXPLANATION": "(also Shaker Sort) is an extension of Bubble Sort. Instead of only moving from left to right, it reverses direction on each pass, alternately pushing the largest element to the right and the smallest to the left. Advantage: Faster than Bubble Sort, because small elements at the end move to the front more quickly (solving the \"turtle problem\"). Disadvantage: Stays in the O(n²) runtime class, so it remains inefficient for large data sets.",
"QUICK_SORT_EXPLANATION": "follows the \"divide and conquer\" principle. A \"pivot\" element is selected, and the array is divided into two halves: elements smaller than the pivot and elements larger than the pivot. Advantage: On average one of the fastest sorting algorithms (O(n log n)); requires no additional memory (in-place). Disadvantage: Slow in the worst case (O(n²)) if the pivot is chosen poorly. Is not stable (changes order of equal elements).",
"HEAP_SORT_EXPLANATION": "organizes the data initially into a special tree structure (Binary Heap). The largest element (the root) is extracted and sorted to the end, then the tree is repaired. Advantage: Guarantees a fast runtime of O(n log n), even in the worst case. Requires almost no additional memory. Disadvantage: Often slightly slower than Quick Sort in practice because the jumps in memory (heap structure) utilize the CPU cache less effectively.",
"TIMSORT_EXPLANATION": "is a hybrid sorting algorithm derived from Merge Sort and Insertion Sort. It divides the array into small 'runs' and sorts each using Insertion Sort, then merges them step by step using Merge Sort. Advantage: Extremely efficient on real-world data that is often partially sorted — O(n log n) in the worst case and O(n) in the best case. It is the standard sorting algorithm in Python and Java. Disadvantage: More complex to implement than a pure algorithm and requires additional memory for the merge step.",
@@ -600,5 +601,40 @@
"INTERVAL_MINUTES": "Minutes",
"INTERVAL_SECONDS": "Seconds",
"INTERVAL_HINT": "Plays a short tone whenever the elapsed time crosses a multiple of the interval."
},
"NOT_FOUND": {
"TITLE": "Page not found",
"MESSAGE": "The page you are looking for does not exist or has been moved.",
"BACK_HOME": "Back to start"
},
"SEO": {
"DEFAULT": {
"TITLE": "Andreas Dahm Playground",
"DESCRIPTION": "Portfolio and interactive playground of Andreas Dahm, senior software developer: algorithm visualizations, GPU simulations and personal projects."
},
"ABOUT": {
"TITLE": "About me Andreas Dahm",
"DESCRIPTION": "Andreas Dahm, senior software developer and architect from Munich: experience, skills and projects in full-stack development, 3D simulation and algorithms."
},
"PROJECTS": {
"TITLE": "Projects Andreas Dahm",
"DESCRIPTION": "Selected projects by Andreas Dahm: a Steam game release, self-hosted infrastructure, game jams and a scientific diploma thesis."
},
"ALGORITHMS": {
"TITLE": "Algorithms Andreas Dahm",
"DESCRIPTION": "Interactive visualizations of algorithms and simulations: pathfinding, sorting, fractals, cloth and pendulum physics, running in the browser."
},
"IMPRINT": {
"TITLE": "Imprint Andreas Dahm",
"DESCRIPTION": "Legal information and contact details for the Andreas Dahm Playground website."
},
"STOPWATCH": {
"TITLE": "Stopwatch Andreas Dahm",
"DESCRIPTION": "A simple online stopwatch with configurable interval beeps."
},
"NOT_FOUND": {
"TITLE": "Page not found Andreas Dahm",
"DESCRIPTION": "The requested page could not be found."
}
}
}

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512" role="img" aria-label="Andreas Dahm">
<rect width="512" height="512" fill="#313131"/>
<text x="256" y="256" fill="#ffffff" font-family="'Roboto','Segoe UI',Arial,sans-serif"
font-size="240" font-weight="500" text-anchor="middle" dominant-baseline="central"
letter-spacing="-8">AD</text>
</svg>

After

Width:  |  Height:  |  Size: 401 B

View File

@@ -2,9 +2,30 @@
<html lang="en">
<head>
<meta charset="utf-8">
<title>Andreas Dahm - Playground</title>
<title>Andreas Dahm Playground</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="Portfolio and interactive playground of Andreas Dahm, senior software developer: algorithm visualizations, GPU simulations and personal projects.">
<meta name="author" content="Andreas Dahm">
<meta name="robots" content="index, follow">
<meta name="theme-color" content="#313131">
<link rel="canonical" href="https://andreas-dahm.eu/">
<link rel="manifest" href="manifest.webmanifest">
<link rel="apple-touch-icon" href="assets/icons/app-icon.svg">
<meta property="og:type" content="website">
<meta property="og:site_name" content="Andreas Dahm Playground">
<meta property="og:title" content="Andreas Dahm Playground">
<meta property="og:description" content="Portfolio and interactive playground of Andreas Dahm, senior software developer: algorithm visualizations, GPU simulations and personal projects.">
<meta property="og:url" content="https://andreas-dahm.eu/">
<meta property="og:image" content="https://andreas-dahm.eu/assets/me.webp">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Andreas Dahm Playground">
<meta name="twitter:description" content="Portfolio and interactive playground of Andreas Dahm, senior software developer: algorithm visualizations, GPU simulations and personal projects.">
<meta name="twitter:image" content="https://andreas-dahm.eu/assets/me.webp">
<link rel="icon" type="image/x-icon" href="assets/favicon.ico">
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">

25
src/manifest.webmanifest Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "Andreas Dahm Playground",
"short_name": "AD Playground",
"description": "Portfolio and interactive playground of Andreas Dahm: algorithm visualizations, GPU simulations and personal projects.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#313131",
"theme_color": "#313131",
"lang": "en",
"icons": [
{
"src": "assets/icons/app-icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
},
{
"src": "favicon.ico",
"sizes": "16x16 29x32",
"type": "image/x-icon"
}
]
}