Compare commits
3 Commits
102cdfcb52
...
3f1093e3c8
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f1093e3c8 | |||
|
|
1087fc40e0 | ||
|
|
a26443af8f |
@@ -52,21 +52,24 @@ jobs:
|
|||||||
- name: Lint & Type Check
|
- name: Lint & Type Check
|
||||||
run: npm run lint --if-present
|
run: npm run lint --if-present
|
||||||
|
|
||||||
# 2. Unit Tests (Logik) Not necessary, because atm no tests written
|
# 2. i18n key sync (en/de must share the same keys)
|
||||||
#- name: Unit Tests
|
- name: i18n Key Check
|
||||||
# run: npx ng test --watch=false --browsers=ChromeHeadless
|
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
|
- name: Build Production
|
||||||
run: npx ng build --configuration production
|
run: npx ng build --configuration production
|
||||||
|
|
||||||
# 4. Lighthouse Audit (Performance & SEO)
|
# 5. Lighthouse Audit (Performance & SEO)
|
||||||
- name: Install Puppeteer
|
# Puppeteer is a devDependency, so `npm ci` already installed it and
|
||||||
run: npm install puppeteer --no-save
|
# downloaded Chrome. executablePath() is async in Puppeteer v25, so await it.
|
||||||
|
|
||||||
- name: Lighthouse CI
|
- name: Lighthouse CI
|
||||||
run: |
|
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
|
export CHROME_PATH=$CHROME_PATH
|
||||||
npx lhci autorun
|
npx lhci autorun
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
# Build
|
# Build
|
||||||
FROM node:22-alpine AS build
|
FROM node:22-alpine AS build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
# The production build never runs tests, so skip Puppeteer's Chrome download.
|
||||||
|
ENV PUPPETEER_SKIP_DOWNLOAD=1
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
"inlineStyleLanguage": "scss",
|
"inlineStyleLanguage": "scss",
|
||||||
"assets": [
|
"assets": [
|
||||||
"src/assets/favicon.ico",
|
"src/assets/favicon.ico",
|
||||||
|
"src/manifest.webmanifest",
|
||||||
{
|
{
|
||||||
"glob": "**/*",
|
"glob": "**/*",
|
||||||
"input": "public"
|
"input": "public"
|
||||||
@@ -59,7 +60,8 @@
|
|||||||
"maximumError": "8kB"
|
"maximumError": "8kB"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"outputHashing": "all"
|
"outputHashing": "all",
|
||||||
|
"serviceWorker": "ngsw-config.json"
|
||||||
},
|
},
|
||||||
"development": {
|
"development": {
|
||||||
"optimization": false,
|
"optimization": false,
|
||||||
@@ -87,6 +89,7 @@
|
|||||||
"test": {
|
"test": {
|
||||||
"builder": "@angular/build:karma",
|
"builder": "@angular/build:karma",
|
||||||
"options": {
|
"options": {
|
||||||
|
"karmaConfig": "karma.conf.js",
|
||||||
"tsConfig": "tsconfig.spec.json",
|
"tsConfig": "tsconfig.spec.json",
|
||||||
"inlineStyleLanguage": "scss",
|
"inlineStyleLanguage": "scss",
|
||||||
"assets": [
|
"assets": [
|
||||||
|
|||||||
43
karma.conf.js
Normal file
43
karma.conf.js
Normal 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,
|
||||||
|
});
|
||||||
|
};
|
||||||
24
nginx.conf
24
nginx.conf
@@ -11,6 +11,30 @@ server {
|
|||||||
try_files $uri =404;
|
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
|
# Angular Bundles cache hard
|
||||||
location ~* \.(?:js|css)$ {
|
location ~* \.(?:js|css)$ {
|
||||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||||
|
|||||||
41
ngsw-config.json
Normal file
41
ngsw-config.json
Normal 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
1987
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
@@ -7,7 +7,8 @@
|
|||||||
"build": "ng build",
|
"build": "ng build",
|
||||||
"watch": "ng build --watch --configuration development",
|
"watch": "ng build --watch --configuration development",
|
||||||
"test": "ng test",
|
"test": "ng test",
|
||||||
"lint": "ng lint"
|
"lint": "ng lint",
|
||||||
|
"i18n:check": "node scripts/check-i18n.mjs"
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -21,10 +22,10 @@
|
|||||||
"@angular/material": "~21.2.6",
|
"@angular/material": "~21.2.6",
|
||||||
"@angular/platform-browser": "~21.2.9",
|
"@angular/platform-browser": "~21.2.9",
|
||||||
"@angular/router": "~21.2.9",
|
"@angular/router": "~21.2.9",
|
||||||
|
"@angular/service-worker": "21.2.9",
|
||||||
"@babylonjs/core": "^8.54.1",
|
"@babylonjs/core": "^8.54.1",
|
||||||
"@ngx-translate/core": "^17.0.0",
|
"@ngx-translate/core": "^17.0.0",
|
||||||
"@ngx-translate/http-loader": "^17.0.0",
|
"@ngx-translate/http-loader": "^17.0.0",
|
||||||
"inquirer": "^13.3.0",
|
|
||||||
"rxjs": "~7.8.2",
|
"rxjs": "~7.8.2",
|
||||||
"swiper": "~12.1.0",
|
"swiper": "~12.1.0",
|
||||||
"tslib": "~2.8.1"
|
"tslib": "~2.8.1"
|
||||||
@@ -39,6 +40,12 @@
|
|||||||
"angular-eslint": "21.3.1",
|
"angular-eslint": "21.3.1",
|
||||||
"eslint": "^10.0.3",
|
"eslint": "^10.0.3",
|
||||||
"jasmine-core": "~6.1.0",
|
"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",
|
"tailwindcss": "^3.4.19",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "8.58.2"
|
"typescript-eslint": "8.58.2"
|
||||||
|
|||||||
65
scripts/check-i18n.mjs
Normal file
65
scripts/check-i18n.mjs
Normal 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.`);
|
||||||
@@ -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 { provideRouter, withInMemoryScrolling } from '@angular/router';
|
||||||
|
|
||||||
import { routes } from './app.routes';
|
import { routes } from './app.routes';
|
||||||
import {provideAnimations} from '@angular/platform-browser/animations';
|
import {provideAnimations} from '@angular/platform-browser/animations';
|
||||||
import {provideHttpClient} from '@angular/common/http';
|
import {provideHttpClient} from '@angular/common/http';
|
||||||
|
import {provideServiceWorker} from '@angular/service-worker';
|
||||||
import {provideTranslateService} from '@ngx-translate/core';
|
import {provideTranslateService} from '@ngx-translate/core';
|
||||||
import {provideTranslateHttpLoader} from '@ngx-translate/http-loader';
|
import {provideTranslateHttpLoader} from '@ngx-translate/http-loader';
|
||||||
import {LocalStoreConstants} from './constants/LocalStoreConstants';
|
import {LocalStoreConstants} from './constants/LocalStoreConstants';
|
||||||
@@ -47,11 +48,15 @@ export const appConfig: ApplicationConfig = {
|
|||||||
sanitizer.bypassSecurityTrustResourceUrl('assets/logos/linkedIn.svg')
|
sanitizer.bypassSecurityTrustResourceUrl('assets/logos/linkedIn.svg')
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
|
provideServiceWorker('ngsw-worker.js', {
|
||||||
|
enabled: !isDevMode(),
|
||||||
|
registrationStrategy: 'registerWhenStable:30000'
|
||||||
|
}),
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
function getInitialLang(): string {
|
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;
|
if (saved) return saved;
|
||||||
const nav = typeof navigator !== 'undefined' ? navigator.language?.toLowerCase() : 'en';
|
const nav = typeof navigator !== 'undefined' ? navigator.language?.toLowerCase() : 'en';
|
||||||
return nav?.startsWith('de') ? 'de' : 'en';
|
return nav?.startsWith('de') ? 'de' : 'en';
|
||||||
|
|||||||
@@ -2,20 +2,20 @@ import { Routes } from '@angular/router';
|
|||||||
import {RouterConstants} from './constants/RouterConstants';
|
import {RouterConstants} from './constants/RouterConstants';
|
||||||
|
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{ path: '', loadComponent: () => import('./pages/about/about.component').then(m => m.AboutComponent) },
|
{ 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) },
|
{ 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) },
|
{ 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) },
|
{ 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) },
|
{ 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) },
|
{ 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) },
|
{ 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) },
|
{ 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) },
|
{ 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) },
|
{ 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) },
|
{ 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) },
|
{ 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) },
|
{ 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) },
|
{ 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) },
|
{ 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' } },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Component } from '@angular/core';
|
import { Component, inject } from '@angular/core';
|
||||||
import { RouterOutlet } from '@angular/router';
|
import { RouterOutlet } from '@angular/router';
|
||||||
import {TopbarComponent} from '../topbar/topbar.component';
|
import {TopbarComponent} from '../topbar/topbar.component';
|
||||||
import {TranslatePipe} from '@ngx-translate/core';
|
import {TranslatePipe} from '@ngx-translate/core';
|
||||||
import {ParticleBackgroundComponent} from '../../shared/components/particles-background/particles-background.component';
|
import {ParticleBackgroundComponent} from '../../shared/components/particles-background/particles-background.component';
|
||||||
|
import {SeoService} from '../../service/seo.service';
|
||||||
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -12,5 +13,10 @@ import {ParticleBackgroundComponent} from '../../shared/components/particles-bac
|
|||||||
styleUrl: './app.component.scss'
|
styleUrl: './app.component.scss'
|
||||||
})
|
})
|
||||||
export class AppComponent {
|
export class AppComponent {
|
||||||
|
private readonly seo = inject(SeoService);
|
||||||
currentYear = new Date().getFullYear();
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
10
src/app/pages/not-found/not-found.component.html
Normal file
10
src/app/pages/not-found/not-found.component.html
Normal 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>
|
||||||
15
src/app/pages/not-found/not-found.component.ts
Normal file
15
src/app/pages/not-found/not-found.component.ts
Normal 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;
|
||||||
|
}
|
||||||
57
src/app/service/gpu-capability.service.spec.ts
Normal file
57
src/app/service/gpu-capability.service.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -31,7 +31,7 @@ export class GpuCapabilityService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async isWebGpuAvailable(): Promise<boolean> {
|
private async isWebGpuAvailable(): Promise<boolean> {
|
||||||
if (!navigator.gpu) {
|
if (typeof navigator === 'undefined' || !navigator.gpu) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,6 +44,10 @@ export class GpuCapabilityService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private isWebGlAvailable(): boolean {
|
private isWebGlAvailable(): boolean {
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
const context = canvas.getContext('webgl2');
|
const context = canvas.getContext('webgl2');
|
||||||
|
|||||||
43
src/app/service/language.service.spec.ts
Normal file
43
src/app/service/language.service.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -26,7 +26,8 @@ export class LanguageService {
|
|||||||
const stored = localStorage.getItem(LocalStoreConstants.LANGUAGE_KEY) as Lang | null;
|
const stored = localStorage.getItem(LocalStoreConstants.LANGUAGE_KEY) as Lang | null;
|
||||||
if (stored === 'de' || stored === 'en') return stored;
|
if (stored === 'de' || stored === 'en') return stored;
|
||||||
} catch (e) { void e; }
|
} 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';
|
return browser.startsWith('de') ? 'de' : 'en';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
78
src/app/service/seo.service.ts
Normal file
78
src/app/service/seo.service.ts
Normal 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
src/app/service/theme.service.spec.ts
Normal file
46
src/app/service/theme.service.spec.ts
Normal 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({
|
@Component({
|
||||||
selector: 'app-particles-background',
|
selector: 'app-particles-background',
|
||||||
@@ -10,6 +11,7 @@ export class ParticleBackgroundComponent implements AfterViewInit, OnDestroy {
|
|||||||
@ViewChild('canvas', { static: true }) canvasRef!: ElementRef<HTMLCanvasElement>;
|
@ViewChild('canvas', { static: true }) canvasRef!: ElementRef<HTMLCanvasElement>;
|
||||||
|
|
||||||
private readonly ngZone = inject(NgZone);
|
private readonly ngZone = inject(NgZone);
|
||||||
|
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
||||||
|
|
||||||
private ctx!: CanvasRenderingContext2D;
|
private ctx!: CanvasRenderingContext2D;
|
||||||
private particles: any[] = [];
|
private particles: any[] = [];
|
||||||
@@ -21,6 +23,11 @@ export class ParticleBackgroundComponent implements AfterViewInit, OnDestroy {
|
|||||||
private readonly particleSpeed = 0.8;
|
private readonly particleSpeed = 0.8;
|
||||||
|
|
||||||
ngAfterViewInit(): void {
|
ngAfterViewInit(): void {
|
||||||
|
// Canvas animation relies on browser-only APIs; skip it during SSR/prerender.
|
||||||
|
if (!this.isBrowser) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const canvas = this.canvasRef.nativeElement;
|
const canvas = this.canvasRef.nativeElement;
|
||||||
this.ctx = canvas.getContext('2d')!;
|
this.ctx = canvas.getContext('2d')!;
|
||||||
|
|
||||||
@@ -38,6 +45,10 @@ export class ParticleBackgroundComponent implements AfterViewInit, OnDestroy {
|
|||||||
|
|
||||||
@HostListener('window:resize')
|
@HostListener('window:resize')
|
||||||
resizeCanvas(): void {
|
resizeCanvas(): void {
|
||||||
|
if (!this.isBrowser) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const canvas = this.canvasRef.nativeElement;
|
const canvas = this.canvasRef.nativeElement;
|
||||||
canvas.width = window.innerWidth;
|
canvas.width = window.innerWidth;
|
||||||
canvas.height = window.innerHeight;
|
canvas.height = window.innerHeight;
|
||||||
|
|||||||
@@ -601,5 +601,40 @@
|
|||||||
"INTERVAL_MINUTES": "Minuten",
|
"INTERVAL_MINUTES": "Minuten",
|
||||||
"INTERVAL_SECONDS": "Sekunden",
|
"INTERVAL_SECONDS": "Sekunden",
|
||||||
"INTERVAL_HINT": "Spielt einen kurzen Ton ab, sobald die verstrichene Zeit ein Vielfaches des Intervalls überschreitet."
|
"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."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -380,6 +380,7 @@
|
|||||||
"EXPLANATION": {
|
"EXPLANATION": {
|
||||||
"TITLE": "Algorithms",
|
"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.",
|
"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).",
|
"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.",
|
"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.",
|
"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_MINUTES": "Minutes",
|
||||||
"INTERVAL_SECONDS": "Seconds",
|
"INTERVAL_SECONDS": "Seconds",
|
||||||
"INTERVAL_HINT": "Plays a short tone whenever the elapsed time crosses a multiple of the interval."
|
"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."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
6
src/assets/icons/app-icon.svg
Normal file
6
src/assets/icons/app-icon.svg
Normal 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 |
@@ -2,9 +2,30 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>Andreas Dahm - Playground</title>
|
<title>Andreas Dahm – Playground</title>
|
||||||
<base href="/">
|
<base href="/">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<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 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/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet">
|
||||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
||||||
|
|||||||
25
src/manifest.webmanifest
Normal file
25
src/manifest.webmanifest
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user