Compare commits
48 Commits
ab3bca4395
...
feature/Up
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16142178f4 | ||
|
|
db2bc7ab97 | ||
|
|
888fb6289d | ||
|
|
f543a5e2f3 | ||
| 3f1093e3c8 | |||
|
|
1087fc40e0 | ||
|
|
a26443af8f | ||
|
|
102cdfcb52 | ||
| 01cf680f79 | |||
|
|
fb911b897a | ||
| e20aa11927 | |||
|
|
39b90a0b81 | ||
| b47bce16b5 | |||
|
|
da44d3816e | ||
| ffe7bbb25e | |||
|
|
9a6e91ea9d | ||
|
|
f9797493ce | ||
|
|
a349f630c6 | ||
|
|
4e24cb5df1 | ||
|
|
0d3411aeb2 | ||
| 9a6b5198ad | |||
|
|
2d82cdfd6b | ||
|
|
00ebedfc90 | ||
| 4bf5b3e2c4 | |||
| ab730f8f3b | |||
| 0104bad59f | |||
| 54b33daa40 | |||
| 61defae20e | |||
| 64842d388b | |||
| 40c3b9dd5a | |||
| 66643d8e18 | |||
| 2ab1d2dd85 | |||
| 150306333e | |||
| 7ff59bf734 | |||
| 5c97667ec1 | |||
| f46a1ed0bf | |||
| f7557efa88 | |||
| 408702be5c | |||
| 88ca86ae54 | |||
|
|
24c4dd3290 | ||
| 548cac026d | |||
|
|
a3ec68acc5 | ||
| 61a2057291 | |||
| 737601636f | |||
| 0037502b00 | |||
| 5485e57bdb | |||
| f4104d02e8 | |||
| 28bde29c8b |
2
.gitattributes
vendored
2
.gitattributes
vendored
@@ -34,7 +34,7 @@ Dockerfile text eol=lf
|
|||||||
*.adoc text eol=lf
|
*.adoc text eol=lf
|
||||||
*.txt text eol=lf
|
*.txt text eol=lf
|
||||||
*.csv text eol=lf
|
*.csv text eol=lf
|
||||||
*.svg text eol=lf # svg ist Text
|
*.svg text eol=lf
|
||||||
|
|
||||||
# ---- binary files never convert ----
|
# ---- binary files never convert ----
|
||||||
*.jar binary
|
*.jar binary
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -92,12 +95,18 @@ jobs:
|
|||||||
echo "branch=$BRANCH" >> "$GITHUB_OUTPUT"
|
echo "branch=$BRANCH" >> "$GITHUB_OUTPUT"
|
||||||
echo "sha=${GITHUB_SHA::12}" >> "$GITHUB_OUTPUT"
|
echo "sha=${GITHUB_SHA::12}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
# Convert repository owner to lowercase
|
||||||
|
OWNER="${GITHUB_REPOSITORY_OWNER}"
|
||||||
|
OWNER="$(echo "$OWNER" | tr '[:upper:]' '[:lower:]')"
|
||||||
|
echo "owner=$OWNER" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- uses: docker/setup-buildx-action@v3
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- uses: docker/login-action@v3
|
- uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
registry: git.andreas-dahm.eu
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
username: ${{ github.repository_owner }}
|
||||||
|
password: ${{ secrets.TOKEN_GITEA }}
|
||||||
|
|
||||||
- name: Build & Push (branch + sha tags)
|
- name: Build & Push (branch + sha tags)
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
@@ -105,8 +114,8 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
docker.io/${{ secrets.DOCKERHUB_USERNAME }}/playground:frontend-a-${{ steps.prep.outputs.branch }}
|
git.andreas-dahm.eu/${{ steps.prep.outputs.owner }}/playground:frontend-a-${{ steps.prep.outputs.branch }}
|
||||||
docker.io/${{ secrets.DOCKERHUB_USERNAME }}/playground:frontend-a-${{ steps.prep.outputs.branch }}-${{ steps.prep.outputs.sha }}
|
git.andreas-dahm.eu/${{ steps.prep.outputs.owner }}/playground:frontend-a-${{ steps.prep.outputs.branch }}-${{ steps.prep.outputs.sha }}
|
||||||
|
|
||||||
- name: Also push moving main tag
|
- name: Also push moving main tag
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
@@ -114,4 +123,4 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
docker.io/${{ secrets.DOCKERHUB_USERNAME }}/playground:frontend-a-main
|
git.andreas-dahm.eu/${{ steps.prep.outputs.owner }}/playground:frontend-a-main
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -44,3 +44,7 @@ Thumbs.db
|
|||||||
|
|
||||||
# Lighthouse
|
# Lighthouse
|
||||||
.lighthouseci/
|
.lighthouseci/
|
||||||
|
.claude/settings.local.json
|
||||||
|
|
||||||
|
# claude
|
||||||
|
.claude/
|
||||||
|
|||||||
5
.postcssrc.json
Normal file
5
.postcssrc.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"plugins": {
|
||||||
|
"@tailwindcss/postcss": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
91
CLAUDE.md
Normal file
91
CLAUDE.md
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
# Claude Code Guidelines
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
This is the frontend of the Playground project, built with Angular 21 and Angular Material. It includes features like a light/dark theme toggle and multi-language support via ngx-translate. The application is a static Single Page Application (SPA) served by NGINX.
|
||||||
|
|
||||||
|
**Key Technologies:**
|
||||||
|
* **Frontend Framework:** Angular 21
|
||||||
|
* **UI Components & Theming:** Angular Material
|
||||||
|
* **Internationalization:** ngx-translate
|
||||||
|
* **Server:** NGINX (for serving the SPA)
|
||||||
|
* **Containerization:** Docker
|
||||||
|
* **CI/CD:** Gitea Actions (self-hosted, `git.andreas-dahm.eu`)
|
||||||
|
|
||||||
|
## Building and Running
|
||||||
|
|
||||||
|
### Local Development
|
||||||
|
|
||||||
|
1. **Install dependencies:**
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
2. **Start development server:**
|
||||||
|
```bash
|
||||||
|
ng serve --open
|
||||||
|
```
|
||||||
|
The app will run at `http://localhost:4200`.
|
||||||
|
|
||||||
|
### Building for Production
|
||||||
|
|
||||||
|
To build the project for production, which creates the optimized static files:
|
||||||
|
```bash
|
||||||
|
ng build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Language
|
||||||
|
- All code must be written in **English** — including variable names, function names, comments, and commit messages.
|
||||||
|
|
||||||
|
## Clean Code
|
||||||
|
- Write simple, clear, and readable code.
|
||||||
|
- Avoid deeply nested or chained calls. Break complex expressions into named intermediate variables or separate steps.
|
||||||
|
- Code should be understandable by junior developers without additional explanation.
|
||||||
|
|
||||||
|
## Braces
|
||||||
|
- Always use curly braces `{}` for branches and loops — even for single-line bodies.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ✅ correct
|
||||||
|
if (isValid) {
|
||||||
|
doSomething();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ❌ avoid
|
||||||
|
if (isValid) doSomething();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Functions
|
||||||
|
- Extract reusable or logically distinct logic into separate, well-named functions.
|
||||||
|
- A function should do one thing and do it well.
|
||||||
|
- Prefer short functions that are easy to test and understand.
|
||||||
|
|
||||||
|
## Comments
|
||||||
|
- Only add comments when they explain the **why** or the **idea** behind the code — not the **what**.
|
||||||
|
- Do not comment on things that are already obvious from the code itself.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ✅ useful comment — explains intent
|
||||||
|
// Retry once to handle transient network errors
|
||||||
|
const result = await fetchWithRetry(url);
|
||||||
|
|
||||||
|
// ❌ useless comment — just restates the code
|
||||||
|
// Call fetchWithRetry with url
|
||||||
|
const result = await fetchWithRetry(url);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development Conventions
|
||||||
|
|
||||||
|
* **Language:** TypeScript
|
||||||
|
* **Framework:** Angular
|
||||||
|
* **Styling:** Tailwind CSS (v4, CSS-first config) is the primary styling approach. Use utility classes in templates. Theme tokens and custom variants are declared in `src/tailwind.css` via `@theme` and `@custom-variant` — there is no `tailwind.config.js`. Preflight stays disabled by importing `tailwindcss/theme.css` and `tailwindcss/utilities.css` individually, so Angular Material's reset stays in charge. SCSS (`styles.scss`) is only for Angular Material theme setup, Material component overrides with `!important`, Swiper `::part()` selectors, and component `:host` blocks. Shared Tailwind component classes live in `src/tailwind.css` via `@utility`.
|
||||||
|
* **Linting:** ESLint is configured (see `eslint.config.js` and `package.json` scripts).
|
||||||
|
* **Internationalization:** Uses `ngx-translate` with `en.json` and `de.json` asset files.
|
||||||
|
|
||||||
|
## Project Structure (Key Areas)
|
||||||
|
|
||||||
|
* `src/app/`: Contains the main application logic, components, services, and routing.
|
||||||
|
* `src/app/pages/`: Specific pages of the application (e.g., about, algorithms, imprint, projects).
|
||||||
|
* `src/assets/`: Static assets including images, internationalization files (`i18n`), and logos.
|
||||||
|
* `Dockerfile`: Defines the Docker image for the application.
|
||||||
|
* `nginx.conf`: NGINX configuration for serving the SPA.
|
||||||
|
* `.gitea/workflows/`: Contains CI/CD workflows (e.g., `build-Frontend-a.yml`).
|
||||||
@@ -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 . .
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ To build and run the application using Docker locally:
|
|||||||
|
|
||||||
* **Language:** TypeScript
|
* **Language:** TypeScript
|
||||||
* **Framework:** Angular
|
* **Framework:** Angular
|
||||||
* **Styling:** SCSS (based on `styles.scss` and component-specific `.scss` files).
|
* **Styling:** Only use tailwind, as much as possible. If not possible use SCSS (based on `styles.scss` and component-specific `.scss` files).
|
||||||
* **Linting:** ESLint is configured (see `eslint.config.js` and `package.json` scripts).
|
* **Linting:** ESLint is configured (see `eslint.config.js` and `package.json` scripts).
|
||||||
* **Internationalization:** Uses `ngx-translate` with `en.json` and `de.json` asset files.
|
* **Internationalization:** Uses `ngx-translate` with `en.json` and `de.json` asset files.
|
||||||
|
|
||||||
|
|||||||
121
README.md
121
README.md
@@ -1,9 +1,8 @@
|
|||||||
## 📗 `playground-frontend/README.md`
|
|
||||||
|
|
||||||
# 🎨 Playground Frontend
|
# 🎨 Playground Frontend
|
||||||
|
|
||||||
This is the **frontend** of the Playground project.
|
This is the **frontend** of the Playground project.
|
||||||
Built with **Angular 21** and **Angular Material**, including a simple light/dark theme toggle and multi-language support via **ngx-translate**.
|
Built with **Angular 21** and **Angular Material**, including a light/dark theme toggle and
|
||||||
|
multi-language support (EN/DE) via **ngx-translate**.
|
||||||
|
|
||||||
The app is built as a static **Single Page Application (SPA)** served by **NGINX**,
|
The app is built as a static **Single Page Application (SPA)** served by **NGINX**,
|
||||||
deployed at:
|
deployed at:
|
||||||
@@ -12,84 +11,136 @@ deployed at:
|
|||||||
---
|
---
|
||||||
|
|
||||||
## 🧩 Tech Stack
|
## 🧩 Tech Stack
|
||||||
|
|
||||||
| Component | Purpose |
|
| Component | Purpose |
|
||||||
|------------|----------|
|
| --------- | ------- |
|
||||||
| Angular 21 | Frontend framework |
|
| Angular 21 | Frontend framework (standalone components, signals, zoneless change detection) |
|
||||||
| Angular Material | UI components & theming |
|
| Angular Material 21 | UI components & theming |
|
||||||
| ngx-translate | i18n / instant translation |
|
| Tailwind CSS 4 | Utility-first styling (CSS-first config, no `tailwind.config.js`) |
|
||||||
|
| ngx-translate 18 | i18n / instant translation |
|
||||||
|
| Babylon.js 9 | WebGL/WebGPU rendering for the algorithm demos |
|
||||||
|
| Swiper 14 | Image carousels in the project dialogs |
|
||||||
|
| Angular Service Worker | PWA / offline caching |
|
||||||
| NGINX | Serves the compiled SPA |
|
| NGINX | Serves the compiled SPA |
|
||||||
| Docker + GitHub Actions | Automated build & image publishing |
|
| Docker + Gitea Actions | Automated build, test & image publishing |
|
||||||
|
|
||||||
|
Requires **Node.js 22**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📁 Project Structure
|
## 📁 Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
playground-frontend/
|
playground-frontend/
|
||||||
├─ src/
|
├─ src/
|
||||||
│ ├─ app/
|
│ ├─ app/
|
||||||
│ │ ├─ app.component.ts
|
│ │ ├─ layout/ # app shell, topbar, dialogs
|
||||||
│ │ ├─ theme.service.ts
|
│ │ ├─ pages/ # about, projects, algorithms, imprint, stopwatch, not-found
|
||||||
│ │ └─ ...
|
│ │ ├─ service/ # theme, language, SEO, GPU capability
|
||||||
│ ├─ assets/i18n/
|
│ │ ├─ shared/ # reusable components & helpers
|
||||||
│ │ ├─ en.json
|
│ │ ├─ constants/
|
||||||
│ │ └─ de.json
|
│ │ ├─ app.config.ts
|
||||||
|
│ │ └─ app.routes.ts
|
||||||
|
│ ├─ assets/
|
||||||
|
│ │ ├─ i18n/ # en.json / de.json (keys must stay in sync)
|
||||||
|
│ │ ├─ logos/, flags/, icons/
|
||||||
|
│ │ └─ projects/ # project screenshots
|
||||||
|
│ ├─ tailwind.css # Tailwind entry + @theme tokens
|
||||||
|
│ ├─ styles.scss # Material theme setup & component overrides
|
||||||
│ └─ index.html
|
│ └─ index.html
|
||||||
|
├─ scripts/check-i18n.mjs # fails the build on i18n key drift
|
||||||
├─ Dockerfile
|
├─ Dockerfile
|
||||||
├─ nginx.conf
|
├─ nginx.conf
|
||||||
└─ .github/workflows/docker.yml
|
├─ ngsw-config.json # service worker caching rules
|
||||||
|
├─ lighthouserc.json
|
||||||
|
└─ .gitea/workflows/build-Frontend-a.yml
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🚀 Local Development
|
## 🚀 Local Development
|
||||||
|
|
||||||
# 1. Install dependencies
|
**1. Install dependencies**
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
# 2. Start development server
|
**2. Start development server**
|
||||||
```bash
|
```bash
|
||||||
ng serve --open
|
ng serve --open
|
||||||
```
|
```
|
||||||
|
|
||||||
App runs at http://localhost:4200
|
App runs at http://localhost:4200
|
||||||
|
|
||||||
|
**3. Docker build (local)**
|
||||||
# 3. 🐳 Docker Build (local)
|
|
||||||
```bash
|
```bash
|
||||||
docker build -t playground-frontend:local .
|
docker build -t playground-frontend:local .
|
||||||
docker run -p 8080:80 playground-frontend:local
|
docker run -p 8080:80 playground-frontend:local
|
||||||
```
|
```
|
||||||
|
|
||||||
Then open http://localhost:8080
|
Then open http://localhost:8080
|
||||||
|
|
||||||
## ⚙️ GitHub Actions (CI/CD)
|
---
|
||||||
|
|
||||||
On every push to main, GitHub Actions will:
|
## 🧪 Scripts
|
||||||
|
|
||||||
Build the Angular project
|
| Command | Purpose |
|
||||||
|
| ------- | ------- |
|
||||||
|
| `npm start` | Dev server |
|
||||||
|
| `npm run build` | Production build |
|
||||||
|
| `npm run watch` | Rebuild on change (development configuration) |
|
||||||
|
| `npm test` | Unit tests (Karma + Jasmine, headless Chrome) |
|
||||||
|
| `npm run lint` | ESLint over `src/**/*.ts` and `src/**/*.html` |
|
||||||
|
| `npm run i18n:check` | Verifies `en.json` and `de.json` share the exact same keys |
|
||||||
|
|
||||||
1. Create a Docker image
|
---
|
||||||
2. Push it to Docker Hub (docker.io/andreasdahm/playground-frontend:main)
|
|
||||||
3. Workflow file: .github/workflows/docker.yml
|
|
||||||
|
|
||||||
Required repository secrets:
|
## ⚙️ Gitea Actions (CI/CD)
|
||||||
|
|
||||||
Name Description
|
CI/CD runs on a **self-hosted Gitea instance** at `git.andreas-dahm.eu`.
|
||||||
DOCKERHUB_USERNAME Your Docker Hub username
|
Workflow file: `.gitea/workflows/build-Frontend-a.yml`
|
||||||
DOCKERHUB_TOKEN Personal access token for Docker Hub
|
|
||||||
|
Triggered on every push to `main` and on pull requests targeting `main`.
|
||||||
|
|
||||||
|
**Job 1 — `quality-check`** (runs on both pushes and PRs):
|
||||||
|
|
||||||
|
1. Lint & type check — `npm run lint`
|
||||||
|
2. i18n key sync — `npm run i18n:check`
|
||||||
|
3. Unit tests — `npx ng test --watch=false`
|
||||||
|
4. Production build — `npx ng build --configuration production`
|
||||||
|
5. Lighthouse audit — `npx lhci autorun` (performance & SEO)
|
||||||
|
|
||||||
|
**Job 2 — `docker`** (only on a push to `main`, and only if `quality-check` passed):
|
||||||
|
|
||||||
|
Builds the image and pushes it to the Gitea container registry at `git.andreas-dahm.eu`
|
||||||
|
with these tags:
|
||||||
|
|
||||||
|
```
|
||||||
|
git.andreas-dahm.eu/<owner>/playground:frontend-a-<branch>
|
||||||
|
git.andreas-dahm.eu/<owner>/playground:frontend-a-<branch>-<short-sha>
|
||||||
|
git.andreas-dahm.eu/<owner>/playground:frontend-a-main
|
||||||
|
```
|
||||||
|
|
||||||
|
**Required repository secret:**
|
||||||
|
|
||||||
|
| Name | Description |
|
||||||
|
| ---- | ----------- |
|
||||||
|
| `TOKEN_GITEA` | Gitea access token used to authenticate against the container registry |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 🌐 Deployment
|
## 🌐 Deployment
|
||||||
|
|
||||||
The built image is deployed via
|
The built image is deployed via the `playground-deploy` repository on the Hostinger KVM
|
||||||
https://github.com/lobothedark/playground-deploy
|
server using **Traefik**:
|
||||||
|
|
||||||
on the Hostinger KVM server using Traefik.
|
|
||||||
|
|
||||||
|
```bash
|
||||||
docker compose pull
|
docker compose pull
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
✅ Live site at https://app.andreas-dahm.eu
|
✅ Live site at https://app.andreas-dahm.eu
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 🪄 Maintainer
|
## 🪄 Maintainer
|
||||||
|
|
||||||
Andreas Dahm
|
Andreas Dahm
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -40,6 +41,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"styles": [
|
"styles": [
|
||||||
|
"src/tailwind.css",
|
||||||
"src/styles.scss",
|
"src/styles.scss",
|
||||||
"node_modules/swiper/swiper-bundle.css"
|
"node_modules/swiper/swiper-bundle.css"
|
||||||
]
|
]
|
||||||
@@ -58,7 +60,8 @@
|
|||||||
"maximumError": "8kB"
|
"maximumError": "8kB"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"outputHashing": "all"
|
"outputHashing": "all",
|
||||||
|
"serviceWorker": "ngsw-config.json"
|
||||||
},
|
},
|
||||||
"development": {
|
"development": {
|
||||||
"optimization": false,
|
"optimization": false,
|
||||||
@@ -86,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": [
|
||||||
@@ -95,6 +99,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"styles": [
|
"styles": [
|
||||||
|
"src/tailwind.css",
|
||||||
"src/styles.scss"
|
"src/styles.scss"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
9994
package-lock.json
generated
9994
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
65
package.json
65
package.json
@@ -7,41 +7,58 @@
|
|||||||
"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": {
|
||||||
"@angular-slider/ngx-slider": "^21.0.0",
|
"@angular-slider/ngx-slider": "^21.0.0",
|
||||||
"@angular/animations": "~21.1.0",
|
"@angular/animations": "~21.2.21",
|
||||||
"@angular/cdk": "~21.1.0",
|
"@angular/cdk": "~21.2.6",
|
||||||
"@angular/common": "~21.1.0",
|
"@angular/common": "~21.2.21",
|
||||||
"@angular/compiler": "~21.1.0",
|
"@angular/compiler": "~21.2.21",
|
||||||
"@angular/core": "~21.1.0",
|
"@angular/core": "~21.2.21",
|
||||||
"@angular/forms": "~21.1.0",
|
"@angular/forms": "~21.2.21",
|
||||||
"@angular/material": "~21.1.0",
|
"@angular/material": "~21.2.6",
|
||||||
"@angular/platform-browser": "~21.1.0",
|
"@angular/platform-browser": "~21.2.21",
|
||||||
"@angular/router": "~21.1.0",
|
"@angular/router": "~21.2.21",
|
||||||
"@babylonjs/core": "^8.50.5",
|
"@angular/service-worker": "~21.2.21",
|
||||||
"@ngx-translate/core": "~17.0.0",
|
"@babylonjs/core": "^9.22.0",
|
||||||
"@ngx-translate/http-loader": "~17.0.0",
|
"@ngx-translate/core": "^18.0.0",
|
||||||
"inquirer": "^13.2.2",
|
"@ngx-translate/http-loader": "^18.0.0",
|
||||||
"rxjs": "~7.8.2",
|
"rxjs": "~7.8.2",
|
||||||
"swiper": "~12.1.0",
|
"swiper": "^14.1.0",
|
||||||
"tslib": "~2.8.1"
|
"tslib": "~2.8.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular/build": "~21.1.0",
|
"@angular/build": "~21.2.21",
|
||||||
"@angular/cli": "~21.1.0",
|
"@angular/cli": "~21.2.7",
|
||||||
"@angular/compiler-cli": "~21.1.0",
|
"@angular/compiler-cli": "~21.2.21",
|
||||||
|
"@eslint/js": "~10.0.1",
|
||||||
"@lhci/cli": "^0.15.1",
|
"@lhci/cli": "^0.15.1",
|
||||||
"@types/jasmine": "~5.1.15",
|
"@puppeteer/browsers": "3.2.1",
|
||||||
"angular-eslint": "21.2.0",
|
"@tailwindcss/postcss": "^4.3.3",
|
||||||
"eslint": "^9.39.2",
|
"@types/jasmine": "~6.0.0",
|
||||||
"jasmine-core": "~6.0.1",
|
"@webgpu/types": "^0.1.72",
|
||||||
|
"angular-eslint": "21.3.1",
|
||||||
|
"eslint": "^10.0.3",
|
||||||
|
"jasmine-core": "^7.0.2",
|
||||||
|
"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": "^4.3.3",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "8.50.1"
|
"typescript-eslint": "^8.67.0"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"tmp": "^0.2.3"
|
"tmp": "^0.2.3",
|
||||||
|
"ip-address": "^10.1.1",
|
||||||
|
"@lhci/cli": {
|
||||||
|
"lighthouse": "^13.4.1",
|
||||||
|
"uuid": "^11.1.1"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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';
|
||||||
|
|||||||
@@ -1,20 +1,21 @@
|
|||||||
import { Routes } from '@angular/router';
|
import { Routes } from '@angular/router';
|
||||||
import {AboutComponent} from './pages/about/about.component';
|
|
||||||
import {RouterConstants} from './constants/RouterConstants';
|
import {RouterConstants} from './constants/RouterConstants';
|
||||||
|
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{ path: '', component: AboutComponent },
|
{ path: '', loadComponent: () => import('./pages/about/about.component').then(m => m.AboutComponent), data: { seo: 'ABOUT' } },
|
||||||
{ path: RouterConstants.ABOUT.PATH, component: RouterConstants.ABOUT.COMPONENT},
|
{ path: RouterConstants.ABOUT.PATH, loadComponent: () => import('./pages/about/about.component').then(m => m.AboutComponent), data: { seo: 'ABOUT' } },
|
||||||
{ path: RouterConstants.PROJECTS.PATH, component: RouterConstants.PROJECTS.COMPONENT},
|
{ path: RouterConstants.PROJECTS.PATH, loadComponent: () => import('./pages/projects/projects.component').then(m => m.ProjectsComponent), data: { seo: 'PROJECTS' } },
|
||||||
{ path: RouterConstants.ALGORITHMS.PATH, component: RouterConstants.ALGORITHMS.COMPONENT},
|
{ path: RouterConstants.ALGORITHMS.PATH, loadComponent: () => import('./pages/algorithms/algorithms.component').then(m => m.AlgorithmsComponent), data: { seo: 'ALGORITHMS' } },
|
||||||
{ path: RouterConstants.PATHFINDING.PATH, component: RouterConstants.PATHFINDING.COMPONENT},
|
{ path: RouterConstants.PATHFINDING.PATH, loadComponent: () => import('./pages/algorithms/pathfinding/pathfinding.component').then(m => m.PathfindingComponent), data: { seo: 'ALGORITHMS' } },
|
||||||
{ path: RouterConstants.SORTING.PATH, component: RouterConstants.SORTING.COMPONENT},
|
{ path: RouterConstants.SORTING.PATH, loadComponent: () => import('./pages/algorithms/sorting/sorting.component').then(m => m.SortingComponent), data: { seo: 'ALGORITHMS' } },
|
||||||
{ path: RouterConstants.IMPRINT.PATH, component: RouterConstants.IMPRINT.COMPONENT},
|
{ path: RouterConstants.IMPRINT.PATH, loadComponent: () => import('./pages/imprint/imprint.component').then(m => m.ImprintComponent), data: { seo: 'IMPRINT' } },
|
||||||
{ path: RouterConstants.GOL.PATH, component: RouterConstants.GOL.COMPONENT},
|
{ path: RouterConstants.GOL.PATH, loadComponent: () => import('./pages/algorithms/conway-gol/conway-gol.component').then(m => m.ConwayGolComponent), data: { seo: 'ALGORITHMS' } },
|
||||||
{ path: RouterConstants.LABYRINTH.PATH, component: RouterConstants.LABYRINTH.COMPONENT},
|
{ path: RouterConstants.LABYRINTH.PATH, loadComponent: () => import('./pages/algorithms/pathfinding/labyrinth/labyrinth.component').then(m => m.LabyrinthComponent), data: { seo: 'ALGORITHMS' } },
|
||||||
{ path: RouterConstants.FRACTAL.PATH, component: RouterConstants.FRACTAL.COMPONENT},
|
{ path: RouterConstants.FRACTAL.PATH, loadComponent: () => import('./pages/algorithms/fractal/fractal.component').then(m => m.FractalComponent), data: { seo: 'ALGORITHMS' } },
|
||||||
{ path: RouterConstants.FRACTAL3d.PATH, component: RouterConstants.FRACTAL3d.COMPONENT},
|
{ path: RouterConstants.FRACTAL3d.PATH, loadComponent: () => import('./pages/algorithms/fractal3d/fractal3d.component').then(m => m.Fractal3dComponent), data: { seo: 'ALGORITHMS' } },
|
||||||
{ path: RouterConstants.PENDULUM.PATH, component: RouterConstants.PENDULUM.COMPONENT},
|
{ path: RouterConstants.PENDULUM.PATH, loadComponent: () => import('./pages/algorithms/pendulum/pendulum.component').then(m => m.default), data: { seo: 'ALGORITHMS' } },
|
||||||
{ path: RouterConstants.CLOTH.PATH, component: RouterConstants.CLOTH.COMPONENT}
|
{ 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' } },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export class AssetsConstants {
|
export class AssetsConstants {
|
||||||
|
|
||||||
static readonly ME = '/assets/me.webp';
|
static readonly ME = '/assets/me.webp';
|
||||||
static readonly LOGO = '/assets/favicon.ico';
|
static readonly LOGO = '/assets/logos/logo-wolf.png';
|
||||||
static readonly FLAG_DE = '/assets/flags/de.svg';
|
static readonly FLAG_DE = '/assets/flags/de.svg';
|
||||||
static readonly FLAG_EN = '/assets/flags/gb.svg';
|
static readonly FLAG_EN = '/assets/flags/gb.svg';
|
||||||
|
|
||||||
@@ -41,6 +41,10 @@ export class AssetsConstants {
|
|||||||
'/assets/projects/diploma/6.jpg'
|
'/assets/projects/diploma/6.jpg'
|
||||||
];
|
];
|
||||||
|
|
||||||
|
static readonly CLAUDE_BOX_IMAGES = [
|
||||||
|
'/assets/projects/claude-in-a-box/1.svg'
|
||||||
|
];
|
||||||
|
|
||||||
static readonly TRIBBLE_IMAGES = [
|
static readonly TRIBBLE_IMAGES = [
|
||||||
'/assets/projects/tribble-the-homeserver/1.png',
|
'/assets/projects/tribble-the-homeserver/1.png',
|
||||||
'/assets/projects/tribble-the-homeserver/2.png',
|
'/assets/projects/tribble-the-homeserver/2.png',
|
||||||
|
|||||||
@@ -1,88 +1,73 @@
|
|||||||
import {AboutComponent} from '../pages/about/about.component';
|
export class RouterConstants {
|
||||||
import {ProjectsComponent} from '../pages/projects/projects.component';
|
|
||||||
import {ImprintComponent} from '../pages/imprint/imprint.component';
|
|
||||||
import {AlgorithmsComponent} from '../pages/algorithms/algorithms.component';
|
|
||||||
import {PathfindingComponent} from '../pages/algorithms/pathfinding/pathfinding.component';
|
|
||||||
import {SortingComponent} from '../pages/algorithms/sorting/sorting.component';
|
|
||||||
import {ConwayGolComponent} from '../pages/algorithms/conway-gol/conway-gol.component';
|
|
||||||
import {LabyrinthComponent} from '../pages/algorithms/pathfinding/labyrinth/labyrinth.component';
|
|
||||||
import {FractalComponent} from '../pages/algorithms/fractal/fractal.component';
|
|
||||||
import {Fractal3dComponent} from '../pages/algorithms/fractal3d/fractal3d.component';
|
|
||||||
import PendulumComponent from '../pages/algorithms/pendulum/pendulum.component';
|
|
||||||
import {ClothComponent} from '../pages/algorithms/cloth/cloth.component';
|
|
||||||
|
|
||||||
export class RouterConstants {
|
|
||||||
|
|
||||||
static readonly ABOUT = {
|
static readonly ABOUT = {
|
||||||
PATH: 'about',
|
PATH: 'about',
|
||||||
LINK: '/about',
|
LINK: '/about',
|
||||||
COMPONENT: AboutComponent
|
|
||||||
};
|
};
|
||||||
|
|
||||||
static readonly PROJECTS = {
|
static readonly PROJECTS = {
|
||||||
PATH: 'projects',
|
PATH: 'projects',
|
||||||
LINK: '/projects',
|
LINK: '/projects',
|
||||||
COMPONENT: ProjectsComponent
|
|
||||||
};
|
};
|
||||||
|
|
||||||
static readonly ALGORITHMS = {
|
static readonly ALGORITHMS = {
|
||||||
PATH: 'algorithms',
|
PATH: 'algorithms',
|
||||||
LINK: '/algorithms',
|
LINK: '/algorithms',
|
||||||
COMPONENT: AlgorithmsComponent
|
|
||||||
};
|
};
|
||||||
|
|
||||||
static readonly PATHFINDING = {
|
static readonly PATHFINDING = {
|
||||||
PATH: 'algorithms/pathfinding',
|
PATH: 'algorithms/pathfinding',
|
||||||
LINK: '/algorithms/pathfinding',
|
LINK: '/algorithms/pathfinding',
|
||||||
COMPONENT: PathfindingComponent
|
|
||||||
};
|
};
|
||||||
|
|
||||||
static readonly SORTING = {
|
static readonly SORTING = {
|
||||||
PATH: 'algorithms/sorting',
|
PATH: 'algorithms/sorting',
|
||||||
LINK: '/algorithms/sorting',
|
LINK: '/algorithms/sorting',
|
||||||
COMPONENT: SortingComponent
|
|
||||||
};
|
};
|
||||||
|
|
||||||
static readonly GOL = {
|
static readonly GOL = {
|
||||||
PATH: 'algorithms/gol',
|
PATH: 'algorithms/gol',
|
||||||
LINK: '/algorithms/gol',
|
LINK: '/algorithms/gol',
|
||||||
COMPONENT: ConwayGolComponent
|
|
||||||
};
|
};
|
||||||
|
|
||||||
static readonly LABYRINTH = {
|
static readonly LABYRINTH = {
|
||||||
PATH: 'algorithms/labyrinth',
|
PATH: 'algorithms/labyrinth',
|
||||||
LINK: '/algorithms/labyrinth',
|
LINK: '/algorithms/labyrinth',
|
||||||
COMPONENT: LabyrinthComponent
|
|
||||||
};
|
};
|
||||||
|
|
||||||
static readonly FRACTAL = {
|
static readonly FRACTAL = {
|
||||||
PATH: 'algorithms/fractal',
|
PATH: 'algorithms/fractal',
|
||||||
LINK: '/algorithms/fractal',
|
LINK: '/algorithms/fractal',
|
||||||
COMPONENT: FractalComponent
|
|
||||||
};
|
};
|
||||||
|
|
||||||
static readonly FRACTAL3d = {
|
static readonly FRACTAL3d = {
|
||||||
PATH: 'algorithms/fractal3d',
|
PATH: 'algorithms/fractal3d',
|
||||||
LINK: '/algorithms/fractal3d',
|
LINK: '/algorithms/fractal3d',
|
||||||
COMPONENT: Fractal3dComponent
|
|
||||||
};
|
};
|
||||||
|
|
||||||
static readonly PENDULUM = {
|
static readonly PENDULUM = {
|
||||||
PATH: 'algorithms/pendulum',
|
PATH: 'algorithms/pendulum',
|
||||||
LINK: '/algorithms/pendulum',
|
LINK: '/algorithms/pendulum',
|
||||||
COMPONENT: PendulumComponent
|
|
||||||
};
|
};
|
||||||
|
|
||||||
static readonly CLOTH = {
|
static readonly CLOTH = {
|
||||||
PATH: 'algorithms/cloth',
|
PATH: 'algorithms/cloth',
|
||||||
LINK: '/algorithms/cloth',
|
LINK: '/algorithms/cloth',
|
||||||
COMPONENT: ClothComponent
|
};
|
||||||
|
|
||||||
|
static readonly FOUR_COLOR = {
|
||||||
|
PATH: 'algorithms/four_color',
|
||||||
|
LINK: '/algorithms/four_color',
|
||||||
};
|
};
|
||||||
|
|
||||||
static readonly IMPRINT = {
|
static readonly IMPRINT = {
|
||||||
PATH: 'imprint',
|
PATH: 'imprint',
|
||||||
LINK: '/imprint',
|
LINK: '/imprint',
|
||||||
COMPONENT: ImprintComponent
|
};
|
||||||
|
|
||||||
|
static readonly STOPWATCH = {
|
||||||
|
PATH: 'stopwatch',
|
||||||
|
LINK: '/stopwatch',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
static readonly QUICK_SORT_WIKI = 'https://de.wikipedia.org/wiki/Quicksort'
|
static readonly QUICK_SORT_WIKI = 'https://de.wikipedia.org/wiki/Quicksort'
|
||||||
static readonly HEAP_SORT_WIKI = 'https://de.wikipedia.org/wiki/Heapsort'
|
static readonly HEAP_SORT_WIKI = 'https://de.wikipedia.org/wiki/Heapsort'
|
||||||
static readonly SHAKE_SORT_WIKI = 'https://de.wikipedia.org/wiki/Shakersort'
|
static readonly SHAKE_SORT_WIKI = 'https://de.wikipedia.org/wiki/Shakersort'
|
||||||
|
static readonly TIM_SORT_WIKI = 'https://de.wikipedia.org/wiki/Timsort'
|
||||||
static readonly CONWAYS_WIKI = 'https://de.wikipedia.org/wiki/Conways_Spiel_des_Lebens'
|
static readonly CONWAYS_WIKI = 'https://de.wikipedia.org/wiki/Conways_Spiel_des_Lebens'
|
||||||
static readonly PRIMS_WIKI = 'https://de.wikipedia.org/wiki/Algorithmus_von_Prim'
|
static readonly PRIMS_WIKI = 'https://de.wikipedia.org/wiki/Algorithmus_von_Prim'
|
||||||
static readonly KRUSKAL_WIKI = 'https://de.wikipedia.org/wiki/Algorithmus_von_Kruskal'
|
static readonly KRUSKAL_WIKI = 'https://de.wikipedia.org/wiki/Algorithmus_von_Kruskal'
|
||||||
@@ -18,4 +19,10 @@
|
|||||||
static readonly MANDELBOX_WIKI = 'https://de.wikipedia.org/wiki/Mandelbox'
|
static readonly MANDELBOX_WIKI = 'https://de.wikipedia.org/wiki/Mandelbox'
|
||||||
static readonly JULIA3D_WIKI = 'https://de.wikipedia.org/wiki/Mandelknolle'
|
static readonly JULIA3D_WIKI = 'https://de.wikipedia.org/wiki/Mandelknolle'
|
||||||
static readonly DOUBLE_PENDULUM_WIKI = 'https://de.wikipedia.org/wiki/Doppelpendel'
|
static readonly DOUBLE_PENDULUM_WIKI = 'https://de.wikipedia.org/wiki/Doppelpendel'
|
||||||
|
static readonly CLOTH_SIMULATION_WIKI = 'https://en.wikipedia.org/wiki/Cloth_modeling'
|
||||||
|
static readonly XPBD_WIKI = 'https://www.emergentmind.com/topics/extended-position-based-dynamics-xpbd'
|
||||||
|
static readonly GPU_COMPUTING_WIKI = 'https://en.wikipedia.org/wiki/General-purpose_computing_on_graphics_processing_units'
|
||||||
|
static readonly DATA_STRUCTURE_WIKI = 'https://de.wikipedia.org/wiki/Datenstruktur'
|
||||||
|
static readonly FOUR_COLOR_THEOREM = 'https://de.wikipedia.org/wiki/Vier-Farben-Satz'
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
<app-particles-background></app-particles-background>
|
<app-particles-background></app-particles-background>
|
||||||
<app-topbar />
|
<app-topbar />
|
||||||
<main class="app-container app-surface">
|
<main class="w-full max-w-app mx-auto mt-4 grow text-app-fg transition-colors duration-220">
|
||||||
<router-outlet />
|
<router-outlet />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer class="foot">
|
<footer class="border-t border-black/8 p-fluid-md text-center opacity-80 bg-app-bg">
|
||||||
<small>© {{ currentYear }} Andreas Dahm - {{ `APP.COPYRIGHT` | translate }}</small>
|
<small>© {{ currentYear }} Andreas Dahm - {{ `APP.COPYRIGHT` | translate }}</small>
|
||||||
|
<br />
|
||||||
|
<small>
|
||||||
|
{{ 'APP.AI_NOTICE' | translate }}
|
||||||
|
<a [routerLink]="['/projects']" [queryParams]="{ project: 'claude-in-a-box' }">{{ 'APP.AI_NOTICE_LINK' | translate }}</a>
|
||||||
|
</small>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
import { Component } from '@angular/core';
|
import { Component, inject } from '@angular/core';
|
||||||
import { RouterOutlet } from '@angular/router';
|
import { RouterLink, 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({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
standalone: true,
|
imports: [RouterLink, RouterOutlet, TopbarComponent, TranslatePipe, ParticleBackgroundComponent],
|
||||||
imports: [RouterOutlet, TopbarComponent, TranslatePipe, ParticleBackgroundComponent],
|
|
||||||
templateUrl: './app.component.html',
|
templateUrl: './app.component.html',
|
||||||
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { MatButtonModule } from '@angular/material/button';
|
|||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
standalone: true,
|
|
||||||
imports: [MatDialogModule, MatButtonModule, MatIconModule],
|
imports: [MatDialogModule, MatButtonModule, MatIconModule],
|
||||||
template: `
|
template: `
|
||||||
<div class="dialog">
|
<div class="dialog">
|
||||||
|
|||||||
@@ -1,23 +1,29 @@
|
|||||||
<mat-toolbar class="topbar" color="primary" (keydown)="onKeydown($event)">
|
<mat-toolbar class="flex! items-center! p-[clamp(0.5rem,1vw,1rem)]! backdrop-blur-sm! backdrop-saturate-[1.1]! bg-white/80! dark:bg-[#313131]/80! border-b! border-black/8!" color="primary" (keydown)="onKeydown($event)">
|
||||||
<a class="brand" routerLink="/">
|
<a class="flex items-center gap-[clamp(0.4rem,1vw,0.6rem)] text-inherit no-underline" routerLink="/">
|
||||||
<img class="logo-dot" src="{{AssetsConstants.LOGO}}" alt="" aria-hidden="true" draggable="false"
|
<img class="w-[clamp(36px,10vw,48px)] h-auto rounded-full" src="{{AssetsConstants.LOGO}}" width="117" height="128" alt="" aria-hidden="true" draggable="false"
|
||||||
oncontextmenu="return false;">
|
oncontextmenu="return false;">
|
||||||
<span class="brand-text">{{ 'APP.TITLE' | translate }}</span>
|
<span class="font-semibold tracking-[0.2px] text-[clamp(1rem,3vw,1.2rem)]">{{ 'APP.TITLE' | translate }}</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<nav class="nav">
|
<nav class="absolute left-1/2 -translate-x-1/2 flex gap-[clamp(0.25rem,1vw,0.5rem)] justify-center mobile:hidden">
|
||||||
<a [routerLink]="RouterConstants.ABOUT.LINK" mat-button>{{ 'TOPBAR.ABOUT' | translate }}</a>
|
<a class="opacity-70 transition-opacity duration-150 hover:opacity-100 relative after:content-[''] after:absolute after:bottom-1 after:left-2.5 after:right-2.5 after:h-0.5 after:bg-current after:rounded-xs after:scale-x-0 after:transition-transform [&.active]:opacity-100 [&.active]:after:scale-x-100"
|
||||||
<a [routerLink]="RouterConstants.PROJECTS.LINK" mat-button>{{ 'TOPBAR.PROJECTS' | translate }}</a>
|
[routerLink]="RouterConstants.ABOUT.LINK" routerLinkActive="active" mat-button>{{ 'TOPBAR.ABOUT' | translate }}</a>
|
||||||
<a [routerLink]="RouterConstants.ALGORITHMS.LINK" mat-button>{{ 'TOPBAR.ALGORITHMS' | translate }}</a>
|
<a class="opacity-70 transition-opacity duration-150 hover:opacity-100 relative after:content-[''] after:absolute after:bottom-1 after:left-2.5 after:right-2.5 after:h-0.5 after:bg-current after:rounded-xs after:scale-x-0 after:transition-transform [&.active]:opacity-100 [&.active]:after:scale-x-100"
|
||||||
<a [routerLink]="RouterConstants.IMPRINT.LINK" mat-button>{{ 'TOPBAR.IMPRINT' | translate }}</a>
|
[routerLink]="RouterConstants.PROJECTS.LINK" routerLinkActive="active" mat-button>{{ 'TOPBAR.PROJECTS' | translate }}</a>
|
||||||
|
<a class="opacity-70 transition-opacity duration-150 hover:opacity-100 relative after:content-[''] after:absolute after:bottom-1 after:left-2.5 after:right-2.5 after:h-0.5 after:bg-current after:rounded-xs after:scale-x-0 after:transition-transform [&.active]:opacity-100 [&.active]:after:scale-x-100"
|
||||||
|
[routerLink]="RouterConstants.ALGORITHMS.LINK" routerLinkActive="active" mat-button>{{ 'TOPBAR.ALGORITHMS' | translate }}</a>
|
||||||
|
<a class="opacity-70 transition-opacity duration-150 hover:opacity-100 relative after:content-[''] after:absolute after:bottom-1 after:left-2.5 after:right-2.5 after:h-0.5 after:bg-current after:rounded-xs after:scale-x-0 after:transition-transform [&.active]:opacity-100 [&.active]:after:scale-x-100"
|
||||||
|
[routerLink]="RouterConstants.STOPWATCH.LINK" routerLinkActive="active" mat-button>{{ 'TOPBAR.STOPWATCH' | translate }}</a>
|
||||||
|
<a class="opacity-70 transition-opacity duration-150 hover:opacity-100 relative after:content-[''] after:absolute after:bottom-1 after:left-2.5 after:right-2.5 after:h-0.5 after:bg-current after:rounded-xs after:scale-x-0 after:transition-transform [&.active]:opacity-100 [&.active]:after:scale-x-100"
|
||||||
|
[routerLink]="RouterConstants.IMPRINT.LINK" routerLinkActive="active" mat-button>{{ 'TOPBAR.IMPRINT' | translate }}</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Mobile nav menu button -->
|
<!-- Mobile nav menu button -->
|
||||||
<button mat-icon-button class="nav-menu-btn" [matMenuTriggerFor]="navMenu" aria-label="Open navigation">
|
<button mat-icon-button class="hidden mobile:inline-flex" [matMenuTriggerFor]="navMenu" aria-label="Open navigation">
|
||||||
<mat-icon>menu</mat-icon>
|
<mat-icon>menu</mat-icon>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<span class="spacer"></span>
|
<span class="flex-1"></span>
|
||||||
|
|
||||||
<!-- Mobile nav menu -->
|
<!-- Mobile nav menu -->
|
||||||
<mat-menu #navMenu="matMenu" xPosition="before">
|
<mat-menu #navMenu="matMenu" xPosition="before">
|
||||||
@@ -30,12 +36,15 @@
|
|||||||
<button mat-menu-item [routerLink]="RouterConstants.ALGORITHMS.LINK">
|
<button mat-menu-item [routerLink]="RouterConstants.ALGORITHMS.LINK">
|
||||||
{{ 'TOPBAR.ALGORITHMS' | translate }}
|
{{ 'TOPBAR.ALGORITHMS' | translate }}
|
||||||
</button>
|
</button>
|
||||||
|
<button mat-menu-item [routerLink]="RouterConstants.STOPWATCH.LINK">
|
||||||
|
{{ 'TOPBAR.STOPWATCH' | translate }}
|
||||||
|
</button>
|
||||||
<button mat-menu-item [routerLink]="RouterConstants.IMPRINT.LINK">
|
<button mat-menu-item [routerLink]="RouterConstants.IMPRINT.LINK">
|
||||||
{{ 'TOPBAR.IMPRINT' | translate }}
|
{{ 'TOPBAR.IMPRINT' | translate }}
|
||||||
</button>
|
</button>
|
||||||
</mat-menu>
|
</mat-menu>
|
||||||
|
|
||||||
<span class="spacer"></span>
|
<span class="flex-1"></span>
|
||||||
|
|
||||||
<!-- Settings: Sprache + Theme -->
|
<!-- Settings: Sprache + Theme -->
|
||||||
<button mat-icon-button [matMenuTriggerFor]="settingsMenu" aria-label="Open settings"
|
<button mat-icon-button [matMenuTriggerFor]="settingsMenu" aria-label="Open settings"
|
||||||
|
|||||||
@@ -1,76 +1,6 @@
|
|||||||
/* ---- Topbar Host & Base ---- */
|
|
||||||
:host {
|
:host {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar {
|
|
||||||
/* Erzeugt den Milchglas-Effekt */
|
|
||||||
backdrop-filter: saturate(1.1) blur(8px);
|
|
||||||
-webkit-backdrop-filter: saturate(1.1) blur(8px);
|
|
||||||
/* Safari Support */
|
|
||||||
|
|
||||||
/* Mischt die Variable mit Transparenz. !important überschreibt Material-Vorgaben */
|
|
||||||
background: color-mix(in oklab, var(--app-topbar-bg) 80%, transparent) !important;
|
|
||||||
border-bottom: 1px solid rgba(0, 0, 0, .08);
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: clamp(0.5rem, 1vw, 1rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- Branding ---- */
|
|
||||||
.brand {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: clamp(0.4rem, 1vw, 0.6rem);
|
|
||||||
color: inherit;
|
|
||||||
text-decoration: none;
|
|
||||||
|
|
||||||
.logo-dot {
|
|
||||||
width: clamp(36px, 10vw, 48px);
|
|
||||||
height: clamp(36px, 10vw, 48px);
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-text {
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: .2px;
|
|
||||||
font-size: clamp(1rem, 3vw, 1.2rem);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- Navigation ---- */
|
|
||||||
.nav {
|
|
||||||
position: absolute;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
display: flex;
|
|
||||||
gap: clamp(0.25rem, 1vw, 0.5rem);
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-menu-btn {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spacer {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- Mobile Responsiveness ---- */
|
|
||||||
@media (max-width: 760px) {
|
|
||||||
.nav {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-menu-btn {
|
|
||||||
display: inline-flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand {
|
|
||||||
flex: unset;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Component, computed, inject } from '@angular/core';
|
import { Component, computed, inject } from '@angular/core';
|
||||||
import { RouterLink } from '@angular/router';
|
import { RouterLink, RouterLinkActive } from '@angular/router';
|
||||||
import { MatToolbarModule } from '@angular/material/toolbar';
|
import { MatToolbarModule } from '@angular/material/toolbar';
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatMenuModule } from '@angular/material/menu';
|
import { MatMenuModule } from '@angular/material/menu';
|
||||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||||
import { TranslateModule } from '@ngx-translate/core';
|
import { TranslatePipe } from '@ngx-translate/core';
|
||||||
import { ThemeService } from '../../service/theme.service';
|
import { ThemeService } from '../../service/theme.service';
|
||||||
import { LanguageService } from '../../service/language.service';
|
import { LanguageService } from '../../service/language.service';
|
||||||
import { MatDivider } from '@angular/material/divider';
|
import { MatDivider } from '@angular/material/divider';
|
||||||
@@ -14,11 +14,10 @@ import {RouterConstants} from '../../constants/RouterConstants';
|
|||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-topbar',
|
selector: 'app-topbar',
|
||||||
standalone: true,
|
|
||||||
imports: [
|
imports: [
|
||||||
RouterLink,
|
RouterLink, RouterLinkActive,
|
||||||
MatToolbarModule, MatIconModule, MatButtonModule, MatMenuModule, MatTooltipModule,
|
MatToolbarModule, MatIconModule, MatButtonModule, MatMenuModule, MatTooltipModule,
|
||||||
TranslateModule, MatDivider
|
TranslatePipe, MatDivider
|
||||||
],
|
],
|
||||||
templateUrl: './topbar.component.html',
|
templateUrl: './topbar.component.html',
|
||||||
styleUrl: './topbar.component.scss'
|
styleUrl: './topbar.component.scss'
|
||||||
|
|||||||
@@ -1,175 +1,179 @@
|
|||||||
<section class="about">
|
<section class="grid gap-fluid-md max-w-app mx-4 mt-auto">
|
||||||
<mat-card class="hero">
|
<mat-card class="card-gradient-bar">
|
||||||
<div class="hero-flex-container">
|
<div class="flex flex-wrap gap-fluid-lg p-fluid-md items-start">
|
||||||
<div class="photo">
|
<div class="flex-[1_1_min(100%,425px)] max-w-full flex justify-center">
|
||||||
<img [ngSrc]="AssetsConstants.ME" width="421" height="512" alt="{{ 'ABOUT.ALT.PROFILE' | translate }}"
|
<img class="block w-full h-auto max-w-[425px] rounded-xl shadow-[0_6px_24px_rgba(0,0,0,0.25)] object-cover"
|
||||||
|
[ngSrc]="AssetsConstants.ME" width="421" height="512" alt="{{ 'ABOUT.ALT.PROFILE' | translate }}"
|
||||||
draggable="false" oncontextmenu="return false;" priority />
|
draggable="false" oncontextmenu="return false;" priority />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="intro">
|
<div class="flex-[999_1_min(100%,400px)]">
|
||||||
<h1>{{ 'ABOUT.HELLO' | translate }}</h1>
|
<h1 class="mt-0 mb-2 text-[clamp(1.5rem,5vw,2.5rem)]">{{ 'ABOUT.HELLO' | translate }}</h1>
|
||||||
<p class="lead">
|
<p class="opacity-90 my-2 mb-4 text-[clamp(1rem,2.5vw,1.15rem)]">
|
||||||
{{ 'ABOUT.LEAD' | translate }}
|
{{ 'ABOUT.LEAD' | translate }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="meta">
|
<div class="flex flex-col gap-1 mb-4">
|
||||||
<div class="row">
|
<div class="flex items-center flex-wrap gap-[0.4rem]">
|
||||||
<mat-icon aria-hidden="true">work</mat-icon>
|
<mat-icon aria-hidden="true">work</mat-icon>
|
||||||
<span>{{ 'ABOUT.ROLE' | translate }}</span>
|
<span>{{ 'ABOUT.ROLE' | translate }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="flex items-center flex-wrap gap-[0.4rem]">
|
||||||
<mat-icon aria-hidden="true">location_on</mat-icon>
|
<mat-icon aria-hidden="true">location_on</mat-icon>
|
||||||
<span>{{ 'ABOUT.LOCATION' | translate }}</span>
|
<span>{{ 'ABOUT.LOCATION' | translate }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="flex items-center flex-wrap gap-[0.4rem]">
|
||||||
<mat-icon aria-hidden="true">email</mat-icon>
|
<mat-icon aria-hidden="true">email</mat-icon>
|
||||||
<a href="" (click)="SharedFunctions.openMail($event)">
|
<button type="button" class="link-button" data-testid="contact-mail"
|
||||||
|
(click)="SharedFunctions.openMail($event)">
|
||||||
{{ 'ABOUT.CONTACT_ME' | translate }}
|
{{ 'ABOUT.CONTACT_ME' | translate }}
|
||||||
</a>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="flex items-center flex-wrap gap-[0.4rem]">
|
||||||
<mat-icon>data_object</mat-icon>
|
<mat-icon aria-hidden="true">data_object</mat-icon>
|
||||||
<a href="{{UrlConstants.CODEBERG}}" target="_blank" rel="noopener">Codeberg</a>
|
<a href="{{UrlConstants.CODEBERG}}" target="_blank" rel="noopener">Codeberg</a>
|
||||||
<span>·</span>
|
<span>·</span>
|
||||||
<mat-icon svgIcon="linkedin"></mat-icon>
|
<mat-icon svgIcon="linkedin" aria-hidden="true"></mat-icon>
|
||||||
<a href="{{UrlConstants.LINKED_IN}}" target="_blank" rel="noopener">LinkedIn</a>
|
<a href="{{UrlConstants.LINKED_IN}}" target="_blank" rel="noopener">LinkedIn</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-fluid-sm">
|
||||||
|
<a mat-flat-button [routerLink]="['/projects']">
|
||||||
|
<mat-icon aria-hidden="true">grid_view</mat-icon>
|
||||||
|
{{ 'ABOUT.VIEW_PROJECTS' | translate }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
|
|
||||||
<mat-card class="skills">
|
<mat-card class="p-[clamp(5px,2vw,15px)]">
|
||||||
<h2>{{ 'ABOUT.SECTION.SKILLS' | translate }}</h2>
|
<h2 class="mt-1 ml-1 text-[clamp(1.2rem,4vw,1.8rem)]">{{ 'ABOUT.SECTION.SKILLS' | translate }}</h2>
|
||||||
<div class="chip-groups">
|
<div class="grid grid-cols-[repeat(auto-fit,minmax(min(100%,250px),1fr))] gap-fluid-sm ml-1 mb-2">
|
||||||
|
@for (group of skillGroups; track group.titleKey) {
|
||||||
<div>
|
<div>
|
||||||
<h3>{{ 'ABOUT.SECTION.BACKEND_ARCH' | translate }}</h3>
|
<h3 class="my-1 text-[0.95rem] opacity-85">{{ group.titleKey | translate }}</h3>
|
||||||
<mat-chip-set aria-label="Backend and Architecture">
|
<mat-chip-set [attr.aria-label]="group.titleKey | translate">
|
||||||
@for (s of skillsArchitecture; track s) {
|
@for (skillKey of group.skillKeys; track skillKey) {
|
||||||
<mat-chip>{{ s | translate }}</mat-chip>
|
<mat-chip>{{ skillKey | translate }}</mat-chip>
|
||||||
}
|
}
|
||||||
</mat-chip-set>
|
</mat-chip-set>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
|
||||||
<h3>{{ 'ABOUT.SECTION.INFRA_CLOUD' | translate }}</h3>
|
|
||||||
<mat-chip-set aria-label="Infrastructure and Cloud">
|
|
||||||
@for (s of skillsCore; track s) {
|
|
||||||
<mat-chip>{{ s | translate }}</mat-chip>
|
|
||||||
}
|
}
|
||||||
</mat-chip-set>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h3>{{ 'ABOUT.SECTION.SIM_ALGO' | translate }}</h3>
|
|
||||||
<mat-chip-set aria-label="Simulation and Algorithms">
|
|
||||||
@for (s of skillsEngineering; track s) {
|
|
||||||
<mat-chip>{{ s | translate }}</mat-chip>
|
|
||||||
}
|
|
||||||
</mat-chip-set>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
|
|
||||||
<mat-card class="experdience">
|
<mat-card class="p-[clamp(5px,2vw,15px)]">
|
||||||
<h2 style="margin-left: 0.5rem;">{{ 'ABOUT.SECTION.EXPERIENCE' | translate }}</h2>
|
<h2 class="mt-1 ml-2 text-[clamp(1.2rem,4vw,1.8rem)]">{{ 'ABOUT.SECTION.EXPERIENCE' | translate }}</h2>
|
||||||
<div class="xp-list">
|
|
||||||
@for (entry of xpKeys; track entry.key) {
|
|
||||||
<div class="xp-item">
|
|
||||||
<div class="xp-head-grid">
|
|
||||||
<div class="logo-wrap">
|
|
||||||
<img src="{{entry.logo}}" alt="" class="company-logo" aria-hidden="true" />
|
|
||||||
</div>
|
|
||||||
<div class="head-row">
|
|
||||||
<strong>{{ (entry.key + '.ROLE') | translate }}</strong>
|
|
||||||
<span class="time">{{ (entry.key + '.TIME') | translate }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="company-row">
|
<div class="relative ml-2">
|
||||||
|
<span class="absolute left-5 top-2 bottom-2 w-px bg-current opacity-15" aria-hidden="true"></span>
|
||||||
|
@for (entry of visibleExperience(); track entry.key; let i = $index) {
|
||||||
|
<div class="relative pl-14 pb-fluid-sm">
|
||||||
|
@if (isFirstRoleAtCompany(i)) {
|
||||||
|
<img src="{{entry.logo}}" alt="" aria-hidden="true"
|
||||||
|
class="absolute left-0 top-0 w-10 h-10 object-contain opacity-90 rounded-[10%] bg-logo-bg" />
|
||||||
|
<div class="font-semibold opacity-90 text-[clamp(0.9rem,2.2vw,1.05rem)] leading-10" data-testid="company">
|
||||||
{{ (entry.key + '.COMPANY') | translate }}
|
{{ (entry.key + '.COMPANY') | translate }}
|
||||||
</div>
|
</div>
|
||||||
|
} @else {
|
||||||
|
<span class="absolute left-5 top-[0.6rem] -translate-x-1/2 w-2 h-2 rounded-full bg-current opacity-30"
|
||||||
|
aria-hidden="true"></span>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-baseline gap-x-4 gap-y-1">
|
||||||
|
<strong class="text-[clamp(0.95rem,2.5vw,1.1rem)]" data-testid="role">{{ (entry.key + '.ROLE') | translate }}</strong>
|
||||||
|
<span class="opacity-75 text-[clamp(0.85rem,2vw,0.95rem)]">{{ (entry.key + '.TIME') | translate }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="highlights">
|
|
||||||
<ul>
|
<ul class="mt-[0.4rem]">
|
||||||
<li>{{ entry.key + '.HIGHLIGHTS.P1' | translate }}</li>
|
@for (highlight of highlightKeys(entry); track highlight) {
|
||||||
<li>{{ entry.key + '.HIGHLIGHTS.P2' | translate }}</li>
|
<li class="my-1 text-[clamp(0.9rem,2vw,1rem)]">{{ highlight | translate }}</li>
|
||||||
<li>{{ entry.key + '.HIGHLIGHTS.P3' | translate }}</li>
|
}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
@if (isLastRoleAtCompany(i) && !$last) {
|
||||||
|
<mat-divider class="mt-fluid-sm!"></mat-divider>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if(entry.key !== xpKeys.at(xpKeys.length-1)?.key)
|
<button mat-button type="button" class="mt-1 ml-2" data-testid="toggle-experience" (click)="toggleEarlierExperience()"
|
||||||
{
|
[attr.aria-expanded]="showEarlierExperience()">
|
||||||
<mat-divider></mat-divider>
|
<mat-icon aria-hidden="true">{{ showEarlierExperience() ? 'expand_less' : 'expand_more' }}</mat-icon>
|
||||||
}
|
{{ (showEarlierExperience() ? 'ABOUT.SECTION.SHOW_LESS' : 'ABOUT.SECTION.SHOW_EARLIER_XP') | translate }}
|
||||||
}
|
</button>
|
||||||
</div>
|
|
||||||
</mat-card>
|
</mat-card>
|
||||||
<mat-card class="projects">
|
|
||||||
<h2>{{ 'ABOUT.SECTION.PROJECTS' | translate }}</h2>
|
|
||||||
|
|
||||||
<div class="xp-list">
|
<mat-card class="p-[clamp(5px,2vw,15px)]">
|
||||||
|
<h2 class="mt-1 ml-1 text-[clamp(1.2rem,4vw,1.8rem)]">{{ 'ABOUT.SECTION.PROJECTS' | translate }}</h2>
|
||||||
|
|
||||||
|
<div class="ml-1 grid gap-fluid-sm">
|
||||||
@for (entry of projectKeys; track entry.key) {
|
@for (entry of projectKeys; track entry.key) {
|
||||||
<div class="xp-item">
|
<div>
|
||||||
<div class="head-row">
|
<div class="flex flex-wrap items-baseline gap-x-4 gap-y-1">
|
||||||
<strong>{{ (entry.key + '.TITLE') | translate }}</strong>
|
<strong class="text-[clamp(0.95rem,2.5vw,1.1rem)]">{{ (entry.key + '.TITLE') | translate }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="company-row">
|
<div class="mt-[0.1rem] opacity-85 text-[clamp(0.85rem,2vw,1rem)]">
|
||||||
{{ (entry.key + '.DESCRIPTION') | translate }}
|
{{ (entry.key + '.DESCRIPTION') | translate }}
|
||||||
</div>
|
</div>
|
||||||
@if (entry.externalLink) {
|
@if (entry.externalLink) {
|
||||||
<div class="link-row">
|
<div class="mt-[0.1rem] opacity-85">
|
||||||
<a class="link-with-icon" href="{{entry.externalLink}}" target="_blank" rel="noopener noreferrer">
|
<a class="inline-flex items-center gap-[0.35rem] leading-none" href="{{entry.externalLink}}" target="_blank" rel="noopener noreferrer">
|
||||||
<mat-icon>open_in_new</mat-icon>
|
<mat-icon class="text-[18px]! w-[18px]! h-[18px]!" aria-hidden="true">open_in_new</mat-icon>
|
||||||
{{ (entry.key + '.LINK_EXTERNAL') | translate }}
|
{{ (entry.key + '.LINK_EXTERNAL') | translate }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<div class="link-row">
|
<div class="mt-[0.1rem] opacity-85">
|
||||||
<a class="link-with-icon" [routerLink]="['/projects']" [queryParams]="{ project: entry.identifier }"
|
<a class="inline-flex items-center gap-[0.35rem] leading-none" [routerLink]="['/projects']" [queryParams]="{ project: entry.identifier }"
|
||||||
rel="noopener noreferrer">
|
rel="noopener noreferrer">
|
||||||
<mat-icon>link</mat-icon>
|
<mat-icon class="text-[18px]! w-[18px]! h-[18px]!" aria-hidden="true">link</mat-icon>
|
||||||
{{ (entry.key + '.LINK_INTERNAL') | translate }}
|
{{ (entry.key + '.LINK_INTERNAL') | translate }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="highlights-noMargin">
|
<div>
|
||||||
<ul>
|
<ul>
|
||||||
<li>{{ entry.key + '.HIGHLIGHTS.P1' | translate }}</li>
|
<li class="my-1 text-[clamp(0.9rem,2vw,1rem)]">{{ entry.key + '.HIGHLIGHTS.P1' | translate }}</li>
|
||||||
<li>{{ entry.key + '.HIGHLIGHTS.P2' | translate }}</li>
|
<li class="my-1 text-[clamp(0.9rem,2vw,1rem)]">{{ entry.key + '.HIGHLIGHTS.P2' | translate }}</li>
|
||||||
<li>{{ entry.key + '.HIGHLIGHTS.P3' | translate }}</li>
|
<li class="my-1 text-[clamp(0.9rem,2vw,1rem)]">{{ entry.key + '.HIGHLIGHTS.P3' | translate }}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if(entry.key !== projectKeys.at(projectKeys.length-1)?.key)
|
@if (!$last) {
|
||||||
{
|
|
||||||
<mat-divider></mat-divider>
|
<mat-divider></mat-divider>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
|
|
||||||
<mat-card class="education">
|
<mat-card class="p-[clamp(5px,2vw,15px)]">
|
||||||
<h2>{{ 'ABOUT.SECTION.EDUCATION' | translate }}</h2>
|
<h2 class="mt-1 ml-1 text-[clamp(1.2rem,4vw,1.8rem)]">{{ 'ABOUT.SECTION.EDUCATION' | translate }}</h2>
|
||||||
|
|
||||||
<div class="xp-list">
|
<div class="ml-1 grid gap-fluid-sm">
|
||||||
<div class="xp-item">
|
<div>
|
||||||
@for (entry of educationKeys; track entry.key) {
|
@for (key of visibleEducationKeys(); track key) {
|
||||||
<div class="head-row">
|
<div class="flex flex-wrap items-baseline gap-x-4 gap-y-1">
|
||||||
<strong>{{ (entry.key + '.WHERE') | translate }}</strong>
|
<strong class="text-[clamp(0.95rem,2.5vw,1.1rem)]" data-testid="education-where">{{ (key + '.WHERE') | translate }}</strong>
|
||||||
<span class="time">{{ (entry.key + '.WHEN') | translate }}</span>
|
<span class="opacity-75 text-[clamp(0.85rem,2vw,0.95rem)]">{{ (key + '.WHEN') | translate }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="company-row">
|
<div class="mt-[0.1rem] opacity-85 text-[clamp(0.85rem,2vw,1rem)]">
|
||||||
{{ (entry.key + '.WHAT') | translate }}
|
{{ (key + '.WHAT') | translate }}
|
||||||
</div>
|
</div>
|
||||||
|
@if (!$last) {
|
||||||
@if(entry.key !== educationKeys.at(educationKeys.length-1)?.key)
|
<mat-divider class="my-2!"></mat-divider>
|
||||||
{
|
|
||||||
<mat-divider style="margin-top: .5rem; margin-bottom: .5rem"></mat-divider>
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button mat-button type="button" class="mt-1" data-testid="toggle-education" (click)="toggleEarlierEducation()"
|
||||||
|
[attr.aria-expanded]="showEarlierEducation()">
|
||||||
|
<mat-icon aria-hidden="true">{{ showEarlierEducation() ? 'expand_less' : 'expand_more' }}</mat-icon>
|
||||||
|
{{ (showEarlierEducation() ? 'ABOUT.SECTION.SHOW_LESS' : 'ABOUT.SECTION.SHOW_EARLIER_EDU') | translate }}
|
||||||
|
</button>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
214
src/app/pages/about/about.component.spec.ts
Normal file
214
src/app/pages/about/about.component.spec.ts
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { Component, provideZonelessChangeDetection } from '@angular/core';
|
||||||
|
import { MatCardModule } from '@angular/material/card';
|
||||||
|
import { By } from '@angular/platform-browser';
|
||||||
|
import { provideRouter } from '@angular/router';
|
||||||
|
import { provideTranslateService } from '@ngx-translate/core';
|
||||||
|
import { AboutComponent } from './about.component';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No translations are loaded, so the translate pipe echoes the key back. That is
|
||||||
|
* exactly what these tests want: they assert which key is rendered where, which
|
||||||
|
* is what the mismatched skill headings got wrong. Expected values are written
|
||||||
|
* out literally rather than read off the component, so a change to the component
|
||||||
|
* cannot silently move the assertion with it.
|
||||||
|
*/
|
||||||
|
describe('AboutComponent', () => {
|
||||||
|
let fixture: ComponentFixture<AboutComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [AboutComponent],
|
||||||
|
providers: [
|
||||||
|
provideZonelessChangeDetection(),
|
||||||
|
provideRouter([]),
|
||||||
|
provideTranslateService({ lang: 'en', fallbackLang: 'en' }),
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(AboutComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
function textOf(selector: string): string[] {
|
||||||
|
return fixture.debugElement
|
||||||
|
.queryAll(By.css(selector))
|
||||||
|
.map(element => element.nativeElement.textContent.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function click(testId: string): void {
|
||||||
|
fixture.debugElement
|
||||||
|
.query(By.css(`[data-testid="${testId}"]`))
|
||||||
|
.nativeElement.click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
function skillGroupTexts(): { heading: string; chips: string[] }[] {
|
||||||
|
return fixture.debugElement.queryAll(By.css('mat-chip-set')).map(chipSet => {
|
||||||
|
const group = chipSet.parent!;
|
||||||
|
const heading = group.query(By.css('h3')).nativeElement.textContent.trim();
|
||||||
|
const chips = group
|
||||||
|
.queryAll(By.css('mat-chip'))
|
||||||
|
.map(chip => chip.nativeElement.textContent.trim());
|
||||||
|
return { heading, chips };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('skills', () => {
|
||||||
|
it('renders the skill groups in the documented order', () => {
|
||||||
|
expect(skillGroupTexts().map(group => group.heading)).toEqual([
|
||||||
|
'ABOUT.SECTION.LANGUAGES',
|
||||||
|
'ABOUT.SECTION.ARCH_CLOUD',
|
||||||
|
'ABOUT.SECTION.SIM_ALGO',
|
||||||
|
'ABOUT.SECTION.AI_ENG',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists languages under the languages heading, not under architecture', () => {
|
||||||
|
const languages = skillGroupTexts().find(group => group.heading === 'ABOUT.SECTION.LANGUAGES');
|
||||||
|
|
||||||
|
expect(languages).toBeDefined();
|
||||||
|
expect(languages!.chips).toContain('ABOUT.SKILLS.JAVA');
|
||||||
|
expect(languages!.chips).not.toContain('ABOUT.TOOLS.DOCKER');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists infrastructure tools under the architecture heading', () => {
|
||||||
|
const architecture = skillGroupTexts().find(group => group.heading === 'ABOUT.SECTION.ARCH_CLOUD');
|
||||||
|
|
||||||
|
expect(architecture).toBeDefined();
|
||||||
|
expect(architecture!.chips).toContain('ABOUT.TOOLS.DOCKER');
|
||||||
|
expect(architecture!.chips).not.toContain('ABOUT.SKILLS.JAVA');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('advertises AI-assisted engineering as its own group', () => {
|
||||||
|
const ai = skillGroupTexts().find(group => group.heading === 'ABOUT.SECTION.AI_ENG');
|
||||||
|
|
||||||
|
expect(ai).toBeDefined();
|
||||||
|
expect(ai!.chips).toContain('ABOUT.SKILLS.AI_HARNESS');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('experience', () => {
|
||||||
|
it('hides the pre-2010 positions until they are requested', () => {
|
||||||
|
expect(textOf('[data-testid="role"]')).toContain('ABOUT.XP.COMPANY9.ROLE');
|
||||||
|
expect(textOf('[data-testid="role"]')).not.toContain('ABOUT.XP.COMPANY0.ROLE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reveals the earlier positions when the toggle button is clicked', () => {
|
||||||
|
click('toggle-experience');
|
||||||
|
|
||||||
|
expect(textOf('[data-testid="role"]')).toContain('ABOUT.XP.COMPANY0.ROLE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports the expanded state of the experience toggle to assistive technology', () => {
|
||||||
|
const toggle = fixture.debugElement.query(By.css('[data-testid="toggle-experience"]')).nativeElement;
|
||||||
|
expect(toggle.getAttribute('aria-expanded')).toBe('false');
|
||||||
|
|
||||||
|
click('toggle-experience');
|
||||||
|
|
||||||
|
expect(toggle.getAttribute('aria-expanded')).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws the employer header once per run of consecutive roles', () => {
|
||||||
|
const companies = textOf('[data-testid="company"]');
|
||||||
|
|
||||||
|
// COMPANY5/4/3 are three consecutive Assyst roles and share one header.
|
||||||
|
expect(companies).toContain('ABOUT.XP.COMPANY5.COMPANY');
|
||||||
|
expect(companies).not.toContain('ABOUT.XP.COMPANY4.COMPANY');
|
||||||
|
expect(companies).not.toContain('ABOUT.XP.COMPANY3.COMPANY');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats the two ColorDigital spells as separate blocks so the timeline stays chronological', () => {
|
||||||
|
const companies = textOf('[data-testid="company"]');
|
||||||
|
|
||||||
|
expect(companies).toContain('ABOUT.XP.COMPANY9.COMPANY');
|
||||||
|
expect(companies).toContain('ABOUT.XP.COMPANY7.COMPANY');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the employer header at the seam when the earlier roles are appended', () => {
|
||||||
|
click('toggle-experience');
|
||||||
|
|
||||||
|
// COMPANY3 (Assyst) and COMPANY2 (TH Bingen) meet at the seam, so the
|
||||||
|
// first earlier role must still get its own header.
|
||||||
|
expect(textOf('[data-testid="company"]')).toContain('ABOUT.XP.COMPANY2.COMPANY');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('education', () => {
|
||||||
|
it('shows only the post-school entries until the toggle button is clicked', () => {
|
||||||
|
expect(textOf('[data-testid="education-where"]')).toContain('ABOUT.EDUCATION.E6.WHERE');
|
||||||
|
expect(textOf('[data-testid="education-where"]')).not.toContain('ABOUT.EDUCATION.E0.WHERE');
|
||||||
|
|
||||||
|
click('toggle-education');
|
||||||
|
|
||||||
|
expect(textOf('[data-testid="education-where"]')).toContain('ABOUT.EDUCATION.E0.WHERE');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('call to action', () => {
|
||||||
|
it('offers no download link, so nothing can point at a missing file', () => {
|
||||||
|
expect(fixture.debugElement.queryAll(By.css('a[download]')).length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes the mail contact as a button rather than an empty link', () => {
|
||||||
|
const emptyLinks = fixture.debugElement
|
||||||
|
.queryAll(By.css('a'))
|
||||||
|
.filter(anchor => anchor.nativeElement.getAttribute('href') === '');
|
||||||
|
|
||||||
|
expect(emptyLinks.length).toBe(0);
|
||||||
|
expect(fixture.debugElement.query(By.css('[data-testid="contact-mail"]'))).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the mail button in the same font as the links it sits next to', () => {
|
||||||
|
const mailButton = fixture.debugElement.query(By.css('[data-testid="contact-mail"]')).nativeElement;
|
||||||
|
const siblingLink = fixture.debugElement
|
||||||
|
.queryAll(By.css('a'))
|
||||||
|
.find(anchor => anchor.nativeElement.textContent.trim() === 'Codeberg')!.nativeElement;
|
||||||
|
|
||||||
|
const buttonStyle = getComputedStyle(mailButton);
|
||||||
|
const linkStyle = getComputedStyle(siblingLink);
|
||||||
|
|
||||||
|
expect(buttonStyle.fontSize).toBe(linkStyle.fontSize);
|
||||||
|
expect(buttonStyle.fontFamily).toBe(linkStyle.fontFamily);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Material typography (`brand-family` in styles.scss) and the Tailwind body
|
||||||
|
* stack are declared in two separate places and are easy to change apart. When
|
||||||
|
* they disagree, only visitors who happen to have the extra font installed
|
||||||
|
* locally see it, so it will not show up in review.
|
||||||
|
*/
|
||||||
|
describe('global font stack', () => {
|
||||||
|
@Component({
|
||||||
|
imports: [MatCardModule],
|
||||||
|
template: `<mat-card><mat-card-title>probe</mat-card-title></mat-card>`,
|
||||||
|
})
|
||||||
|
class FontProbeComponent {}
|
||||||
|
|
||||||
|
function normalizeStack(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/["']/g, '')
|
||||||
|
.split(',')
|
||||||
|
.map(family => family.trim())
|
||||||
|
.filter(family => family.length > 0)
|
||||||
|
.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
it('resolves Material card titles to the same font stack as body copy', async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [FontProbeComponent],
|
||||||
|
providers: [provideZonelessChangeDetection()],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
const probe = TestBed.createComponent(FontProbeComponent);
|
||||||
|
probe.detectChanges();
|
||||||
|
|
||||||
|
const title = probe.nativeElement.querySelector('.mat-mdc-card-title') as HTMLElement;
|
||||||
|
const titleStack = normalizeStack(getComputedStyle(title).fontFamily);
|
||||||
|
const bodyStack = normalizeStack(getComputedStyle(document.body).fontFamily);
|
||||||
|
|
||||||
|
expect(titleStack).toBe(bodyStack);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,20 +1,31 @@
|
|||||||
import { Component} from '@angular/core';
|
import { Component, computed, signal } from '@angular/core';
|
||||||
import { NgOptimizedImage } from '@angular/common';
|
import { NgOptimizedImage } from '@angular/common';
|
||||||
import { MatCardModule } from '@angular/material/card';
|
import { MatCardModule } from '@angular/material/card';
|
||||||
import { MatChipsModule } from '@angular/material/chips';
|
import { MatChipsModule } from '@angular/material/chips';
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatDividerModule } from '@angular/material/divider';
|
import { MatDividerModule } from '@angular/material/divider';
|
||||||
import { TranslateModule } from '@ngx-translate/core';
|
import { TranslatePipe } from '@ngx-translate/core';
|
||||||
import {UrlConstants} from '../../constants/UrlConstants';
|
import {UrlConstants} from '../../constants/UrlConstants';
|
||||||
import {AssetsConstants} from '../../constants/AssetsConstants';
|
import {AssetsConstants} from '../../constants/AssetsConstants';
|
||||||
import {RouterLink} from '@angular/router';
|
import {RouterLink} from '@angular/router';
|
||||||
import {SharedFunctions} from '../../shared/SharedFunctions';
|
import {SharedFunctions} from '../../shared/SharedFunctions';
|
||||||
|
|
||||||
|
/** One role. Consecutive entries sharing a `company` render as a single employer block. */
|
||||||
|
interface ExperienceEntry {
|
||||||
|
key: string;
|
||||||
|
company: string;
|
||||||
|
logo: string;
|
||||||
|
highlightCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SkillGroup {
|
||||||
|
titleKey: string;
|
||||||
|
skillKeys: string[];
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-about',
|
selector: 'app-about',
|
||||||
standalone: true,
|
|
||||||
imports: [
|
imports: [
|
||||||
NgOptimizedImage,
|
NgOptimizedImage,
|
||||||
MatCardModule,
|
MatCardModule,
|
||||||
@@ -22,7 +33,7 @@ import {SharedFunctions} from '../../shared/SharedFunctions';
|
|||||||
MatIconModule,
|
MatIconModule,
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
MatDividerModule,
|
MatDividerModule,
|
||||||
TranslateModule,
|
TranslatePipe,
|
||||||
RouterLink
|
RouterLink
|
||||||
],
|
],
|
||||||
templateUrl: './about.component.html',
|
templateUrl: './about.component.html',
|
||||||
@@ -30,46 +41,83 @@ import {SharedFunctions} from '../../shared/SharedFunctions';
|
|||||||
})
|
})
|
||||||
export class AboutComponent {
|
export class AboutComponent {
|
||||||
|
|
||||||
xpKeys = [
|
/** Roles older than the current decade are hidden until the reader asks for them. */
|
||||||
|
readonly showEarlierExperience = signal(false);
|
||||||
|
readonly showEarlierEducation = signal(false);
|
||||||
|
|
||||||
|
readonly recentExperience: ExperienceEntry[] = [
|
||||||
|
{
|
||||||
|
key: 'ABOUT.XP.COMPANY9',
|
||||||
|
company: 'colordigital',
|
||||||
|
logo: AssetsConstants.COLORDIGITAL_LOGO,
|
||||||
|
highlightCount: 3
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'ABOUT.XP.COMPANY8',
|
key: 'ABOUT.XP.COMPANY8',
|
||||||
logo: AssetsConstants.TERAPORT_LOGO
|
company: 'teraport',
|
||||||
|
logo: AssetsConstants.TERAPORT_LOGO,
|
||||||
|
highlightCount: 3
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'ABOUT.XP.COMPANY7',
|
key: 'ABOUT.XP.COMPANY7',
|
||||||
logo: AssetsConstants.COLORDIGITAL_LOGO
|
company: 'colordigital-earlier',
|
||||||
|
logo: AssetsConstants.COLORDIGITAL_LOGO,
|
||||||
|
highlightCount: 3
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'ABOUT.XP.COMPANY6',
|
key: 'ABOUT.XP.COMPANY6',
|
||||||
logo: AssetsConstants.COLORDIGITAL_LOGO
|
company: 'colordigital-earlier',
|
||||||
|
logo: AssetsConstants.COLORDIGITAL_LOGO,
|
||||||
|
highlightCount: 3
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'ABOUT.XP.COMPANY5',
|
key: 'ABOUT.XP.COMPANY5',
|
||||||
logo: AssetsConstants.ASSYST_LOGO
|
company: 'assyst',
|
||||||
|
logo: AssetsConstants.ASSYST_LOGO,
|
||||||
|
highlightCount: 3
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'ABOUT.XP.COMPANY4',
|
key: 'ABOUT.XP.COMPANY4',
|
||||||
logo: AssetsConstants.ASSYST_LOGO
|
company: 'assyst',
|
||||||
|
logo: AssetsConstants.ASSYST_LOGO,
|
||||||
|
highlightCount: 3
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'ABOUT.XP.COMPANY3',
|
key: 'ABOUT.XP.COMPANY3',
|
||||||
logo: AssetsConstants.ASSYST_LOGO
|
company: 'assyst',
|
||||||
},
|
logo: AssetsConstants.ASSYST_LOGO,
|
||||||
{
|
highlightCount: 3
|
||||||
key: 'ABOUT.XP.COMPANY2',
|
|
||||||
logo: AssetsConstants.TH_BINGEN_LOGO
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'ABOUT.XP.COMPANY1',
|
|
||||||
logo: AssetsConstants.TH_BINGEN_LOGO
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'ABOUT.XP.COMPANY0',
|
|
||||||
logo: AssetsConstants.CHAMAELEON_LOGO
|
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
projectKeys = [
|
readonly earlierExperience: ExperienceEntry[] = [
|
||||||
|
{
|
||||||
|
key: 'ABOUT.XP.COMPANY2',
|
||||||
|
company: 'th-bingen',
|
||||||
|
logo: AssetsConstants.TH_BINGEN_LOGO,
|
||||||
|
highlightCount: 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'ABOUT.XP.COMPANY1',
|
||||||
|
company: 'th-bingen',
|
||||||
|
logo: AssetsConstants.TH_BINGEN_LOGO,
|
||||||
|
highlightCount: 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'ABOUT.XP.COMPANY0',
|
||||||
|
company: 'chamaeleon',
|
||||||
|
logo: AssetsConstants.CHAMAELEON_LOGO,
|
||||||
|
highlightCount: 3
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
readonly projectKeys = [
|
||||||
|
{
|
||||||
|
key: 'ABOUT.PROJECT.CLAUDE_BOX',
|
||||||
|
externalLink: '',
|
||||||
|
internalLink: 'projects',
|
||||||
|
identifier: 'claude-in-a-box',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'ABOUT.PROJECT.P2',
|
key: 'ABOUT.PROJECT.P2',
|
||||||
externalLink: 'https://andreas-dahm.eu/',
|
externalLink: 'https://andreas-dahm.eu/',
|
||||||
@@ -102,56 +150,115 @@ export class AboutComponent {
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
educationKeys = [
|
readonly recentEducationKeys = [
|
||||||
{
|
'ABOUT.EDUCATION.E6',
|
||||||
key: 'ABOUT.EDUCATION.E6'
|
'ABOUT.EDUCATION.E5',
|
||||||
},
|
'ABOUT.EDUCATION.E4'
|
||||||
{
|
|
||||||
key: 'ABOUT.EDUCATION.E5'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'ABOUT.EDUCATION.E4'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'ABOUT.EDUCATION.E3'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'ABOUT.EDUCATION.E2'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'ABOUT.EDUCATION.E1'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'ABOUT.EDUCATION.E0'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
skillsCore = [
|
|
||||||
'ABOUT.SKILLS.JAVA',
|
|
||||||
'ABOUT.SKILLS.SPRING',
|
|
||||||
'ABOUT.SKILLS.ANGULAR',
|
|
||||||
'ABOUT.SKILLS.TYPESCRIPT',
|
|
||||||
'ABOUT.SKILLS.CSHARP',
|
|
||||||
'ABOUT.SKILLS.PYTHON'
|
|
||||||
];
|
];
|
||||||
|
|
||||||
skillsArchitecture = [
|
readonly earlierEducationKeys = [
|
||||||
|
'ABOUT.EDUCATION.E3',
|
||||||
|
'ABOUT.EDUCATION.E2',
|
||||||
|
'ABOUT.EDUCATION.E1',
|
||||||
|
'ABOUT.EDUCATION.E0'
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Heading and chips travel together so the two can no longer drift apart —
|
||||||
|
* they used to be declared in separate places and had ended up mismatched.
|
||||||
|
*/
|
||||||
|
readonly skillGroups: SkillGroup[] = [
|
||||||
|
{
|
||||||
|
titleKey: 'ABOUT.SECTION.LANGUAGES',
|
||||||
|
skillKeys: [
|
||||||
|
'ABOUT.SKILLS.JAVA',
|
||||||
|
'ABOUT.SKILLS.SPRING',
|
||||||
|
'ABOUT.SKILLS.TYPESCRIPT',
|
||||||
|
'ABOUT.SKILLS.ANGULAR',
|
||||||
|
'ABOUT.SKILLS.CSHARP',
|
||||||
|
'ABOUT.SKILLS.PYTHON',
|
||||||
|
'ABOUT.SKILLS.VUE',
|
||||||
|
'ABOUT.SKILLS.REACT'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titleKey: 'ABOUT.SECTION.ARCH_CLOUD',
|
||||||
|
skillKeys: [
|
||||||
'ABOUT.SKILLS.ARCH_MICROSERVICES',
|
'ABOUT.SKILLS.ARCH_MICROSERVICES',
|
||||||
'ABOUT.SKILLS.ARCH_CLOUD',
|
'ABOUT.SKILLS.ARCH_CLOUD',
|
||||||
'ABOUT.TOOLS.DOCKER',
|
'ABOUT.TOOLS.DOCKER',
|
||||||
'ABOUT.TOOLS.K8S',
|
'ABOUT.TOOLS.K8S',
|
||||||
'ABOUT.TOOLS.JENKINS',
|
'ABOUT.TOOLS.POSTGRES',
|
||||||
'ABOUT.TOOLS.POSTGRES'
|
'ABOUT.TOOLS.MONGO',
|
||||||
];
|
'ABOUT.TOOLS.GITLAB',
|
||||||
|
'ABOUT.TOOLS.JENKINS'
|
||||||
skillsEngineering = [
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titleKey: 'ABOUT.SECTION.SIM_ALGO',
|
||||||
|
skillKeys: [
|
||||||
'ABOUT.SKILLS.ENG_ALGO',
|
'ABOUT.SKILLS.ENG_ALGO',
|
||||||
'ABOUT.SKILLS.ENG_SIM',
|
'ABOUT.SKILLS.ENG_SIM',
|
||||||
'ABOUT.SKILLS.ENG_GPU',
|
'ABOUT.SKILLS.ENG_GPU',
|
||||||
'ABOUT.SKILLS.UNITY',
|
'ABOUT.SKILLS.ENG_PERF',
|
||||||
'ABOUT.SKILLS.ENG_PERF'
|
'ABOUT.SKILLS.UNITY'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titleKey: 'ABOUT.SECTION.AI_ENG',
|
||||||
|
skillKeys: [
|
||||||
|
'ABOUT.SKILLS.AI_AGENTS',
|
||||||
|
'ABOUT.SKILLS.AI_MCP',
|
||||||
|
'ABOUT.SKILLS.AI_CONTEXT',
|
||||||
|
'ABOUT.SKILLS.AI_HARNESS',
|
||||||
|
'ABOUT.SKILLS.AI_GATES'
|
||||||
|
]
|
||||||
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
readonly visibleExperience = computed(() => {
|
||||||
|
if (this.showEarlierExperience()) {
|
||||||
|
return [...this.recentExperience, ...this.earlierExperience];
|
||||||
|
}
|
||||||
|
return this.recentExperience;
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly visibleEducationKeys = computed(() => {
|
||||||
|
if (this.showEarlierEducation()) {
|
||||||
|
return [...this.recentEducationKeys, ...this.earlierEducationKeys];
|
||||||
|
}
|
||||||
|
return this.recentEducationKeys;
|
||||||
|
});
|
||||||
|
|
||||||
|
toggleEarlierExperience(): void {
|
||||||
|
this.showEarlierExperience.update(shown => !shown);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleEarlierEducation(): void {
|
||||||
|
this.showEarlierEducation.update(shown => !shown);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The employer header is drawn once per run of consecutive roles at the same company. */
|
||||||
|
isFirstRoleAtCompany(index: number): boolean {
|
||||||
|
if (index === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const entries = this.visibleExperience();
|
||||||
|
return entries[index - 1].company !== entries[index].company;
|
||||||
|
}
|
||||||
|
|
||||||
|
isLastRoleAtCompany(index: number): boolean {
|
||||||
|
const entries = this.visibleExperience();
|
||||||
|
if (index === entries.length - 1) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return entries[index + 1].company !== entries[index].company;
|
||||||
|
}
|
||||||
|
|
||||||
|
highlightKeys(entry: ExperienceEntry): string[] {
|
||||||
|
return Array.from({ length: entry.highlightCount }, (_, i) => `${entry.key}.HIGHLIGHTS.P${i + 1}`);
|
||||||
|
}
|
||||||
|
|
||||||
protected readonly UrlConstants = UrlConstants;
|
protected readonly UrlConstants = UrlConstants;
|
||||||
protected readonly AssetsConstants = AssetsConstants;
|
protected readonly AssetsConstants = AssetsConstants;
|
||||||
protected readonly SharedFunctions = SharedFunctions;
|
protected readonly SharedFunctions = SharedFunctions;
|
||||||
|
|||||||
@@ -3,4 +3,5 @@ export interface AlgorithmCategory {
|
|||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
routerLink: string;
|
routerLink: string;
|
||||||
|
icon: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
<h1>{{ 'ALGORITHM.TITLE' |translate }}</h1>
|
<div class="grid gap-fluid-md grid-cols-[repeat(auto-fill,minmax(min(100%,450px),1fr))] max-w-app mx-4 mt-auto">
|
||||||
<div class="card-grid">
|
<h1 class="m-0 mb-2 text-[clamp(1.4rem,4vw,2rem)]">{{ 'ALGORITHM.TITLE' | translate }}</h1>
|
||||||
@for (category of categories$ | async; track category.id) {
|
</div>
|
||||||
<mat-card class="algo-card" [routerLink]="[category.routerLink]">
|
<div class="grid gap-fluid-md grid-cols-[repeat(auto-fill,minmax(min(100%,450px),1fr))] max-w-app mx-4 mt-auto">
|
||||||
<mat-card-header>
|
@for (category of categories; track category.id) {
|
||||||
<mat-card-title>{{ category.title | translate }}</mat-card-title>
|
<mat-card class="card-gradient-bar transition-transform duration-200 ease-in-out flex flex-col cursor-pointer hover:-translate-y-1 hover:shadow-[0_8px_24px_rgba(0,0,0,0.12)]" [routerLink]="[category.routerLink]">
|
||||||
</mat-card-header>
|
|
||||||
<mat-card-content>
|
<mat-card-content>
|
||||||
<p>{{ category.description | translate}}</p>
|
<div class="flex items-center text-(--mat-sys-primary) mb-4">
|
||||||
|
<mat-icon class="text-[26px]! w-[26px]! h-[26px]!">{{ category.icon }}</mat-icon>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-[1.05rem] font-semibold mb-2 m-0">{{ category.title | translate }}</h3>
|
||||||
|
<p class="m-0 opacity-75 text-sm leading-relaxed">{{ category.description | translate }}</p>
|
||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,25 +1,19 @@
|
|||||||
import { Component, OnInit, inject } from '@angular/core';
|
import { Component, inject } from '@angular/core';
|
||||||
import { AlgorithmsService } from './algorithms.service';
|
import { AlgorithmsService } from './algorithms.service';
|
||||||
import { AlgorithmCategory } from './algorithm-category';
|
import { AlgorithmCategory } from './algorithm-category';
|
||||||
import { Observable } from 'rxjs';
|
|
||||||
import { CommonModule } from '@angular/common';
|
|
||||||
import { RouterLink } from '@angular/router';
|
import { RouterLink } from '@angular/router';
|
||||||
import { MatCardModule } from '@angular/material/card';
|
import { MatCardModule } from '@angular/material/card';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
import {TranslatePipe} from '@ngx-translate/core';
|
import {TranslatePipe} from '@ngx-translate/core';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-algorithms',
|
selector: 'app-algorithms',
|
||||||
templateUrl: './algorithms.component.html',
|
templateUrl: './algorithms.component.html',
|
||||||
styleUrls: ['./algorithms.component.scss'],
|
styleUrl: './algorithms.component.scss',
|
||||||
standalone: true,
|
imports: [RouterLink, MatCardModule, MatIconModule, TranslatePipe],
|
||||||
imports: [CommonModule, RouterLink, MatCardModule, TranslatePipe],
|
|
||||||
})
|
})
|
||||||
export class AlgorithmsComponent implements OnInit {
|
export class AlgorithmsComponent {
|
||||||
private readonly algorithmsService = inject(AlgorithmsService);
|
private readonly algorithmsService = inject(AlgorithmsService);
|
||||||
|
|
||||||
categories$: Observable<AlgorithmCategory[]> | undefined;
|
readonly categories: AlgorithmCategory[] = this.algorithmsService.getCategories();
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
this.categories$ = this.algorithmsService.getCategories();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { AlgorithmCategory } from './algorithm-category';
|
import { AlgorithmCategory } from './algorithm-category';
|
||||||
import { Observable, of } from 'rxjs';
|
|
||||||
import {RouterConstants} from '../../constants/RouterConstants';
|
import {RouterConstants} from '../../constants/RouterConstants';
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
@@ -13,53 +12,68 @@ export class AlgorithmsService {
|
|||||||
id: 'pathfinding',
|
id: 'pathfinding',
|
||||||
title: 'ALGORITHM.PATHFINDING.TITLE',
|
title: 'ALGORITHM.PATHFINDING.TITLE',
|
||||||
description: 'ALGORITHM.PATHFINDING.DESCRIPTION',
|
description: 'ALGORITHM.PATHFINDING.DESCRIPTION',
|
||||||
routerLink: RouterConstants.PATHFINDING.LINK
|
routerLink: RouterConstants.PATHFINDING.LINK,
|
||||||
|
icon: 'route'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'sorting',
|
id: 'sorting',
|
||||||
title: 'ALGORITHM.SORTING.TITLE',
|
title: 'ALGORITHM.SORTING.TITLE',
|
||||||
description: 'ALGORITHM.SORTING.DESCRIPTION',
|
description: 'ALGORITHM.SORTING.DESCRIPTION',
|
||||||
routerLink: RouterConstants.SORTING.LINK
|
routerLink: RouterConstants.SORTING.LINK,
|
||||||
|
icon: 'sort'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'gameOfLife',
|
id: 'gameOfLife',
|
||||||
title: 'ALGORITHM.GOL.TITLE',
|
title: 'ALGORITHM.GOL.TITLE',
|
||||||
description: 'ALGORITHM.GOL.DESCRIPTION',
|
description: 'ALGORITHM.GOL.DESCRIPTION',
|
||||||
routerLink: RouterConstants.GOL.LINK
|
routerLink: RouterConstants.GOL.LINK,
|
||||||
|
icon: 'grid_on'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'labyrinth',
|
id: 'labyrinth',
|
||||||
title: 'ALGORITHM.LABYRINTH.TITLE',
|
title: 'ALGORITHM.LABYRINTH.TITLE',
|
||||||
description: 'ALGORITHM.LABYRINTH.DESCRIPTION',
|
description: 'ALGORITHM.LABYRINTH.DESCRIPTION',
|
||||||
routerLink: RouterConstants.LABYRINTH.LINK
|
routerLink: RouterConstants.LABYRINTH.LINK,
|
||||||
|
icon: 'grid_view'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'fractal',
|
id: 'fractal',
|
||||||
title: 'ALGORITHM.FRACTAL.TITLE',
|
title: 'ALGORITHM.FRACTAL.TITLE',
|
||||||
description: 'ALGORITHM.FRACTAL.DESCRIPTION',
|
description: 'ALGORITHM.FRACTAL.DESCRIPTION',
|
||||||
routerLink: RouterConstants.FRACTAL.LINK
|
routerLink: RouterConstants.FRACTAL.LINK,
|
||||||
|
icon: 'blur_on'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'fractal3d',
|
id: 'fractal3d',
|
||||||
title: 'ALGORITHM.FRACTAL3D.TITLE',
|
title: 'ALGORITHM.FRACTAL3D.TITLE',
|
||||||
description: 'ALGORITHM.FRACTAL3D.DESCRIPTION',
|
description: 'ALGORITHM.FRACTAL3D.DESCRIPTION',
|
||||||
routerLink: RouterConstants.FRACTAL3d.LINK
|
routerLink: RouterConstants.FRACTAL3d.LINK,
|
||||||
|
icon: 'view_in_ar'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'pendulum',
|
id: 'pendulum',
|
||||||
title: 'ALGORITHM.PENDULUM.TITLE',
|
title: 'ALGORITHM.PENDULUM.TITLE',
|
||||||
description: 'ALGORITHM.PENDULUM.DESCRIPTION',
|
description: 'ALGORITHM.PENDULUM.DESCRIPTION',
|
||||||
routerLink: RouterConstants.PENDULUM.LINK
|
routerLink: RouterConstants.PENDULUM.LINK,
|
||||||
|
icon: 'rotate_right'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'cloth',
|
id: 'cloth',
|
||||||
title: 'ALGORITHM.CLOTH.TITLE',
|
title: 'ALGORITHM.CLOTH.TITLE',
|
||||||
description: 'ALGORITHM.CLOTH.DESCRIPTION',
|
description: 'ALGORITHM.CLOTH.DESCRIPTION',
|
||||||
routerLink: RouterConstants.CLOTH.LINK
|
routerLink: RouterConstants.CLOTH.LINK,
|
||||||
|
icon: 'texture'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fourColor',
|
||||||
|
title: 'ALGORITHM.FOUR_COLOR.TITLE',
|
||||||
|
description: 'ALGORITHM.FOUR_COLOR.DESCRIPTION',
|
||||||
|
routerLink: RouterConstants.FOUR_COLOR.LINK,
|
||||||
|
icon: 'palette'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
getCategories(): Observable<AlgorithmCategory[]> {
|
getCategories(): AlgorithmCategory[] {
|
||||||
return of(this.categories);
|
return this.categories;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
48
src/app/pages/algorithms/cloth/cloth-glsl.shader.ts
Normal file
48
src/app/pages/algorithms/cloth/cloth-glsl.shader.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* GLSL shaders for cloth rendering on WebGL.
|
||||||
|
* Replicates the visual output of the WGSL cloth shaders:
|
||||||
|
* checkerboard pattern with Lambertian lighting.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const CLOTH_VERTEX_SHADER_GLSL = `
|
||||||
|
precision highp float;
|
||||||
|
|
||||||
|
attribute vec3 position;
|
||||||
|
attribute vec2 uv;
|
||||||
|
|
||||||
|
uniform mat4 viewProjection;
|
||||||
|
|
||||||
|
varying vec2 vUV;
|
||||||
|
varying vec3 vWorldPos;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vUV = uv;
|
||||||
|
vWorldPos = position;
|
||||||
|
gl_Position = viewProjection * vec4(position, 1.0);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const CLOTH_FRAGMENT_SHADER_GLSL = `
|
||||||
|
#extension GL_OES_standard_derivatives : enable
|
||||||
|
precision highp float;
|
||||||
|
|
||||||
|
varying vec2 vUV;
|
||||||
|
varying vec3 vWorldPos;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec3 dx = dFdx(vWorldPos);
|
||||||
|
vec3 dy = dFdy(vWorldPos);
|
||||||
|
vec3 normal = normalize(cross(dx, dy));
|
||||||
|
|
||||||
|
vec3 lightDir = normalize(vec3(1.0, 1.0, 0.5));
|
||||||
|
float diffuse = max(0.0, abs(dot(normal, lightDir)));
|
||||||
|
float ambient = 0.3;
|
||||||
|
float lightIntensity = ambient + (diffuse * 0.7);
|
||||||
|
|
||||||
|
float grid = mod(floor(vUV.x * 20.0) + floor(vUV.y * 20.0), 2.0);
|
||||||
|
vec3 baseColor = mix(vec3(0.8, 0.4, 0.15), vec3(0.9, 0.5, 0.2), grid);
|
||||||
|
vec3 finalColor = baseColor * lightIntensity;
|
||||||
|
|
||||||
|
gl_FragColor = vec4(finalColor, 1.0);
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -1,17 +1,26 @@
|
|||||||
<mat-card class="algo-container">
|
<mat-card class="w-full max-w-[1920px] p-5">
|
||||||
<mat-card-header>
|
<mat-card-header>
|
||||||
<mat-card-title>{{ 'CLOTH.TITLE' | translate }}</mat-card-title>
|
<mat-card-title>{{ 'CLOTH.TITLE' | translate }}</mat-card-title>
|
||||||
</mat-card-header>
|
</mat-card-header>
|
||||||
<mat-card-content>
|
<mat-card-content>
|
||||||
<app-information [algorithmInformation]="algoInformation"/>
|
<app-information [algorithmInformation]="algoInformation"/>
|
||||||
<div class="controls-container">
|
<div class="flex flex-col mb-4">
|
||||||
<div class="controls-panel">
|
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
|
||||||
<button mat-raised-button color="primary" (click)="toggleWind()">
|
<button mat-raised-button color="primary" (click)="toggleWind()">
|
||||||
{{ isWindActive ? ('CLOTH.WIND_OFF' | translate) : ('CLOTH.WIND_ON' | translate) }}
|
{{ isWindActive ? ('CLOTH.WIND_OFF' | translate) : ('CLOTH.WIND_ON' | translate) }}
|
||||||
</button>
|
</button>
|
||||||
<button mat-raised-button color="primary" (click)="toggleMesh()">
|
<button mat-raised-button color="primary" (click)="toggleMesh()">
|
||||||
{{ isOutlineActive ? ('CLOTH.OUTLINE_OFF' | translate) : ('CLOTH.OUTLINE_ON' | translate) }}
|
{{ isOutlineActive ? ('CLOTH.OUTLINE_OFF' | translate) : ('CLOTH.OUTLINE_ON' | translate) }}
|
||||||
</button>
|
</button>
|
||||||
|
<button mat-raised-button color="accent" (click)="restartSimulation()">
|
||||||
|
{{ 'CLOTH.RESTART_SIMULATION' | translate }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2.5">
|
||||||
|
<span>{{ 'CLOTH.ELONGATION' | translate }}: {{ elongation }}</span>
|
||||||
|
<mat-slider min="0.5" max="2.0" step="0.1">
|
||||||
|
<input matSliderThumb [(ngModel)]="elongation">
|
||||||
|
</mat-slider>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<app-babylon-canvas
|
<app-babylon-canvas
|
||||||
|
|||||||
@@ -1,25 +1,17 @@
|
|||||||
/**
|
import {Component} from '@angular/core';
|
||||||
* File: cloth.component.ts
|
import {FormsModule} from '@angular/forms';
|
||||||
* Description: Component for cloth simulation using WebGPU compute shaders.
|
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
|
||||||
*/
|
import {MatSliderModule} from '@angular/material/slider';
|
||||||
|
import {TranslatePipe} from '@ngx-translate/core';
|
||||||
import { Component } from '@angular/core';
|
import {BabylonCanvas, RenderConfig, SceneEventData} from '../../../shared/components/render-canvas/babylon-canvas.component';
|
||||||
import { MatCard, MatCardContent, MatCardHeader, MatCardTitle } from '@angular/material/card';
|
|
||||||
import { TranslatePipe } from '@ngx-translate/core';
|
|
||||||
import { BabylonCanvas, RenderConfig, SceneEventData } from '../../../shared/components/render-canvas/babylon-canvas.component';
|
|
||||||
import {ComputeShader, StorageBuffer, MeshBuilder, ShaderMaterial, ShaderLanguage, ArcRotateCamera, GroundMesh, WebGPUEngine, Scene} from '@babylonjs/core';
|
|
||||||
import {
|
|
||||||
CLOTH_FRAGMENT_SHADER_WGSL,
|
|
||||||
CLOTH_INTEGRATE_COMPUTE_WGSL,
|
|
||||||
CLOTH_SOLVE_COMPUTE_WGSL,
|
|
||||||
CLOTH_VELOCITY_COMPUTE_WGSL,
|
|
||||||
CLOTH_VERTEX_SHADER_WGSL
|
|
||||||
} from './cloth.shader';
|
|
||||||
import {MatButton} from '@angular/material/button';
|
import {MatButton} from '@angular/material/button';
|
||||||
import {ClothBuffers, ClothConfig, ClothData, ClothPipelines} from './cloth.model';
|
import {ClothConfig} from './cloth.model';
|
||||||
import {Information} from '../information/information';
|
import {Information} from '../information/information';
|
||||||
import {AlgorithmInformation} from '../information/information.models';
|
import {AlgorithmInformation} from '../information/information.models';
|
||||||
import {UrlConstants} from '../../../constants/UrlConstants';
|
import {UrlConstants} from '../../../constants/UrlConstants';
|
||||||
|
import {ClothSimulationStrategy} from './strategies/cloth-simulation.strategy';
|
||||||
|
import {ClothGpuStrategy} from './strategies/cloth-gpu.strategy';
|
||||||
|
import {ClothCpuStrategy} from './strategies/cloth-cpu.strategy';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-cloth',
|
selector: 'app-cloth',
|
||||||
@@ -31,6 +23,8 @@ import {UrlConstants} from '../../../constants/UrlConstants';
|
|||||||
TranslatePipe,
|
TranslatePipe,
|
||||||
BabylonCanvas,
|
BabylonCanvas,
|
||||||
MatButton,
|
MatButton,
|
||||||
|
MatSliderModule,
|
||||||
|
FormsModule,
|
||||||
Information
|
Information
|
||||||
],
|
],
|
||||||
templateUrl: './cloth.component.html',
|
templateUrl: './cloth.component.html',
|
||||||
@@ -39,14 +33,16 @@ import {UrlConstants} from '../../../constants/UrlConstants';
|
|||||||
export class ClothComponent {
|
export class ClothComponent {
|
||||||
private currentSceneData: SceneEventData | null = null;
|
private currentSceneData: SceneEventData | null = null;
|
||||||
private simulationTime: number = 0;
|
private simulationTime: number = 0;
|
||||||
private clothMesh: GroundMesh | null = null;
|
private strategy: ClothSimulationStrategy | null = null;
|
||||||
|
|
||||||
public isWindActive: boolean = false;
|
public isWindActive: boolean = false;
|
||||||
public isOutlineActive: boolean = false;
|
public isOutlineActive: boolean = false;
|
||||||
|
public stiffness: number = 80;
|
||||||
|
public elongation: number = 1.0;
|
||||||
|
|
||||||
public renderConfig: RenderConfig = {
|
public renderConfig: RenderConfig = {
|
||||||
mode: '3D',
|
mode: '3D',
|
||||||
initialViewSize: 20,
|
initialViewSize: 20
|
||||||
shaderLanguage: ShaderLanguage.WGSL
|
|
||||||
};
|
};
|
||||||
|
|
||||||
algoInformation: AlgorithmInformation = {
|
algoInformation: AlgorithmInformation = {
|
||||||
@@ -55,25 +51,25 @@ export class ClothComponent {
|
|||||||
{
|
{
|
||||||
name: 'CLOTH.EXPLANATION.CLOTH_SIMULATION_EXPLANATION_TITLE',
|
name: 'CLOTH.EXPLANATION.CLOTH_SIMULATION_EXPLANATION_TITLE',
|
||||||
description: 'CLOTH.EXPLANATION.CLOTH_SIMULATION_EXPLANATION',
|
description: 'CLOTH.EXPLANATION.CLOTH_SIMULATION_EXPLANATION',
|
||||||
link: UrlConstants.MANDELBULB_WIKI,
|
link: UrlConstants.CLOTH_SIMULATION_WIKI,
|
||||||
translateName: true
|
translateName: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'CLOTH.EXPLANATION.XPBD_EXPLANATION_TITLE',
|
name: 'CLOTH.EXPLANATION.XPBD_EXPLANATION_TITLE',
|
||||||
description: 'CLOTH.EXPLANATION.XPBD_EXPLANATION',
|
description: 'CLOTH.EXPLANATION.XPBD_EXPLANATION',
|
||||||
link: UrlConstants.MANDELBOX_WIKI,
|
link: UrlConstants.XPBD_WIKI,
|
||||||
translateName: true
|
translateName: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'CLOTH.EXPLANATION.GPU_PARALLELIZATION_EXPLANATION_TITLE',
|
name: 'CLOTH.EXPLANATION.GPU_PARALLELIZATION_EXPLANATION_TITLE',
|
||||||
description: 'CLOTH.EXPLANATION.GPU_PARALLELIZATION_EXPLANATION',
|
description: 'CLOTH.EXPLANATION.GPU_PARALLELIZATION_EXPLANATION',
|
||||||
link: UrlConstants.JULIA3D_WIKI,
|
link: UrlConstants.GPU_COMPUTING_WIKI,
|
||||||
translateName: true
|
translateName: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'CLOTH.EXPLANATION.DATA_STRUCTURES_EXPLANATION_TITLE',
|
name: 'CLOTH.EXPLANATION.DATA_STRUCTURES_EXPLANATION_TITLE',
|
||||||
description: 'CLOTH.EXPLANATION.DATA_STRUCTURES_EXPLANATION',
|
description: 'CLOTH.EXPLANATION.DATA_STRUCTURES_EXPLANATION',
|
||||||
link: UrlConstants.JULIA3D_WIKI,
|
link: UrlConstants.DATA_STRUCTURE_WIKI,
|
||||||
translateName: true
|
translateName: true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -82,10 +78,6 @@ export class ClothComponent {
|
|||||||
disclaimerListEntry: ['CLOTH.EXPLANATION.DISCLAIMER_1', 'CLOTH.EXPLANATION.DISCLAIMER_2', 'CLOTH.EXPLANATION.DISCLAIMER_3', 'CLOTH.EXPLANATION.DISCLAIMER_4']
|
disclaimerListEntry: ['CLOTH.EXPLANATION.DISCLAIMER_1', 'CLOTH.EXPLANATION.DISCLAIMER_2', 'CLOTH.EXPLANATION.DISCLAIMER_3', 'CLOTH.EXPLANATION.DISCLAIMER_4']
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when the Babylon scene is ready.
|
|
||||||
* @param event The scene event data.
|
|
||||||
*/
|
|
||||||
public onSceneReady(event: SceneEventData): void {
|
public onSceneReady(event: SceneEventData): void {
|
||||||
this.currentSceneData = event;
|
this.currentSceneData = event;
|
||||||
this.createSimulation();
|
this.createSimulation();
|
||||||
@@ -97,42 +89,55 @@ export class ClothComponent {
|
|||||||
|
|
||||||
public toggleMesh(): void {
|
public toggleMesh(): void {
|
||||||
this.isOutlineActive = !this.isOutlineActive;
|
this.isOutlineActive = !this.isOutlineActive;
|
||||||
if (!this.clothMesh?.material) {
|
const mesh = this.strategy?.getMesh();
|
||||||
|
if (!mesh?.material) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.clothMesh.material.wireframe = this.isOutlineActive;
|
mesh.material.wireframe = this.isOutlineActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
public restartSimulation(): void {
|
||||||
|
this.simulationTime = 0;
|
||||||
|
this.createSimulation();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes and starts the cloth simulation.
|
|
||||||
*/
|
|
||||||
private createSimulation(): void {
|
private createSimulation(): void {
|
||||||
if (!this.currentSceneData) return;
|
if (!this.currentSceneData) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const { engine, scene } = this.currentSceneData;
|
const {engine, scene, gpuTier} = this.currentSceneData;
|
||||||
|
|
||||||
// 1. Define physics parameters
|
|
||||||
const config = this.getClothConfig();
|
const config = this.getClothConfig();
|
||||||
|
|
||||||
// 2. Generate initial CPU data (positions, constraints)
|
if (this.strategy) {
|
||||||
const clothData = this.generateClothData(config);
|
this.strategy.dispose();
|
||||||
|
}
|
||||||
// 3. Upload to GPU
|
|
||||||
const buffers = this.createStorageBuffers(engine, clothData);
|
this.strategy = gpuTier === 'webgpu'
|
||||||
|
? new ClothGpuStrategy()
|
||||||
// 4. Create Compute Shaders
|
: new ClothCpuStrategy();
|
||||||
const pipelines = this.setupComputePipelines(engine, buffers);
|
|
||||||
|
this.strategy.init(scene, engine, config);
|
||||||
// 5. Setup Rendering (Mesh, Material, Camera)
|
this.startParamUpdateLoop(scene, engine);
|
||||||
this.setupRenderMesh(scene, config, buffers.positions);
|
}
|
||||||
|
|
||||||
// 6. Start the physics loop
|
private startParamUpdateLoop(scene: any, engine: any): void {
|
||||||
this.startRenderLoop(engine, scene, config, buffers, pipelines);
|
scene.onAfterRenderObservable.clear();
|
||||||
|
scene.onAfterRenderObservable.add(() => {
|
||||||
|
this.simulationTime += engine.getDeltaTime() / 1000.0;
|
||||||
|
|
||||||
|
if (this.strategy) {
|
||||||
|
this.strategy.updateParams({
|
||||||
|
stiffness: this.stiffness,
|
||||||
|
elongation: this.elongation,
|
||||||
|
isWindActive: this.isWindActive,
|
||||||
|
simulationTime: this.simulationTime,
|
||||||
|
deltaTime: engine.getDeltaTime() / 1000.0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================================================
|
|
||||||
// 1. CONFIGURATION
|
|
||||||
// ========================================================================
|
|
||||||
private getClothConfig(): ClothConfig {
|
private getClothConfig(): ClothConfig {
|
||||||
const gridWidth = 100;
|
const gridWidth = 100;
|
||||||
const gridHeight = 100;
|
const gridHeight = 100;
|
||||||
@@ -150,228 +155,4 @@ export class ClothComponent {
|
|||||||
particleInvMass: 1.0 / particleMass
|
particleInvMass: 1.0 / particleMass
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================================================
|
|
||||||
// 2. DATA GENERATION (CPU)
|
|
||||||
// ========================================================================
|
|
||||||
private generateClothData(config: ClothConfig): ClothData {
|
|
||||||
const positionsData = new Float32Array(config.numVertices * 4);
|
|
||||||
const prevPositionsData = new Float32Array(config.numVertices * 4);
|
|
||||||
const velocitiesData = new Float32Array(config.numVertices * 4);
|
|
||||||
|
|
||||||
const constraintsP0: number[] = [];
|
|
||||||
const constraintsP1: number[] = [];
|
|
||||||
const constraintsP2: number[] = [];
|
|
||||||
const constraintsP3: number[] = [];
|
|
||||||
|
|
||||||
const addConstraint = (arr: number[], a: number, b: number): void => {
|
|
||||||
arr.push(a, b, config.spacing, 1.0);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fill positions (Pin top row)
|
|
||||||
for (let y = 0; y < config.gridHeight; y++) {
|
|
||||||
for (let x = 0; x < config.gridWidth; x++) {
|
|
||||||
const idx = (y * config.gridWidth + x) * 4;
|
|
||||||
positionsData[idx + 0] = (x - config.gridWidth / 2) * config.spacing;
|
|
||||||
positionsData[idx + 1] = 5.0 - (y * config.spacing);
|
|
||||||
positionsData[idx + 2] = 0.0;
|
|
||||||
positionsData[idx + 3] = (y === 0) ? 0.0 : config.particleInvMass;
|
|
||||||
|
|
||||||
prevPositionsData[idx + 0] = positionsData[idx + 0];
|
|
||||||
prevPositionsData[idx + 1] = positionsData[idx + 1];
|
|
||||||
prevPositionsData[idx + 2] = positionsData[idx + 2];
|
|
||||||
prevPositionsData[idx + 3] = positionsData[idx + 3];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Graph Coloring (4 Phases)
|
|
||||||
for (let y = 0; y < config.gridHeight; y++) {
|
|
||||||
for (let x = 0; x < config.gridWidth - 1; x += 2) addConstraint(constraintsP0, y * config.gridWidth + x, y * config.gridWidth + x + 1);
|
|
||||||
for (let x = 1; x < config.gridWidth - 1; x += 2) addConstraint(constraintsP1, y * config.gridWidth + x, y * config.gridWidth + x + 1);
|
|
||||||
}
|
|
||||||
for (let y = 0; y < config.gridHeight - 1; y += 2) {
|
|
||||||
for (let x = 0; x < config.gridWidth; x++) addConstraint(constraintsP2, y * config.gridWidth + x, (y + 1) * config.gridWidth + x);
|
|
||||||
}
|
|
||||||
for (let y = 1; y < config.gridHeight - 1; y += 2) {
|
|
||||||
for (let x = 0; x < config.gridWidth; x++) addConstraint(constraintsP3, y * config.gridWidth + x, (y + 1) * config.gridWidth + x);
|
|
||||||
}
|
|
||||||
|
|
||||||
const constraintsP4: number[] = [];
|
|
||||||
const constraintsP5: number[] = [];
|
|
||||||
const constraintsP6: number[] = [];
|
|
||||||
const constraintsP7: number[] = [];
|
|
||||||
|
|
||||||
const diagSpacing = config.spacing * Math.SQRT2;
|
|
||||||
const addDiagConstraint = (arr: number[], a: number, b: number): void => {
|
|
||||||
arr.push(a, b, diagSpacing, 1.0);
|
|
||||||
};
|
|
||||||
|
|
||||||
for (let y = 0; y < config.gridHeight - 1; y++) {
|
|
||||||
const arr = (y % 2 === 0) ? constraintsP4 : constraintsP5;
|
|
||||||
for (let x = 0; x < config.gridWidth - 1; x++) {
|
|
||||||
addDiagConstraint(arr, y * config.gridWidth + x, (y + 1) * config.gridWidth + (x + 1));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let y = 0; y < config.gridHeight - 1; y++) {
|
|
||||||
const arr = (y % 2 === 0) ? constraintsP6 : constraintsP7;
|
|
||||||
for (let x = 0; x < config.gridWidth - 1; x++) {
|
|
||||||
addDiagConstraint(arr, y * config.gridWidth + (x + 1), (y + 1) * config.gridWidth + x);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
positions: positionsData,
|
|
||||||
prevPositions: prevPositionsData,
|
|
||||||
velocities: velocitiesData,
|
|
||||||
constraints: [
|
|
||||||
constraintsP0, constraintsP1, constraintsP2, constraintsP3,
|
|
||||||
constraintsP4, constraintsP5, constraintsP6, constraintsP7
|
|
||||||
],
|
|
||||||
params: new Float32Array(8)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================================================
|
|
||||||
// 3. BUFFER CREATION (GPU)
|
|
||||||
// ========================================================================
|
|
||||||
private createStorageBuffers(engine: WebGPUEngine, data: ClothData): ClothBuffers {
|
|
||||||
const createBuffer = (arrayData: Float32Array | number[]): StorageBuffer => {
|
|
||||||
const buffer = new StorageBuffer(engine, arrayData.length * 4);
|
|
||||||
buffer.update(arrayData instanceof Float32Array ? arrayData : new Float32Array(arrayData));
|
|
||||||
return buffer;
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
positions: createBuffer(data.positions),
|
|
||||||
prevPositions: createBuffer(data.prevPositions),
|
|
||||||
velocities: createBuffer(data.velocities),
|
|
||||||
params: createBuffer(data.params),
|
|
||||||
constraints: data.constraints.map(cData => createBuffer(cData))
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================================================
|
|
||||||
// 4. COMPUTE SHADERS
|
|
||||||
// ========================================================================
|
|
||||||
private setupComputePipelines(engine: WebGPUEngine, buffers: ClothBuffers): ClothPipelines {
|
|
||||||
|
|
||||||
// Helper for integrating & velocity
|
|
||||||
const createBasicShader = (name: string, source: string) => {
|
|
||||||
const cs = new ComputeShader(name, engine, { computeSource: source }, {
|
|
||||||
bindingsMapping: {
|
|
||||||
"p": { group: 0, binding: 0 },
|
|
||||||
"positions": { group: 0, binding: 1 },
|
|
||||||
"prev_positions": { group: 0, binding: 2 },
|
|
||||||
"velocities": { group: 0, binding: 3 }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
cs.setStorageBuffer("p", buffers.params);
|
|
||||||
cs.setStorageBuffer("positions", buffers.positions);
|
|
||||||
cs.setStorageBuffer("prev_positions", buffers.prevPositions);
|
|
||||||
cs.setStorageBuffer("velocities", buffers.velocities);
|
|
||||||
return cs;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper for solvers
|
|
||||||
const createSolverShader = (name: string, constraintBuffer: StorageBuffer) => {
|
|
||||||
const cs = new ComputeShader(name, engine, { computeSource: CLOTH_SOLVE_COMPUTE_WGSL }, {
|
|
||||||
bindingsMapping: {
|
|
||||||
"p": { group: 0, binding: 0 },
|
|
||||||
"positions": { group: 0, binding: 1 },
|
|
||||||
"constraints": { group: 0, binding: 2 }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
cs.setStorageBuffer("p", buffers.params);
|
|
||||||
cs.setStorageBuffer("positions", buffers.positions);
|
|
||||||
cs.setStorageBuffer("constraints", constraintBuffer);
|
|
||||||
return cs;
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
integrate: createBasicShader("integrate", CLOTH_INTEGRATE_COMPUTE_WGSL),
|
|
||||||
solvers: buffers.constraints.map((cBuffer, i) => createSolverShader(`solve${i}`, cBuffer)),
|
|
||||||
velocity: createBasicShader("velocity", CLOTH_VELOCITY_COMPUTE_WGSL)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================================================
|
|
||||||
// 5. RENDERING SETUP
|
|
||||||
// ========================================================================
|
|
||||||
private setupRenderMesh(scene: Scene, config: ClothConfig, positionsBuffer: StorageBuffer): void {
|
|
||||||
if (this.clothMesh) {
|
|
||||||
scene.removeMesh(this.clothMesh);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.clothMesh = MeshBuilder.CreateGround("cloth", { width: 10, height: 10, subdivisions: config.gridWidth - 1 }, scene);
|
|
||||||
|
|
||||||
const clothMaterial = new ShaderMaterial("clothMat", scene, {
|
|
||||||
vertexSource: CLOTH_VERTEX_SHADER_WGSL,
|
|
||||||
fragmentSource: CLOTH_FRAGMENT_SHADER_WGSL
|
|
||||||
}, {
|
|
||||||
attributes: ["position", "uv"],
|
|
||||||
uniforms: ["viewProjection"],
|
|
||||||
storageBuffers: ["positions"],
|
|
||||||
shaderLanguage: ShaderLanguage.WGSL
|
|
||||||
});
|
|
||||||
|
|
||||||
clothMaterial.backFaceCulling = false;
|
|
||||||
clothMaterial.setStorageBuffer("positions", positionsBuffer);
|
|
||||||
this.clothMesh.material = clothMaterial;
|
|
||||||
|
|
||||||
const camera = scene.activeCamera as ArcRotateCamera;
|
|
||||||
if (camera) {
|
|
||||||
camera.alpha = Math.PI / 4;
|
|
||||||
camera.beta = Math.PI / 2.5;
|
|
||||||
camera.radius = 15;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================================================
|
|
||||||
// 6. RENDER LOOP
|
|
||||||
// ========================================================================
|
|
||||||
private startRenderLoop(engine: WebGPUEngine, scene: Scene, config: ClothConfig, buffers: ClothBuffers, pipelines: ClothPipelines): void {
|
|
||||||
const paramsData = new Float32Array(8);
|
|
||||||
|
|
||||||
// Pre-calculate constraint dispatch sizes for the 4 phases
|
|
||||||
const constraintsLength = buffers.constraints.map(b => (b as any)._buffer.capacity / 4 / 4); // Elements / vec4 length
|
|
||||||
const dispatchXConstraints = constraintsLength.map(len => Math.ceil(len / 64));
|
|
||||||
const dispatchXVertices = Math.ceil(config.numVertices / 64);
|
|
||||||
const substeps = 15;
|
|
||||||
|
|
||||||
scene.onBeforeRenderObservable.clear();
|
|
||||||
scene.onBeforeRenderObservable.add(() => {
|
|
||||||
this.simulationTime += engine.getDeltaTime() / 1000.0;
|
|
||||||
|
|
||||||
// Update Physics Parameters
|
|
||||||
const windX = this.isWindActive ? 5.0 : 0.0;
|
|
||||||
const windY = 0.0;
|
|
||||||
const windZ = this.isWindActive ? 15.0 : 0.0;
|
|
||||||
const scaledCompliance = 0.00001 * config.particleInvMass * config.spacing;
|
|
||||||
|
|
||||||
paramsData[0] = 0.016; // dt
|
|
||||||
paramsData[1] = -9.81; // gravity
|
|
||||||
paramsData[2] = scaledCompliance;
|
|
||||||
paramsData[3] = config.numVertices;
|
|
||||||
paramsData[4] = windX;
|
|
||||||
paramsData[5] = windY;
|
|
||||||
paramsData[6] = windZ;
|
|
||||||
paramsData[7] = this.simulationTime;
|
|
||||||
|
|
||||||
buffers.params.update(paramsData);
|
|
||||||
|
|
||||||
// 1. Predict positions
|
|
||||||
pipelines.integrate.dispatch(dispatchXVertices, 1, 1);
|
|
||||||
|
|
||||||
// 2. XPBD Solver (Substeps) - Graph Coloring Phase
|
|
||||||
for (let i = 0; i < substeps; i++) {
|
|
||||||
for (let phase = 0; phase < pipelines.solvers.length; phase++) {
|
|
||||||
pipelines.solvers[phase].dispatch(dispatchXConstraints[phase], 1, 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Update velocities
|
|
||||||
pipelines.velocity.dispatch(dispatchXVertices, 1, 1);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
// --- SIMULATION CONFIGURATION ---
|
|
||||||
import {ComputeShader, StorageBuffer} from '@babylonjs/core';
|
|
||||||
|
|
||||||
export interface ClothConfig {
|
export interface ClothConfig {
|
||||||
gridWidth: number;
|
gridWidth: number;
|
||||||
gridHeight: number;
|
gridHeight: number;
|
||||||
@@ -10,27 +7,10 @@ export interface ClothConfig {
|
|||||||
particleInvMass: number;
|
particleInvMass: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- RAW CPU DATA ---
|
|
||||||
export interface ClothData {
|
export interface ClothData {
|
||||||
positions: Float32Array;
|
positions: Float32Array;
|
||||||
prevPositions: Float32Array;
|
prevPositions: Float32Array;
|
||||||
velocities: Float32Array;
|
velocities: Float32Array;
|
||||||
constraints: number[][]; // Array containing the 4 phases
|
constraints: number[][];
|
||||||
params: Float32Array;
|
params: Float32Array;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- WEBGPU BUFFERS ---
|
|
||||||
export interface ClothBuffers {
|
|
||||||
positions: StorageBuffer;
|
|
||||||
prevPositions: StorageBuffer;
|
|
||||||
velocities: StorageBuffer;
|
|
||||||
params: StorageBuffer;
|
|
||||||
constraints: StorageBuffer[]; // 4 phase buffers
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- COMPUTE PIPELINES ---
|
|
||||||
export interface ClothPipelines {
|
|
||||||
integrate: ComputeShader;
|
|
||||||
solvers: ComputeShader[]; // 4 solve shaders
|
|
||||||
velocity: ComputeShader;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ export const CLOTH_SHARED_STRUCTS = `
|
|||||||
wind_x: f32,
|
wind_x: f32,
|
||||||
wind_y: f32,
|
wind_y: f32,
|
||||||
wind_z: f32,
|
wind_z: f32,
|
||||||
time: f32
|
time: f32,
|
||||||
|
elongation: f32
|
||||||
};
|
};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -26,9 +27,8 @@ export const CLOTH_VERTEX_SHADER_WGSL = `
|
|||||||
|
|
||||||
uniform viewProjection : mat4x4<f32>;
|
uniform viewProjection : mat4x4<f32>;
|
||||||
|
|
||||||
// Varyings, um Daten an den Fragment-Shader zu senden
|
|
||||||
varying vUV : vec2<f32>;
|
varying vUV : vec2<f32>;
|
||||||
varying vWorldPos : vec3<f32>; // NEU: Wir brauchen die 3D-Position für das Licht!
|
varying vWorldPos : vec3<f32>;
|
||||||
|
|
||||||
@vertex
|
@vertex
|
||||||
fn main(input : VertexInputs) -> FragmentInputs {
|
fn main(input : VertexInputs) -> FragmentInputs {
|
||||||
@@ -38,7 +38,7 @@ export const CLOTH_VERTEX_SHADER_WGSL = `
|
|||||||
output.position = uniforms.viewProjection * vec4<f32>(worldPos, 1.0);
|
output.position = uniforms.viewProjection * vec4<f32>(worldPos, 1.0);
|
||||||
|
|
||||||
output.vUV = input.uv;
|
output.vUV = input.uv;
|
||||||
output.vWorldPos = worldPos; // Position weitergeben
|
output.vWorldPos = worldPos;
|
||||||
|
|
||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
@@ -133,13 +133,15 @@ export const CLOTH_SOLVE_COMPUTE_WGSL = CLOTH_SHARED_STRUCTS + `
|
|||||||
if (idx >= arrayLength(&constraints)) { return; }
|
if (idx >= arrayLength(&constraints)) { return; }
|
||||||
|
|
||||||
let constraint = constraints[idx];
|
let constraint = constraints[idx];
|
||||||
let isActive = constraint.w;
|
|
||||||
|
|
||||||
if (isActive < 0.5) { return; }
|
// constraint.w: 0.0 = inactive, 1.0 = horizontal/diagonal, 2.0 = vertical
|
||||||
|
if (constraint.w < 0.5) { return; }
|
||||||
|
|
||||||
let idA = u32(constraint.x);
|
let idA = u32(constraint.x);
|
||||||
let idB = u32(constraint.y);
|
let idB = u32(constraint.y);
|
||||||
let restLength = constraint.z;
|
|
||||||
|
// constraint.w encodes type: 1.0 = horizontal/diagonal, 2.0 = vertical (elongation applies)
|
||||||
|
let restLength =constraint.z * p.elongation;
|
||||||
|
|
||||||
var pA = positions[idA];
|
var pA = positions[idA];
|
||||||
var pB = positions[idB];
|
var pB = positions[idB];
|
||||||
|
|||||||
149
src/app/pages/algorithms/cloth/strategies/cloth-cpu-physics.ts
Normal file
149
src/app/pages/algorithms/cloth/strategies/cloth-cpu-physics.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
/**
|
||||||
|
* CPU-side cloth physics mirroring the WGSL compute shaders.
|
||||||
|
* All data uses the same Float32Array vec4 layout: [x, y, z, invMass] per vertex.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ClothPhysicsParams {
|
||||||
|
dt: number;
|
||||||
|
gravityY: number;
|
||||||
|
compliance: number;
|
||||||
|
numVertices: number;
|
||||||
|
windX: number;
|
||||||
|
windY: number;
|
||||||
|
windZ: number;
|
||||||
|
time: number;
|
||||||
|
elongation: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors CLOTH_INTEGRATE_COMPUTE_WGSL:
|
||||||
|
* Applies gravity and wind forces, predicts new positions.
|
||||||
|
*/
|
||||||
|
export function integratePositions(
|
||||||
|
positions: Float32Array,
|
||||||
|
prevPositions: Float32Array,
|
||||||
|
velocities: Float32Array,
|
||||||
|
params: ClothPhysicsParams
|
||||||
|
): void {
|
||||||
|
for (let idx = 0; idx < params.numVertices; idx++) {
|
||||||
|
const base = idx * 4;
|
||||||
|
const invMass = positions[base + 3];
|
||||||
|
|
||||||
|
if (invMass <= 0.0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
velocities[base + 1] += params.gravityY * params.dt;
|
||||||
|
|
||||||
|
const posX = positions[base + 0];
|
||||||
|
const posY = positions[base + 1];
|
||||||
|
|
||||||
|
const flutter = Math.sin(posX * 2.0 + params.time * 5.0) * Math.cos(posY * 2.0 + params.time * 3.0);
|
||||||
|
|
||||||
|
const windForceX = params.windX + (flutter * params.windX * 0.8);
|
||||||
|
const windForceY = params.windY + (flutter * 2.0);
|
||||||
|
const windForceZ = params.windZ + (flutter * params.windZ * 0.8);
|
||||||
|
|
||||||
|
velocities[base + 0] += windForceX * params.dt;
|
||||||
|
velocities[base + 1] += windForceY * params.dt;
|
||||||
|
velocities[base + 2] += windForceZ * params.dt;
|
||||||
|
|
||||||
|
prevPositions[base + 0] = positions[base + 0];
|
||||||
|
prevPositions[base + 1] = positions[base + 1];
|
||||||
|
prevPositions[base + 2] = positions[base + 2];
|
||||||
|
prevPositions[base + 3] = positions[base + 3];
|
||||||
|
|
||||||
|
positions[base + 0] += velocities[base + 0] * params.dt;
|
||||||
|
positions[base + 1] += velocities[base + 1] * params.dt;
|
||||||
|
positions[base + 2] += velocities[base + 2] * params.dt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors CLOTH_SOLVE_COMPUTE_WGSL:
|
||||||
|
* XPBD constraint solving for one phase of constraints.
|
||||||
|
* Each constraint is stored as [idA, idB, restLength, type] (4 floats).
|
||||||
|
*/
|
||||||
|
export function solveConstraints(
|
||||||
|
positions: Float32Array,
|
||||||
|
constraints: Float32Array,
|
||||||
|
params: ClothPhysicsParams
|
||||||
|
): void {
|
||||||
|
const numConstraints = constraints.length / 4;
|
||||||
|
|
||||||
|
for (let idx = 0; idx < numConstraints; idx++) {
|
||||||
|
const cBase = idx * 4;
|
||||||
|
const constraintType = constraints[cBase + 3];
|
||||||
|
|
||||||
|
if (constraintType < 0.5) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const idA = constraints[cBase + 0];
|
||||||
|
const idB = constraints[cBase + 1];
|
||||||
|
const restLength = constraints[cBase + 2] * params.elongation;
|
||||||
|
|
||||||
|
const baseA = idA * 4;
|
||||||
|
const baseB = idB * 4;
|
||||||
|
|
||||||
|
const wA = positions[baseA + 3];
|
||||||
|
const wB = positions[baseB + 3];
|
||||||
|
const wSum = wA + wB;
|
||||||
|
|
||||||
|
if (wSum <= 0.0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dirX = positions[baseA + 0] - positions[baseB + 0];
|
||||||
|
const dirY = positions[baseA + 1] - positions[baseB + 1];
|
||||||
|
const dirZ = positions[baseA + 2] - positions[baseB + 2];
|
||||||
|
const dist = Math.sqrt(dirX * dirX + dirY * dirY + dirZ * dirZ);
|
||||||
|
|
||||||
|
if (dist < 0.0001) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nX = dirX / dist;
|
||||||
|
const nY = dirY / dist;
|
||||||
|
const nZ = dirZ / dist;
|
||||||
|
const c = dist - restLength;
|
||||||
|
|
||||||
|
const alpha = params.compliance / (params.dt * params.dt);
|
||||||
|
const lambda = -c / (wSum + alpha);
|
||||||
|
|
||||||
|
if (wA > 0.0) {
|
||||||
|
positions[baseA + 0] += nX * (lambda * wA);
|
||||||
|
positions[baseA + 1] += nY * (lambda * wA);
|
||||||
|
positions[baseA + 2] += nZ * (lambda * wA);
|
||||||
|
}
|
||||||
|
if (wB > 0.0) {
|
||||||
|
positions[baseB + 0] += nX * (-lambda * wB);
|
||||||
|
positions[baseB + 1] += nY * (-lambda * wB);
|
||||||
|
positions[baseB + 2] += nZ * (-lambda * wB);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors CLOTH_VELOCITY_COMPUTE_WGSL:
|
||||||
|
* Derives velocity from position changes: v = (pos - prevPos) / dt
|
||||||
|
*/
|
||||||
|
export function updateVelocities(
|
||||||
|
positions: Float32Array,
|
||||||
|
prevPositions: Float32Array,
|
||||||
|
velocities: Float32Array,
|
||||||
|
params: ClothPhysicsParams
|
||||||
|
): void {
|
||||||
|
for (let idx = 0; idx < params.numVertices; idx++) {
|
||||||
|
const base = idx * 4;
|
||||||
|
const invMass = positions[base + 3];
|
||||||
|
|
||||||
|
if (invMass <= 0.0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
velocities[base + 0] = (positions[base + 0] - prevPositions[base + 0]) / params.dt;
|
||||||
|
velocities[base + 1] = (positions[base + 1] - prevPositions[base + 1]) / params.dt;
|
||||||
|
velocities[base + 2] = (positions[base + 2] - prevPositions[base + 2]) / params.dt;
|
||||||
|
}
|
||||||
|
}
|
||||||
216
src/app/pages/algorithms/cloth/strategies/cloth-cpu.strategy.ts
Normal file
216
src/app/pages/algorithms/cloth/strategies/cloth-cpu.strategy.ts
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
import {ArcRotateCamera, Engine, GroundMesh, MeshBuilder, Scene, ShaderMaterial, VertexBuffer, WebGPUEngine} from '@babylonjs/core';
|
||||||
|
import {ClothConfig, ClothData} from '../cloth.model';
|
||||||
|
import {CLOTH_FRAGMENT_SHADER_GLSL, CLOTH_VERTEX_SHADER_GLSL} from '../cloth-glsl.shader';
|
||||||
|
import {ClothPhysicsParams, integratePositions, solveConstraints, updateVelocities} from './cloth-cpu-physics';
|
||||||
|
import {ClothSimulationParams, ClothSimulationStrategy} from './cloth-simulation.strategy';
|
||||||
|
|
||||||
|
export class ClothCpuStrategy implements ClothSimulationStrategy {
|
||||||
|
private clothMesh: GroundMesh | null = null;
|
||||||
|
private scene: Scene | null = null;
|
||||||
|
private config: ClothConfig | null = null;
|
||||||
|
|
||||||
|
private positions!: Float32Array;
|
||||||
|
private prevPositions!: Float32Array;
|
||||||
|
private velocities!: Float32Array;
|
||||||
|
private constraintPhases!: Float32Array[];
|
||||||
|
|
||||||
|
private physicsParams: ClothPhysicsParams = {
|
||||||
|
dt: 0.016,
|
||||||
|
gravityY: -9.81,
|
||||||
|
compliance: 0.00001,
|
||||||
|
numVertices: 0,
|
||||||
|
windX: 0,
|
||||||
|
windY: 0,
|
||||||
|
windZ: 0,
|
||||||
|
time: 0,
|
||||||
|
elongation: 1.0
|
||||||
|
};
|
||||||
|
|
||||||
|
init(scene: Scene, engine: WebGPUEngine | Engine, config: ClothConfig): void {
|
||||||
|
this.scene = scene;
|
||||||
|
this.config = config;
|
||||||
|
this.physicsParams.numVertices = config.numVertices;
|
||||||
|
|
||||||
|
const clothData = this.generateClothData(config);
|
||||||
|
this.positions = clothData.positions;
|
||||||
|
this.prevPositions = clothData.prevPositions;
|
||||||
|
this.velocities = clothData.velocities;
|
||||||
|
this.constraintPhases = clothData.constraints.map(c => new Float32Array(c));
|
||||||
|
|
||||||
|
this.setupRenderMesh(scene, config);
|
||||||
|
this.startRenderLoop(scene, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateParams(params: ClothSimulationParams): void {
|
||||||
|
const windX = params.isWindActive ? 5.0 : 0.0;
|
||||||
|
const windZ = params.isWindActive ? 15.0 : 0.0;
|
||||||
|
|
||||||
|
const softCompliance = 10.0;
|
||||||
|
const rigidCompliance = 0.00001;
|
||||||
|
const t = (params.stiffness - 1) / 99.0;
|
||||||
|
|
||||||
|
this.physicsParams.compliance = softCompliance * Math.pow(rigidCompliance / softCompliance, t);
|
||||||
|
this.physicsParams.windX = windX;
|
||||||
|
this.physicsParams.windY = 0.0;
|
||||||
|
this.physicsParams.windZ = windZ;
|
||||||
|
this.physicsParams.time = params.simulationTime;
|
||||||
|
this.physicsParams.elongation = params.elongation;
|
||||||
|
}
|
||||||
|
|
||||||
|
getMesh(): GroundMesh | null {
|
||||||
|
return this.clothMesh;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
if (this.scene && this.clothMesh) {
|
||||||
|
this.scene.removeMesh(this.clothMesh);
|
||||||
|
}
|
||||||
|
this.clothMesh = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateClothData(config: ClothConfig): ClothData {
|
||||||
|
const positionsData = new Float32Array(config.numVertices * 4);
|
||||||
|
const prevPositionsData = new Float32Array(config.numVertices * 4);
|
||||||
|
const velocitiesData = new Float32Array(config.numVertices * 4);
|
||||||
|
|
||||||
|
const constraintsP0: number[] = [];
|
||||||
|
const constraintsP1: number[] = [];
|
||||||
|
const constraintsP2: number[] = [];
|
||||||
|
const constraintsP3: number[] = [];
|
||||||
|
|
||||||
|
const addHorizontalConstraint = (arr: number[], a: number, b: number): void => {
|
||||||
|
arr.push(a, b, config.spacing, 1.0);
|
||||||
|
};
|
||||||
|
const addVerticalConstraint = (arr: number[], a: number, b: number): void => {
|
||||||
|
arr.push(a, b, config.spacing, 2.0);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let y = 0; y < config.gridHeight; y++) {
|
||||||
|
for (let x = 0; x < config.gridWidth; x++) {
|
||||||
|
const idx = (y * config.gridWidth + x) * 4;
|
||||||
|
positionsData[idx + 0] = (x - config.gridWidth / 2) * config.spacing;
|
||||||
|
positionsData[idx + 1] = 5.0 - (y * config.spacing);
|
||||||
|
positionsData[idx + 2] = 0.0;
|
||||||
|
positionsData[idx + 3] = (y === 0) ? 0.0 : config.particleInvMass;
|
||||||
|
|
||||||
|
prevPositionsData[idx + 0] = positionsData[idx + 0];
|
||||||
|
prevPositionsData[idx + 1] = positionsData[idx + 1];
|
||||||
|
prevPositionsData[idx + 2] = positionsData[idx + 2];
|
||||||
|
prevPositionsData[idx + 3] = positionsData[idx + 3];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let y = 0; y < config.gridHeight; y++) {
|
||||||
|
for (let x = 0; x < config.gridWidth - 1; x += 2) { addHorizontalConstraint(constraintsP0, y * config.gridWidth + x, y * config.gridWidth + x + 1); }
|
||||||
|
for (let x = 1; x < config.gridWidth - 1; x += 2) { addHorizontalConstraint(constraintsP1, y * config.gridWidth + x, y * config.gridWidth + x + 1); }
|
||||||
|
}
|
||||||
|
for (let y = 0; y < config.gridHeight - 1; y += 2) {
|
||||||
|
for (let x = 0; x < config.gridWidth; x++) { addVerticalConstraint(constraintsP2, y * config.gridWidth + x, (y + 1) * config.gridWidth + x); }
|
||||||
|
}
|
||||||
|
for (let y = 1; y < config.gridHeight - 1; y += 2) {
|
||||||
|
for (let x = 0; x < config.gridWidth; x++) { addVerticalConstraint(constraintsP3, y * config.gridWidth + x, (y + 1) * config.gridWidth + x); }
|
||||||
|
}
|
||||||
|
|
||||||
|
const constraintsP4: number[] = [];
|
||||||
|
const constraintsP5: number[] = [];
|
||||||
|
const constraintsP6: number[] = [];
|
||||||
|
const constraintsP7: number[] = [];
|
||||||
|
const diagSpacing = config.spacing * Math.SQRT2;
|
||||||
|
const addDiagConstraint = (arr: number[], a: number, b: number): void => {
|
||||||
|
arr.push(a, b, diagSpacing, 1.0);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let y = 0; y < config.gridHeight - 1; y++) {
|
||||||
|
const arr = (y % 2 === 0) ? constraintsP4 : constraintsP5;
|
||||||
|
for (let x = 0; x < config.gridWidth - 1; x++) {
|
||||||
|
addDiagConstraint(arr, y * config.gridWidth + x, (y + 1) * config.gridWidth + (x + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let y = 0; y < config.gridHeight - 1; y++) {
|
||||||
|
const arr = (y % 2 === 0) ? constraintsP6 : constraintsP7;
|
||||||
|
for (let x = 0; x < config.gridWidth - 1; x++) {
|
||||||
|
addDiagConstraint(arr, y * config.gridWidth + (x + 1), (y + 1) * config.gridWidth + x);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
positions: positionsData,
|
||||||
|
prevPositions: prevPositionsData,
|
||||||
|
velocities: velocitiesData,
|
||||||
|
constraints: [
|
||||||
|
constraintsP0, constraintsP1, constraintsP2, constraintsP3,
|
||||||
|
constraintsP4, constraintsP5, constraintsP6, constraintsP7
|
||||||
|
],
|
||||||
|
params: new Float32Array(9)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupRenderMesh(scene: Scene, config: ClothConfig): void {
|
||||||
|
if (this.clothMesh) {
|
||||||
|
scene.removeMesh(this.clothMesh);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.clothMesh = MeshBuilder.CreateGround("cloth", {
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
subdivisions: config.gridWidth - 1,
|
||||||
|
updatable: true
|
||||||
|
}, scene);
|
||||||
|
|
||||||
|
const clothMaterial = new ShaderMaterial("clothMat", scene, {
|
||||||
|
vertexSource: CLOTH_VERTEX_SHADER_GLSL,
|
||||||
|
fragmentSource: CLOTH_FRAGMENT_SHADER_GLSL
|
||||||
|
}, {
|
||||||
|
attributes: ["position", "uv"],
|
||||||
|
uniforms: ["viewProjection"]
|
||||||
|
});
|
||||||
|
|
||||||
|
clothMaterial.backFaceCulling = false;
|
||||||
|
this.clothMesh.material = clothMaterial;
|
||||||
|
|
||||||
|
const camera = scene.activeCamera as ArcRotateCamera;
|
||||||
|
if (camera) {
|
||||||
|
camera.alpha = Math.PI / 4;
|
||||||
|
camera.beta = Math.PI / 2.5;
|
||||||
|
camera.radius = 15;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts xyz from the vec4 positions array into a vec3 array for mesh vertex update.
|
||||||
|
*/
|
||||||
|
private extractPositionsVec3(positions: Float32Array, numVertices: number): Float32Array {
|
||||||
|
const result = new Float32Array(numVertices * 3);
|
||||||
|
for (let i = 0; i < numVertices; i++) {
|
||||||
|
result[i * 3 + 0] = positions[i * 4 + 0];
|
||||||
|
result[i * 3 + 1] = positions[i * 4 + 1];
|
||||||
|
result[i * 3 + 2] = positions[i * 4 + 2];
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private startRenderLoop(scene: Scene, config: ClothConfig): void {
|
||||||
|
const substeps = 15;
|
||||||
|
|
||||||
|
scene.onBeforeRenderObservable.clear();
|
||||||
|
scene.onBeforeRenderObservable.add(() => {
|
||||||
|
if (!this.clothMesh) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
integratePositions(this.positions, this.prevPositions, this.velocities, this.physicsParams);
|
||||||
|
|
||||||
|
for (let i = 0; i < substeps; i++) {
|
||||||
|
for (const phase of this.constraintPhases) {
|
||||||
|
solveConstraints(this.positions, phase, this.physicsParams);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateVelocities(this.positions, this.prevPositions, this.velocities, this.physicsParams);
|
||||||
|
|
||||||
|
const posVec3 = this.extractPositionsVec3(this.positions, config.numVertices);
|
||||||
|
this.clothMesh.updateVerticesData(VertexBuffer.PositionKind, posVec3);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
275
src/app/pages/algorithms/cloth/strategies/cloth-gpu.strategy.ts
Normal file
275
src/app/pages/algorithms/cloth/strategies/cloth-gpu.strategy.ts
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
import {ArcRotateCamera, ComputeShader, Engine, GroundMesh, MeshBuilder, Scene, ShaderLanguage, ShaderMaterial, StorageBuffer, WebGPUEngine} from '@babylonjs/core';
|
||||||
|
import {ClothConfig, ClothData} from '../cloth.model';
|
||||||
|
import {
|
||||||
|
CLOTH_FRAGMENT_SHADER_WGSL,
|
||||||
|
CLOTH_INTEGRATE_COMPUTE_WGSL,
|
||||||
|
CLOTH_SOLVE_COMPUTE_WGSL,
|
||||||
|
CLOTH_VELOCITY_COMPUTE_WGSL,
|
||||||
|
CLOTH_VERTEX_SHADER_WGSL
|
||||||
|
} from '../cloth.shader';
|
||||||
|
import {ClothSimulationParams, ClothSimulationStrategy} from './cloth-simulation.strategy';
|
||||||
|
|
||||||
|
interface GpuBuffers {
|
||||||
|
positions: StorageBuffer;
|
||||||
|
prevPositions: StorageBuffer;
|
||||||
|
velocities: StorageBuffer;
|
||||||
|
params: StorageBuffer;
|
||||||
|
constraints: StorageBuffer[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GpuPipelines {
|
||||||
|
integrate: ComputeShader;
|
||||||
|
solvers: ComputeShader[];
|
||||||
|
velocity: ComputeShader;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ClothGpuStrategy implements ClothSimulationStrategy {
|
||||||
|
private clothMesh: GroundMesh | null = null;
|
||||||
|
private scene: Scene | null = null;
|
||||||
|
private paramsData = new Float32Array(9);
|
||||||
|
private buffers: GpuBuffers | null = null;
|
||||||
|
private pipelines: GpuPipelines | null = null;
|
||||||
|
private dispatchXConstraints: number[] = [];
|
||||||
|
private dispatchXVertices = 0;
|
||||||
|
private numVertices = 0;
|
||||||
|
|
||||||
|
init(scene: Scene, engine: WebGPUEngine | Engine, config: ClothConfig): void {
|
||||||
|
this.scene = scene;
|
||||||
|
this.numVertices = config.numVertices;
|
||||||
|
const gpuEngine = engine as WebGPUEngine;
|
||||||
|
|
||||||
|
const clothData = this.generateClothData(config);
|
||||||
|
this.buffers = this.createStorageBuffers(gpuEngine, clothData);
|
||||||
|
this.pipelines = this.setupComputePipelines(gpuEngine, this.buffers);
|
||||||
|
this.setupRenderMesh(scene, config, this.buffers.positions);
|
||||||
|
this.setupDispatchSizes(config, this.buffers);
|
||||||
|
this.startRenderLoop(scene);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateParams(params: ClothSimulationParams): void {
|
||||||
|
if (!this.buffers) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const windX = params.isWindActive ? 5.0 : 0.0;
|
||||||
|
const windZ = params.isWindActive ? 15.0 : 0.0;
|
||||||
|
|
||||||
|
const softCompliance = 10.0;
|
||||||
|
const rigidCompliance = 0.00001;
|
||||||
|
const t = (params.stiffness - 1) / 99.0;
|
||||||
|
const compliance = softCompliance * Math.pow(rigidCompliance / softCompliance, t);
|
||||||
|
|
||||||
|
this.paramsData[0] = 0.016;
|
||||||
|
this.paramsData[1] = -9.81;
|
||||||
|
this.paramsData[2] = compliance;
|
||||||
|
this.paramsData[3] = this.numVertices;
|
||||||
|
this.paramsData[4] = windX;
|
||||||
|
this.paramsData[5] = 0.0;
|
||||||
|
this.paramsData[6] = windZ;
|
||||||
|
this.paramsData[7] = params.simulationTime;
|
||||||
|
this.paramsData[8] = params.elongation;
|
||||||
|
}
|
||||||
|
|
||||||
|
getMesh(): GroundMesh | null {
|
||||||
|
return this.clothMesh;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
if (this.scene && this.clothMesh) {
|
||||||
|
this.scene.removeMesh(this.clothMesh);
|
||||||
|
}
|
||||||
|
this.clothMesh = null;
|
||||||
|
this.buffers = null;
|
||||||
|
this.pipelines = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateClothData(config: ClothConfig): ClothData {
|
||||||
|
const positionsData = new Float32Array(config.numVertices * 4);
|
||||||
|
const prevPositionsData = new Float32Array(config.numVertices * 4);
|
||||||
|
const velocitiesData = new Float32Array(config.numVertices * 4);
|
||||||
|
|
||||||
|
const constraintsP0: number[] = [];
|
||||||
|
const constraintsP1: number[] = [];
|
||||||
|
const constraintsP2: number[] = [];
|
||||||
|
const constraintsP3: number[] = [];
|
||||||
|
|
||||||
|
const addHorizontalConstraint = (arr: number[], a: number, b: number): void => {
|
||||||
|
arr.push(a, b, config.spacing, 1.0);
|
||||||
|
};
|
||||||
|
const addVerticalConstraint = (arr: number[], a: number, b: number): void => {
|
||||||
|
arr.push(a, b, config.spacing, 2.0);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let y = 0; y < config.gridHeight; y++) {
|
||||||
|
for (let x = 0; x < config.gridWidth; x++) {
|
||||||
|
const idx = (y * config.gridWidth + x) * 4;
|
||||||
|
positionsData[idx + 0] = (x - config.gridWidth / 2) * config.spacing;
|
||||||
|
positionsData[idx + 1] = 5.0 - (y * config.spacing);
|
||||||
|
positionsData[idx + 2] = 0.0;
|
||||||
|
positionsData[idx + 3] = (y === 0) ? 0.0 : config.particleInvMass;
|
||||||
|
|
||||||
|
prevPositionsData[idx + 0] = positionsData[idx + 0];
|
||||||
|
prevPositionsData[idx + 1] = positionsData[idx + 1];
|
||||||
|
prevPositionsData[idx + 2] = positionsData[idx + 2];
|
||||||
|
prevPositionsData[idx + 3] = positionsData[idx + 3];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let y = 0; y < config.gridHeight; y++) {
|
||||||
|
for (let x = 0; x < config.gridWidth - 1; x += 2) { addHorizontalConstraint(constraintsP0, y * config.gridWidth + x, y * config.gridWidth + x + 1); }
|
||||||
|
for (let x = 1; x < config.gridWidth - 1; x += 2) { addHorizontalConstraint(constraintsP1, y * config.gridWidth + x, y * config.gridWidth + x + 1); }
|
||||||
|
}
|
||||||
|
for (let y = 0; y < config.gridHeight - 1; y += 2) {
|
||||||
|
for (let x = 0; x < config.gridWidth; x++) { addVerticalConstraint(constraintsP2, y * config.gridWidth + x, (y + 1) * config.gridWidth + x); }
|
||||||
|
}
|
||||||
|
for (let y = 1; y < config.gridHeight - 1; y += 2) {
|
||||||
|
for (let x = 0; x < config.gridWidth; x++) { addVerticalConstraint(constraintsP3, y * config.gridWidth + x, (y + 1) * config.gridWidth + x); }
|
||||||
|
}
|
||||||
|
|
||||||
|
const constraintsP4: number[] = [];
|
||||||
|
const constraintsP5: number[] = [];
|
||||||
|
const constraintsP6: number[] = [];
|
||||||
|
const constraintsP7: number[] = [];
|
||||||
|
const diagSpacing = config.spacing * Math.SQRT2;
|
||||||
|
const addDiagConstraint = (arr: number[], a: number, b: number): void => {
|
||||||
|
arr.push(a, b, diagSpacing, 1.0);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let y = 0; y < config.gridHeight - 1; y++) {
|
||||||
|
const arr = (y % 2 === 0) ? constraintsP4 : constraintsP5;
|
||||||
|
for (let x = 0; x < config.gridWidth - 1; x++) {
|
||||||
|
addDiagConstraint(arr, y * config.gridWidth + x, (y + 1) * config.gridWidth + (x + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let y = 0; y < config.gridHeight - 1; y++) {
|
||||||
|
const arr = (y % 2 === 0) ? constraintsP6 : constraintsP7;
|
||||||
|
for (let x = 0; x < config.gridWidth - 1; x++) {
|
||||||
|
addDiagConstraint(arr, y * config.gridWidth + (x + 1), (y + 1) * config.gridWidth + x);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
positions: positionsData,
|
||||||
|
prevPositions: prevPositionsData,
|
||||||
|
velocities: velocitiesData,
|
||||||
|
constraints: [
|
||||||
|
constraintsP0, constraintsP1, constraintsP2, constraintsP3,
|
||||||
|
constraintsP4, constraintsP5, constraintsP6, constraintsP7
|
||||||
|
],
|
||||||
|
params: new Float32Array(9)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private createStorageBuffers(engine: WebGPUEngine, data: ClothData): GpuBuffers {
|
||||||
|
const createBuffer = (arrayData: Float32Array | number[]): StorageBuffer => {
|
||||||
|
const buffer = new StorageBuffer(engine, arrayData.length * 4);
|
||||||
|
buffer.update(arrayData instanceof Float32Array ? arrayData : new Float32Array(arrayData));
|
||||||
|
return buffer;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
positions: createBuffer(data.positions),
|
||||||
|
prevPositions: createBuffer(data.prevPositions),
|
||||||
|
velocities: createBuffer(data.velocities),
|
||||||
|
params: createBuffer(data.params),
|
||||||
|
constraints: data.constraints.map(cData => createBuffer(cData))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupComputePipelines(engine: WebGPUEngine, buffers: GpuBuffers): GpuPipelines {
|
||||||
|
const createBasicShader = (name: string, source: string) => {
|
||||||
|
const cs = new ComputeShader(name, engine, {computeSource: source}, {
|
||||||
|
bindingsMapping: {
|
||||||
|
"p": {group: 0, binding: 0},
|
||||||
|
"positions": {group: 0, binding: 1},
|
||||||
|
"prev_positions": {group: 0, binding: 2},
|
||||||
|
"velocities": {group: 0, binding: 3}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
cs.setStorageBuffer("p", buffers.params);
|
||||||
|
cs.setStorageBuffer("positions", buffers.positions);
|
||||||
|
cs.setStorageBuffer("prev_positions", buffers.prevPositions);
|
||||||
|
cs.setStorageBuffer("velocities", buffers.velocities);
|
||||||
|
return cs;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createSolverShader = (name: string, constraintBuffer: StorageBuffer) => {
|
||||||
|
const cs = new ComputeShader(name, engine, {computeSource: CLOTH_SOLVE_COMPUTE_WGSL}, {
|
||||||
|
bindingsMapping: {
|
||||||
|
"p": {group: 0, binding: 0},
|
||||||
|
"positions": {group: 0, binding: 1},
|
||||||
|
"constraints": {group: 0, binding: 2}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
cs.setStorageBuffer("p", buffers.params);
|
||||||
|
cs.setStorageBuffer("positions", buffers.positions);
|
||||||
|
cs.setStorageBuffer("constraints", constraintBuffer);
|
||||||
|
return cs;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
integrate: createBasicShader("integrate", CLOTH_INTEGRATE_COMPUTE_WGSL),
|
||||||
|
solvers: buffers.constraints.map((cBuffer, i) => createSolverShader(`solve${i}`, cBuffer)),
|
||||||
|
velocity: createBasicShader("velocity", CLOTH_VELOCITY_COMPUTE_WGSL)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupRenderMesh(scene: Scene, config: ClothConfig, positionsBuffer: StorageBuffer): void {
|
||||||
|
if (this.clothMesh) {
|
||||||
|
scene.removeMesh(this.clothMesh);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.clothMesh = MeshBuilder.CreateGround("cloth", {width: 10, height: 10, subdivisions: config.gridWidth - 1}, scene);
|
||||||
|
|
||||||
|
const clothMaterial = new ShaderMaterial("clothMat", scene, {
|
||||||
|
vertexSource: CLOTH_VERTEX_SHADER_WGSL,
|
||||||
|
fragmentSource: CLOTH_FRAGMENT_SHADER_WGSL
|
||||||
|
}, {
|
||||||
|
attributes: ["position", "uv"],
|
||||||
|
uniforms: ["viewProjection"],
|
||||||
|
storageBuffers: ["positions"],
|
||||||
|
shaderLanguage: ShaderLanguage.WGSL
|
||||||
|
});
|
||||||
|
|
||||||
|
clothMaterial.backFaceCulling = false;
|
||||||
|
clothMaterial.setStorageBuffer("positions", positionsBuffer);
|
||||||
|
this.clothMesh.material = clothMaterial;
|
||||||
|
|
||||||
|
const camera = scene.activeCamera as ArcRotateCamera;
|
||||||
|
if (camera) {
|
||||||
|
camera.alpha = Math.PI / 4;
|
||||||
|
camera.beta = Math.PI / 2.5;
|
||||||
|
camera.radius = 15;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupDispatchSizes(config: ClothConfig, buffers: GpuBuffers): void {
|
||||||
|
const constraintsLength = buffers.constraints.map(b => (b as any)._buffer.capacity / 4 / 4);
|
||||||
|
this.dispatchXConstraints = constraintsLength.map(len => Math.ceil(len / 64));
|
||||||
|
this.dispatchXVertices = Math.ceil(config.numVertices / 64);
|
||||||
|
}
|
||||||
|
|
||||||
|
private startRenderLoop(scene: Scene): void {
|
||||||
|
const substeps = 15;
|
||||||
|
|
||||||
|
scene.onBeforeRenderObservable.clear();
|
||||||
|
scene.onBeforeRenderObservable.add(() => {
|
||||||
|
if (!this.buffers || !this.pipelines) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.buffers.params.update(this.paramsData);
|
||||||
|
|
||||||
|
this.pipelines.integrate.dispatch(this.dispatchXVertices, 1, 1);
|
||||||
|
|
||||||
|
for (let i = 0; i < substeps; i++) {
|
||||||
|
for (let phase = 0; phase < this.pipelines.solvers.length; phase++) {
|
||||||
|
this.pipelines.solvers[phase].dispatch(this.dispatchXConstraints[phase], 1, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pipelines.velocity.dispatch(this.dispatchXVertices, 1, 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import {Engine, GroundMesh, Scene, WebGPUEngine} from '@babylonjs/core';
|
||||||
|
import {ClothConfig} from '../cloth.model';
|
||||||
|
|
||||||
|
export interface ClothSimulationParams {
|
||||||
|
stiffness: number;
|
||||||
|
elongation: number;
|
||||||
|
isWindActive: boolean;
|
||||||
|
simulationTime: number;
|
||||||
|
deltaTime: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClothSimulationStrategy {
|
||||||
|
init(scene: Scene, engine: WebGPUEngine | Engine, config: ClothConfig): void;
|
||||||
|
updateParams(params: ClothSimulationParams): void;
|
||||||
|
getMesh(): GroundMesh | null;
|
||||||
|
dispose(): void;
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
<mat-card class="algo-container">
|
<mat-card class="w-full max-w-[1920px] p-5">
|
||||||
<mat-card-header>
|
<mat-card-header>
|
||||||
<mat-card-title>{{ 'GOL.TITLE' | translate }}</mat-card-title>
|
<mat-card-title>{{ 'GOL.TITLE' | translate }}</mat-card-title>
|
||||||
</mat-card-header>
|
</mat-card-header>
|
||||||
<mat-card-content>
|
<mat-card-content>
|
||||||
<app-information [algorithmInformation]="algoInformation"/>
|
<app-information [algorithmInformation]="algoInformation"/>
|
||||||
<div class="controls-container">
|
<div class="flex flex-col mb-4">
|
||||||
<div class="controls-panel">
|
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
|
||||||
<button mat-raised-button (click)="generate(Scenario.SIMPLE)">
|
<button mat-raised-button (click)="generate(Scenario.SIMPLE)">
|
||||||
<mat-icon>arrow_right</mat-icon> {{ 'GOL.SIMPLE_SCENE' | translate }}
|
<mat-icon>arrow_right</mat-icon> {{ 'GOL.SIMPLE_SCENE' | translate }}
|
||||||
</button>
|
</button>
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
<mat-icon>check_box_outline_blank</mat-icon> {{ 'GOL.EMPTY_SCENE' | translate }}
|
<mat-icon>check_box_outline_blank</mat-icon> {{ 'GOL.EMPTY_SCENE' | translate }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="controls-panel">
|
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
|
||||||
@if (gameStarted())
|
@if (gameStarted())
|
||||||
{
|
{
|
||||||
<button mat-raised-button (click)="pauseGame()">
|
<button mat-raised-button (click)="pauseGame()">
|
||||||
@@ -35,8 +35,8 @@
|
|||||||
}
|
}
|
||||||
<p>{{ 'SORTING.EXECUTION_TIME' | translate }}: {{ executionTime }} ms</p>
|
<p>{{ 'SORTING.EXECUTION_TIME' | translate }}: {{ executionTime }} ms</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="input-container">
|
<div class="flex gap-3 items-center flex-wrap">
|
||||||
<mat-form-field appearance="outline" class="input-field">
|
<mat-form-field appearance="outline" class="w-[150px]">
|
||||||
<mat-label>{{ 'ALGORITHM.GRID_HEIGHT' | translate }}</mat-label>
|
<mat-label>{{ 'ALGORITHM.GRID_HEIGHT' | translate }}</mat-label>
|
||||||
<input
|
<input
|
||||||
matInput
|
matInput
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
(ngModelChange)="pauseGame(); genericGridComponent.gridRows = gridRows; genericGridComponent.applyGridSize()"
|
(ngModelChange)="pauseGame(); genericGridComponent.gridRows = gridRows; genericGridComponent.applyGridSize()"
|
||||||
/>
|
/>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
<mat-form-field appearance="outline" class="input-field">
|
<mat-form-field appearance="outline" class="w-[150px]">
|
||||||
<mat-label>{{ 'ALGORITHM.GRID_WIDTH' | translate }}</mat-label>
|
<mat-label>{{ 'ALGORITHM.GRID_WIDTH' | translate }}</mat-label>
|
||||||
<input
|
<input
|
||||||
matInput
|
matInput
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
(ngModelChange)="pauseGame(); genericGridComponent.gridCols = gridCols; genericGridComponent.applyGridSize()"
|
(ngModelChange)="pauseGame(); genericGridComponent.gridCols = gridCols; genericGridComponent.applyGridSize()"
|
||||||
/>
|
/>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
<mat-form-field appearance="outline" class="input-field">
|
<mat-form-field appearance="outline" class="w-[150px]">
|
||||||
<mat-label>{{ 'GOL.SPEED' | translate }}</mat-label>
|
<mat-label>{{ 'GOL.SPEED' | translate }}</mat-label>
|
||||||
<input
|
<input
|
||||||
matInput
|
matInput
|
||||||
@@ -72,9 +72,9 @@
|
|||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="legend">
|
<div class="flex flex-wrap gap-4 items-center text-[0.9em]">
|
||||||
<span><span class="legend-color alive"></span> {{ 'GOL.ALIVE' | translate }}</span>
|
<span><span class="legend-swatch bg-black"></span> {{ 'GOL.ALIVE' | translate }}</span>
|
||||||
<span><span class="legend-color empty"></span> {{ 'GOL.DEAD' | translate }}</span>
|
<span><span class="legend-swatch bg-gray-300"></span> {{ 'GOL.DEAD' | translate }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<app-generic-grid
|
<app-generic-grid
|
||||||
|
|||||||
@@ -178,10 +178,9 @@ export class ConwayGolComponent implements AfterViewInit {
|
|||||||
|
|
||||||
async startGame(): Promise<void> {
|
async startGame(): Promise<void> {
|
||||||
this.gameStarted.set(true);
|
this.gameStarted.set(true);
|
||||||
let lifeIsDead = false;
|
|
||||||
while (this.gameStarted()){
|
while (this.gameStarted()){
|
||||||
const startTime = performance.now();
|
const startTime = performance.now();
|
||||||
lifeIsDead = true;
|
let lifeIsDead = true;
|
||||||
for (let row = 0; row < this.gridRows; row++) {
|
for (let row = 0; row < this.gridRows; row++) {
|
||||||
for (let col = 0; col < this.gridCols; col++) {
|
for (let col = 0; col < this.gridCols; col++) {
|
||||||
lifeIsDead = this.checkLifeRules(row, col, this.writeGrid) && lifeIsDead;
|
lifeIsDead = this.checkLifeRules(row, col, this.writeGrid) && lifeIsDead;
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<mat-card class="w-full max-w-[1920px] p-5">
|
||||||
|
<mat-card-header>
|
||||||
|
<mat-card-title>{{ 'FOUR_COLOR.TITLE' | translate }}</mat-card-title>
|
||||||
|
</mat-card-header>
|
||||||
|
<mat-card-content>
|
||||||
|
<app-information [algorithmInformation]="algoInformation"/>
|
||||||
|
|
||||||
|
<div class="flex flex-col mb-4">
|
||||||
|
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
|
||||||
|
<button mat-flat-button color="primary" (click)="generateNewMap()">{{ 'FOUR_COLOR.GENERATE' | translate }}</button>
|
||||||
|
<button mat-flat-button color="accent" (click)="autoSolve()">{{ 'FOUR_COLOR.SOLVE' | translate }}</button>
|
||||||
|
<button mat-stroked-button (click)="resetColors()">{{ 'FOUR_COLOR.CLEAR' | translate }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
|
||||||
|
<div class="flex gap-3 items-center flex-wrap">
|
||||||
|
<mat-form-field appearance="outline" class="w-[150px]">
|
||||||
|
<mat-label>{{ 'ALGORITHM.GRID_HEIGHT' | translate }}</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
type="number"
|
||||||
|
[min]="MIN_GRID_SIZE"
|
||||||
|
[max]="MAX_GRID_SIZE"
|
||||||
|
[(ngModel)]="gridRows"
|
||||||
|
(ngModelChange)="applyGridSize()"
|
||||||
|
/>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="w-[150px]">
|
||||||
|
<mat-label>{{ 'ALGORITHM.GRID_WIDTH' | translate }}</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
type="number"
|
||||||
|
[min]="MIN_GRID_SIZE"
|
||||||
|
[max]="MAX_GRID_SIZE"
|
||||||
|
[(ngModel)]="gridCols"
|
||||||
|
(ngModelChange)="applyGridSize()"
|
||||||
|
/>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-4 items-center text-[0.9em]">
|
||||||
|
<span><span class="legend-swatch bg-[#FF5252]"></span> {{ 'FOUR_COLOR.COLOR_1' | translate }}</span>
|
||||||
|
<span><span class="legend-swatch bg-[#448AFF]"></span> {{ 'FOUR_COLOR.COLOR_2' | translate }}</span>
|
||||||
|
<span><span class="legend-swatch bg-[#4CAF50]"></span> {{ 'FOUR_COLOR.COLOR_3' | translate }}</span>
|
||||||
|
<span><span class="legend-swatch bg-[#FFEB3B]"></span> {{ 'FOUR_COLOR.COLOR_4' | translate }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5 flex gap-2.5 py-2.5 px-4 rounded-sm border-l-[5px] font-medium min-w-[300px] items-center"
|
||||||
|
[ngClass]="{
|
||||||
|
'border-l-gray-500 bg-app-bg': solutionStatus === 'INCOMPLETE',
|
||||||
|
'border-l-green-500 bg-green-50 text-green-800': solutionStatus === 'SOLVED',
|
||||||
|
'border-l-orange-500 bg-orange-50 text-orange-700': solutionStatus === 'CONFLICTS',
|
||||||
|
'border-l-red-500 bg-red-50 text-red-800': solutionStatus === 'INVALID'
|
||||||
|
}">
|
||||||
|
<span class="uppercase text-[0.8em] opacity-70">{{ 'FOUR_COLOR.STATUS.LABEL' | translate }}:</span>
|
||||||
|
<span>{{ 'FOUR_COLOR.STATUS.' + solutionStatus | translate }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-center items-center w-full max-w-[1000px] mx-auto">
|
||||||
|
<canvas #fourColorCanvas
|
||||||
|
class="cursor-pointer max-w-full h-auto [image-rendering:pixelated]"
|
||||||
|
(mousedown)="onMouseDown($event)"
|
||||||
|
(mousemove)="onMouseMove($event)"
|
||||||
|
(touchstart)="onTouchStart($event)"
|
||||||
|
(touchmove)="onTouchMove($event)"
|
||||||
|
></canvas>
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
374
src/app/pages/algorithms/four-color/four-color.component.ts
Normal file
374
src/app/pages/algorithms/four-color/four-color.component.ts
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
import {AfterViewInit, Component, ElementRef, inject, ViewChild} from '@angular/core';
|
||||||
|
import {CommonModule} from '@angular/common';
|
||||||
|
import {FormsModule} from '@angular/forms';
|
||||||
|
import {MatButtonModule} from '@angular/material/button';
|
||||||
|
import {MatCardModule} from '@angular/material/card';
|
||||||
|
import {MatFormFieldModule} from '@angular/material/form-field';
|
||||||
|
import {MatInputModule} from '@angular/material/input';
|
||||||
|
import {TranslatePipe, TranslateService} from '@ngx-translate/core';
|
||||||
|
import {MatSnackBar} from '@angular/material/snack-bar';
|
||||||
|
|
||||||
|
import {DEFAULT_GRID_COLS, DEFAULT_GRID_ROWS, MAX_GRID_PX, MAX_GRID_SIZE, MIN_GRID_SIZE, FourColorNode, Region} from './four-color.models';
|
||||||
|
import {AlgorithmInformation} from '../information/information.models';
|
||||||
|
import {Information} from '../information/information';
|
||||||
|
import {GridPos} from '../../../shared/components/generic-grid/generic-grid';
|
||||||
|
import {SharedFunctions} from '../../../shared/SharedFunctions';
|
||||||
|
import {UrlConstants} from '../../../constants/UrlConstants';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-four-color',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatCardModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatInputModule,
|
||||||
|
TranslatePipe,
|
||||||
|
Information
|
||||||
|
],
|
||||||
|
templateUrl: './four-color.component.html',
|
||||||
|
styleUrl: './four-color.component.scss'
|
||||||
|
})
|
||||||
|
export class FourColorComponent implements AfterViewInit {
|
||||||
|
private readonly translate = inject(TranslateService);
|
||||||
|
private readonly snackBar = inject(MatSnackBar);
|
||||||
|
|
||||||
|
readonly MIN_GRID_SIZE = MIN_GRID_SIZE;
|
||||||
|
readonly MAX_GRID_SIZE = MAX_GRID_SIZE;
|
||||||
|
|
||||||
|
algoInformation: AlgorithmInformation = {
|
||||||
|
title: 'FOUR_COLOR.EXPLANATION.TITLE',
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
name: 'FOUR_COLOR.TITLE',
|
||||||
|
translateName: true,
|
||||||
|
description: 'FOUR_COLOR.EXPLANATION.EXPLANATION',
|
||||||
|
link: UrlConstants.FOUR_COLOR_THEOREM
|
||||||
|
}
|
||||||
|
],
|
||||||
|
disclaimer: 'FOUR_COLOR.EXPLANATION.DISCLAIMER',
|
||||||
|
disclaimerBottom: 'FOUR_COLOR.EXPLANATION.DISCLAIMER_BOTTOM',
|
||||||
|
disclaimerListEntry: [
|
||||||
|
'FOUR_COLOR.EXPLANATION.DISCLAIMER_1',
|
||||||
|
'FOUR_COLOR.EXPLANATION.DISCLAIMER_2',
|
||||||
|
'FOUR_COLOR.EXPLANATION.DISCLAIMER_3',
|
||||||
|
'FOUR_COLOR.EXPLANATION.DISCLAIMER_4'
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
gridRows = DEFAULT_GRID_ROWS;
|
||||||
|
gridCols = DEFAULT_GRID_COLS;
|
||||||
|
grid: FourColorNode[][] = [];
|
||||||
|
regions: Region[] = [];
|
||||||
|
executionTime = 0;
|
||||||
|
solutionStatus: 'INCOMPLETE' | 'SOLVED' | 'CONFLICTS' | 'INVALID' = 'INCOMPLETE';
|
||||||
|
|
||||||
|
@ViewChild('fourColorCanvas') canvasRef!: ElementRef<HTMLCanvasElement>;
|
||||||
|
private ctx!: CanvasRenderingContext2D;
|
||||||
|
private nodeSize = 0;
|
||||||
|
|
||||||
|
ngAfterViewInit(): void {
|
||||||
|
this.ctx = this.canvasRef.nativeElement.getContext('2d')!;
|
||||||
|
this.initializeGrid();
|
||||||
|
}
|
||||||
|
|
||||||
|
applyGridSize(): void {
|
||||||
|
if (this.gridRows < MIN_GRID_SIZE) this.gridRows = MIN_GRID_SIZE;
|
||||||
|
if (this.gridRows > MAX_GRID_SIZE) this.gridRows = MAX_GRID_SIZE;
|
||||||
|
if (this.gridCols < MIN_GRID_SIZE) this.gridCols = MIN_GRID_SIZE;
|
||||||
|
if (this.gridCols > MAX_GRID_SIZE) this.gridCols = MAX_GRID_SIZE;
|
||||||
|
|
||||||
|
this.initializeGrid();
|
||||||
|
}
|
||||||
|
|
||||||
|
initializeGrid(): void {
|
||||||
|
this.grid = [];
|
||||||
|
this.solutionStatus = 'INCOMPLETE';
|
||||||
|
for (let r = 0; r < this.gridRows; r++) {
|
||||||
|
const row: FourColorNode[] = [];
|
||||||
|
for (let c = 0; c < this.gridCols; c++) {
|
||||||
|
row.push({
|
||||||
|
row: r,
|
||||||
|
col: c,
|
||||||
|
regionId: -1,
|
||||||
|
color: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.grid.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.generateRegions();
|
||||||
|
this.resizeCanvas();
|
||||||
|
this.drawGrid();
|
||||||
|
}
|
||||||
|
|
||||||
|
private resizeCanvas(): void {
|
||||||
|
const canvas = this.canvasRef.nativeElement;
|
||||||
|
const maxDim = Math.max(this.gridRows, this.gridCols);
|
||||||
|
this.nodeSize = Math.floor(MAX_GRID_PX / maxDim);
|
||||||
|
|
||||||
|
canvas.width = this.gridCols * this.nodeSize;
|
||||||
|
canvas.height = this.gridRows * this.nodeSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
generateRegions(): void {
|
||||||
|
const numRegions = Math.floor((this.gridRows * this.gridCols) / 30);
|
||||||
|
this.regions = [];
|
||||||
|
const seeds = this.determineSeeds(numRegions);
|
||||||
|
this.regionGrowth(seeds);
|
||||||
|
this.determineAdjacency();
|
||||||
|
}
|
||||||
|
|
||||||
|
private determineAdjacency() {
|
||||||
|
for (let row = 0; row < this.gridRows; row++) {
|
||||||
|
for (let col = 0; col < this.gridCols; col++) {
|
||||||
|
const currentRegionId = this.grid[row][col].regionId;
|
||||||
|
const neighbors = this.getNeighbors(row, col);
|
||||||
|
for (const neighbor of neighbors) {
|
||||||
|
const neighborRegionId = this.grid[neighbor.row][neighbor.col].regionId;
|
||||||
|
if (neighborRegionId !== -1 && neighborRegionId !== currentRegionId) {
|
||||||
|
this.regions[currentRegionId].neighbors.add(neighborRegionId);
|
||||||
|
this.regions[neighborRegionId].neighbors.add(currentRegionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private regionGrowth(seeds: GridPos[]) {
|
||||||
|
const queue: GridPos[] = [...seeds];
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const {row, col} = queue.shift()!;
|
||||||
|
const regionId = this.grid[row][col].regionId;
|
||||||
|
|
||||||
|
const neighbors = this.getNeighbors(row, col);
|
||||||
|
for (const neighbor of neighbors) {
|
||||||
|
if (this.grid[neighbor.row][neighbor.col].regionId === -1) {
|
||||||
|
this.grid[neighbor.row][neighbor.col].regionId = regionId;
|
||||||
|
queue.push(neighbor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private determineSeeds(numRegions: number) {
|
||||||
|
const seeds: GridPos[] = [];
|
||||||
|
for (let i = 0; i < numRegions; i++) {
|
||||||
|
let r = SharedFunctions.randomIntFromInterval(0, this.gridRows - 1);
|
||||||
|
let c = SharedFunctions.randomIntFromInterval(0, this.gridCols - 1);
|
||||||
|
while (this.grid[r][c].regionId !== -1) {
|
||||||
|
r = SharedFunctions.randomIntFromInterval(0, this.gridRows - 1);
|
||||||
|
c = SharedFunctions.randomIntFromInterval(0, this.gridCols - 1);
|
||||||
|
}
|
||||||
|
this.grid[r][c].regionId = i;
|
||||||
|
seeds.push({row: r, col: c});
|
||||||
|
this.regions.push({id: i, color: 0, neighbors: new Set<number>()});
|
||||||
|
}
|
||||||
|
return seeds;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getNeighbors(row: number, col: number): GridPos[] {
|
||||||
|
const res: GridPos[] = [];
|
||||||
|
if (row > 0) res.push({row: row - 1, col});
|
||||||
|
if (row < this.gridRows - 1) res.push({row: row + 1, col});
|
||||||
|
if (col > 0) res.push({row, col: col - 1});
|
||||||
|
if (col < this.gridCols - 1) res.push({row, col: col + 1});
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
drawGrid(): void {
|
||||||
|
if (!this.ctx) return;
|
||||||
|
|
||||||
|
this.ctx.clearRect(0, 0, this.canvasRef.nativeElement.width, this.canvasRef.nativeElement.height);
|
||||||
|
|
||||||
|
// 1. Draw Cell Backgrounds
|
||||||
|
for (let r = 0; r < this.gridRows; r++) {
|
||||||
|
for (let c = 0; c < this.gridCols; c++) {
|
||||||
|
const node = this.grid[r][c];
|
||||||
|
this.ctx.fillStyle = this.getNodeColor(node);
|
||||||
|
this.ctx.fillRect(c * this.nodeSize, r * this.nodeSize, this.nodeSize, this.nodeSize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Draw Region Borders
|
||||||
|
this.ctx.strokeStyle = '#000';
|
||||||
|
this.ctx.lineWidth = 2;
|
||||||
|
this.ctx.beginPath();
|
||||||
|
for (let r = 0; r < this.gridRows; r++) {
|
||||||
|
for (let c = 0; c < this.gridCols; c++) {
|
||||||
|
const currentRegion = this.grid[r][c].regionId;
|
||||||
|
|
||||||
|
// Right border
|
||||||
|
if (c < this.gridCols - 1 && this.grid[r][c+1].regionId !== currentRegion) {
|
||||||
|
this.ctx.moveTo((c + 1) * this.nodeSize, r * this.nodeSize);
|
||||||
|
this.ctx.lineTo((c + 1) * this.nodeSize, (r + 1) * this.nodeSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bottom border
|
||||||
|
if (r < this.gridRows - 1 && this.grid[r+1][c].regionId !== currentRegion) {
|
||||||
|
this.ctx.moveTo(c * this.nodeSize, (r + 1) * this.nodeSize);
|
||||||
|
this.ctx.lineTo((c + 1) * this.nodeSize, (r + 1) * this.nodeSize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.ctx.stroke();
|
||||||
|
|
||||||
|
// 3. Draw Outer Border
|
||||||
|
this.ctx.strokeStyle = '#000';
|
||||||
|
this.ctx.lineWidth = 2;
|
||||||
|
this.ctx.strokeRect(0, 0, this.gridCols * this.nodeSize, this.gridRows * this.nodeSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getNodeColor(node: FourColorNode): string {
|
||||||
|
switch (node.color) {
|
||||||
|
case 1: return '#FF5252'; // Red
|
||||||
|
case 2: return '#448AFF'; // Blue
|
||||||
|
case 3: return '#4CAF50'; // Green
|
||||||
|
case 4: return '#FFEB3B'; // Yellow
|
||||||
|
default: return 'white';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMouseDown(event: MouseEvent): void {
|
||||||
|
const pos = this.getGridPos(event);
|
||||||
|
if (pos) this.handleInteraction(pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMouseMove(event: MouseEvent): void {
|
||||||
|
if (event.buttons !== 1){
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.getGridPos(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
onTouchStart(event: TouchEvent): void {
|
||||||
|
event.preventDefault();
|
||||||
|
const touch = event.touches[0];
|
||||||
|
const pos = this.getGridPos(touch);
|
||||||
|
if (pos) this.handleInteraction(pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
onTouchMove(event: TouchEvent): void {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
private getGridPos(event: MouseEvent | Touch): GridPos | null {
|
||||||
|
const rect = this.canvasRef.nativeElement.getBoundingClientRect();
|
||||||
|
const x = event.clientX - rect.left;
|
||||||
|
const y = event.clientY - rect.top;
|
||||||
|
|
||||||
|
const col = Math.floor(x / (rect.width / this.gridCols));
|
||||||
|
const row = Math.floor(y / (rect.height / this.gridRows));
|
||||||
|
|
||||||
|
if (row >= 0 && row < this.gridRows && col >= 0 && col < this.gridCols) {
|
||||||
|
return {row, col};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleInteraction(pos: GridPos): void {
|
||||||
|
const node = this.grid[pos.row][pos.col];
|
||||||
|
if (node.regionId === -1){
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const region = this.regions[node.regionId];
|
||||||
|
region.color = (region.color % 4) + 1;
|
||||||
|
this.updateRegionColors(region);
|
||||||
|
this.checkSolution();
|
||||||
|
this.drawGrid();
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateRegionColors(region: Region): void {
|
||||||
|
for (let row = 0; row < this.gridRows; row++) {
|
||||||
|
for (let col = 0; col < this.gridCols; col++) {
|
||||||
|
if (this.grid[row][col].regionId === region.id) {
|
||||||
|
this.grid[row][col].color = region.color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resetColors(): void {
|
||||||
|
for (const region of this.regions) {
|
||||||
|
region.color = 0;
|
||||||
|
this.updateRegionColors(region);
|
||||||
|
}
|
||||||
|
this.solutionStatus = 'INCOMPLETE';
|
||||||
|
this.drawGrid();
|
||||||
|
}
|
||||||
|
|
||||||
|
autoSolve(): void {
|
||||||
|
const startTime = performance.now();
|
||||||
|
this.resetColors();
|
||||||
|
|
||||||
|
const success = this.backtrackSolve(0);
|
||||||
|
const endTime = performance.now();
|
||||||
|
this.executionTime = endTime - startTime;
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
this.checkSolution();
|
||||||
|
this.drawGrid();
|
||||||
|
} else {
|
||||||
|
const message = this.translate.instant('FOUR_COLOR.ALERT.NO_SOLUTION');
|
||||||
|
this.snackBar.open(message, 'ALERT');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private backtrackSolve(regionIndex: number): boolean {
|
||||||
|
if (regionIndex === this.regions.length) return true;
|
||||||
|
|
||||||
|
const region = this.regions[regionIndex];
|
||||||
|
const availableColors = [1, 2, 3, 4];
|
||||||
|
|
||||||
|
for (const color of availableColors) {
|
||||||
|
if (this.isColorValid(region, color)) {
|
||||||
|
region.color = color;
|
||||||
|
this.updateRegionColors(region);
|
||||||
|
|
||||||
|
if (this.backtrackSolve(regionIndex + 1)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
region.color = 0;
|
||||||
|
//this.updateRegionColors(region);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isColorValid(region: Region, color: number): boolean {
|
||||||
|
for (const neighborId of region.neighbors) {
|
||||||
|
if (this.regions[neighborId].color === color){
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
checkSolution(): void {
|
||||||
|
let allColored = true;
|
||||||
|
let hasConflicts = false;
|
||||||
|
|
||||||
|
for (const region of this.regions) {
|
||||||
|
if (region.color === 0) {
|
||||||
|
allColored = false;
|
||||||
|
}
|
||||||
|
if (region.color > 0 && !this.isColorValid(region, region.color)) {
|
||||||
|
hasConflicts = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasConflicts) {
|
||||||
|
this.solutionStatus = allColored ? 'INVALID' : 'CONFLICTS';
|
||||||
|
} else {
|
||||||
|
this.solutionStatus = allColored ? 'SOLVED' : 'INCOMPLETE';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
generateNewMap(): void {
|
||||||
|
this.initializeGrid();
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/app/pages/algorithms/four-color/four-color.models.ts
Normal file
18
src/app/pages/algorithms/four-color/four-color.models.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
export interface FourColorNode {
|
||||||
|
row: number;
|
||||||
|
col: number;
|
||||||
|
regionId: number;
|
||||||
|
color: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Region {
|
||||||
|
id: number;
|
||||||
|
color: number;
|
||||||
|
neighbors: Set<number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_GRID_ROWS = 25;
|
||||||
|
export const DEFAULT_GRID_COLS = 25;
|
||||||
|
export const MIN_GRID_SIZE = 20;
|
||||||
|
export const MAX_GRID_SIZE = 38;
|
||||||
|
export const MAX_GRID_PX = 600;
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
<mat-card class="algo-container">
|
<mat-card class="w-full max-w-[1920px] p-5">
|
||||||
<mat-card-header>
|
<mat-card-header>
|
||||||
<mat-card-title>{{ 'FRACTAL.TITLE' | translate }}</mat-card-title>
|
<mat-card-title>{{ 'FRACTAL.TITLE' | translate }}</mat-card-title>
|
||||||
</mat-card-header>
|
</mat-card-header>
|
||||||
<mat-card-content>
|
<mat-card-content>
|
||||||
<app-information [algorithmInformation]="algoInformation"/>
|
<app-information [algorithmInformation]="algoInformation"/>
|
||||||
<div class="controls-container">
|
<div class="flex flex-col mb-4">
|
||||||
<div class="controls-panel">
|
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
|
||||||
<mat-form-field appearance="fill">
|
<mat-form-field class="w-[200px]" appearance="fill">
|
||||||
<mat-label>{{ 'FRACTAL.ALGORITHM' | translate }}</mat-label>
|
<mat-label>{{ 'FRACTAL.ALGORITHM' | translate }}</mat-label>
|
||||||
<mat-select [value]="'Mandelbrot'" (selectionChange)="onAlgorithmChange($event.value)">
|
<mat-select [value]="'Mandelbrot'" (selectionChange)="onAlgorithmChange($event.value)">
|
||||||
<mat-option value="Mandelbrot">Mandelbrot</mat-option>
|
<mat-option value="Mandelbrot">Mandelbrot</mat-option>
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
<mat-option value="Newton">Newton</mat-option>
|
<mat-option value="Newton">Newton</mat-option>
|
||||||
</mat-select>
|
</mat-select>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
<mat-form-field appearance="fill">
|
<mat-form-field class="w-[200px]" appearance="fill">
|
||||||
<mat-label>{{ 'FRACTAL.COLOR_SCHEME' | translate }}</mat-label>
|
<mat-label>{{ 'FRACTAL.COLOR_SCHEME' | translate }}</mat-label>
|
||||||
<mat-select [value]="'Blue-Gold'" (selectionChange)="onColorChanged($event.value)">
|
<mat-select [value]="'Blue-Gold'" (selectionChange)="onColorChanged($event.value)">
|
||||||
<mat-option value="Blue-Gold">Blue-Gold</mat-option>
|
<mat-option value="Blue-Gold">Blue-Gold</mat-option>
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
<mat-icon>undo</mat-icon> {{ 'FRACTAL.RESET' | translate }}
|
<mat-icon>undo</mat-icon> {{ 'FRACTAL.RESET' | translate }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="zoom-controls" style="display: flex; align-items: center; gap: 10px;">
|
<div style="display: flex; align-items: center; gap: 10px;">
|
||||||
<mat-icon>zoom_out</mat-icon>
|
<mat-icon>zoom_out</mat-icon>
|
||||||
<ngx-slider [(value)]="sliderValue" [options]="options" (valueChange)="onSliderChange($event)" ></ngx-slider>
|
<ngx-slider [(value)]="sliderValue" [options]="options" (valueChange)="onSliderChange($event)" ></ngx-slider>
|
||||||
<mat-icon>zoom_in</mat-icon>
|
<mat-icon>zoom_in</mat-icon>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<mat-card class="algo-container">
|
<mat-card class="w-full max-w-[1920px] p-5">
|
||||||
<mat-card-header>
|
<mat-card-header>
|
||||||
<mat-card-title>{{ 'FRACTAL3D.TITLE' | translate }}</mat-card-title>
|
<mat-card-title>{{ 'FRACTAL3D.TITLE' | translate }}</mat-card-title>
|
||||||
</mat-card-header>
|
</mat-card-header>
|
||||||
<mat-card-content>
|
<mat-card-content>
|
||||||
<app-information [algorithmInformation]="algoInformation"/>
|
<app-information [algorithmInformation]="algoInformation"/>
|
||||||
<div class="controls-container">
|
<div class="flex flex-col mb-4">
|
||||||
<div class="controls-panel">
|
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
|
||||||
<button matButton="filled" (click)="onFractalTypeChange(0)">{{ 'FRACTAL3D.MANDELBULB' | translate }}</button>
|
<button matButton="filled" (click)="onFractalTypeChange(0)">{{ 'FRACTAL3D.MANDELBULB' | translate }}</button>
|
||||||
<button matButton="filled" (click)="onFractalTypeChange(1)">{{ 'FRACTAL3D.MANDELBOX' | translate }}</button>
|
<button matButton="filled" (click)="onFractalTypeChange(1)">{{ 'FRACTAL3D.MANDELBOX' | translate }}</button>
|
||||||
<button matButton="filled" (click)="onFractalTypeChange(2)">{{ 'FRACTAL3D.JULIA' | translate }}</button>
|
<button matButton="filled" (click)="onFractalTypeChange(2)">{{ 'FRACTAL3D.JULIA' | translate }}</button>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<div class="algo-info">
|
<div class="mb-4 py-3 px-4 border border-[#ddd] rounded-lg">
|
||||||
<h3>{{ algorithmInformation.title | translate }}</h3>
|
<h3 class="m-0 mb-2">{{ algorithmInformation.title | translate }}</h3>
|
||||||
|
|
||||||
@if(algorithmInformation.entries && algorithmInformation.entries.length > 0){
|
@if(algorithmInformation.entries && algorithmInformation.entries.length > 0){
|
||||||
@for (algo of algorithmInformation.entries; track algo)
|
@for (algo of algorithmInformation.entries; track algo)
|
||||||
{
|
{
|
||||||
<p>
|
<p class="my-2">
|
||||||
<strong>
|
<strong>
|
||||||
@if(algo.translateName){
|
@if(algo.translateName){
|
||||||
{{ algo.name | translate}}
|
{{ algo.name | translate}}
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
|
|
||||||
@if (algorithmInformation.disclaimer !== '')
|
@if (algorithmInformation.disclaimer !== '')
|
||||||
{
|
{
|
||||||
<p>
|
<p class="my-2">
|
||||||
<strong>{{ 'ALGORITHM.NOTE' | translate}}</strong> {{ algorithmInformation.disclaimer | translate}}
|
<strong>{{ 'ALGORITHM.NOTE' | translate}}</strong> {{ algorithmInformation.disclaimer | translate}}
|
||||||
</p>
|
</p>
|
||||||
@if (algorithmInformation.disclaimerListEntry && algorithmInformation.disclaimerListEntry.length > 0)
|
@if (algorithmInformation.disclaimerListEntry && algorithmInformation.disclaimerListEntry.length > 0)
|
||||||
|
|||||||
@@ -1,27 +1,27 @@
|
|||||||
<mat-card class="algo-container">
|
<mat-card class="w-full max-w-[1920px] p-5">
|
||||||
<mat-card-header>
|
<mat-card-header>
|
||||||
<mat-card-title>{{ 'LABYRINTH.TITLE' | translate }}</mat-card-title>
|
<mat-card-title>{{ 'LABYRINTH.TITLE' | translate }}</mat-card-title>
|
||||||
</mat-card-header>
|
</mat-card-header>
|
||||||
<mat-card-content>
|
<mat-card-content>
|
||||||
<app-information [algorithmInformation]="algoInformation"/>
|
<app-information [algorithmInformation]="algoInformation"/>
|
||||||
<div class="controls-container">
|
<div class="flex flex-col mb-4">
|
||||||
<div class="controls-panel">
|
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
|
||||||
<button matButton="filled" [disabled]="isAnimationRunning()" (click)="visualize('dijkstra')">{{ 'PATHFINDING.DIJKSTRA' | translate }}</button>
|
<button matButton="filled" [disabled]="isAnimationRunning()" (click)="visualize('dijkstra')">{{ 'PATHFINDING.DIJKSTRA' | translate }}</button>
|
||||||
<button matButton="filled" [disabled]="isAnimationRunning()" (click)="visualize('astar')">{{ 'PATHFINDING.ASTAR' | translate }}</button>
|
<button matButton="filled" [disabled]="isAnimationRunning()" (click)="visualize('astar')">{{ 'PATHFINDING.ASTAR' | translate }}</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="controls-panel">
|
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
|
||||||
<button matButton="filled" [disabled]="isAnimationRunning()" (click)="createRandom(true)">{{ 'LABYRINTH.PRIM' | translate }}</button>
|
<button matButton="filled" [disabled]="isAnimationRunning()" (click)="createRandom(true)">{{ 'LABYRINTH.PRIM' | translate }}</button>
|
||||||
<button matButton="filled" [disabled]="isAnimationRunning()" (click)="createRandom(false)">{{ 'LABYRINTH.KRUSKAL' | translate }}</button>
|
<button matButton="filled" [disabled]="isAnimationRunning()" (click)="createRandom(false)">{{ 'LABYRINTH.KRUSKAL' | translate }}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="legend">
|
<div class="flex flex-wrap gap-4 items-center text-[0.9em]">
|
||||||
<span><span class="legend-color start"></span> {{ 'PATHFINDING.START_NODE' | translate }}</span>
|
<span><span class="legend-swatch bg-green-600"></span> {{ 'PATHFINDING.START_NODE' | translate }}</span>
|
||||||
<span><span class="legend-color end"></span> {{ 'PATHFINDING.END_NODE' | translate }}</span>
|
<span><span class="legend-swatch bg-red-600"></span> {{ 'PATHFINDING.END_NODE' | translate }}</span>
|
||||||
<span><span class="legend-color wall"></span> {{ 'PATHFINDING.WALL' | translate }}</span>
|
<span><span class="legend-swatch bg-black"></span> {{ 'PATHFINDING.WALL' | translate }}</span>
|
||||||
<span><span class="legend-color visited"></span> {{ 'PATHFINDING.VISITED' | translate }}</span>
|
<span><span class="legend-swatch bg-sky-300"></span> {{ 'PATHFINDING.VISITED' | translate }}</span>
|
||||||
<span><span class="legend-color path"></span> {{ 'PATHFINDING.PATH' | translate }}</span>
|
<span><span class="legend-swatch bg-[gold]"></span> {{ 'PATHFINDING.PATH' | translate }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="controls-panel">
|
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
|
||||||
<p>{{ 'PATHFINDING.PATH_LENGTH' | translate }}: {{ pathLength }}</p>
|
<p>{{ 'PATHFINDING.PATH_LENGTH' | translate }}: {{ pathLength }}</p>
|
||||||
<p>{{ 'PATHFINDING.EXECUTION_TIME' | translate }}: {{ executionTime | number:'1.2-2' }} ms</p>
|
<p>{{ 'PATHFINDING.EXECUTION_TIME' | translate }}: {{ executionTime | number:'1.2-2' }} ms</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,33 +1,33 @@
|
|||||||
<mat-card class="algo-container">
|
<mat-card class="w-full max-w-[1920px] p-5">
|
||||||
<mat-card-header>
|
<mat-card-header>
|
||||||
<mat-card-title>{{ 'PATHFINDING.TITLE' | translate }}</mat-card-title>
|
<mat-card-title>{{ 'PATHFINDING.TITLE' | translate }}</mat-card-title>
|
||||||
</mat-card-header>
|
</mat-card-header>
|
||||||
<mat-card-content>
|
<mat-card-content>
|
||||||
<app-information [algorithmInformation]="algoInformation"/>
|
<app-information [algorithmInformation]="algoInformation"/>
|
||||||
|
|
||||||
<div class="controls-container">
|
<div class="flex flex-col mb-4">
|
||||||
<div class="controls-panel">
|
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
|
||||||
<button matButton="filled" (click)="visualize('dijkstra')">{{ 'PATHFINDING.DIJKSTRA' | translate }}</button>
|
<button matButton="filled" (click)="visualize('dijkstra')">{{ 'PATHFINDING.DIJKSTRA' | translate }}</button>
|
||||||
<button matButton="filled" (click)="visualize('astar')">{{ 'PATHFINDING.ASTAR' | translate }}</button>
|
<button matButton="filled" (click)="visualize('astar')">{{ 'PATHFINDING.ASTAR' | translate }}</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="controls-panel">
|
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
|
||||||
<button matButton="filled" (click)="createCase({withWalls: true, scenario: 'normal'})">{{ 'PATHFINDING.NORMAL_CASE' | translate }}</button>
|
<button matButton="filled" (click)="createCase({withWalls: true, scenario: 'normal'})">{{ 'PATHFINDING.NORMAL_CASE' | translate }}</button>
|
||||||
<button matButton="filled" (click)="createCase({withWalls: true, scenario: 'random'})">{{ 'PATHFINDING.RANDOM_CASE' | translate }}</button>
|
<button matButton="filled" (click)="createCase({withWalls: true, scenario: 'random'})">{{ 'PATHFINDING.RANDOM_CASE' | translate }}</button>
|
||||||
<button matButton="filled" (click)="createCase({withWalls: true, scenario: 'edge'})">{{ 'PATHFINDING.EDGE_CASE' | translate }}</button>
|
<button matButton="filled" (click)="createCase({withWalls: true, scenario: 'edge'})">{{ 'PATHFINDING.EDGE_CASE' | translate }}</button>
|
||||||
<button matButton="filled" (click)="createCase({withWalls: false, scenario: 'normal'})">{{ 'PATHFINDING.CLEAR_BOARD' | translate }}</button>
|
<button matButton="filled" (click)="createCase({withWalls: false, scenario: 'normal'})">{{ 'PATHFINDING.CLEAR_BOARD' | translate }}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="controls-panel">
|
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
|
||||||
<mat-button-toggle-group [(ngModel)]="selectedNodeType" aria-label="Node Type Selection">
|
<mat-button-toggle-group class="rounded-sm overflow-hidden" [(ngModel)]="selectedNodeType" aria-label="Node Type Selection">
|
||||||
<mat-button-toggle [value]="NodeType.Start">{{ 'PATHFINDING.START_NODE' | translate }}</mat-button-toggle>
|
<mat-button-toggle [value]="NodeType.Start">{{ 'PATHFINDING.START_NODE' | translate }}</mat-button-toggle>
|
||||||
<mat-button-toggle [value]="NodeType.End">{{ 'PATHFINDING.END_NODE' | translate }}</mat-button-toggle>
|
<mat-button-toggle [value]="NodeType.End">{{ 'PATHFINDING.END_NODE' | translate }}</mat-button-toggle>
|
||||||
<mat-button-toggle [value]="NodeType.Wall">{{ 'PATHFINDING.WALL' | translate }}</mat-button-toggle>
|
<mat-button-toggle [value]="NodeType.Wall">{{ 'PATHFINDING.WALL' | translate }}</mat-button-toggle>
|
||||||
<mat-button-toggle [value]="NodeType.None">{{ 'PATHFINDING.CLEAR_NODE' | translate }}</mat-button-toggle>
|
<mat-button-toggle [value]="NodeType.None">{{ 'PATHFINDING.CLEAR_NODE' | translate }}</mat-button-toggle>
|
||||||
</mat-button-toggle-group>
|
</mat-button-toggle-group>
|
||||||
</div>
|
</div>
|
||||||
<div class="controls-panel">
|
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
|
||||||
<div class="input-container">
|
<div class="flex gap-3 items-center flex-wrap">
|
||||||
<mat-form-field appearance="outline" class="input-field">
|
<mat-form-field appearance="outline" class="w-[150px]">
|
||||||
<mat-label>{{ 'ALGORITHM.GRID_HEIGHT' | translate }}</mat-label>
|
<mat-label>{{ 'ALGORITHM.GRID_HEIGHT' | translate }}</mat-label>
|
||||||
<input
|
<input
|
||||||
matInput
|
matInput
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
(ngModelChange)="genericGridComponent.gridRows = gridRows; genericGridComponent.applyGridSize()"
|
(ngModelChange)="genericGridComponent.gridRows = gridRows; genericGridComponent.applyGridSize()"
|
||||||
/> </mat-form-field>
|
/> </mat-form-field>
|
||||||
|
|
||||||
<mat-form-field appearance="outline" class="input-field">
|
<mat-form-field appearance="outline" class="w-[150px]">
|
||||||
<mat-label>{{ 'ALGORITHM.GRID_WIDTH' | translate }}</mat-label>
|
<mat-label>{{ 'ALGORITHM.GRID_WIDTH' | translate }}</mat-label>
|
||||||
<input
|
<input
|
||||||
matInput
|
matInput
|
||||||
@@ -51,14 +51,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="legend">
|
<div class="flex flex-wrap gap-4 items-center text-[0.9em]">
|
||||||
<span><span class="legend-color start"></span> {{ 'PATHFINDING.START_NODE' | translate }}</span>
|
<span><span class="legend-swatch bg-green-600"></span> {{ 'PATHFINDING.START_NODE' | translate }}</span>
|
||||||
<span><span class="legend-color end"></span> {{ 'PATHFINDING.END_NODE' | translate }}</span>
|
<span><span class="legend-swatch bg-red-600"></span> {{ 'PATHFINDING.END_NODE' | translate }}</span>
|
||||||
<span><span class="legend-color wall"></span> {{ 'PATHFINDING.WALL' | translate }}</span>
|
<span><span class="legend-swatch bg-black"></span> {{ 'PATHFINDING.WALL' | translate }}</span>
|
||||||
<span><span class="legend-color visited"></span> {{ 'PATHFINDING.VISITED' | translate }}</span>
|
<span><span class="legend-swatch bg-sky-300"></span> {{ 'PATHFINDING.VISITED' | translate }}</span>
|
||||||
<span><span class="legend-color path"></span> {{ 'PATHFINDING.PATH' | translate }}</span>
|
<span><span class="legend-swatch bg-[gold]"></span> {{ 'PATHFINDING.PATH' | translate }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="controls-panel">
|
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
|
||||||
<p>{{ 'PATHFINDING.PATH_LENGTH' | translate }}: {{ pathLength }}</p>
|
<p>{{ 'PATHFINDING.PATH_LENGTH' | translate }}: {{ pathLength }}</p>
|
||||||
<p>{{ 'PATHFINDING.EXECUTION_TIME' | translate }}: {{ executionTime | number:'1.2-2' }} ms</p>
|
<p>{{ 'PATHFINDING.EXECUTION_TIME' | translate }}: {{ executionTime | number:'1.2-2' }} ms</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -77,4 +77,4 @@
|
|||||||
(gridChange)="grid = $event"
|
(gridChange)="grid = $event"
|
||||||
></app-generic-grid>
|
></app-generic-grid>
|
||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ import {MatButtonModule} from '@angular/material/button';
|
|||||||
import {MatButtonToggleModule} from '@angular/material/button-toggle';
|
import {MatButtonToggleModule} from '@angular/material/button-toggle';
|
||||||
import {MatFormFieldModule} from '@angular/material/form-field';
|
import {MatFormFieldModule} from '@angular/material/form-field';
|
||||||
import {MatInputModule} from '@angular/material/input';
|
import {MatInputModule} from '@angular/material/input';
|
||||||
|
import {MatSnackBar} from '@angular/material/snack-bar';
|
||||||
|
|
||||||
import {TranslateModule, TranslateService} from '@ngx-translate/core';
|
import {TranslatePipe, TranslateService} from '@ngx-translate/core';
|
||||||
|
|
||||||
import {DEFAULT_GRID_COLS, DEFAULT_GRID_ROWS, MAX_GRID_PX, MAX_GRID_SIZE, MAX_RANDOM_WALLS_FACTORS, MIN_GRID_SIZE, Node} from './pathfinding.models';
|
import {DEFAULT_GRID_COLS, DEFAULT_GRID_ROWS, MAX_GRID_PX, MAX_GRID_SIZE, MAX_RANDOM_WALLS_FACTORS, MIN_GRID_SIZE, Node} from './pathfinding.models';
|
||||||
import {PathfindingService} from './service/pathfinding.service';
|
import {PathfindingService} from './service/pathfinding.service';
|
||||||
@@ -27,7 +28,6 @@ enum NodeType {
|
|||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-pathfinding',
|
selector: 'app-pathfinding',
|
||||||
standalone: true,
|
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
@@ -35,7 +35,7 @@ enum NodeType {
|
|||||||
MatButtonToggleModule,
|
MatButtonToggleModule,
|
||||||
MatFormFieldModule,
|
MatFormFieldModule,
|
||||||
MatInputModule,
|
MatInputModule,
|
||||||
TranslateModule,
|
TranslatePipe,
|
||||||
MatCard,
|
MatCard,
|
||||||
MatCardHeader,
|
MatCardHeader,
|
||||||
MatCardTitle,
|
MatCardTitle,
|
||||||
@@ -48,6 +48,7 @@ enum NodeType {
|
|||||||
export class PathfindingComponent implements AfterViewInit {
|
export class PathfindingComponent implements AfterViewInit {
|
||||||
private readonly pathfindingService = inject(PathfindingService);
|
private readonly pathfindingService = inject(PathfindingService);
|
||||||
private readonly translate = inject(TranslateService);
|
private readonly translate = inject(TranslateService);
|
||||||
|
private readonly snackBar = inject(MatSnackBar);
|
||||||
|
|
||||||
readonly NodeType = NodeType;
|
readonly NodeType = NodeType;
|
||||||
readonly MIN_GRID_SIZE = MIN_GRID_SIZE;
|
readonly MIN_GRID_SIZE = MIN_GRID_SIZE;
|
||||||
@@ -483,7 +484,8 @@ export class PathfindingComponent implements AfterViewInit {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
alert(this.translate.instant('PATHFINDING.ALERT.START_END_NODES'));
|
const message = this.translate.instant('PATHFINDING.ALERT.START_END_NODES');
|
||||||
|
this.snackBar.open(message, 'OK', { duration: 5000, horizontalPosition: 'center', verticalPosition: 'top' });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
134
src/app/pages/algorithms/pendulum/pendulum-glsl.shader.ts
Normal file
134
src/app/pages/algorithms/pendulum/pendulum-glsl.shader.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* GLSL shaders for pendulum rendering on WebGL.
|
||||||
|
* Replicates the visual output of the WGSL pendulum compute+fragment shaders.
|
||||||
|
* Uses a feedback texture (ping-pong) for trail persistence.
|
||||||
|
*
|
||||||
|
* Coordinate note: WGSL pixel buffer has Y=0 at top (screen space).
|
||||||
|
* GLSL UVs have Y=0 at bottom. We flip Y via (1.0 - uv.y) to match.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const PENDULUM_VERTEX_SHADER_GLSL = `
|
||||||
|
precision highp float;
|
||||||
|
|
||||||
|
attribute vec3 position;
|
||||||
|
attribute vec2 uv;
|
||||||
|
|
||||||
|
varying vec2 vUV;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vUV = uv;
|
||||||
|
gl_Position = vec4(position, 1.0);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shader for the feedback pass: renders pendulum state to a render target texture.
|
||||||
|
* The R channel stores trail1 intensity, G stores trail2 intensity.
|
||||||
|
*/
|
||||||
|
export const PENDULUM_FEEDBACK_FRAGMENT_GLSL = `
|
||||||
|
precision highp float;
|
||||||
|
|
||||||
|
uniform vec2 resolution;
|
||||||
|
uniform float theta1;
|
||||||
|
uniform float theta2;
|
||||||
|
uniform float l1;
|
||||||
|
uniform float l2;
|
||||||
|
uniform float trailDecay;
|
||||||
|
uniform sampler2D previousFrame;
|
||||||
|
|
||||||
|
varying vec2 vUV;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
// Flip Y to match WGSL screen-space (Y=0 at top)
|
||||||
|
vec2 uv = vec2(vUV.x, 1.0 - vUV.y);
|
||||||
|
float aspect = resolution.x / resolution.y;
|
||||||
|
vec2 uvCorrected = vec2(uv.x * aspect, uv.y);
|
||||||
|
|
||||||
|
vec4 prev = texture2D(previousFrame, vUV);
|
||||||
|
float trail1 = prev.r * trailDecay;
|
||||||
|
float trail2 = prev.g * trailDecay;
|
||||||
|
|
||||||
|
vec2 origin = vec2(0.5 * aspect, 0.3);
|
||||||
|
float displayScale = 0.15;
|
||||||
|
|
||||||
|
vec2 p1 = origin + vec2(sin(theta1), cos(theta1)) * l1 * displayScale;
|
||||||
|
vec2 p2 = p1 + vec2(sin(theta2), cos(theta2)) * l2 * displayScale;
|
||||||
|
|
||||||
|
float dMass1 = length(uvCorrected - p1);
|
||||||
|
float dMass2 = length(uvCorrected - p2);
|
||||||
|
|
||||||
|
if (dMass1 < 0.02) {
|
||||||
|
trail1 = 1.0;
|
||||||
|
}
|
||||||
|
if (dMass2 < 0.02) {
|
||||||
|
trail2 = 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
gl_FragColor = vec4(trail1, trail2, 0.0, 1.0);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display shader: reads trail data from feedback texture and renders final colors.
|
||||||
|
*/
|
||||||
|
export const PENDULUM_DISPLAY_FRAGMENT_GLSL = `
|
||||||
|
precision highp float;
|
||||||
|
|
||||||
|
uniform vec2 resolution;
|
||||||
|
uniform float theta1;
|
||||||
|
uniform float theta2;
|
||||||
|
uniform float l1;
|
||||||
|
uniform float l2;
|
||||||
|
uniform sampler2D trailTexture;
|
||||||
|
|
||||||
|
varying vec2 vUV;
|
||||||
|
|
||||||
|
float sdSegment(vec2 p, vec2 a, vec2 b) {
|
||||||
|
vec2 pa = p - a;
|
||||||
|
vec2 ba = b - a;
|
||||||
|
float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0);
|
||||||
|
return length(pa - ba * h);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
// Flip Y to match WGSL screen-space (Y=0 at top)
|
||||||
|
vec2 uv = vec2(vUV.x, 1.0 - vUV.y);
|
||||||
|
float aspect = resolution.x / resolution.y;
|
||||||
|
vec2 uvCorrected = vec2(uv.x * aspect, uv.y);
|
||||||
|
|
||||||
|
vec4 trailData = texture2D(trailTexture, vUV);
|
||||||
|
float trail1 = trailData.r;
|
||||||
|
float trail2 = trailData.g;
|
||||||
|
|
||||||
|
vec2 origin = vec2(0.5 * aspect, 0.3);
|
||||||
|
float displayScale = 0.15;
|
||||||
|
|
||||||
|
vec2 p1 = origin + vec2(sin(theta1), cos(theta1)) * l1 * displayScale;
|
||||||
|
vec2 p2 = p1 + vec2(sin(theta2), cos(theta2)) * l2 * displayScale;
|
||||||
|
|
||||||
|
float dLine1 = sdSegment(uvCorrected, origin, p1);
|
||||||
|
float dLine2 = sdSegment(uvCorrected, p1, p2);
|
||||||
|
float dMass1 = length(uvCorrected - p1);
|
||||||
|
float dMass2 = length(uvCorrected - p2);
|
||||||
|
|
||||||
|
vec3 bgColor = vec3(0.1, 0.1, 0.15);
|
||||||
|
vec3 mass1Color = vec3(1.0, 0.0, 0.0);
|
||||||
|
vec3 mass2Color = vec3(0.0, 1.0, 0.0);
|
||||||
|
vec3 line1Color = vec3(1.0, 1.0, 0.0);
|
||||||
|
vec3 line2Color = vec3(1.0, 0.0, 1.0);
|
||||||
|
|
||||||
|
vec3 finalColor = bgColor;
|
||||||
|
finalColor = mix(finalColor, mass1Color, clamp(trail1, 0.0, 1.0));
|
||||||
|
finalColor = mix(finalColor, mass2Color, clamp(trail2, 0.0, 1.0));
|
||||||
|
|
||||||
|
if (dMass1 >= 0.02 && dMass2 >= 0.02) {
|
||||||
|
if (dLine1 < 0.003) {
|
||||||
|
finalColor = line1Color;
|
||||||
|
} else if (dLine2 < 0.003) {
|
||||||
|
finalColor = line2Color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
gl_FragColor = vec4(finalColor, 1.0);
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -1,45 +1,45 @@
|
|||||||
<mat-card class="algo-container">
|
<mat-card class="w-full max-w-[1920px] p-5">
|
||||||
<mat-card-header>
|
<mat-card-header>
|
||||||
<mat-card-title>{{ 'PENDULUM.TITLE' | translate }}</mat-card-title>
|
<mat-card-title>{{ 'PENDULUM.TITLE' | translate }}</mat-card-title>
|
||||||
</mat-card-header>
|
</mat-card-header>
|
||||||
<mat-card-content>
|
<mat-card-content>
|
||||||
<app-information [algorithmInformation]="algoInformation"/>
|
<app-information [algorithmInformation]="algoInformation"/>
|
||||||
<div class="controls-container">
|
<div class="flex flex-col mb-4">
|
||||||
<div class="sliders-grid">
|
<div class="grid grid-cols-2 tablet:grid-cols-1 tablet:gap-4 mb-6">
|
||||||
<div class="slider-item">
|
<div class="flex items-center gap-4 mr-4">
|
||||||
<p>{{ 'PENDULUM.TRAIL_DECAY_TIME' | translate }}</p>
|
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.TRAIL_DECAY_TIME' | translate }}</p>
|
||||||
<ngx-slider [(value)]="simParams.trailDecay" [options]="trailDecayOptions" ></ngx-slider>
|
<ngx-slider class="grow" [(value)]="simParams.trailDecay" [options]="trailDecayOptions" ></ngx-slider>
|
||||||
</div>
|
</div>
|
||||||
<div class="slider-item">
|
<div class="flex items-center gap-4 mr-4">
|
||||||
<p>{{ 'PENDULUM.ATTRACTION' | translate }}</p>
|
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.ATTRACTION' | translate }}</p>
|
||||||
<ngx-slider [(value)]="simParams.g" [options]="gravityOptions" ></ngx-slider>
|
<ngx-slider class="grow" [(value)]="simParams.g" [options]="gravityOptions" ></ngx-slider>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="slider-item">
|
<div class="flex items-center gap-4 mr-4">
|
||||||
<p>{{ 'PENDULUM.L1_LENGTH' | translate }}</p>
|
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.L1_LENGTH' | translate }}</p>
|
||||||
<ngx-slider [(value)]="simParams.l1" [options]="lengthOptions" ></ngx-slider>
|
<ngx-slider class="grow" [(value)]="simParams.l1" [options]="lengthOptions" ></ngx-slider>
|
||||||
</div>
|
</div>
|
||||||
<div class="slider-item">
|
<div class="flex items-center gap-4 mr-4">
|
||||||
<p>{{ 'PENDULUM.L2_LENGTH' | translate }}</p>
|
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.L2_LENGTH' | translate }}</p>
|
||||||
<ngx-slider [(value)]="simParams.l2" [options]="lengthOptions" ></ngx-slider>
|
<ngx-slider class="grow" [(value)]="simParams.l2" [options]="lengthOptions" ></ngx-slider>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="slider-item">
|
<div class="flex items-center gap-4 mr-4">
|
||||||
<p>{{ 'PENDULUM.M1_MASS' | translate }}</p>
|
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.M1_MASS' | translate }}</p>
|
||||||
<ngx-slider [(value)]="simParams.m1" [options]="massOptions" ></ngx-slider>
|
<ngx-slider class="grow" [(value)]="simParams.m1" [options]="massOptions" ></ngx-slider>
|
||||||
</div>
|
</div>
|
||||||
<div class="slider-item">
|
<div class="flex items-center gap-4 mr-4">
|
||||||
<p>{{ 'PENDULUM.M2_MASS' | translate }}</p>
|
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.M2_MASS' | translate }}</p>
|
||||||
<ngx-slider [(value)]="simParams.m2" [options]="massOptions" ></ngx-slider>
|
<ngx-slider class="grow" [(value)]="simParams.m2" [options]="massOptions" ></ngx-slider>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="slider-item full-width">
|
<div class="flex items-center gap-4 mr-4 col-span-full">
|
||||||
<p>{{ 'PENDULUM.DAMPING' | translate }}</p>
|
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.DAMPING' | translate }}</p>
|
||||||
<ngx-slider [(value)]="simParams.damping" [options]="dampingOptions" ></ngx-slider>
|
<ngx-slider class="grow" [(value)]="simParams.damping" [options]="dampingOptions" ></ngx-slider>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="actions-container">
|
<div class="flex flex-wrap gap-3 mb-4">
|
||||||
<button mat-raised-button color="primary" (click)="pushPendulum(true)">
|
<button mat-raised-button color="primary" (click)="pushPendulum(true)">
|
||||||
{{ 'PENDULUM.POKE_M1' | translate }}
|
{{ 'PENDULUM.POKE_M1' | translate }}
|
||||||
</button>
|
</button>
|
||||||
@@ -51,11 +51,11 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="legend" style="margin-top: 10px">
|
<div class="flex flex-wrap gap-4 items-center text-[0.9em] mt-2.5">
|
||||||
<span><span class="legend-color L1"></span> L1</span>
|
<span><span class="legend-swatch bg-yellow-400"></span> L1</span>
|
||||||
<span><span class="legend-color L2"></span> L2</span>
|
<span><span class="legend-swatch bg-fuchsia-500"></span> L2</span>
|
||||||
<span><span class="legend-color M1"></span> M1</span>
|
<span><span class="legend-swatch bg-red-600"></span> M1</span>
|
||||||
<span><span class="legend-color M2"></span> M2</span>
|
<span><span class="legend-swatch bg-green-600"></span> M2</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<app-babylon-canvas
|
<app-babylon-canvas
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
.sliders-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
|
|
||||||
.slider-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 1rem;
|
|
||||||
margin-right: 1rem;
|
|
||||||
|
|
||||||
p {
|
|
||||||
width: 100px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
ngx-slider {
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.full-width {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions-container {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.75rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
|
||||||
.sliders-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import {Component} from '@angular/core';
|
import {Component} from '@angular/core';
|
||||||
import {BabylonCanvas, RenderConfig, SceneEventData} from '../../../shared/components/render-canvas/babylon-canvas.component';
|
import {BabylonCanvas, RenderConfig, SceneEventData} from '../../../shared/components/render-canvas/babylon-canvas.component';
|
||||||
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
|
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
|
||||||
import {ComputeShader, ShaderLanguage, StorageBuffer} from '@babylonjs/core';
|
|
||||||
import {PENDULUM_FRAGMENT_SHADER_WGSL, PENDULUM_PHYSIC_COMPUTE_SHADER_WGSL, PENDULUM_RENDER_COMPUTE_SHADER_WGSL, PENDULUM_VERTEX_SHADER_WGSL} from './pendulum.shader';
|
|
||||||
import {FormsModule} from '@angular/forms';
|
import {FormsModule} from '@angular/forms';
|
||||||
import {NgxSliderModule, Options} from '@angular-slider/ngx-slider';
|
import {NgxSliderModule, Options} from '@angular-slider/ngx-slider';
|
||||||
import {DEFAULT_DAMPING, DEFAULT_G, DEFAULT_L1_LENGTH, DEFAULT_M1_MASS, DEFAULT_L2_LENGTH, DEFAULT_M2_MASS, DEFAULT_TRAIL_DECAY, MAX_DAMPING, MAX_G, MAX_LENGTH, MAX_MASS, MAX_TRAIL_DECAY, MIN_DAMPING, MIN_G, MIN_LENGTH, MIN_MASS, MIN_TRAIL_DECAY, IMPULSE_M2, IMPULSE_M1} from './pendulum.model';
|
import {DEFAULT_DAMPING, DEFAULT_G, DEFAULT_L1_LENGTH, DEFAULT_M1_MASS, DEFAULT_L2_LENGTH, DEFAULT_M2_MASS, DEFAULT_TRAIL_DECAY, MAX_DAMPING, MAX_G, MAX_LENGTH, MAX_MASS, MAX_TRAIL_DECAY, MIN_DAMPING, MIN_G, MIN_LENGTH, MIN_MASS, MIN_TRAIL_DECAY, IMPULSE_M2, IMPULSE_M1} from './pendulum.model';
|
||||||
@@ -11,6 +10,9 @@ import {MatButton} from '@angular/material/button';
|
|||||||
import {Information} from '../information/information';
|
import {Information} from '../information/information';
|
||||||
import {AlgorithmInformation} from '../information/information.models';
|
import {AlgorithmInformation} from '../information/information.models';
|
||||||
import {UrlConstants} from '../../../constants/UrlConstants';
|
import {UrlConstants} from '../../../constants/UrlConstants';
|
||||||
|
import {PendulumSimulationStrategy} from './strategies/pendulum-simulation.strategy';
|
||||||
|
import {PendulumGpuStrategy} from './strategies/pendulum-gpu.strategy';
|
||||||
|
import {PendulumCpuStrategy} from './strategies/pendulum-cpu.strategy';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-pendulum',
|
selector: 'app-pendulum',
|
||||||
@@ -31,7 +33,6 @@ import {UrlConstants} from '../../../constants/UrlConstants';
|
|||||||
})
|
})
|
||||||
class PendulumComponent {
|
class PendulumComponent {
|
||||||
|
|
||||||
// --- CONFIGURATION ---
|
|
||||||
algoInformation: AlgorithmInformation = {
|
algoInformation: AlgorithmInformation = {
|
||||||
title: 'PENDULUM.EXPLANATION.TITLE',
|
title: 'PENDULUM.EXPLANATION.TITLE',
|
||||||
entries: [
|
entries: [
|
||||||
@@ -46,15 +47,9 @@ class PendulumComponent {
|
|||||||
disclaimerListEntry: ['PENDULUM.EXPLANATION.DISCLAIMER_1', 'PENDULUM.EXPLANATION.DISCLAIMER_2', 'PENDULUM.EXPLANATION.DISCLAIMER_3', 'PENDULUM.EXPLANATION.DISCLAIMER_4']
|
disclaimerListEntry: ['PENDULUM.EXPLANATION.DISCLAIMER_1', 'PENDULUM.EXPLANATION.DISCLAIMER_2', 'PENDULUM.EXPLANATION.DISCLAIMER_3', 'PENDULUM.EXPLANATION.DISCLAIMER_4']
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
renderConfig: RenderConfig = {
|
renderConfig: RenderConfig = {
|
||||||
mode: '2D',
|
mode: '2D',
|
||||||
initialViewSize: 2,
|
initialViewSize: 2
|
||||||
shaderLanguage: ShaderLanguage.WGSL,
|
|
||||||
vertexShader: PENDULUM_VERTEX_SHADER_WGSL,
|
|
||||||
fragmentShader: PENDULUM_FRAGMENT_SHADER_WGSL,
|
|
||||||
uniformNames: [],
|
|
||||||
uniformBufferNames: []
|
|
||||||
};
|
};
|
||||||
|
|
||||||
trailDecayOptions: Options = {
|
trailDecayOptions: Options = {
|
||||||
@@ -107,7 +102,6 @@ class PendulumComponent {
|
|||||||
hidePointerLabels: false
|
hidePointerLabels: false
|
||||||
};
|
};
|
||||||
|
|
||||||
// Central management of physics parameters
|
|
||||||
readonly simParams = {
|
readonly simParams = {
|
||||||
time: 0,
|
time: 0,
|
||||||
dt: 0.015,
|
dt: 0.015,
|
||||||
@@ -123,6 +117,7 @@ class PendulumComponent {
|
|||||||
};
|
};
|
||||||
|
|
||||||
private currentSceneData: SceneEventData | null = null;
|
private currentSceneData: SceneEventData | null = null;
|
||||||
|
private strategy: PendulumSimulationStrategy | null = null;
|
||||||
|
|
||||||
onSceneReady(event: SceneEventData) {
|
onSceneReady(event: SceneEventData) {
|
||||||
this.currentSceneData = event;
|
this.currentSceneData = event;
|
||||||
@@ -130,83 +125,34 @@ class PendulumComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private createSimulation() {
|
private createSimulation() {
|
||||||
if (!this.currentSceneData){
|
if (!this.currentSceneData) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const {engine, scene} = this.currentSceneData;
|
|
||||||
engine.resize();
|
|
||||||
|
|
||||||
const width = engine.getRenderWidth();
|
const {engine, scene, gpuTier} = this.currentSceneData;
|
||||||
const height = engine.getRenderHeight();
|
|
||||||
const totalPixels = width * height;
|
|
||||||
|
|
||||||
// --- 1. BUFFERS ---
|
if (this.strategy) {
|
||||||
const pixelBuffer = new StorageBuffer(engine, totalPixels * 4);
|
this.strategy.dispose();
|
||||||
|
|
||||||
const stateBuffer = new StorageBuffer(engine, 4 * 4);
|
|
||||||
stateBuffer.update(new Float32Array([Math.PI / 4, Math.PI / 2, 0, 0])); // Initial angles
|
|
||||||
|
|
||||||
const paramsBuffer = new StorageBuffer(engine, 14 * 4);
|
|
||||||
const paramsData = new Float32Array(14);
|
|
||||||
|
|
||||||
// --- 2. SHADERS ---
|
|
||||||
const csPhysics = new ComputeShader("physics", engine,
|
|
||||||
{computeSource: PENDULUM_PHYSIC_COMPUTE_SHADER_WGSL},
|
|
||||||
{bindingsMapping: {"state": {group: 0, binding: 0}, "p": {group: 0, binding: 1}}}
|
|
||||||
);
|
|
||||||
csPhysics.setStorageBuffer("state", stateBuffer);
|
|
||||||
csPhysics.setStorageBuffer("p", paramsBuffer);
|
|
||||||
|
|
||||||
const csRender = new ComputeShader("render", engine,
|
|
||||||
{computeSource: PENDULUM_RENDER_COMPUTE_SHADER_WGSL},
|
|
||||||
{bindingsMapping: {"pixelBuffer": {group: 0, binding: 0}, "p": {group: 0, binding: 1}, "state": {group: 0, binding: 2}}}
|
|
||||||
);
|
|
||||||
csRender.setStorageBuffer("pixelBuffer", pixelBuffer);
|
|
||||||
csRender.setStorageBuffer("p", paramsBuffer);
|
|
||||||
csRender.setStorageBuffer("state", stateBuffer);
|
|
||||||
|
|
||||||
// --- 3. MATERIAL ---
|
|
||||||
const plane = scene.getMeshByName("plane");
|
|
||||||
if (plane?.material) {
|
|
||||||
const mat = plane.material as any;
|
|
||||||
mat.setStorageBuffer("pixelBuffer", pixelBuffer);
|
|
||||||
mat.setStorageBuffer("p", paramsBuffer);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//remove old observables if available
|
this.strategy = gpuTier === 'webgpu'
|
||||||
scene.onBeforeRenderObservable.clear();
|
? new PendulumGpuStrategy()
|
||||||
// --- 4. RENDER LOOP ---
|
: new PendulumCpuStrategy();
|
||||||
scene.onBeforeRenderObservable.add(() => {
|
|
||||||
|
this.strategy.init(scene, engine);
|
||||||
|
this.startParamUpdateLoop(scene);
|
||||||
|
}
|
||||||
|
|
||||||
|
private startParamUpdateLoop(scene: any): void {
|
||||||
|
scene.onAfterRenderObservable.clear();
|
||||||
|
scene.onAfterRenderObservable.add(() => {
|
||||||
this.simParams.time += this.simParams.dt;
|
this.simParams.time += this.simParams.dt;
|
||||||
|
|
||||||
const currentWidth = engine.getRenderWidth();
|
if (this.strategy) {
|
||||||
const currentHeight = engine.getRenderHeight();
|
this.strategy.updateParams({...this.simParams});
|
||||||
|
}
|
||||||
// Fill parameter array (must match the exact order of the WGSL struct!)
|
|
||||||
paramsData[0] = currentWidth;
|
|
||||||
paramsData[1] = currentHeight;
|
|
||||||
paramsData[2] = this.simParams.time;
|
|
||||||
paramsData[3] = this.simParams.dt;
|
|
||||||
paramsData[4] = this.simParams.g;
|
|
||||||
paramsData[5] = this.simParams.m1;
|
|
||||||
paramsData[6] = this.simParams.m2;
|
|
||||||
paramsData[7] = this.simParams.l1;
|
|
||||||
paramsData[8] = this.simParams.l2;
|
|
||||||
paramsData[9] = this.simParams.damping;
|
|
||||||
paramsData[10] = this.simParams.trailDecay;
|
|
||||||
paramsData[11] = this.simParams.impulseM1;
|
|
||||||
paramsData[12] = this.simParams.impulseM2;
|
|
||||||
paramsData[13] = 0; // Pad
|
|
||||||
|
|
||||||
this.resetImpulses();
|
this.resetImpulses();
|
||||||
|
|
||||||
paramsBuffer.update(paramsData);
|
|
||||||
|
|
||||||
// Trigger simulation and rendering
|
|
||||||
csPhysics.dispatch(1, 1, 1);
|
|
||||||
|
|
||||||
const dispatchCount = Math.ceil((currentWidth * currentHeight) / 64);
|
|
||||||
csRender.dispatch(dispatchCount, 1, 1);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,8 +167,7 @@ class PendulumComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pushPendulum(m1: boolean) {
|
pushPendulum(m1: boolean) {
|
||||||
if (m1)
|
if (m1) {
|
||||||
{
|
|
||||||
this.simParams.impulseM1 = IMPULSE_M1;
|
this.simParams.impulseM1 = IMPULSE_M1;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -235,6 +180,4 @@ class PendulumComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default PendulumComponent;
|
||||||
|
|
||||||
export default PendulumComponent
|
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* CPU-side double pendulum physics mirroring PENDULUM_PHYSIC_COMPUTE_SHADER_WGSL.
|
||||||
|
* Equations from: https://en.wikipedia.org/wiki/Double_pendulum
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface PendulumState {
|
||||||
|
theta1: number;
|
||||||
|
theta2: number;
|
||||||
|
v1: number;
|
||||||
|
v2: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PendulumPhysicsParams {
|
||||||
|
dt: number;
|
||||||
|
g: number;
|
||||||
|
m1: number;
|
||||||
|
m2: number;
|
||||||
|
l1: number;
|
||||||
|
l2: number;
|
||||||
|
damping: number;
|
||||||
|
impulseM1: number;
|
||||||
|
impulseM2: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stepPendulumPhysics(state: PendulumState, params: PendulumPhysicsParams): void {
|
||||||
|
const t1 = state.theta1;
|
||||||
|
const t2 = state.theta2;
|
||||||
|
const v1 = state.v1;
|
||||||
|
const v2 = state.v2;
|
||||||
|
|
||||||
|
const deltaT = t1 - t2;
|
||||||
|
|
||||||
|
const num1 = -params.g * (2.0 * params.m1 + params.m2) * Math.sin(t1)
|
||||||
|
- params.m2 * params.g * Math.sin(t1 - 2.0 * t2)
|
||||||
|
- 2.0 * Math.sin(deltaT) * params.m2 * (v2 * v2 * params.l2 + v1 * v1 * params.l1 * Math.cos(deltaT));
|
||||||
|
const den1 = params.l1 * (2.0 * params.m1 + params.m2 - params.m2 * Math.cos(2.0 * deltaT));
|
||||||
|
const a1 = num1 / den1;
|
||||||
|
|
||||||
|
const num2 = 2.0 * Math.sin(deltaT) * (v1 * v1 * params.l1 * (params.m1 + params.m2) + params.g * (params.m1 + params.m2) * Math.cos(t1) + v2 * v2 * params.l2 * params.m2 * Math.cos(deltaT));
|
||||||
|
const den2 = params.l2 * (2.0 * params.m1 + params.m2 - params.m2 * Math.cos(2.0 * deltaT));
|
||||||
|
const a2 = num2 / den2;
|
||||||
|
|
||||||
|
const newV1 = (v1 + a1 * params.dt) * params.damping + params.impulseM1;
|
||||||
|
const newV2 = (v2 + a2 * params.dt) * params.damping + params.impulseM2;
|
||||||
|
|
||||||
|
state.v1 = newV1;
|
||||||
|
state.v2 = newV2;
|
||||||
|
state.theta1 = t1 + newV1 * params.dt;
|
||||||
|
state.theta2 = t2 + newV2 * params.dt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import {Constants, Engine, MeshBuilder, RenderTargetTexture, Scene, ShaderMaterial, Texture, Vector2, WebGPUEngine} from '@babylonjs/core';
|
||||||
|
import {PENDULUM_DISPLAY_FRAGMENT_GLSL, PENDULUM_FEEDBACK_FRAGMENT_GLSL, PENDULUM_VERTEX_SHADER_GLSL} from '../pendulum-glsl.shader';
|
||||||
|
import {PendulumState, stepPendulumPhysics} from './pendulum-cpu-physics';
|
||||||
|
import {PendulumSimParams, PendulumSimulationStrategy} from './pendulum-simulation.strategy';
|
||||||
|
|
||||||
|
export class PendulumCpuStrategy implements PendulumSimulationStrategy {
|
||||||
|
private scene: Scene | null = null;
|
||||||
|
private state: PendulumState = {theta1: Math.PI / 4, theta2: Math.PI / 2, v1: 0, v2: 0};
|
||||||
|
private params: PendulumSimParams | null = null;
|
||||||
|
private feedbackMaterial: ShaderMaterial | null = null;
|
||||||
|
private displayMaterial: ShaderMaterial | null = null;
|
||||||
|
private rttA: RenderTargetTexture | null = null;
|
||||||
|
private rttB: RenderTargetTexture | null = null;
|
||||||
|
private pingPong = false;
|
||||||
|
|
||||||
|
init(scene: Scene, engine: WebGPUEngine | Engine): void {
|
||||||
|
this.scene = scene;
|
||||||
|
|
||||||
|
const existingPlane = scene.getMeshByName("plane");
|
||||||
|
if (existingPlane) {
|
||||||
|
scene.removeMesh(existingPlane);
|
||||||
|
}
|
||||||
|
|
||||||
|
const width = engine.getRenderWidth();
|
||||||
|
const height = engine.getRenderHeight();
|
||||||
|
|
||||||
|
this.rttA = new RenderTargetTexture("rttA", {width, height}, scene, false, true, Constants.TEXTURETYPE_FLOAT);
|
||||||
|
this.rttB = new RenderTargetTexture("rttB", {width, height}, scene, false, true, Constants.TEXTURETYPE_FLOAT);
|
||||||
|
|
||||||
|
this.rttA.wrapU = Texture.CLAMP_ADDRESSMODE;
|
||||||
|
this.rttA.wrapV = Texture.CLAMP_ADDRESSMODE;
|
||||||
|
this.rttB.wrapU = Texture.CLAMP_ADDRESSMODE;
|
||||||
|
this.rttB.wrapV = Texture.CLAMP_ADDRESSMODE;
|
||||||
|
|
||||||
|
// Size 2 maps positions to -1..1 which fills clip space (vertex shader bypasses camera)
|
||||||
|
const feedbackPlane = MeshBuilder.CreatePlane("feedbackPlane", {size: 2}, scene);
|
||||||
|
feedbackPlane.alwaysSelectAsActiveMesh = true;
|
||||||
|
// Use layer mask to exclude from main scene rendering (only rendered via RTT)
|
||||||
|
feedbackPlane.layerMask = 0x10000000;
|
||||||
|
|
||||||
|
this.feedbackMaterial = new ShaderMaterial("feedbackMat", scene, {
|
||||||
|
vertexSource: PENDULUM_VERTEX_SHADER_GLSL,
|
||||||
|
fragmentSource: PENDULUM_FEEDBACK_FRAGMENT_GLSL
|
||||||
|
}, {
|
||||||
|
attributes: ["position", "uv"],
|
||||||
|
uniforms: ["resolution", "theta1", "theta2", "l1", "l2", "trailDecay"],
|
||||||
|
samplers: ["previousFrame"]
|
||||||
|
});
|
||||||
|
this.feedbackMaterial.backFaceCulling = false;
|
||||||
|
feedbackPlane.material = this.feedbackMaterial;
|
||||||
|
|
||||||
|
this.rttA.renderList!.push(feedbackPlane);
|
||||||
|
this.rttB.renderList!.push(feedbackPlane);
|
||||||
|
|
||||||
|
// Size 2 maps positions to -1..1 which fills clip space (vertex shader bypasses camera)
|
||||||
|
const displayPlane = MeshBuilder.CreatePlane("displayPlane", {size: 2}, scene);
|
||||||
|
displayPlane.alwaysSelectAsActiveMesh = true;
|
||||||
|
|
||||||
|
this.displayMaterial = new ShaderMaterial("displayMat", scene, {
|
||||||
|
vertexSource: PENDULUM_VERTEX_SHADER_GLSL,
|
||||||
|
fragmentSource: PENDULUM_DISPLAY_FRAGMENT_GLSL
|
||||||
|
}, {
|
||||||
|
attributes: ["position", "uv"],
|
||||||
|
uniforms: ["resolution", "theta1", "theta2", "l1", "l2"],
|
||||||
|
samplers: ["trailTexture"]
|
||||||
|
});
|
||||||
|
this.displayMaterial.backFaceCulling = false;
|
||||||
|
this.displayMaterial.disableDepthWrite = true;
|
||||||
|
displayPlane.material = this.displayMaterial;
|
||||||
|
|
||||||
|
// RTTs are rendered manually via writeTarget.render() -- do NOT add to customRenderTargets
|
||||||
|
this.startRenderLoop(scene, width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateParams(params: PendulumSimParams): void {
|
||||||
|
this.params = params;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
if (this.scene) {
|
||||||
|
this.scene.onBeforeRenderObservable.clear();
|
||||||
|
}
|
||||||
|
this.rttA?.dispose();
|
||||||
|
this.rttB?.dispose();
|
||||||
|
this.feedbackMaterial?.dispose();
|
||||||
|
this.displayMaterial?.dispose();
|
||||||
|
this.scene = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private startRenderLoop(scene: Scene, width: number, height: number): void {
|
||||||
|
const resolution = new Vector2(width, height);
|
||||||
|
|
||||||
|
scene.onBeforeRenderObservable.clear();
|
||||||
|
scene.onBeforeRenderObservable.add(() => {
|
||||||
|
if (!this.params || !this.feedbackMaterial || !this.displayMaterial || !this.rttA || !this.rttB) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
stepPendulumPhysics(this.state, {
|
||||||
|
dt: this.params.dt,
|
||||||
|
g: this.params.g,
|
||||||
|
m1: this.params.m1,
|
||||||
|
m2: this.params.m2,
|
||||||
|
l1: this.params.l1,
|
||||||
|
l2: this.params.l2,
|
||||||
|
damping: this.params.damping,
|
||||||
|
impulseM1: this.params.impulseM1,
|
||||||
|
impulseM2: this.params.impulseM2
|
||||||
|
});
|
||||||
|
|
||||||
|
const readTarget = this.pingPong ? this.rttA : this.rttB;
|
||||||
|
const writeTarget = this.pingPong ? this.rttB : this.rttA;
|
||||||
|
|
||||||
|
this.feedbackMaterial.setTexture("previousFrame", readTarget);
|
||||||
|
this.feedbackMaterial.setVector2("resolution", resolution);
|
||||||
|
this.feedbackMaterial.setFloat("theta1", this.state.theta1);
|
||||||
|
this.feedbackMaterial.setFloat("theta2", this.state.theta2);
|
||||||
|
this.feedbackMaterial.setFloat("l1", this.params.l1);
|
||||||
|
this.feedbackMaterial.setFloat("l2", this.params.l2);
|
||||||
|
this.feedbackMaterial.setFloat("trailDecay", this.params.trailDecay);
|
||||||
|
|
||||||
|
writeTarget.render();
|
||||||
|
|
||||||
|
this.displayMaterial.setTexture("trailTexture", writeTarget);
|
||||||
|
this.displayMaterial.setVector2("resolution", resolution);
|
||||||
|
this.displayMaterial.setFloat("theta1", this.state.theta1);
|
||||||
|
this.displayMaterial.setFloat("theta2", this.state.theta2);
|
||||||
|
this.displayMaterial.setFloat("l1", this.params.l1);
|
||||||
|
this.displayMaterial.setFloat("l2", this.params.l2);
|
||||||
|
|
||||||
|
this.pingPong = !this.pingPong;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import {Camera, ComputeShader, Engine, MeshBuilder, Scene, ShaderLanguage, ShaderMaterial, StorageBuffer, WebGPUEngine} from '@babylonjs/core';
|
||||||
|
import {PENDULUM_FRAGMENT_SHADER_WGSL, PENDULUM_PHYSIC_COMPUTE_SHADER_WGSL, PENDULUM_RENDER_COMPUTE_SHADER_WGSL, PENDULUM_VERTEX_SHADER_WGSL} from '../pendulum.shader';
|
||||||
|
import {PendulumSimParams, PendulumSimulationStrategy} from './pendulum-simulation.strategy';
|
||||||
|
|
||||||
|
export class PendulumGpuStrategy implements PendulumSimulationStrategy {
|
||||||
|
private scene: Scene | null = null;
|
||||||
|
private paramsData = new Float32Array(14);
|
||||||
|
private paramsBuffer: StorageBuffer | null = null;
|
||||||
|
|
||||||
|
init(scene: Scene, engine: WebGPUEngine | Engine): void {
|
||||||
|
this.scene = scene;
|
||||||
|
const gpuEngine = engine as WebGPUEngine;
|
||||||
|
gpuEngine.resize();
|
||||||
|
|
||||||
|
const width = gpuEngine.getRenderWidth();
|
||||||
|
const height = gpuEngine.getRenderHeight();
|
||||||
|
const totalPixels = width * height;
|
||||||
|
|
||||||
|
const pixelBuffer = new StorageBuffer(gpuEngine, totalPixels * 4);
|
||||||
|
|
||||||
|
const stateBuffer = new StorageBuffer(gpuEngine, 4 * 4);
|
||||||
|
stateBuffer.update(new Float32Array([Math.PI / 4, Math.PI / 2, 0, 0]));
|
||||||
|
|
||||||
|
this.paramsBuffer = new StorageBuffer(gpuEngine, 14 * 4);
|
||||||
|
|
||||||
|
const csPhysics = new ComputeShader("physics", gpuEngine,
|
||||||
|
{computeSource: PENDULUM_PHYSIC_COMPUTE_SHADER_WGSL},
|
||||||
|
{bindingsMapping: {"state": {group: 0, binding: 0}, "p": {group: 0, binding: 1}}}
|
||||||
|
);
|
||||||
|
csPhysics.setStorageBuffer("state", stateBuffer);
|
||||||
|
csPhysics.setStorageBuffer("p", this.paramsBuffer);
|
||||||
|
|
||||||
|
const csRender = new ComputeShader("render", gpuEngine,
|
||||||
|
{computeSource: PENDULUM_RENDER_COMPUTE_SHADER_WGSL},
|
||||||
|
{bindingsMapping: {"pixelBuffer": {group: 0, binding: 0}, "p": {group: 0, binding: 1}, "state": {group: 0, binding: 2}}}
|
||||||
|
);
|
||||||
|
csRender.setStorageBuffer("pixelBuffer", pixelBuffer);
|
||||||
|
csRender.setStorageBuffer("p", this.paramsBuffer);
|
||||||
|
csRender.setStorageBuffer("state", stateBuffer);
|
||||||
|
|
||||||
|
// Create WGSL display plane and material
|
||||||
|
const displayMaterial = new ShaderMaterial("pendulumWgslMat", scene, {
|
||||||
|
vertexSource: PENDULUM_VERTEX_SHADER_WGSL,
|
||||||
|
fragmentSource: PENDULUM_FRAGMENT_SHADER_WGSL
|
||||||
|
}, {
|
||||||
|
attributes: ["position"],
|
||||||
|
uniforms: [],
|
||||||
|
storageBuffers: ["pixelBuffer", "p"],
|
||||||
|
shaderLanguage: ShaderLanguage.WGSL
|
||||||
|
});
|
||||||
|
displayMaterial.setStorageBuffer("pixelBuffer", pixelBuffer);
|
||||||
|
displayMaterial.setStorageBuffer("p", this.paramsBuffer);
|
||||||
|
displayMaterial.backFaceCulling = false;
|
||||||
|
displayMaterial.disableDepthWrite = true;
|
||||||
|
|
||||||
|
const plane = MeshBuilder.CreatePlane("pendulumPlane", {size: 100}, scene);
|
||||||
|
const camera = scene.activeCamera as Camera;
|
||||||
|
if (camera) {
|
||||||
|
plane.lookAt(camera.position);
|
||||||
|
}
|
||||||
|
plane.alwaysSelectAsActiveMesh = true;
|
||||||
|
plane.material = displayMaterial;
|
||||||
|
|
||||||
|
scene.onBeforeRenderObservable.clear();
|
||||||
|
scene.onBeforeRenderObservable.add(() => {
|
||||||
|
if (!this.paramsBuffer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentWidth = gpuEngine.getRenderWidth();
|
||||||
|
const currentHeight = gpuEngine.getRenderHeight();
|
||||||
|
this.paramsData[0] = currentWidth;
|
||||||
|
this.paramsData[1] = currentHeight;
|
||||||
|
|
||||||
|
this.paramsBuffer.update(this.paramsData);
|
||||||
|
|
||||||
|
csPhysics.dispatch(1, 1, 1);
|
||||||
|
|
||||||
|
const dispatchCount = Math.ceil((currentWidth * currentHeight) / 64);
|
||||||
|
csRender.dispatch(dispatchCount, 1, 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateParams(params: PendulumSimParams): void {
|
||||||
|
// paramsData[0] (width) and paramsData[1] (height) are set in the render loop
|
||||||
|
this.paramsData[2] = params.time;
|
||||||
|
this.paramsData[3] = params.dt;
|
||||||
|
this.paramsData[4] = params.g;
|
||||||
|
this.paramsData[5] = params.m1;
|
||||||
|
this.paramsData[6] = params.m2;
|
||||||
|
this.paramsData[7] = params.l1;
|
||||||
|
this.paramsData[8] = params.l2;
|
||||||
|
this.paramsData[9] = params.damping;
|
||||||
|
this.paramsData[10] = params.trailDecay;
|
||||||
|
this.paramsData[11] = params.impulseM1;
|
||||||
|
this.paramsData[12] = params.impulseM2;
|
||||||
|
this.paramsData[13] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
if (this.scene) {
|
||||||
|
this.scene.onBeforeRenderObservable.clear();
|
||||||
|
const plane = this.scene.getMeshByName("pendulumPlane");
|
||||||
|
if (plane) {
|
||||||
|
this.scene.removeMesh(plane);
|
||||||
|
plane.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.paramsBuffer = null;
|
||||||
|
this.scene = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import {Engine, Scene, WebGPUEngine} from '@babylonjs/core';
|
||||||
|
|
||||||
|
export interface PendulumSimParams {
|
||||||
|
time: number;
|
||||||
|
dt: number;
|
||||||
|
g: number;
|
||||||
|
m1: number;
|
||||||
|
m2: number;
|
||||||
|
l1: number;
|
||||||
|
l2: number;
|
||||||
|
damping: number;
|
||||||
|
trailDecay: number;
|
||||||
|
impulseM1: number;
|
||||||
|
impulseM2: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PendulumSimulationStrategy {
|
||||||
|
init(scene: Scene, engine: WebGPUEngine | Engine): void;
|
||||||
|
updateParams(params: PendulumSimParams): void;
|
||||||
|
dispose(): void;
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class SortingAudioService {
|
||||||
|
private audioContext: AudioContext | null = null;
|
||||||
|
|
||||||
|
private ensureContext(): AudioContext {
|
||||||
|
this.audioContext ??= new AudioContext();
|
||||||
|
if (this.audioContext.state === 'suspended') {
|
||||||
|
this.audioContext.resume();
|
||||||
|
}
|
||||||
|
return this.audioContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call this on the user gesture (button click) so the AudioContext can be created/resumed.
|
||||||
|
initOnUserGesture(): void {
|
||||||
|
this.ensureContext();
|
||||||
|
}
|
||||||
|
|
||||||
|
playTone(value: number, maxValue: number, animationSpeedMs: number): void {
|
||||||
|
const ctx = this.ensureContext();
|
||||||
|
const frequency = this.valueToFrequency(value, maxValue);
|
||||||
|
|
||||||
|
// Keep tone duration slightly shorter than the animation frame to avoid overlap
|
||||||
|
const duration = Math.min(0.1, (animationSpeedMs * 0.75) / 1000);
|
||||||
|
|
||||||
|
const oscillator = ctx.createOscillator();
|
||||||
|
const gainNode = ctx.createGain();
|
||||||
|
|
||||||
|
oscillator.connect(gainNode);
|
||||||
|
gainNode.connect(ctx.destination);
|
||||||
|
|
||||||
|
oscillator.type = 'sawtooth';
|
||||||
|
oscillator.frequency.setValueAtTime(frequency, ctx.currentTime);
|
||||||
|
|
||||||
|
gainNode.gain.setValueAtTime(0.15, ctx.currentTime);
|
||||||
|
gainNode.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration);
|
||||||
|
|
||||||
|
oscillator.start(ctx.currentTime);
|
||||||
|
oscillator.stop(ctx.currentTime + duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
playSortedSweep(sortedValues: number[], maxValue: number): void {
|
||||||
|
const ctx = this.ensureContext();
|
||||||
|
// Play a quick ascending sweep through all sorted bar values
|
||||||
|
const step = Math.ceil(sortedValues.length / 40);
|
||||||
|
sortedValues.forEach((value, i) => {
|
||||||
|
if (i % step !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const delayMs = (i / step) * 18;
|
||||||
|
setTimeout(() => {
|
||||||
|
const frequency = this.valueToFrequency(value, maxValue);
|
||||||
|
const oscillator = ctx.createOscillator();
|
||||||
|
const gainNode = ctx.createGain();
|
||||||
|
|
||||||
|
oscillator.connect(gainNode);
|
||||||
|
gainNode.connect(ctx.destination);
|
||||||
|
|
||||||
|
oscillator.type = 'sine';
|
||||||
|
oscillator.frequency.setValueAtTime(frequency, ctx.currentTime);
|
||||||
|
|
||||||
|
gainNode.gain.setValueAtTime(0.15, ctx.currentTime);
|
||||||
|
gainNode.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.06);
|
||||||
|
|
||||||
|
oscillator.start(ctx.currentTime);
|
||||||
|
oscillator.stop(ctx.currentTime + 0.06);
|
||||||
|
}, delayMs);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maps a bar value linearly to the frequency range 180–1100 Hz (roughly 3 octaves)
|
||||||
|
private valueToFrequency(value: number, maxValue: number): number {
|
||||||
|
const minFreq = 400;
|
||||||
|
const maxFreq = 800;
|
||||||
|
return minFreq + (value / maxValue) * (maxFreq - minFreq);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -56,7 +56,7 @@ export class SortingService {
|
|||||||
|
|
||||||
let start = -1;
|
let start = -1;
|
||||||
let end = array.length-1;
|
let end = array.length-1;
|
||||||
let changed = false;
|
let changed: boolean;
|
||||||
do {
|
do {
|
||||||
changed = false;
|
changed = false;
|
||||||
start += 1;
|
start += 1;
|
||||||
@@ -224,6 +224,103 @@ export class SortingService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- TIM SORT ---
|
||||||
|
timSort(array: SortData[]): SortSnapshot[] {
|
||||||
|
const snapshots: SortSnapshot[] = [];
|
||||||
|
const arr = array.map(item => ({ ...item }));
|
||||||
|
const n = arr.length;
|
||||||
|
const RUN = 32;
|
||||||
|
|
||||||
|
snapshots.push(this.createSnapshot(arr));
|
||||||
|
|
||||||
|
// Step 1: Sort small runs with insertion sort
|
||||||
|
for (let i = 0; i < n; i += RUN) {
|
||||||
|
const end = Math.min(i + RUN - 1, n - 1);
|
||||||
|
this.insertionSortRange(arr, i, end, snapshots);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Merge the sorted runs
|
||||||
|
for (let size = RUN; size < n; size *= 2) {
|
||||||
|
for (let left = 0; left < n; left += 2 * size) {
|
||||||
|
const mid = Math.min(left + size - 1, n - 1);
|
||||||
|
const right = Math.min(left + 2 * size - 1, n - 1);
|
||||||
|
if (mid < right) {
|
||||||
|
this.mergeRanges(arr, left, mid, right, snapshots);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
arr.forEach(item => item.state = 'sorted');
|
||||||
|
snapshots.push(this.createSnapshot(arr));
|
||||||
|
|
||||||
|
return snapshots;
|
||||||
|
}
|
||||||
|
|
||||||
|
private insertionSortRange(arr: SortData[], left: number, right: number, snapshots: SortSnapshot[]): void {
|
||||||
|
for (let i = left + 1; i <= right; i++) {
|
||||||
|
const tempValue = arr[i].value;
|
||||||
|
arr[i].state = 'comparing';
|
||||||
|
snapshots.push(this.createSnapshot(arr));
|
||||||
|
|
||||||
|
let j = i - 1;
|
||||||
|
while (j >= left && arr[j].value > tempValue) {
|
||||||
|
arr[j].state = 'comparing';
|
||||||
|
arr[j + 1].value = arr[j].value;
|
||||||
|
arr[j].state = 'unsorted';
|
||||||
|
snapshots.push(this.createSnapshot(arr));
|
||||||
|
j--;
|
||||||
|
}
|
||||||
|
|
||||||
|
arr[j + 1].value = tempValue;
|
||||||
|
arr[i].state = 'unsorted';
|
||||||
|
snapshots.push(this.createSnapshot(arr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mergeRanges(arr: SortData[], left: number, mid: number, right: number, snapshots: SortSnapshot[]): void {
|
||||||
|
const leftPart = arr.slice(left, mid + 1).map(item => ({ ...item }));
|
||||||
|
const rightPart = arr.slice(mid + 1, right + 1).map(item => ({ ...item }));
|
||||||
|
|
||||||
|
let i = 0;
|
||||||
|
let j = 0;
|
||||||
|
let k = left;
|
||||||
|
|
||||||
|
while (i < leftPart.length && j < rightPart.length) {
|
||||||
|
// Highlight the write target and the right-side source (arr[mid+1+j] is still
|
||||||
|
// untouched in the array since k never overtakes it during a merge)
|
||||||
|
const rightSourceIdx = mid + 1 + j;
|
||||||
|
arr[k].state = 'comparing';
|
||||||
|
arr[rightSourceIdx].state = 'comparing';
|
||||||
|
snapshots.push(this.createSnapshot(arr));
|
||||||
|
|
||||||
|
arr[rightSourceIdx].state = 'unsorted';
|
||||||
|
|
||||||
|
if (leftPart[i].value <= rightPart[j].value) {
|
||||||
|
arr[k].value = leftPart[i].value;
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
arr[k].value = rightPart[j].value;
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
|
||||||
|
arr[k].state = 'unsorted';
|
||||||
|
k++;
|
||||||
|
snapshots.push(this.createSnapshot(arr));
|
||||||
|
}
|
||||||
|
|
||||||
|
while (i < leftPart.length) {
|
||||||
|
arr[k].value = leftPart[i].value;
|
||||||
|
i++;
|
||||||
|
k++;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (j < rightPart.length) {
|
||||||
|
arr[k].value = rightPart[j].value;
|
||||||
|
j++;
|
||||||
|
k++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private swap(arr: SortData[], i: number, j: number) {
|
private swap(arr: SortData[], i: number, j: number) {
|
||||||
const temp = arr[i].value;
|
const temp = arr[i].value;
|
||||||
arr[i].value = arr[j].value;
|
arr[i].value = arr[j].value;
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<mat-card class="algo-container sorting-card">
|
<mat-card class="w-full max-w-[1920px] p-[clamp(10px,3vw,20px)]">
|
||||||
<mat-card-header>
|
<mat-card-header>
|
||||||
<mat-card-title>{{ 'SORTING.TITLE' | translate }}</mat-card-title>
|
<mat-card-title>{{ 'SORTING.TITLE' | translate }}</mat-card-title>
|
||||||
</mat-card-header>
|
</mat-card-header>
|
||||||
<mat-card-content>
|
<mat-card-content>
|
||||||
<app-information [algorithmInformation]="algoInformation"/>
|
<app-information [algorithmInformation]="algoInformation"/>
|
||||||
<div class="controls-panel">
|
<div class="flex gap-[clamp(5px,2vw,10px)] mb-[clamp(10px,3vw,20px)] items-center flex-wrap mt-2.5 text-[0.9em]">
|
||||||
<mat-form-field appearance="fill">
|
<mat-form-field class="w-[clamp(150px,20vw,200px)]" appearance="fill">
|
||||||
<mat-label>{{ 'SORTING.ALGORITHM' | translate }}</mat-label>
|
<mat-label>{{ 'SORTING.ALGORITHM' | translate }}</mat-label>
|
||||||
<mat-select [(ngModel)]="selectedAlgorithm">
|
<mat-select [(ngModel)]="selectedAlgorithm">
|
||||||
@for (algo of algoInformation.entries; track algo.name) {
|
@for (algo of algoInformation.entries; track algo.name) {
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
</mat-select>
|
</mat-select>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
<mat-form-field appearance="outline">
|
<mat-form-field class="w-[clamp(150px,20vw,200px)]" appearance="outline">
|
||||||
<mat-label>{{ 'SORTING.ARRAY_SIZE' | translate }}</mat-label>
|
<mat-label>{{ 'SORTING.ARRAY_SIZE' | translate }}</mat-label>
|
||||||
<input
|
<input
|
||||||
matInput
|
matInput
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
/>
|
/>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
<div class="controls-panel">
|
<div class="flex gap-[clamp(5px,2vw,10px)] mb-[clamp(10px,3vw,20px)] items-center flex-wrap">
|
||||||
<button mat-raised-button color="primary" (click)="startSorting()">
|
<button mat-raised-button color="primary" (click)="startSorting()">
|
||||||
<mat-icon>play_arrow</mat-icon> {{ 'SORTING.START' | translate }}
|
<mat-icon>play_arrow</mat-icon> {{ 'SORTING.START' | translate }}
|
||||||
</button>
|
</button>
|
||||||
@@ -37,19 +37,25 @@
|
|||||||
<button mat-raised-button (click)="generateNewArray()">
|
<button mat-raised-button (click)="generateNewArray()">
|
||||||
<mat-icon>add_box</mat-icon> {{ 'SORTING.GENERATE_NEW_ARRAY' | translate }}
|
<mat-icon>add_box</mat-icon> {{ 'SORTING.GENERATE_NEW_ARRAY' | translate }}
|
||||||
</button>
|
</button>
|
||||||
|
<button mat-raised-button (click)="toggleSound()">
|
||||||
|
<mat-icon>{{ isSoundEnabled ? 'volume_up' : 'volume_off' }}</mat-icon>
|
||||||
|
{{ isSoundEnabled ? ('SORTING.SOUND_OFF' | translate) : ('SORTING.SOUND_ON' | translate) }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="controls-panel">
|
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
|
||||||
<p>{{ 'SORTING.EXECUTION_TIME' | translate }}: {{ executionTime }} ms</p>
|
<p>{{ 'SORTING.EXECUTION_TIME' | translate }}: {{ executionTime }} ms</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="visualization-area">
|
<div class="flex items-end h-[clamp(200px,40vh,400px)] border-b border-app-fg mb-[clamp(10px,3vw,20px)] gap-px bg-card-bg">
|
||||||
@for (item of sortArray; track $index) {
|
@for (item of sortArray; track $index) {
|
||||||
<div
|
<div
|
||||||
class="bar"
|
class="grow w-2.5 min-w-px transition-all duration-50"
|
||||||
[style.height.px]="item.value * 3"
|
[style.height.px]="item.value * 3"
|
||||||
[class.unsorted]="item.state === 'unsorted'"
|
[ngClass]="{
|
||||||
[class.comparing]="item.state === 'comparing'"
|
'bg-[#424242]': item.state === 'unsorted',
|
||||||
[class.sorted]="item.state === 'sorted'"
|
'bg-[#ffeb3b]': item.state === 'comparing',
|
||||||
|
'bg-[#4caf50]': item.state === 'sorted'
|
||||||
|
}"
|
||||||
></div>
|
></div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ import {MatFormFieldModule} from "@angular/material/form-field";
|
|||||||
import {MatSelectModule} from "@angular/material/select";
|
import {MatSelectModule} from "@angular/material/select";
|
||||||
import {MatButtonModule} from "@angular/material/button";
|
import {MatButtonModule} from "@angular/material/button";
|
||||||
import {MatIconModule} from "@angular/material/icon";
|
import {MatIconModule} from "@angular/material/icon";
|
||||||
import {TranslateModule} from "@ngx-translate/core";
|
import {TranslatePipe} from "@ngx-translate/core";
|
||||||
import { SortingService } from './service/sorting.service';
|
import { SortingService } from './service/sorting.service';
|
||||||
|
import { SortingAudioService } from './service/sorting-audio.service';
|
||||||
import {SortData, SortSnapshot} from './sorting.models';
|
import {SortData, SortSnapshot} from './sorting.models';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import {MatInput} from '@angular/material/input';
|
import {MatInput} from '@angular/material/input';
|
||||||
@@ -15,14 +16,14 @@ import {AlgorithmInformation} from '../information/information.models';
|
|||||||
import {Information} from '../information/information';
|
import {Information} from '../information/information';
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-sorting',
|
selector: 'app-sorting',
|
||||||
standalone: true,
|
imports: [CommonModule, MatCardModule, MatFormFieldModule, MatSelectModule, MatButtonModule, MatIconModule, TranslatePipe, FormsModule, MatInput, Information],
|
||||||
imports: [CommonModule, MatCardModule, MatFormFieldModule, MatSelectModule, MatButtonModule, MatIconModule, TranslateModule, FormsModule, MatInput, Information],
|
|
||||||
templateUrl: './sorting.component.html',
|
templateUrl: './sorting.component.html',
|
||||||
styleUrls: ['./sorting.component.scss']
|
styleUrl: './sorting.component.scss'
|
||||||
})
|
})
|
||||||
export class SortingComponent implements OnInit {
|
export class SortingComponent implements OnInit {
|
||||||
|
|
||||||
private readonly sortingService: SortingService = inject(SortingService);
|
private readonly sortingService: SortingService = inject(SortingService);
|
||||||
|
private readonly audioService: SortingAudioService = inject(SortingAudioService);
|
||||||
private readonly cdr: ChangeDetectorRef = inject(ChangeDetectorRef);
|
private readonly cdr: ChangeDetectorRef = inject(ChangeDetectorRef);
|
||||||
|
|
||||||
readonly MAX_ARRAY_SIZE: number = 200;
|
readonly MAX_ARRAY_SIZE: number = 200;
|
||||||
@@ -50,6 +51,11 @@ export class SortingComponent implements OnInit {
|
|||||||
name: 'Heap Sort',
|
name: 'Heap Sort',
|
||||||
description: 'SORTING.EXPLANATION.HEAP_SORT_EXPLANATION',
|
description: 'SORTING.EXPLANATION.HEAP_SORT_EXPLANATION',
|
||||||
link: UrlConstants.HEAP_SORT_WIKI
|
link: UrlConstants.HEAP_SORT_WIKI
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Tim Sort',
|
||||||
|
description: 'SORTING.EXPLANATION.TIMSORT_EXPLANATION',
|
||||||
|
link: UrlConstants.TIM_SORT_WIKI
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
disclaimer: 'SORTING.EXPLANATION.DISCLAIMER',
|
disclaimer: 'SORTING.EXPLANATION.DISCLAIMER',
|
||||||
@@ -66,9 +72,10 @@ export class SortingComponent implements OnInit {
|
|||||||
unsortedArrayCopy: SortData[] = [];
|
unsortedArrayCopy: SortData[] = [];
|
||||||
arraySize = 50;
|
arraySize = 50;
|
||||||
maxArrayValue = 100;
|
maxArrayValue = 100;
|
||||||
animationSpeed = 50; // Milliseconds per step
|
animationSpeed = 100; // Milliseconds per step
|
||||||
selectedAlgorithm: string = this.algoInformation.entries[0].name;
|
selectedAlgorithm: string = this.algoInformation.entries[0].name;
|
||||||
executionTime = 0;
|
executionTime = 0;
|
||||||
|
isSoundEnabled = false;
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.generateNewArray();
|
this.generateNewArray();
|
||||||
@@ -113,8 +120,14 @@ export class SortingComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toggleSound(): void {
|
||||||
|
this.isSoundEnabled = !this.isSoundEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
async startSorting(): Promise<void> {
|
async startSorting(): Promise<void> {
|
||||||
this.resetSorting();
|
this.resetSorting();
|
||||||
|
// Init the AudioContext on this user gesture so the browser allows sound
|
||||||
|
this.audioService.initOnUserGesture();
|
||||||
const startTime = performance.now();
|
const startTime = performance.now();
|
||||||
let snapshots: SortSnapshot[] = [];
|
let snapshots: SortSnapshot[] = [];
|
||||||
|
|
||||||
@@ -131,6 +144,9 @@ export class SortingComponent implements OnInit {
|
|||||||
case 'Cocktail Sort':
|
case 'Cocktail Sort':
|
||||||
snapshots = this.sortingService.cocktailSort(this.sortArray);
|
snapshots = this.sortingService.cocktailSort(this.sortArray);
|
||||||
break;
|
break;
|
||||||
|
case 'Tim Sort':
|
||||||
|
snapshots = this.sortingService.timSort(this.sortArray);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const endTime = performance.now();
|
const endTime = performance.now();
|
||||||
@@ -149,11 +165,22 @@ export class SortingComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.isSoundEnabled) {
|
||||||
|
// Play a tone for each comparing bar (max 2 at once to avoid noise)
|
||||||
|
snapshot.array
|
||||||
|
.filter(item => item.state === 'comparing')
|
||||||
|
.slice(0, 2)
|
||||||
|
.forEach(item => this.audioService.playTone(item.value, this.maxArrayValue, this.animationSpeed));
|
||||||
|
}
|
||||||
|
|
||||||
this.cdr.detectChanges();
|
this.cdr.detectChanges();
|
||||||
|
|
||||||
if (index === snapshots.length - 1) {
|
if (index === snapshots.length - 1) {
|
||||||
this.sortArray.forEach(item => item.state = 'sorted');
|
this.sortArray.forEach(item => item.state = 'sorted');
|
||||||
this.cdr.detectChanges();
|
this.cdr.detectChanges();
|
||||||
|
if (this.isSoundEnabled) {
|
||||||
|
this.audioService.playSortedSweep(this.sortArray.map(item => item.value), this.maxArrayValue);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, index * this.animationSpeed);
|
}, index * this.animationSpeed);
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
<section class="imprint">
|
<section class="grid gap-fluid-md max-w-app mx-4 mt-auto">
|
||||||
<mat-card class="imprint-card">
|
<mat-card class="p-fluid-md">
|
||||||
<h2 class="imprint-title">{{ 'IMPRINT.TITLE' | translate }}</h2>
|
<h2 class="m-0 mb-4 text-[clamp(1rem,3vw,1.2rem)] font-semibold">{{ 'IMPRINT.TITLE' | translate }}</h2>
|
||||||
|
|
||||||
<div class="imprint-section">
|
<div class="grid gap-1 mb-4">
|
||||||
<p class="imprint-label">{{ 'IMPRINT.PARAGRAPH' | translate }}</p>
|
<p class="text-xs tracking-[0.04em] uppercase opacity-70 m-0">{{ 'IMPRINT.PARAGRAPH' | translate }}</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
Andreas Dahm<br />
|
Andreas Dahm<br />
|
||||||
Bgm.-Germeier-Str. 2<br />
|
Hauptstraße 37A<br />
|
||||||
85586 Poing<br />
|
85614 Kirchseeon<br />
|
||||||
{{ 'IMPRINT.COUNTRY' | translate }}
|
{{ 'IMPRINT.COUNTRY' | translate }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="imprint-section">
|
<div class="grid gap-1 mb-4">
|
||||||
<p class="imprint-label">{{ 'IMPRINT.CONTACT' | translate }}</p>
|
<p class="text-xs tracking-[0.04em] uppercase opacity-70 m-0">{{ 'IMPRINT.CONTACT' | translate }}</p>
|
||||||
<p>
|
<p>
|
||||||
E-Mail:
|
E-Mail:
|
||||||
<a href="mailto:andreas.dahm@gmail.com">
|
<a href="mailto:andreas.dahm@gmail.com">
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
@@ -1,38 +1,38 @@
|
|||||||
<h2 mat-dialog-title>{{ project.title | translate }}</h2>
|
<h2 mat-dialog-title>{{ project.title | translate }}</h2>
|
||||||
<mat-dialog-content #dialogContent>
|
<mat-dialog-content #dialogContent>
|
||||||
<div class="project-dialog-layout">
|
<div class="flex flex-col gap-6">
|
||||||
<div class="project-info">
|
<div>
|
||||||
<p class="introduction">{{ project.introduction | translate }}</p>
|
<p class="text-lg leading-relaxed opacity-90 mb-4">{{ project.introduction | translate }}</p>
|
||||||
|
|
||||||
<div class="features-list">
|
<div class="mb-6">
|
||||||
<ul>
|
<ul class="pl-5">
|
||||||
@for(bullet of project.bulletPoints; track bullet) {
|
@for(bullet of project.bulletPoints; track bullet) {
|
||||||
<li>{{ bullet | translate }}</li>
|
<li class="mb-2">{{ bullet | translate }}</li>
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="insight-grid">
|
<div class="grid grid-cols-[repeat(auto-fit,minmax(300px,1fr))] sm-dialog:grid-cols-1 gap-4 mb-4">
|
||||||
<div class="insight-card technical">
|
<div class="p-5 rounded-xl bg-black/3 border border-black/5 dark:bg-white/5 dark:border-white/10">
|
||||||
<div class="insight-header">
|
<div class="flex items-center gap-3 mb-3 text-link">
|
||||||
<mat-icon>settings_suggest</mat-icon>
|
<mat-icon class="text-[24px]! w-6! h-6!">settings_suggest</mat-icon>
|
||||||
<h3>{{ 'PROJECTS.SECTION.TECHNICAL' | translate }}</h3>
|
<h3 class="m-0 text-sm uppercase tracking-wider font-semibold">{{ 'PROJECTS.SECTION.TECHNICAL' | translate }}</h3>
|
||||||
</div>
|
</div>
|
||||||
<ul>
|
<ul class="m-0 pl-5 text-[0.95rem] leading-relaxed opacity-85">
|
||||||
@for(challenge of project.challenges; track challenge) {
|
@for(challenge of project.challenges; track challenge) {
|
||||||
<li>{{ challenge | translate }}</li>
|
<li class="mb-1">{{ challenge | translate }}</li>
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="insight-card softskills">
|
<div class="p-5 rounded-xl bg-black/3 border border-black/5 dark:bg-white/5 dark:border-white/10">
|
||||||
<div class="insight-header">
|
<div class="flex items-center gap-3 mb-3 text-link">
|
||||||
<mat-icon>psychology</mat-icon>
|
<mat-icon class="text-[24px]! w-6! h-6!">psychology</mat-icon>
|
||||||
<h3>{{ 'PROJECTS.SECTION.LEARNINGS' | translate }}</h3>
|
<h3 class="m-0 text-sm uppercase tracking-wider font-semibold">{{ 'PROJECTS.SECTION.LEARNINGS' | translate }}</h3>
|
||||||
</div>
|
</div>
|
||||||
<ul>
|
<ul class="m-0 pl-5 text-[0.95rem] leading-relaxed opacity-85">
|
||||||
@for(learning of project.learnings; track learning) {
|
@for(learning of project.learnings; track learning) {
|
||||||
<li>{{ learning | translate }}</li>
|
<li class="mb-1">{{ learning | translate }}</li>
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -41,14 +41,14 @@
|
|||||||
|
|
||||||
@if (project.images.length > 0)
|
@if (project.images.length > 0)
|
||||||
{
|
{
|
||||||
<div class="media-section">
|
<div class="my-4 rounded-xl overflow-hidden bg-black">
|
||||||
<swiper-container class="my-swiper" [attr.slides-per-view]="1" [attr.space-between]="12" [attr.navigation]="true"
|
<swiper-container class="my-swiper rounded-xl" [attr.slides-per-view]="1" [attr.space-between]="12" [attr.navigation]="true"
|
||||||
[attr.pagination]="true" [attr.keyboard]="true" style="width: 100%;">
|
[attr.pagination]="true" [attr.keyboard]="true" style="width: 100%;">
|
||||||
@for (img of project.images; track img) {
|
@for (img of project.images; track img) {
|
||||||
<swiper-slide>
|
<swiper-slide class="rounded-xl overflow-hidden flex flex-col bg-[#222]">
|
||||||
<img class="slide-img" [src]="img.url" [alt]="project.title | translate" />
|
<img class="w-full h-auto max-h-[clamp(300px,60vh,512px)]! object-contain block shrink-0" [src]="img.url" [alt]="project.title | translate" />
|
||||||
@if (img.source) {
|
@if (img.source) {
|
||||||
<div class="slide-source">
|
<div class="text-xs text-[#aaa] bg-[#2a2a2a] p-2 text-right border-t border-[#444]">
|
||||||
{{ img.source }}
|
{{ img.source }}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -58,8 +58,8 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="footer-details">
|
<div class="flex flex-col gap-4 pt-4 border-t border-black/10 dark:border-white/10">
|
||||||
<div class="tech-stack">
|
<div class="flex flex-wrap">
|
||||||
<mat-chip-set aria-label="Technologies">
|
<mat-chip-set aria-label="Technologies">
|
||||||
@for(tech of project.technologies; track tech) {
|
@for(tech of project.technologies; track tech) {
|
||||||
<mat-chip>{{tech}}</mat-chip>
|
<mat-chip>{{tech}}</mat-chip>
|
||||||
@@ -67,7 +67,7 @@
|
|||||||
</mat-chip-set>
|
</mat-chip-set>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="link-section">
|
<div class="flex flex-wrap gap-fluid-sm mt-6">
|
||||||
@for(link of project.links; track link)
|
@for(link of project.links; track link)
|
||||||
{
|
{
|
||||||
<a mat-button href="{{link.url}}" target="_blank" rel="noopener noreferrer">
|
<a mat-button href="{{link.url}}" target="_blank" rel="noopener noreferrer">
|
||||||
|
|||||||
@@ -1,117 +0,0 @@
|
|||||||
.project-dialog-layout {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.introduction {
|
|
||||||
font-size: 1.1rem;
|
|
||||||
line-height: 1.6;
|
|
||||||
opacity: 0.9;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.features-list {
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
ul {
|
|
||||||
padding-left: 1.2rem;
|
|
||||||
li {
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.insight-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
|
||||||
gap: 1rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.insight-card {
|
|
||||||
padding: 1.25rem;
|
|
||||||
border-radius: 12px;
|
|
||||||
background: rgba(0, 0, 0, 0.03);
|
|
||||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
|
||||||
|
|
||||||
.insight-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
color: var(--link-color);
|
|
||||||
|
|
||||||
mat-icon {
|
|
||||||
font-size: 24px;
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ul {
|
|
||||||
margin: 0;
|
|
||||||
padding-left: 1.2rem;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
opacity: 0.85;
|
|
||||||
|
|
||||||
li {
|
|
||||||
margin-bottom: 0.4rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .insight-card {
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
border-color: rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-section {
|
|
||||||
margin: 1rem 0;
|
|
||||||
border-radius: 12px;
|
|
||||||
overflow: hidden;
|
|
||||||
background: #000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-details {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
padding-top: 1rem;
|
|
||||||
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .footer-details {
|
|
||||||
border-top-color: rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tech-stack {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-section {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.5rem;
|
|
||||||
|
|
||||||
a {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.insight-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -15,8 +15,7 @@ import {MatButton} from '@angular/material/button';
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-project-dialog',
|
selector: 'app-project-dialog',
|
||||||
templateUrl: './project-dialog.component.html',
|
templateUrl: './project-dialog.component.html',
|
||||||
styleUrls: ['./project-dialog.component.scss'],
|
styleUrl: './project-dialog.component.scss',
|
||||||
standalone: true,
|
|
||||||
imports: [
|
imports: [
|
||||||
MatDialogTitle,
|
MatDialogTitle,
|
||||||
MatDialogContent,
|
MatDialogContent,
|
||||||
|
|||||||
@@ -1,52 +1,52 @@
|
|||||||
<div class="card-grid">
|
<div class="grid gap-fluid-md grid-cols-[repeat(auto-fill,minmax(min(100%,450px),1fr))] max-w-app mx-4 mt-auto">
|
||||||
@if (featuredProject(); as project) {
|
@if (featuredProject(); as project) {
|
||||||
<mat-card class="project-card featured">
|
<mat-card class="card-gradient-bar transition-transform duration-200 ease-in-out flex flex-col h-full col-span-full hover:translate-y-[-5px] hover:shadow-[0_4px_20px_rgba(0,0,0,0.15)]">
|
||||||
<mat-card-header>
|
<mat-card-header class="pb-4">
|
||||||
<mat-card-title>{{ project.title | translate }}</mat-card-title>
|
<mat-card-title>{{ project.title | translate }}</mat-card-title>
|
||||||
<mat-card-subtitle>{{ project.shortDescription | translate }}</mat-card-subtitle>
|
<mat-card-subtitle>{{ project.shortDescription | translate }}</mat-card-subtitle>
|
||||||
</mat-card-header>
|
</mat-card-header>
|
||||||
@if(project.images.length > 0) {
|
@if(project.images.length > 0) {
|
||||||
<img mat-card-image [src]="project.images[0].url" [alt]="project.title | translate">
|
<img mat-card-image class="w-full h-[clamp(150px,25vw,250px)] object-cover" [src]="project.images[0].url" [alt]="project.title | translate">
|
||||||
} @else {
|
} @else {
|
||||||
<div class="icon-container">
|
<div class="flex justify-center items-center h-[clamp(150px,20vw,200px)] bg-[#f0f0f0]">
|
||||||
<mat-icon class="fallback-icon">{{ project.icon }}</mat-icon>
|
<mat-icon class="text-[clamp(3rem,8vw,4rem)] w-[clamp(3rem,8vw,4rem)] h-[clamp(3rem,8vw,4rem)] text-[#666]">{{ project.icon }}</mat-icon>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<mat-card-content>
|
<mat-card-content class="grow pt-4 pb-4">
|
||||||
<p>{{ project.introduction | translate }}</p>
|
<p>{{ project.introduction | translate }}</p>
|
||||||
<mat-chip-set aria-label="Technologies">
|
<mat-chip-set class="pt-fluid-sm" aria-label="Technologies">
|
||||||
@for(tech of project.technologies; track tech) {
|
@for(tech of project.technologies; track tech) {
|
||||||
<mat-chip>{{tech}}</mat-chip>
|
<mat-chip>{{tech}}</mat-chip>
|
||||||
}
|
}
|
||||||
</mat-chip-set>
|
</mat-chip-set>
|
||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
<mat-card-actions>
|
<mat-card-actions class="mt-auto">
|
||||||
<button mat-button (click)="openProjectDialog(project)">{{ 'PROJECTS.READ_MORE' | translate }}</button>
|
<button mat-button (click)="openProjectDialog(project)">{{ 'PROJECTS.READ_MORE' | translate }}</button>
|
||||||
</mat-card-actions>
|
</mat-card-actions>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
}
|
}
|
||||||
|
|
||||||
@for (project of otherProjects(); track project) {
|
@for (project of otherProjects(); track project) {
|
||||||
<mat-card class="project-card">
|
<mat-card class="card-gradient-bar transition-transform duration-200 ease-in-out flex flex-col h-full hover:translate-y-[-5px] hover:shadow-[0_4px_20px_rgba(0,0,0,0.15)]">
|
||||||
<mat-card-header>
|
<mat-card-header class="pb-4">
|
||||||
<mat-card-title>{{ project.title | translate }}</mat-card-title>
|
<mat-card-title>{{ project.title | translate }}</mat-card-title>
|
||||||
</mat-card-header>
|
</mat-card-header>
|
||||||
@if(project.images.length > 0) {
|
@if(project.images.length > 0) {
|
||||||
<img mat-card-image [src]="project.images[0].url" [alt]="project.title | translate">
|
<img mat-card-image class="w-full h-[clamp(150px,25vw,250px)] object-cover" [src]="project.images[0].url" [alt]="project.title | translate">
|
||||||
} @else {
|
} @else {
|
||||||
<div class="icon-container">
|
<div class="flex justify-center items-center h-[clamp(150px,20vw,200px)] bg-[#f0f0f0]">
|
||||||
<mat-icon class="fallback-icon">{{ project.icon }}</mat-icon>
|
<mat-icon class="text-[clamp(3rem,8vw,4rem)] w-[clamp(3rem,8vw,4rem)] h-[clamp(3rem,8vw,4rem)] text-[#666]">{{ project.icon }}</mat-icon>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<mat-card-content>
|
<mat-card-content class="grow pt-4 pb-4">
|
||||||
<p>{{ project.shortDescription | translate }}</p>
|
<p>{{ project.shortDescription | translate }}</p>
|
||||||
<mat-chip-set aria-label="Technologies">
|
<mat-chip-set class="pt-fluid-sm" aria-label="Technologies">
|
||||||
@for(tech of project.technologies; track tech) {
|
@for(tech of project.technologies; track tech) {
|
||||||
<mat-chip>{{tech}}</mat-chip>
|
<mat-chip>{{tech}}</mat-chip>
|
||||||
}
|
}
|
||||||
</mat-chip-set>
|
</mat-chip-set>
|
||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
<mat-card-actions>
|
<mat-card-actions class="mt-auto">
|
||||||
<button mat-button (click)="openProjectDialog(project)">{{ 'PROJECTS.READ_MORE' | translate }}</button>
|
<button mat-button (click)="openProjectDialog(project)">{{ 'PROJECTS.READ_MORE' | translate }}</button>
|
||||||
</mat-card-actions>
|
</mat-card-actions>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ export interface Projects {
|
|||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-projects',
|
selector: 'app-projects',
|
||||||
standalone: true,
|
|
||||||
imports: [
|
imports: [
|
||||||
MatCardModule,
|
MatCardModule,
|
||||||
MatChipsModule,
|
MatChipsModule,
|
||||||
@@ -54,6 +53,33 @@ export class ProjectsComponent implements OnInit, OnDestroy {
|
|||||||
private queryParamSub: Subscription | undefined;
|
private queryParamSub: Subscription | undefined;
|
||||||
|
|
||||||
allProjects: Projects[] = [
|
allProjects: Projects[] = [
|
||||||
|
{
|
||||||
|
identifier: "claude-in-a-box",
|
||||||
|
title: 'PROJECTS.CLAUDE_BOX.TITLE',
|
||||||
|
shortDescription: 'PROJECTS.CLAUDE_BOX.SHORT_DESCRIPTION',
|
||||||
|
introduction: 'PROJECTS.CLAUDE_BOX.INTRODUCTION',
|
||||||
|
images: AssetsConstants.CLAUDE_BOX_IMAGES.map(url => ({ url, source: '' })),
|
||||||
|
icon: 'smart_toy',
|
||||||
|
assets: '',
|
||||||
|
links: [],
|
||||||
|
bulletPoints: [
|
||||||
|
'PROJECTS.CLAUDE_BOX.BULLET_1',
|
||||||
|
'PROJECTS.CLAUDE_BOX.BULLET_2',
|
||||||
|
'PROJECTS.CLAUDE_BOX.BULLET_3',
|
||||||
|
'PROJECTS.CLAUDE_BOX.BULLET_4',
|
||||||
|
],
|
||||||
|
challenges: [
|
||||||
|
'PROJECTS.CLAUDE_BOX.CHALLENGE_1',
|
||||||
|
'PROJECTS.CLAUDE_BOX.CHALLENGE_2',
|
||||||
|
'PROJECTS.CLAUDE_BOX.CHALLENGE_3',
|
||||||
|
],
|
||||||
|
learnings: [
|
||||||
|
'PROJECTS.CLAUDE_BOX.LEARNING_1',
|
||||||
|
'PROJECTS.CLAUDE_BOX.LEARNING_2',
|
||||||
|
],
|
||||||
|
isFeatured: false,
|
||||||
|
technologies: ['Claude Code', 'MCP', 'Docker', 'Bash', 'Node.js']
|
||||||
|
},
|
||||||
{
|
{
|
||||||
identifier: "playground",
|
identifier: "playground",
|
||||||
title: 'PROJECTS.PLAYGROUND.TITLE',
|
title: 'PROJECTS.PLAYGROUND.TITLE',
|
||||||
@@ -157,7 +183,7 @@ export class ProjectsComponent implements OnInit, OnDestroy {
|
|||||||
'PROJECTS.DIPLOMA.LEARNING_2',
|
'PROJECTS.DIPLOMA.LEARNING_2',
|
||||||
],
|
],
|
||||||
isFeatured: false,
|
isFeatured: false,
|
||||||
technologies: ['C++', 'OpenGL', 'Qt', '3D-Scanner']
|
technologies: ['Java', 'Performance', 'Algorithm', 'Simulation']
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
identifier: "tribble-the-homeserver",
|
identifier: "tribble-the-homeserver",
|
||||||
|
|||||||
122
src/app/pages/stopwatch/stopwatch.component.html
Normal file
122
src/app/pages/stopwatch/stopwatch.component.html
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
<mat-card class="w-full max-w-[1920px] p-5">
|
||||||
|
<mat-card-header>
|
||||||
|
<mat-card-title>{{ 'STOPWATCH.TITLE' | translate }}</mat-card-title>
|
||||||
|
</mat-card-header>
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="flex flex-col items-center gap-6 mt-4">
|
||||||
|
<div
|
||||||
|
class="font-mono tabular-nums tracking-wider text-[clamp(2.5rem,8vw,5rem)] font-semibold leading-none rounded-2xl px-6 py-3 ring-2 ring-transparent transition-all duration-300 ease-out"
|
||||||
|
[class]="flashing()
|
||||||
|
? 'scale-110 text-(--mat-sys-primary)! ring-(--mat-sys-primary)! shadow-[0_0_40px_var(--mat-sys-primary)]'
|
||||||
|
: ''"
|
||||||
|
>
|
||||||
|
{{ display() }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-3 justify-center">
|
||||||
|
<button mat-flat-button color="primary" (click)="start()" [disabled]="state() === 'running'">
|
||||||
|
<mat-icon>play_arrow</mat-icon>
|
||||||
|
{{ 'STOPWATCH.START' | translate }}
|
||||||
|
</button>
|
||||||
|
<button mat-flat-button color="accent" (click)="pause()" [disabled]="state() !== 'running'">
|
||||||
|
<mat-icon>pause</mat-icon>
|
||||||
|
{{ 'STOPWATCH.PAUSE' | translate }}
|
||||||
|
</button>
|
||||||
|
<button mat-stroked-button (click)="stop()" [disabled]="state() === 'idle' && elapsedMs() === 0">
|
||||||
|
<mat-icon>stop</mat-icon>
|
||||||
|
{{ 'STOPWATCH.STOP' | translate }}
|
||||||
|
</button>
|
||||||
|
<button mat-stroked-button (click)="reset()" [disabled]="elapsedMs() === 0 && state() === 'idle'">
|
||||||
|
<mat-icon>restart_alt</mat-icon>
|
||||||
|
{{ 'STOPWATCH.RESET' | translate }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3 items-center w-full max-w-[520px]">
|
||||||
|
<mat-checkbox [checked]="intervalEnabled()" (change)="intervalEnabled.set($event.checked)">
|
||||||
|
{{ 'STOPWATCH.INTERVAL_ENABLED' | translate }}
|
||||||
|
</mat-checkbox>
|
||||||
|
|
||||||
|
<p class="m-0 font-medium text-sm">{{ 'STOPWATCH.FIRST_INTERVAL' | translate }}</p>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-3 items-center justify-center">
|
||||||
|
<mat-form-field appearance="outline" class="w-[160px]">
|
||||||
|
<mat-label>{{ 'STOPWATCH.INTERVAL_MINUTES' | translate }}</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="1"
|
||||||
|
[disabled]="!intervalEnabled()"
|
||||||
|
[ngModel]="firstIntervalMinutes()"
|
||||||
|
(ngModelChange)="updateIntervalValue(firstIntervalMinutes, $event)"
|
||||||
|
/>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="w-[160px]">
|
||||||
|
<mat-label>{{ 'STOPWATCH.INTERVAL_SECONDS' | translate }}</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="59"
|
||||||
|
step="1"
|
||||||
|
[disabled]="!intervalEnabled()"
|
||||||
|
[ngModel]="firstIntervalSeconds()"
|
||||||
|
(ngModelChange)="updateIntervalValue(firstIntervalSeconds, $event)"
|
||||||
|
/>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<mat-checkbox
|
||||||
|
[checked]="secondIntervalEnabled()"
|
||||||
|
[disabled]="!intervalEnabled()"
|
||||||
|
(change)="secondIntervalEnabled.set($event.checked)"
|
||||||
|
>
|
||||||
|
{{ 'STOPWATCH.SECOND_INTERVAL_ENABLED' | translate }}
|
||||||
|
</mat-checkbox>
|
||||||
|
|
||||||
|
@if (secondIntervalEnabled()) {
|
||||||
|
<div class="flex flex-wrap gap-3 items-center justify-center">
|
||||||
|
<mat-form-field appearance="outline" class="w-[160px]">
|
||||||
|
<mat-label>{{ 'STOPWATCH.INTERVAL_MINUTES' | translate }}</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="1"
|
||||||
|
[disabled]="!intervalEnabled()"
|
||||||
|
[ngModel]="secondIntervalMinutes()"
|
||||||
|
(ngModelChange)="updateIntervalValue(secondIntervalMinutes, $event)"
|
||||||
|
/>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="w-[160px]">
|
||||||
|
<mat-label>{{ 'STOPWATCH.INTERVAL_SECONDS' | translate }}</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="59"
|
||||||
|
step="1"
|
||||||
|
[disabled]="!intervalEnabled()"
|
||||||
|
[ngModel]="secondIntervalSeconds()"
|
||||||
|
(ngModelChange)="updateIntervalValue(secondIntervalSeconds, $event)"
|
||||||
|
/>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<p class="m-0 opacity-70 text-sm text-center">
|
||||||
|
{{ 'STOPWATCH.INTERVAL_HINT' | translate }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
@if (intervalEnabled() && schedulePreview()) {
|
||||||
|
<p class="m-0 opacity-70 text-sm text-center">
|
||||||
|
{{ 'STOPWATCH.INTERVAL_SCHEDULE' | translate }} {{ schedulePreview() }} …
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
39
src/app/pages/stopwatch/stopwatch.component.spec.ts
Normal file
39
src/app/pages/stopwatch/stopwatch.component.spec.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import {collectBeepTimes, countBeeps, slotOfBeep} from './stopwatch.component';
|
||||||
|
|
||||||
|
const MINUTE = 60_000;
|
||||||
|
|
||||||
|
describe('stopwatch interval scheduling', () => {
|
||||||
|
const firstMs = 1 * MINUTE;
|
||||||
|
const secondMs = 2 * MINUTE;
|
||||||
|
|
||||||
|
it('counts no beeps without a configured first interval', () => {
|
||||||
|
expect(countBeeps(10 * MINUTE, 0, secondMs)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to a single repeating interval when the second one is off', () => {
|
||||||
|
expect(countBeeps(0.9 * MINUTE, firstMs, 0)).toBe(0);
|
||||||
|
expect(countBeeps(1 * MINUTE, firstMs, 0)).toBe(1);
|
||||||
|
expect(countBeeps(3 * MINUTE, firstMs, 0)).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('alternates both intervals so beeps fall on 1, 3, 4, 6, 7 minutes', () => {
|
||||||
|
const expectedBeepCountPerMinute = [0, 1, 1, 2, 3, 3, 4, 5, 5, 6];
|
||||||
|
|
||||||
|
expectedBeepCountPerMinute.forEach((expected, minute) => {
|
||||||
|
expect(countBeeps(minute * MINUTE, firstMs, secondMs)).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports the alternating beep times', () => {
|
||||||
|
const times = collectBeepTimes(firstMs, secondMs, 5);
|
||||||
|
|
||||||
|
expect(times).toEqual([1 * MINUTE, 3 * MINUTE, 4 * MINUTE, 6 * MINUTE, 7 * MINUTE]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('assigns odd beeps to the first interval and even ones to the second', () => {
|
||||||
|
expect(slotOfBeep(1, secondMs)).toBe('first');
|
||||||
|
expect(slotOfBeep(2, secondMs)).toBe('second');
|
||||||
|
expect(slotOfBeep(3, secondMs)).toBe('first');
|
||||||
|
expect(slotOfBeep(2, 0)).toBe('first');
|
||||||
|
});
|
||||||
|
});
|
||||||
323
src/app/pages/stopwatch/stopwatch.component.ts
Normal file
323
src/app/pages/stopwatch/stopwatch.component.ts
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
import {Component, computed, effect, inject, OnDestroy, signal, WritableSignal} from '@angular/core';
|
||||||
|
import {FormsModule} from '@angular/forms';
|
||||||
|
import {MatButtonModule} from '@angular/material/button';
|
||||||
|
import {MatCardModule} from '@angular/material/card';
|
||||||
|
import {MatFormFieldModule} from '@angular/material/form-field';
|
||||||
|
import {MatIconModule} from '@angular/material/icon';
|
||||||
|
import {MatInputModule} from '@angular/material/input';
|
||||||
|
import {MatCheckboxModule} from '@angular/material/checkbox';
|
||||||
|
import {Title} from '@angular/platform-browser';
|
||||||
|
import {TranslatePipe} from '@ngx-translate/core';
|
||||||
|
|
||||||
|
type StopwatchState = 'idle' | 'running' | 'paused';
|
||||||
|
|
||||||
|
/** Which of the two configured intervals a beep belongs to. */
|
||||||
|
type IntervalSlot = 'first' | 'second';
|
||||||
|
|
||||||
|
const FIRST_INTERVAL_FREQUENCY_HZ = 880;
|
||||||
|
const SECOND_INTERVAL_FREQUENCY_HZ = 587;
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-stopwatch',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
FormsModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatCardModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatCheckboxModule,
|
||||||
|
TranslatePipe
|
||||||
|
],
|
||||||
|
templateUrl: './stopwatch.component.html'
|
||||||
|
})
|
||||||
|
export class StopwatchComponent implements OnDestroy {
|
||||||
|
readonly state = signal<StopwatchState>('idle');
|
||||||
|
readonly elapsedMs = signal(0);
|
||||||
|
|
||||||
|
readonly intervalEnabled = signal(false);
|
||||||
|
readonly firstIntervalMinutes = signal(1);
|
||||||
|
readonly firstIntervalSeconds = signal(0);
|
||||||
|
|
||||||
|
readonly secondIntervalEnabled = signal(false);
|
||||||
|
readonly secondIntervalMinutes = signal(2);
|
||||||
|
readonly secondIntervalSeconds = signal(0);
|
||||||
|
|
||||||
|
readonly flashing = signal(false);
|
||||||
|
|
||||||
|
readonly display = computed(() => formatElapsed(this.elapsedMs()));
|
||||||
|
|
||||||
|
readonly firstIntervalMs = computed(
|
||||||
|
() => toMilliseconds(this.firstIntervalMinutes(), this.firstIntervalSeconds())
|
||||||
|
);
|
||||||
|
|
||||||
|
readonly secondIntervalMs = computed(() => {
|
||||||
|
if (!this.secondIntervalEnabled()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return toMilliseconds(this.secondIntervalMinutes(), this.secondIntervalSeconds());
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Absolute times of the upcoming beeps, used as a preview of the alternating schedule. */
|
||||||
|
readonly schedulePreview = computed(() => {
|
||||||
|
const beepTimes = collectBeepTimes(this.firstIntervalMs(), this.secondIntervalMs(), 4);
|
||||||
|
return beepTimes.map(formatElapsedShort).join(' · ');
|
||||||
|
});
|
||||||
|
|
||||||
|
private intervalId: number | null = null;
|
||||||
|
private startTimestamp = 0;
|
||||||
|
private accumulatedMs = 0;
|
||||||
|
private previousElapsedMs = 0;
|
||||||
|
private audioContext: AudioContext | null = null;
|
||||||
|
private flashTimeoutId: number | null = null;
|
||||||
|
private readonly titleService = inject(Title);
|
||||||
|
private readonly originalTitle = this.titleService.getTitle();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
effect(() => {
|
||||||
|
const state = this.state();
|
||||||
|
const elapsed = this.elapsedMs();
|
||||||
|
this.updateDocumentTitle(state, elapsed);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
if (this.state() === 'running') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.ensureAudioContext();
|
||||||
|
this.resumeAudioContext();
|
||||||
|
this.startTimestamp = performance.now();
|
||||||
|
this.previousElapsedMs = this.accumulatedMs;
|
||||||
|
this.state.set('running');
|
||||||
|
this.startTicking();
|
||||||
|
}
|
||||||
|
|
||||||
|
pause(): void {
|
||||||
|
if (this.state() !== 'running') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.accumulatedMs = this.elapsedMs();
|
||||||
|
this.cancelTick();
|
||||||
|
this.state.set('paused');
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
this.cancelTick();
|
||||||
|
this.accumulatedMs = 0;
|
||||||
|
this.previousElapsedMs = 0;
|
||||||
|
this.elapsedMs.set(0);
|
||||||
|
this.state.set('idle');
|
||||||
|
}
|
||||||
|
|
||||||
|
reset(): void {
|
||||||
|
this.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Number inputs can emit empty strings or negative values, so sanitize before storing. */
|
||||||
|
updateIntervalValue(target: WritableSignal<number>, value: unknown): void {
|
||||||
|
target.set(toNonNegativeInteger(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.cancelTick();
|
||||||
|
if (this.flashTimeoutId !== null) {
|
||||||
|
clearTimeout(this.flashTimeoutId);
|
||||||
|
this.flashTimeoutId = null;
|
||||||
|
}
|
||||||
|
if (this.audioContext) {
|
||||||
|
this.audioContext.close().catch(() => undefined);
|
||||||
|
this.audioContext = null;
|
||||||
|
}
|
||||||
|
this.titleService.setTitle(this.originalTitle);
|
||||||
|
}
|
||||||
|
|
||||||
|
private startTicking(): void {
|
||||||
|
this.cancelTick();
|
||||||
|
this.tick();
|
||||||
|
// setInterval keeps firing in background tabs (throttled to ~1s) so beeps still play when unfocused.
|
||||||
|
this.intervalId = window.setInterval(this.tick, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
private tick = (): void => {
|
||||||
|
if (this.state() !== 'running') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const now = performance.now();
|
||||||
|
const newElapsed = this.accumulatedMs + (now - this.startTimestamp);
|
||||||
|
|
||||||
|
this.checkIntervalCrossing(this.previousElapsedMs, newElapsed);
|
||||||
|
this.previousElapsedMs = newElapsed;
|
||||||
|
this.elapsedMs.set(newElapsed);
|
||||||
|
};
|
||||||
|
|
||||||
|
private cancelTick(): void {
|
||||||
|
if (this.intervalId !== null) {
|
||||||
|
clearInterval(this.intervalId);
|
||||||
|
this.intervalId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateDocumentTitle(state: StopwatchState, elapsed: number): void {
|
||||||
|
if (state === 'idle') {
|
||||||
|
this.titleService.setTitle(this.originalTitle);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const prefix = state === 'running' ? '▶' : '⏸';
|
||||||
|
this.titleService.setTitle(`${prefix} ${formatElapsedShort(elapsed)} · ${this.originalTitle}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private checkIntervalCrossing(prevMs: number, currMs: number): void {
|
||||||
|
if (!this.intervalEnabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const firstMs = this.firstIntervalMs();
|
||||||
|
const secondMs = this.secondIntervalMs();
|
||||||
|
|
||||||
|
const previousBeeps = countBeeps(prevMs, firstMs, secondMs);
|
||||||
|
const currentBeeps = countBeeps(currMs, firstMs, secondMs);
|
||||||
|
if (currentBeeps <= previousBeeps) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const slot = slotOfBeep(currentBeeps, secondMs);
|
||||||
|
this.beep(slot === 'first' ? FIRST_INTERVAL_FREQUENCY_HZ : SECOND_INTERVAL_FREQUENCY_HZ);
|
||||||
|
this.triggerFlash();
|
||||||
|
}
|
||||||
|
|
||||||
|
private triggerFlash(): void {
|
||||||
|
if (this.flashTimeoutId !== null) {
|
||||||
|
clearTimeout(this.flashTimeoutId);
|
||||||
|
}
|
||||||
|
this.flashing.set(true);
|
||||||
|
this.flashTimeoutId = window.setTimeout(() => {
|
||||||
|
this.flashing.set(false);
|
||||||
|
this.flashTimeoutId = null;
|
||||||
|
}, 450);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureAudioContext(): void {
|
||||||
|
if (this.audioContext) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const Ctor = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
||||||
|
if (Ctor) {
|
||||||
|
this.audioContext = new Ctor();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private resumeAudioContext(): void {
|
||||||
|
// Browsers suspend AudioContext until a user gesture; resume on start so beeps can play in background tabs.
|
||||||
|
if (this.audioContext && this.audioContext.state === 'suspended') {
|
||||||
|
this.audioContext.resume().catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private beep(frequencyHz: number): void {
|
||||||
|
if (!this.audioContext) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ctx = this.audioContext;
|
||||||
|
const oscillator = ctx.createOscillator();
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
|
||||||
|
oscillator.type = 'sine';
|
||||||
|
oscillator.frequency.value = frequencyHz;
|
||||||
|
|
||||||
|
const now = ctx.currentTime;
|
||||||
|
const duration = 0.18;
|
||||||
|
gain.gain.setValueAtTime(0, now);
|
||||||
|
gain.gain.linearRampToValueAtTime(0.25, now + 0.01);
|
||||||
|
gain.gain.linearRampToValueAtTime(0, now + duration);
|
||||||
|
|
||||||
|
oscillator.connect(gain);
|
||||||
|
gain.connect(ctx.destination);
|
||||||
|
oscillator.start(now);
|
||||||
|
oscillator.stop(now + duration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toMilliseconds(minutes: number, seconds: number): number {
|
||||||
|
return minutes * 60_000 + seconds * 1_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNonNegativeInteger(value: unknown): number {
|
||||||
|
const parsed = Math.floor(Number(value));
|
||||||
|
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two intervals alternate: the first beep lands after `firstMs`, the next one
|
||||||
|
* `secondMs` later, then `firstMs` again. With first = 1min and second = 2min the
|
||||||
|
* beeps therefore fall on 1, 3, 4, 6, 7 ... minutes.
|
||||||
|
*/
|
||||||
|
export function countBeeps(elapsedMs: number, firstMs: number, secondMs: number): number {
|
||||||
|
if (firstMs <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (secondMs <= 0) {
|
||||||
|
return Math.floor(elapsedMs / firstMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cycleMs = firstMs + secondMs;
|
||||||
|
const completedCycles = Math.floor(elapsedMs / cycleMs);
|
||||||
|
const remainderMs = elapsedMs % cycleMs;
|
||||||
|
const firstBeepOfCycleReached = remainderMs >= firstMs ? 1 : 0;
|
||||||
|
|
||||||
|
return completedCycles * 2 + firstBeepOfCycleReached;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Beeps alternate, so odd beep numbers end the first interval and even ones the second. */
|
||||||
|
export function slotOfBeep(beepNumber: number, secondMs: number): IntervalSlot {
|
||||||
|
if (secondMs <= 0) {
|
||||||
|
return 'first';
|
||||||
|
}
|
||||||
|
return beepNumber % 2 === 1 ? 'first' : 'second';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectBeepTimes(firstMs: number, secondMs: number, count: number): number[] {
|
||||||
|
if (firstMs <= 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const times: number[] = [];
|
||||||
|
let current = 0;
|
||||||
|
for (let beepNumber = 1; beepNumber <= count; beepNumber++) {
|
||||||
|
const isFirstSlot = slotOfBeep(beepNumber, secondMs) === 'first';
|
||||||
|
current += isFirstSlot ? firstMs : secondMs;
|
||||||
|
times.push(current);
|
||||||
|
}
|
||||||
|
return times;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatElapsed(totalMs: number): string {
|
||||||
|
const ms = Math.floor(totalMs % 1000);
|
||||||
|
const totalSeconds = Math.floor(totalMs / 1000);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
const totalMinutes = Math.floor(totalSeconds / 60);
|
||||||
|
const minutes = totalMinutes % 60;
|
||||||
|
const hours = Math.floor(totalMinutes / 60);
|
||||||
|
|
||||||
|
return `${pad(hours, 2)}:${pad(minutes, 2)}:${pad(seconds, 2)}.${pad(ms, 3)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatElapsedShort(totalMs: number): string {
|
||||||
|
const totalSeconds = Math.floor(totalMs / 1000);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
const totalMinutes = Math.floor(totalSeconds / 60);
|
||||||
|
const minutes = totalMinutes % 60;
|
||||||
|
const hours = Math.floor(totalMinutes / 60);
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${pad(hours, 2)}:${pad(minutes, 2)}:${pad(seconds, 2)}`;
|
||||||
|
}
|
||||||
|
return `${pad(minutes, 2)}:${pad(seconds, 2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad(value: number, length: number): string {
|
||||||
|
return value.toString().padStart(length, '0');
|
||||||
|
}
|
||||||
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
59
src/app/service/gpu-capability.service.ts
Normal file
59
src/app/service/gpu-capability.service.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import {Injectable, signal} from '@angular/core';
|
||||||
|
|
||||||
|
export type GpuTier = 'webgpu' | 'webgl' | 'none';
|
||||||
|
|
||||||
|
@Injectable({providedIn: 'root'})
|
||||||
|
export class GpuCapabilityService {
|
||||||
|
private cachedTier: GpuTier | null = null;
|
||||||
|
readonly tier = signal<GpuTier | null>(null);
|
||||||
|
|
||||||
|
async detect(): Promise<GpuTier> {
|
||||||
|
if (this.cachedTier) {
|
||||||
|
return this.cachedTier;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.probe();
|
||||||
|
this.cachedTier = result;
|
||||||
|
this.tier.set(result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async probe(): Promise<GpuTier> {
|
||||||
|
if (await this.isWebGpuAvailable()) {
|
||||||
|
return 'webgpu';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isWebGlAvailable()) {
|
||||||
|
return 'webgl';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
private async isWebGpuAvailable(): Promise<boolean> {
|
||||||
|
if (typeof navigator === 'undefined' || !navigator.gpu) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const adapter = await navigator.gpu.requestAdapter();
|
||||||
|
return adapter !== null;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private isWebGlAvailable(): boolean {
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const context = canvas.getContext('webgl2');
|
||||||
|
return context !== null;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
57
src/app/service/language.service.spec.ts
Normal file
57
src/app/service/language.service.spec.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
const documentLanguage = document.documentElement.lang;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
document.documentElement.lang = documentLanguage;
|
||||||
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks the document with the active language so screen readers switch voice', () => {
|
||||||
|
localStorage.setItem(LocalStoreConstants.LANGUAGE_KEY, 'en');
|
||||||
|
const service = createService();
|
||||||
|
|
||||||
|
expect(document.documentElement.lang).toBe('en');
|
||||||
|
|
||||||
|
service.use('de');
|
||||||
|
|
||||||
|
expect(document.documentElement.lang).toBe('de');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Injectable, inject, signal } from '@angular/core';
|
import { Injectable, inject, signal } from '@angular/core';
|
||||||
|
import { DOCUMENT } from '@angular/common';
|
||||||
import { TranslateService } from '@ngx-translate/core';
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
import {LocalStoreConstants} from '../constants/LocalStoreConstants';
|
import {LocalStoreConstants} from '../constants/LocalStoreConstants';
|
||||||
|
|
||||||
@@ -7,27 +8,39 @@ type Lang = 'de' | 'en';
|
|||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class LanguageService {
|
export class LanguageService {
|
||||||
private readonly translate = inject(TranslateService);
|
private readonly translate = inject(TranslateService);
|
||||||
|
private readonly document = inject(DOCUMENT);
|
||||||
readonly lang = signal<Lang>(this.getInitial());
|
readonly lang = signal<Lang>(this.getInitial());
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.translate.addLangs(['de', 'en']);
|
// translate service lang and fallback are already configured via provideTranslateService in app.config
|
||||||
this.translate.setFallbackLang('en');
|
// just ensure the stored preference is active on startup
|
||||||
this.lang.set(this.getInitial());
|
this.translate.use(this.lang());
|
||||||
this.use(this.lang());
|
this.markDocumentLanguage(this.lang());
|
||||||
}
|
}
|
||||||
|
|
||||||
use(l: Lang) {
|
use(l: Lang) {
|
||||||
this.lang.set(l);
|
this.lang.set(l);
|
||||||
this.translate.use(l);
|
this.translate.use(l);
|
||||||
|
this.markDocumentLanguage(l);
|
||||||
try { localStorage.setItem(LocalStoreConstants.LANGUAGE_KEY, l); } catch (e) { void e; }
|
try { localStorage.setItem(LocalStoreConstants.LANGUAGE_KEY, l); } catch (e) { void e; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* index.html ships a static lang="en". Without this the document keeps
|
||||||
|
* claiming English after a switch, so a screen reader reads the German
|
||||||
|
* content with an English voice.
|
||||||
|
*/
|
||||||
|
private markDocumentLanguage(l: Lang): void {
|
||||||
|
this.document.documentElement.lang = l;
|
||||||
|
}
|
||||||
|
|
||||||
private getInitial(): Lang {
|
private getInitial(): Lang {
|
||||||
try {
|
try {
|
||||||
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';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
import { Injectable, NgZone, signal } from '@angular/core';
|
|
||||||
import {LocalStoreConstants} from '../constants/LocalStoreConstants';
|
|
||||||
|
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
|
||||||
export class ReloadService {
|
|
||||||
private readonly _reloadTick = signal(0);
|
|
||||||
readonly reloadTick = this._reloadTick.asReadonly();
|
|
||||||
|
|
||||||
private readonly _languageChangedTick = signal(0);
|
|
||||||
readonly languageChangedTick = this._languageChangedTick.asReadonly();
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
private informListeners(e: StorageEvent, zone: NgZone) {
|
|
||||||
if (e.key === LocalStoreConstants.LANGUAGE_KEY) {
|
|
||||||
zone.run(() => this._languageChangedTick.update(v => v + 1));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bumpLanguageChanged(): void {
|
|
||||||
this._reloadTick.update(v => v + 1);
|
|
||||||
localStorage.setItem(LocalStoreConstants.RELOAD_ALL_LANG_LISTENER_KEY, String(Date.now()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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 +1,5 @@
|
|||||||
<canvas #gridCanvas></canvas>
|
<div class="flex justify-center items-center w-full max-w-[1000px] mx-auto">
|
||||||
|
<canvas #gridCanvas
|
||||||
|
class="block w-full h-auto aspect-square min-w-[200px] max-w-[1000px] touch-none rounded-[clamp(10px,2vw,20px)] outline-hidden">
|
||||||
|
</canvas>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ export interface GridPos { row: number; col: number }
|
|||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-generic-grid',
|
selector: 'app-generic-grid',
|
||||||
standalone: true,
|
|
||||||
imports: [CommonModule],
|
imports: [CommonModule],
|
||||||
templateUrl: './generic-grid.html',
|
templateUrl: './generic-grid.html',
|
||||||
styleUrl: './generic-grid.scss',
|
styleUrl: './generic-grid.scss',
|
||||||
@@ -36,7 +35,7 @@ export class GenericGridComponent implements AfterViewInit {
|
|||||||
grid: any[][] = [];
|
grid: any[][] = [];
|
||||||
|
|
||||||
isDrawing = false;
|
isDrawing = false;
|
||||||
private lastCell: GridPos | null = null;
|
protected lastCell: GridPos | null = null;
|
||||||
|
|
||||||
ngAfterViewInit(): void {
|
ngAfterViewInit(): void {
|
||||||
this.ctx = this.getContextOrThrow();
|
this.ctx = this.getContextOrThrow();
|
||||||
|
|||||||
@@ -7,9 +7,3 @@
|
|||||||
z-index: -1;
|
z-index: -1;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
canvas {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
border-width: 0;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
<div class="canvas-container">
|
<div class="flex justify-center items-center w-full max-w-[1000px] mx-auto">
|
||||||
<canvas #renderCanvas></canvas>
|
<canvas #renderCanvas
|
||||||
|
class="block w-full h-auto aspect-square min-w-[200px] max-w-[1000px] touch-none rounded-[clamp(10px,2vw,20px)] outline-hidden">
|
||||||
|
</canvas>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import {AfterViewInit, Component, ElementRef, EventEmitter, inject, Input, NgZone, OnDestroy, Output, ViewChild} from '@angular/core';
|
import {AfterViewInit, Component, ElementRef, EventEmitter, inject, Input, NgZone, OnDestroy, Output, ViewChild} from '@angular/core';
|
||||||
import {ArcRotateCamera, Camera, MeshBuilder, Scene, ShaderLanguage, ShaderMaterial, Vector2, Vector3, WebGPUEngine} from '@babylonjs/core';
|
import {MatSnackBar} from '@angular/material/snack-bar';
|
||||||
|
import {TranslateService} from '@ngx-translate/core';
|
||||||
|
import {ArcRotateCamera, Camera, Engine, MeshBuilder, Scene, ShaderLanguage, ShaderMaterial, Vector2, Vector3, WebGPUEngine} from '@babylonjs/core';
|
||||||
|
import {GpuCapabilityService, GpuTier} from '../../../service/gpu-capability.service';
|
||||||
|
|
||||||
export interface RenderConfig {
|
export interface RenderConfig {
|
||||||
mode: '2D' | '3D';
|
mode: '2D' | '3D';
|
||||||
@@ -15,7 +18,8 @@ export type RenderCallback = (material: ShaderMaterial, camera: Camera, canvas:
|
|||||||
|
|
||||||
export interface SceneEventData {
|
export interface SceneEventData {
|
||||||
scene: Scene;
|
scene: Scene;
|
||||||
engine: WebGPUEngine;
|
engine: WebGPUEngine | Engine;
|
||||||
|
gpuTier: GpuTier;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -26,6 +30,9 @@ export interface SceneEventData {
|
|||||||
})
|
})
|
||||||
export class BabylonCanvas implements AfterViewInit, OnDestroy {
|
export class BabylonCanvas implements AfterViewInit, OnDestroy {
|
||||||
readonly ngZone = inject(NgZone);
|
readonly ngZone = inject(NgZone);
|
||||||
|
private readonly snackBar = inject(MatSnackBar);
|
||||||
|
private readonly translate = inject(TranslateService);
|
||||||
|
private readonly gpuCapability = inject(GpuCapabilityService);
|
||||||
|
|
||||||
@ViewChild('renderCanvas', { static: true }) canvasRef!: ElementRef<HTMLCanvasElement>;
|
@ViewChild('renderCanvas', { static: true }) canvasRef!: ElementRef<HTMLCanvasElement>;
|
||||||
|
|
||||||
@@ -34,11 +41,13 @@ export class BabylonCanvas implements AfterViewInit, OnDestroy {
|
|||||||
|
|
||||||
@Output() sceneReady = new EventEmitter<SceneEventData>();
|
@Output() sceneReady = new EventEmitter<SceneEventData>();
|
||||||
@Output() sceneResized = new EventEmitter<SceneEventData>();
|
@Output() sceneResized = new EventEmitter<SceneEventData>();
|
||||||
|
@Output() engineUnavailable = new EventEmitter<{ reason: string }>();
|
||||||
|
|
||||||
private engine!: WebGPUEngine;
|
private engine!: WebGPUEngine | Engine;
|
||||||
private scene!: Scene;
|
private scene!: Scene;
|
||||||
private shaderMaterial!: ShaderMaterial;
|
private shaderMaterial!: ShaderMaterial;
|
||||||
private camera!: Camera;
|
private camera!: Camera;
|
||||||
|
private gpuTier: GpuTier = 'none';
|
||||||
|
|
||||||
//Listener
|
//Listener
|
||||||
private readonly resizeHandler = () => this.handleResize();
|
private readonly resizeHandler = () => this.handleResize();
|
||||||
@@ -52,19 +61,51 @@ export class BabylonCanvas implements AfterViewInit, OnDestroy {
|
|||||||
window.removeEventListener('resize', this.resizeHandler);
|
window.removeEventListener('resize', this.resizeHandler);
|
||||||
|
|
||||||
const canvas = this.canvasRef?.nativeElement;
|
const canvas = this.canvasRef?.nativeElement;
|
||||||
if (canvas) {
|
canvas?.removeEventListener('wheel', this.wheelHandler);
|
||||||
canvas.removeEventListener('wheel', this.wheelHandler);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.engine) {
|
this.engine?.dispose();
|
||||||
this.engine.dispose();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async initBabylon(): Promise<void> {
|
private async initBabylon(): Promise<void> {
|
||||||
const canvas = this.canvasRef.nativeElement;
|
const canvas = this.canvasRef.nativeElement;
|
||||||
this.engine = new WebGPUEngine(canvas);
|
const tier = await this.gpuCapability.detect();
|
||||||
await this.engine.initAsync().then(() => {
|
this.gpuTier = tier;
|
||||||
|
|
||||||
|
if (tier === 'webgpu') {
|
||||||
|
await this.initWebGpuEngine(canvas);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tier === 'webgl') {
|
||||||
|
this.showSnackBar('GPU.WEBGL_FALLBACK');
|
||||||
|
this.initWebGlEngine(canvas);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.showSnackBar('GPU.NOT_SUPPORTED');
|
||||||
|
this.engineUnavailable.emit({reason: 'no_gpu'});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async initWebGpuEngine(canvas: HTMLCanvasElement): Promise<void> {
|
||||||
|
const tmpEngine = new WebGPUEngine(canvas);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await tmpEngine.initAsync();
|
||||||
|
this.engine = tmpEngine;
|
||||||
|
this.setupScene(canvas);
|
||||||
|
} catch {
|
||||||
|
this.showSnackBar('GPU.WEBGL_FALLBACK');
|
||||||
|
this.gpuTier = 'webgl';
|
||||||
|
this.initWebGlEngine(canvas);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private initWebGlEngine(canvas: HTMLCanvasElement): void {
|
||||||
|
this.engine = new Engine(canvas, true);
|
||||||
|
this.setupScene(canvas);
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupScene(canvas: HTMLCanvasElement): void {
|
||||||
this.scene = new Scene(this.engine);
|
this.scene = new Scene(this.engine);
|
||||||
this.setupCamera(canvas);
|
this.setupCamera(canvas);
|
||||||
this.addListener(canvas);
|
this.addListener(canvas);
|
||||||
@@ -72,10 +113,15 @@ export class BabylonCanvas implements AfterViewInit, OnDestroy {
|
|||||||
this.createFullScreenRect();
|
this.createFullScreenRect();
|
||||||
this.sceneReady.emit({
|
this.sceneReady.emit({
|
||||||
scene: this.scene,
|
scene: this.scene,
|
||||||
engine: this.engine
|
engine: this.engine,
|
||||||
|
gpuTier: this.gpuTier
|
||||||
});
|
});
|
||||||
this.addRenderLoop(canvas);
|
this.addRenderLoop(canvas);
|
||||||
});
|
}
|
||||||
|
|
||||||
|
private showSnackBar(translationKey: string): void {
|
||||||
|
const message = this.translate.instant(translationKey);
|
||||||
|
this.snackBar.open(message, 'OK', {duration: 8000, horizontalPosition: 'center', verticalPosition: 'top'});
|
||||||
}
|
}
|
||||||
|
|
||||||
private addListener(canvas: HTMLCanvasElement) {
|
private addListener(canvas: HTMLCanvasElement) {
|
||||||
@@ -191,7 +237,8 @@ export class BabylonCanvas implements AfterViewInit, OnDestroy {
|
|||||||
|
|
||||||
this.sceneResized?.emit({
|
this.sceneResized?.emit({
|
||||||
scene: this.scene,
|
scene: this.scene,
|
||||||
engine: this.engine
|
engine: this.engine,
|
||||||
|
gpuTier: this.gpuTier
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,41 +1,58 @@
|
|||||||
{
|
{
|
||||||
"APP": {
|
"APP": {
|
||||||
"TITLE": "Playground",
|
"TITLE": "Playground",
|
||||||
"COPYRIGHT": "Bilder und Sourcecode sind urheberrechtlich geschützt, keine Nutzung ohne Zustimmung!"
|
"COPYRIGHT": "Bilder und Sourcecode sind urheberrechtlich geschützt, keine Nutzung ohne Zustimmung!",
|
||||||
|
"AI_NOTICE": "Entstanden mit einem KI-gestützten Engineering-Workflow.",
|
||||||
|
"AI_NOTICE_LINK": "Wie ich mit KI arbeite"
|
||||||
},
|
},
|
||||||
"TOPBAR": {
|
"TOPBAR": {
|
||||||
"ABOUT": "Über mich",
|
"ABOUT": "Über mich",
|
||||||
"IMPRINT": "Impressum",
|
"IMPRINT": "Impressum",
|
||||||
"PROJECTS": "Projekte",
|
"PROJECTS": "Projekte",
|
||||||
"ALGORITHMS": "Algorithmen",
|
"ALGORITHMS": "Algorithmen",
|
||||||
|
"STOPWATCH": "Stoppuhr",
|
||||||
"SETTINGS": "Einstellungen",
|
"SETTINGS": "Einstellungen",
|
||||||
"LANGUAGE": "Sprache",
|
"LANGUAGE": "Sprache",
|
||||||
"APPEARANCE": "Darstellung"
|
"APPEARANCE": "Darstellung"
|
||||||
},
|
},
|
||||||
"THEME": { "LIGHT": "Hell", "DARK": "Dunkel" },
|
"THEME": {
|
||||||
"LANG": { "LABEL": "Sprache", "EN": "Englisch", "DE": "Deutsch" },
|
"LIGHT": "Hell",
|
||||||
|
"DARK": "Dunkel"
|
||||||
|
},
|
||||||
|
"LANG": {
|
||||||
|
"LABEL": "Sprache",
|
||||||
|
"EN": "Englisch",
|
||||||
|
"DE": "Deutsch"
|
||||||
|
},
|
||||||
"ABOUT": {
|
"ABOUT": {
|
||||||
"ALT": { "PROFILE": "Profilfoto von Andreas Dahm" },
|
"ALT": {
|
||||||
|
"PROFILE": "Profilfoto von Andreas Dahm"
|
||||||
|
},
|
||||||
"HELLO": "Hallo, ich bin Andreas.",
|
"HELLO": "Hallo, ich bin Andreas.",
|
||||||
"LEAD": "Bereits in meiner Ausbildung zum Fachinformatiker habe ich gemerkt, wie sehr mich Softwareentwicklung interessiert. Nach meinem Studium der Angewandten Informatik an der FH Bingen arbeite ich seit 2010 durchgehend als Softwareentwickler. In dieser Zeit konnte ich in verschiedenen Bereichen Erfahrungen sammeln, darunter 3D-Simulation und moderne Web-Anwendungen. Auch privat beschäftige ich mich gern mit Softwareentwicklung, besonders mit Spieleentwicklung und algorithmischen Themen. Schön, dass du hier bist!",
|
"LEAD": "Ich arbeite seit 2010 als Softwareentwickler, die meiste Zeit davon in zwei recht unterschiedlichen Bereichen. Der eine ist Echtzeit-3D-Simulation: Stoffphysik, Kollisionserkennung, GPU-Shader. Der andere ist Cloud-Architektur für größere Unternehmensplattformen. Ein paar Jahre habe ich ein kleines Backend-Team geleitet, den Umbau eines großen Legacy-Monolithen Richtung Microservices geplant und zusammen mit meinem Bruder ein Spiel auf Steam veröffentlicht. Zuletzt stecke ich viel Zeit in Tooling rund um KI-gestützte Entwicklung, vor allem in die Frage, wie das Ergebnis überprüfbar bleibt. Privat baue ich Spiele und probiere Sachen mit Algorithmen aus. Das meiste davon landet irgendwann auf dieser Seite.",
|
||||||
"ROLE": "Senior Software Entwickler / Full-Stack Entwickler / Softwarearchitekt",
|
"ROLE": "Senior Software Entwickler / Full-Stack Entwickler / Softwarearchitekt",
|
||||||
"LOCATION": "München · Remote",
|
"LOCATION": "München · Remote",
|
||||||
"DOWNLOAD_CV": "Lebenslauf herunterladen",
|
|
||||||
"VIEW_PROJECTS": "Projekte ansehen",
|
"VIEW_PROJECTS": "Projekte ansehen",
|
||||||
"CONTACT_ME": "Kontaktiere mich",
|
"CONTACT_ME": "Kontaktiere mich",
|
||||||
"SECTION": {
|
"SECTION": {
|
||||||
"SKILLS": "Fähigkeiten & Stack",
|
"SKILLS": "Fähigkeiten & Stack",
|
||||||
"BACKEND_ARCH": "Backend & Architektur",
|
"LANGUAGES": "Sprachen & Frameworks",
|
||||||
"INFRA_CLOUD": "Infrastruktur & Cloud",
|
"ARCH_CLOUD": "Architektur & Cloud",
|
||||||
"SIM_ALGO": "Simulation & Algorithmen",
|
"SIM_ALGO": "Simulation & Algorithmen",
|
||||||
|
"AI_ENG": "KI-gestütztes Engineering",
|
||||||
"EXPERIENCE": "Erfahrung",
|
"EXPERIENCE": "Erfahrung",
|
||||||
"PROJECTS": "Projekte",
|
"PROJECTS": "Projekte",
|
||||||
"EDUCATION": "Ausbildung"
|
"EDUCATION": "Ausbildung",
|
||||||
|
"SHOW_EARLIER_XP": "Frühere Stationen anzeigen",
|
||||||
|
"SHOW_EARLIER_EDU": "Frühere Ausbildung anzeigen",
|
||||||
|
"SHOW_LESS": "Weniger anzeigen"
|
||||||
},
|
},
|
||||||
"SKILLS": {
|
"SKILLS": {
|
||||||
"JAVA": "Java 8/21+",
|
"JAVA": "Java",
|
||||||
"SPRING": "Spring Boot 2/3",
|
"SPRING": "Spring Boot",
|
||||||
"ANGULAR": "Angular 19+",
|
"ANGULAR": "Angular",
|
||||||
|
"VUE": "Vue.js",
|
||||||
|
"REACT": "React",
|
||||||
"DOCKER": "Docker",
|
"DOCKER": "Docker",
|
||||||
"UNITY": "Unity",
|
"UNITY": "Unity",
|
||||||
"PYTHON": "Python",
|
"PYTHON": "Python",
|
||||||
@@ -47,7 +64,12 @@
|
|||||||
"ENG_SIM": "3D Simulation",
|
"ENG_SIM": "3D Simulation",
|
||||||
"ENG_GPU": "WebGPU / OpenGL / GLSL",
|
"ENG_GPU": "WebGPU / OpenGL / GLSL",
|
||||||
"ENG_PERF": "Performance Optimization",
|
"ENG_PERF": "Performance Optimization",
|
||||||
"ENG_3D": "3D-Scanner Tech"
|
"ENG_3D": "3D-Scanner Tech",
|
||||||
|
"AI_AGENTS": "Claude Code / Agent SDK",
|
||||||
|
"AI_MCP": "MCP-Server",
|
||||||
|
"AI_CONTEXT": "Context- & Prompt-Engineering",
|
||||||
|
"AI_HARNESS": "Agent-Harness-Design",
|
||||||
|
"AI_GATES": "Automatisierte Review-Gates"
|
||||||
},
|
},
|
||||||
"TOOLS": {
|
"TOOLS": {
|
||||||
"GIT": "Git",
|
"GIT": "Git",
|
||||||
@@ -61,10 +83,20 @@
|
|||||||
"DOCKER": "Docker"
|
"DOCKER": "Docker"
|
||||||
},
|
},
|
||||||
"XP": {
|
"XP": {
|
||||||
|
"COMPANY9": {
|
||||||
|
"COMPANY": "ColorDigital GmbH",
|
||||||
|
"ROLE": "Senior Software Engineer - DMIxOS Team",
|
||||||
|
"TIME": "März 2026 – heute",
|
||||||
|
"HIGHLIGHTS": {
|
||||||
|
"P1": "Konzeption und Implementierung der zentralen Cloud-Architektur.",
|
||||||
|
"P2": "Full-Stack-Entwicklung und Optimierung neuer sowie bestehender Systemkomponenten.",
|
||||||
|
"P3": "Technische Integration von Partnerunternehmen an das eigene Ökosystem."
|
||||||
|
}
|
||||||
|
},
|
||||||
"COMPANY8": {
|
"COMPANY8": {
|
||||||
"COMPANY": "Teraport GmbH",
|
"COMPANY": "Teraport GmbH",
|
||||||
"ROLE": "Senior Software Developer / Architect",
|
"ROLE": "Senior Software Developer / Architect",
|
||||||
"TIME": "Feb. 2024 – heute",
|
"TIME": "Feb. 2024 – Feb. 2026",
|
||||||
"HIGHLIGHTS": {
|
"HIGHLIGHTS": {
|
||||||
"P1": "Architektur und Implementierung der Datenbankanbindung mit Hibernate 6.x.",
|
"P1": "Architektur und Implementierung der Datenbankanbindung mit Hibernate 6.x.",
|
||||||
"P2": "Konzeption und Entwicklung einer Full-Stack-Webanwendung für Kollisionsanalysen (Angular + Spring Boot + Docker).",
|
"P2": "Konzeption und Entwicklung einer Full-Stack-Webanwendung für Kollisionsanalysen (Angular + Spring Boot + Docker).",
|
||||||
@@ -147,12 +179,22 @@
|
|||||||
"TIME": "Jul. 2002 – Jun. 2005",
|
"TIME": "Jul. 2002 – Jun. 2005",
|
||||||
"HIGHLIGHTS": {
|
"HIGHLIGHTS": {
|
||||||
"P1": "Entwicklung in PERL, PHP und ASP.",
|
"P1": "Entwicklung in PERL, PHP und ASP.",
|
||||||
"P2": "Portierung, Wartung und Reengineering von bestender Software.",
|
"P2": "Portierung, Wartung und Reengineering von bestehender Software.",
|
||||||
"P3": "Regelmäßige Durchführung von Systemtests und Qualitätskontrollen, sowie deren Dokumentation."
|
"P3": "Regelmäßige Durchführung von Systemtests und Qualitätskontrollen, sowie deren Dokumentation."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"PROJECT": {
|
"PROJECT": {
|
||||||
|
"CLAUDE_BOX": {
|
||||||
|
"TITLE": "Claude-in-a-Box",
|
||||||
|
"DESCRIPTION": "Eine Entwicklungsumgebung im Container, in der ein KI-Coding-Agent nach einem festen Regelsatz arbeitet und nicht nach dem, was ich gerade eintippe. Er zieht sich die Regeln für den Stack, den er im Repository vorfindet, und ein Git-Hook stoppt den Commit, wenn Build oder Tests rot sind.",
|
||||||
|
"LINK_INTERNAL": "Projektdetails",
|
||||||
|
"HIGHLIGHTS": {
|
||||||
|
"P1": "Der Agent lädt die Regeln für den Stack, den er im Repo vorfindet. Ein Java-Service bekommt also andere als ein Frontend.",
|
||||||
|
"P2": "Ein Commit-Hook führt Build und Tests aus und blockiert den Commit, wenn sie fehlschlagen.",
|
||||||
|
"P3": "MCP-Server für Codesuche und lesenden Jira-Zugriff, dazu eigene Review-Agenten, die den Diff durchgehen."
|
||||||
|
}
|
||||||
|
},
|
||||||
"P2": {
|
"P2": {
|
||||||
"TITLE": "Playground",
|
"TITLE": "Playground",
|
||||||
"DESCRIPTION": "Dieses Projekt ist, wie der Name schon sagt, ein persönlicher Playground, der im Laufe der Zeit wachsen soll. Es präsentiert verschiedene private Projekte, an denen ich arbeite, und dokumentiert deren Fortschritt, zentrale Ideen und wichtige Meilensteine.",
|
"DESCRIPTION": "Dieses Projekt ist, wie der Name schon sagt, ein persönlicher Playground, der im Laufe der Zeit wachsen soll. Es präsentiert verschiedene private Projekte, an denen ich arbeite, und dokumentiert deren Fortschritt, zentrale Ideen und wichtige Meilensteine.",
|
||||||
@@ -198,7 +240,7 @@
|
|||||||
},
|
},
|
||||||
"TRIBBLE": {
|
"TRIBBLE": {
|
||||||
"TITLE": "Homeserver 'Tribble'",
|
"TITLE": "Homeserver 'Tribble'",
|
||||||
"DESCRIPTION": "In diesem Projekt geht es um die Einrichtung und Wartung meines eigenen Homeservers. Er betreibt mehrere Docker-Container wie Gitea, Jellyfin und mehr. Es ist eine großartige Lernerfahrung im Bereich Self-Hosting und Systemadministration.",
|
"DESCRIPTION": "In diesem Projekt geht es um die Einrichtung und Wartung meines eigenen Homeservers. Er betreibt mehrere Docker-Container wie Gitea, Jellyfin and more. Es ist eine großartige Lernerfahrung im Bereich Self-Hosting und Systemadministration.",
|
||||||
"LINK_INTERNAL": "Projektdetails",
|
"LINK_INTERNAL": "Projektdetails",
|
||||||
"HIGHLIGHTS": {
|
"HIGHLIGHTS": {
|
||||||
"P1": "Self-Hosting verschiedener Dienste mit Docker.",
|
"P1": "Self-Hosting verschiedener Dienste mit Docker.",
|
||||||
@@ -261,7 +303,7 @@
|
|||||||
"BULLET_1": "Entwicklung mit Angular 19+ und Material Design.",
|
"BULLET_1": "Entwicklung mit Angular 19+ und Material Design.",
|
||||||
"BULLET_2": "Implementierung performanter Visualisierungen (WebGPU, Shader, Canvas).",
|
"BULLET_2": "Implementierung performanter Visualisierungen (WebGPU, Shader, Canvas).",
|
||||||
"BULLET_3": "Automatisierte CI/CD-Pipelines und Containerisierung mit Docker.",
|
"BULLET_3": "Automatisierte CI/CD-Pipelines und Containerisierung mit Docker.",
|
||||||
"BULLET_4": "Internationalisierung (i18n) für globale Reichweite.",
|
"BULLET_4": "Internationalization (i18n) für globale Reichweite.",
|
||||||
"CHALLENGE_1": "Optimierung der Render-Performance bei komplexen 3D-Fraktalen in Echtzeit.",
|
"CHALLENGE_1": "Optimierung der Render-Performance bei komplexen 3D-Fraktalen in Echtzeit.",
|
||||||
"CHALLENGE_2": "Architektur einer skalierbaren und wartbaren Frontend-Struktur für diverse Sub-Projekte.",
|
"CHALLENGE_2": "Architektur einer skalierbaren und wartbaren Frontend-Struktur für diverse Sub-Projekte.",
|
||||||
"LEARNING_1": "Effektives UI/UX-Design für komplexe datengesteuerte Visualisierungen.",
|
"LEARNING_1": "Effektives UI/UX-Design für komplexe datengesteuerte Visualisierungen.",
|
||||||
@@ -297,8 +339,8 @@
|
|||||||
"GAME_JAMS": {
|
"GAME_JAMS": {
|
||||||
"TITLE": "Rapid Prototyping & Game Jams",
|
"TITLE": "Rapid Prototyping & Game Jams",
|
||||||
"SHORT_DESCRIPTION": "Sammlung innovativer Spielkonzepte, entstanden in unter 48 Stunden.",
|
"SHORT_DESCRIPTION": "Sammlung innovativer Spielkonzepte, entstanden in unter 48 Stunden.",
|
||||||
"INTRODUCTION": "Teilnahme an internationalen Wettbewerben (z.B. Ludum Dare). Hier geht es darum, unter extremem Zeitdruck funktionale und spaßige Prototypen zu erschaffen.",
|
"INTRODUCTION": "Teilnahme an nationalen Wettbewerben (z.B. Beansjam). Hier geht es darum, unter extremem Zeitdruck funktionale und spaßige Prototypen zu erschaffen.",
|
||||||
"BULLET_1": "Fokus auf 'Core Game Loop' und schnelles Feedback.",
|
"BULLET_1": "Fokus on 'Core Game Loop' und schnelles Feedback.",
|
||||||
"BULLET_2": "Kollaborative Entwicklung in kleinen, agilen Teams.",
|
"BULLET_2": "Kollaborative Entwicklung in kleinen, agilen Teams.",
|
||||||
"BULLET_3": "Effektives Zeitmanagement und Scope-Kontrolle.",
|
"BULLET_3": "Effektives Zeitmanagement und Scope-Kontrolle.",
|
||||||
"BULLET_4": "Veröffentlichung und Iteration basierend auf Community-Votings.",
|
"BULLET_4": "Veröffentlichung und Iteration basierend auf Community-Votings.",
|
||||||
@@ -312,17 +354,31 @@
|
|||||||
"SHORT_DESCRIPTION": "Echtzeit-Kollisionserkennung für komplexe, flexible 3D-Objekte.",
|
"SHORT_DESCRIPTION": "Echtzeit-Kollisionserkennung für komplexe, flexible 3D-Objekte.",
|
||||||
"INTRODUCTION": "Forschungsarbeit im Bereich Computergraphik. Entwicklung eines Algorithmus zur physikalisch korrekten Simulation von Stoffen und Kleidung in Echtzeit.",
|
"INTRODUCTION": "Forschungsarbeit im Bereich Computergraphik. Entwicklung eines Algorithmus zur physikalisch korrekten Simulation von Stoffen und Kleidung in Echtzeit.",
|
||||||
"BULLET_1": "Mathematische Modellierung von Mass-Spring-Systemen.",
|
"BULLET_1": "Mathematische Modellierung von Mass-Spring-Systemen.",
|
||||||
"BULLET_2": "Low-Level Programmierung mit C++, OpenGL und Shader-Code.",
|
"BULLET_2": "Low-Level Programmierung mit Java.",
|
||||||
"BULLET_3": "Optimierung durch räumliche Datenstrukturen (AABB Trees, Bounding Spheres).",
|
"BULLET_3": "Optimierung durch räumliche Datenstrukturen (AABB Trees, Bounding Spheres).",
|
||||||
"BULLET_4": "Wissenschaftliche Evaluation der Simulations-Präzision.",
|
"BULLET_4": "Wissenschaftliche Evaluation der Simulations-Präzision.",
|
||||||
"CHALLENGE_1": "Behandlung von 'Self-Collisions' bei hochauflösenden Meshes ohne Performance-Einbruch.",
|
"CHALLENGE_1": "Behandlung von 'Self-Collisions' bei hochauflösenden Meshes ohne Performance-Einbruch.",
|
||||||
"CHALLENGE_2": "Mathematische Stabilisierung der Integrationsverfahren bei hohen Krafteinwirkungen.",
|
"CHALLENGE_2": "Mathematische Stabilisierung der Integrationsverfahren bei hohen Krafteinwirkungen.",
|
||||||
"LEARNING_1": "Transfer von theoretischen Forschungsarbeiten in produktiven, performanten Code.",
|
"LEARNING_1": "Transfer von theoretischen Forschungsarbeiten in produktiven, performanten Code.",
|
||||||
"LEARNING_2": "Präzises Arbeiten und Dokumentation nach wissenschaftlichen Standards."
|
"LEARNING_2": "Präzises Arbeiten und Dokumentation nach wissenschaftlichen Standards."
|
||||||
|
},
|
||||||
|
"CLAUDE_BOX": {
|
||||||
|
"TITLE": "Claude-in-a-Box",
|
||||||
|
"SHORT_DESCRIPTION": "Ein Container-Setup, um mit einem KI-Coding-Agenten nach einem festen Regelsatz zu arbeiten.",
|
||||||
|
"INTRODUCTION": "Ein KI-Coding-Agent produziert den ganzen Tag Code, ohne sich zu beschweren. Das Problem ist, dass das Ergebnis meistens vernünftig aussieht, auch wenn es falsch ist, und die ganzen Kosten damit im Review landen. Also habe ich ein Docker-Setup gebaut, das den Agenten denselben Regeln unterwirft wie alle anderen, die am Repository arbeiten. Er liest die Architekturregeln des Projekts, in dem er gerade steckt, und der Build muss durchlaufen, bevor ein Commit möglich ist. Auf den Hook, der genau das durchsetzt, komme ich immer wieder zurück, weil er das Einzige ist, woran der Agent nicht vorbeikommt.",
|
||||||
|
"BULLET_1": "Container mit festen Toolchains: JDKs und Maven über SDKMAN, Node und ein Home-Volume, das einen Rebuild übersteht.",
|
||||||
|
"BULLET_2": "Die Regeln werden per Erkennung geladen. Ein Repo mit pom.xml bekommt die Java- und Spring-Regeln, eines mit package.json die TypeScript-Regeln, und Repos mit eigenen Architekturnotizen bekommen diese zusätzlich.",
|
||||||
|
"BULLET_3": "Ein Commit-Hook ermittelt den Stack, führt den passenden Build und die Tests aus und verweigert den Commit, wenn sie fehlschlagen.",
|
||||||
|
"BULLET_4": "MCP-Server für Code-Graph-Abfragen und lesende Jira-Zugriffe, dazu Review-Agenten, die einen fertigen Diff prüfen, bevor er als erledigt gilt.",
|
||||||
|
"CHALLENGE_1": "Die Regeln kurz genug zu halten, dass sie noch etwas bringen. Lange allgemeine Richtlinien werden einfach ignoriert, also sind sie in Dateien pro Stack und pro Repo aufgeteilt, die nur bei Bedarf geladen werden.",
|
||||||
|
"CHALLENGE_2": "Das Quality Gate so zu bauen, dass der Agent sich nicht daran vorbeireden kann. Die Prüfung läuft als Git-Hook außerhalb des Agenten, ein fehlgeschlagener Build blockiert den Commit also unabhängig davon, was der Agent über seine eigene Arbeit denkt.",
|
||||||
|
"CHALLENGE_3": "Die Toolchains in einem Container ohne Root zum Laufen zu bringen. Builds, Browser und Sprach-Runtimes mussten alle in Volumes landen, die dem Entwickler-User gehören.",
|
||||||
|
"LEARNING_1": "Die Formulierung der Prompts war deutlich weniger wichtig, als ich dachte. Fast der ganze Unterschied kam vom Drumherum.",
|
||||||
|
"LEARNING_2": "Den Output des Agenten genauso zu reviewen und durch dieselben Gates zu schicken wie den Pull Request eines neuen Teammitglieds hat sich als die nützlichste Regel überhaupt herausgestellt."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"IMPRINT": {
|
"IMPRINT": {
|
||||||
"TITLE" : "Impressum",
|
"TITLE": "Impressum",
|
||||||
"PARAGRAPH": "Angaben gemäß § 5 DDG",
|
"PARAGRAPH": "Angaben gemäß § 5 DDG",
|
||||||
"COUNTRY": "Deutschland",
|
"COUNTRY": "Deutschland",
|
||||||
"CONTACT": "Kontakt"
|
"CONTACT": "Kontakt"
|
||||||
@@ -359,14 +415,17 @@
|
|||||||
"START": "Sortierung starten",
|
"START": "Sortierung starten",
|
||||||
"RESET": "Zurücksetzen",
|
"RESET": "Zurücksetzen",
|
||||||
"GENERATE_NEW_ARRAY": "Neues Array generieren",
|
"GENERATE_NEW_ARRAY": "Neues Array generieren",
|
||||||
|
"SOUND_ON": "Ton an",
|
||||||
|
"SOUND_OFF": "Ton aus",
|
||||||
"EXECUTION_TIME": "Ausführungszeit",
|
"EXECUTION_TIME": "Ausführungszeit",
|
||||||
"ARRAY_SIZE": "Anzahl der Balken",
|
"ARRAY_SIZE": "Anzahl der Balken",
|
||||||
"EXPLANATION": {
|
"EXPLANATION": {
|
||||||
"TITLE": "Algorithmen",
|
"TITLE": "Algorithmen",
|
||||||
"BUBBLE_SORT_EXPLANATION":"vergleicht wiederholt benachbarte Elemente und tauscht sie, wenn sie in der falschen Reihenfolge stehen. Das größte Element \"blubbert\" dabei wie eine Luftblase ans Ende der Liste. Vorteil: Extrem einfach zu verstehen und zu implementieren; erkennt bereits sortierte Listen sehr schnell. Nachteil: Sehr ineffizient bei großen Listen (Laufzeit O(n²)). In der Praxis kaum genutzt.",
|
"BUBBLE_SORT_EXPLANATION": "vergleicht wiederholt benachbarte Elemente und tauscht sie, wenn sie in der falschen Reihenfolge stehen. Das größte Element \"blubbert\" dabei wie eine Luftblase ans Ende der Liste. Vorteil: Extrem einfach zu verstehen und zu implementieren; erkennt bereits sortierte Listen sehr schnell. Nachteil: Sehr ineffizient bei großen Listen (Laufzeit O(n²)). In der Praxis kaum genutzt.",
|
||||||
"QUICK_SORT_EXPLANATION": "folgt dem \"Teile und Herrsche\"-Prinzip. Ein \"Pivot\"-Element wird gewählt, und das Array wird in zwei Hälften geteilt: Elemente kleiner als das Pivot und Elemente größer als das Pivot. Vorteil: Im Durchschnitt einer der schnellsten Sortieralgorithmen (O(n log n)); benötigt keinen zusätzlichen Speicher (In-Place). Nachteil: Im schlechtesten Fall (Worst Case) langsam (O(n²)), wenn das Pivot ungünstig gewählt wird. Ist nicht stabil (ändert Reihenfolge gleicher Elemente).",
|
"QUICK_SORT_EXPLANATION": "folgt dem \"Teile und Herrsche\"-Prinzip. Ein \"Pivot\"-Element wird gewählt, und das Array wird in zwei Hälften geteilt: Elemente kleiner als das Pivot und Elemente größer als das Pivot. Vorteil: Im Durchschnitt einer der schnellsten Sortieralgorithmen (O(n log n)); benötigt keinen zusätzlichen Speicher (In-Place). Nachteil: Im schlechtesten Fall (Worst Case) langsam (O(n²)), wenn das Pivot ungünstig gewählt wird. Ist nicht stabil (ändert Reihenfolge gleicher Elemente).",
|
||||||
"HEAP_SORT_EXPLANATION": "organisiert die Daten zunächst in einer speziellen Baumstruktur (Binary Heap). Das größte Element (die Wurzel) wird entnommen und ans Ende sortiert, dann wird der Baum repariert. Vorteil: Garantiert eine schnelle Laufzeit von O(n log n), selbst im schlechtesten Fall. Benötigt fast keinen zusätzlichen Speicher. Nachteil: In der Praxis oft etwas langsamer als Quick Sort, da die Sprünge im Speicher (Heap-Struktur) den CPU-Cache schlechter nutzen.",
|
"HEAP_SORT_EXPLANATION": "organisiert die Daten zunächst in einer speziellen Baumstruktur (Binary Heap). Das größte Element (die Wurzel) wird entnommen und ans Ende sortiert, dann wird der Baum repariert. Vorteil: Garantiert eine schnelle Laufzeit von O(n log n), selbst im schlechtesten Fall. Benötigt fast keinen zusätzlichen Speicher. Nachteil: In der Praxis oft etwas langsamer als Quick Sort, da die Sprünge im Speicher (Heap-Struktur) den CPU-Cache schlechter nutzen.",
|
||||||
"COCKTAIL_SORT_EXPLANATION" : "(auch Shaker Sort) ist eine Erweiterung des Bubble Sort. Statt nur von links nach rechts zu gehen, wechselt er bei jedem Durchlauf die Richtung und schiebt abwechselnd das größte Element nach rechts und das kleinste nach links. Vorteil: Schneller als Bubble Sort, da kleine Elemente am Ende schneller nach vorne wandern (\"Schildkröten-Problem\" gelöst). Nachteil: Bleibt in der Laufzeitklasse O(n²), also für große Datenmengen ineffizient.",
|
"TIMSORT_EXPLANATION": "ist ein hybrider Sortieralgorithmus, der aus Merge Sort und Insertion Sort kombiniert ist. Er unterteilt das Array in kleine 'Runs' und sortiert jeden davon mit Insertion Sort, um sie anschließend schrittweise mit Merge Sort zusammenzuführen. Vorteil: Extrem effizient bei realen Daten, die oft teilweise sortiert sind – O(n log n) im schlechtesten und O(n) im besten Fall. Er ist der Standardalgorithmus in Python und Java. Nachteil: Komplexer zu implementieren als ein reiner Algorithmus und benötigt zusätzlichen Speicher für den Merge-Schritt.",
|
||||||
|
"COCKTAIL_SORT_EXPLANATION": "(auch Shaker Sort) ist eine Erweiterung des Bubble Sort. Statt nur von links nach rechts zu gehen, wechselt er bei jedem Durchlauf die Richtung und schiebt abwechselnd das größte Element nach rechts und das kleinste nach links. Vorteil: Schneller als Bubble Sort, da kleine Elemente am Ende schneller nach vorne wandern (\"Schildkröten-Problem\" gelöst). Nachteil: Bleibt in der Laufzeitklasse O(n²), also für große Datenmengen ineffizient.",
|
||||||
"DISCLAIMER": "Die Wahl des \"besten\" Sortieralgorithmus hängt stark von den Daten und den Rahmenbedingungen ab. In der Informatik betrachtet man oft drei Szenarien:",
|
"DISCLAIMER": "Die Wahl des \"besten\" Sortieralgorithmus hängt stark von den Daten und den Rahmenbedingungen ab. In der Informatik betrachtet man oft drei Szenarien:",
|
||||||
"DISCLAIMER_1": "Best Case: Die Daten sind schon fast sortiert (hier glänzt z.B. Bubble Sort).",
|
"DISCLAIMER_1": "Best Case: Die Daten sind schon fast sortiert (hier glänzt z.B. Bubble Sort).",
|
||||||
"DISCLAIMER_2": "Average Case: Der statistische Normalfall.",
|
"DISCLAIMER_2": "Average Case: Der statistische Normalfall.",
|
||||||
@@ -388,7 +447,7 @@
|
|||||||
"SPEED": "Zeit pro Generation",
|
"SPEED": "Zeit pro Generation",
|
||||||
"EXPLANATION": {
|
"EXPLANATION": {
|
||||||
"TITLE": "Erklärung",
|
"TITLE": "Erklärung",
|
||||||
"EXPLANATION" : "Das Spiel läuft schrittweise ab. Zunächst wird eine Anfangsgeneration von lebenden Zellen auf dem Spielfeld definiert. Aus der vorliegenden Generation (dem Gesamtbild des Spielfeldes) wird die Folgegeneration ermittelt. Der Zustand jeder einzelnen Zelle in der Folgegeneration ergibt sich dabei nach einfachen Regeln aus ihrem aktuellen Zustand sowie den aktuellen Zuständen ihrer acht Nachbarzellen (Moore-Nachbarschaft).",
|
"EXPLANATION": "Das Spiel läuft schrittweise ab. Zunächst wird eine Anfangsgeneration von lebenden Zellen auf dem Spielfeld definiert. Aus der vorliegenden Generation (dem Gesamtbild des Spielfeldes) wird die Folgegeneration ermittelt. Der Zustand jeder einzelnen Zelle in der Folgegeneration ergibt sich dabei nach einfachen Regeln aus ihrem aktuellen Zustand sowie den aktuellen Zuständen ihrer acht Nachbarzellen (Moore-Nachbarschaft).",
|
||||||
"DISCLAIMER": "Nach Conways ursprünglicher Regel lebt eine Zelle in der nächsten Runde, wenn zuvor in ihrer 3x3-Umgebung insgesamt genau drei Zellen leben, wobei sie selbst nur bei Bedarf mitgezählt wird, das heißt:",
|
"DISCLAIMER": "Nach Conways ursprünglicher Regel lebt eine Zelle in der nächsten Runde, wenn zuvor in ihrer 3x3-Umgebung insgesamt genau drei Zellen leben, wobei sie selbst nur bei Bedarf mitgezählt wird, das heißt:",
|
||||||
"DISCLAIMER_1": "Eine lebende Zelle lebt auch in der Folgegeneration, wenn sie entweder zwei oder drei lebende Nachbarn hat.",
|
"DISCLAIMER_1": "Eine lebende Zelle lebt auch in der Folgegeneration, wenn sie entweder zwei oder drei lebende Nachbarn hat.",
|
||||||
"DISCLAIMER_2": "Eine tote Zelle „wird geboren“ (lebt in der Folgegeneration), wenn sie genau drei lebende Nachbarn hat.",
|
"DISCLAIMER_2": "Eine tote Zelle „wird geboren“ (lebt in der Folgegeneration), wenn sie genau drei lebende Nachbarn hat.",
|
||||||
@@ -422,7 +481,7 @@
|
|||||||
"MANDELBROT_EXPLANATION": "basiert auf der iterativen Formel 'z_{n+1} = z_n^2 + c'. Sie prüft für jeden Punkt in der komplexen Ebene, ob die Zahlenfolge stabil bleibt oder ins Unendliche entkommt. Vorteil: Gilt als 'Apfelmännchen' und Mutter der Fraktale. Sie bietet eine unendliche Vielfalt an selbstähnlichen Strukturen, in die man ewig hineinzoomen kann.",
|
"MANDELBROT_EXPLANATION": "basiert auf der iterativen Formel 'z_{n+1} = z_n^2 + c'. Sie prüft für jeden Punkt in der komplexen Ebene, ob die Zahlenfolge stabil bleibt oder ins Unendliche entkommt. Vorteil: Gilt als 'Apfelmännchen' und Mutter der Fraktale. Sie bietet eine unendliche Vielfalt an selbstähnlichen Strukturen, in die man ewig hineinzoomen kann.",
|
||||||
"JULIA_EXPLANATION": "nutzt dieselbe Formel wie Mandelbrot, fixiert jedoch den Parameter 'c' und variiert den Startwert. Je nach Wahl von 'c' entstehen filigrane, wolkenartige Gebilde oder zusammenhanglose 'Staubwolken'. Vorteil: Ermöglicht eine enorme ästiehetische Varianz, da jede Koordinate der Mandelbrot-Menge ein völlig eigenes, einzigartiges Julia-Fraktal erzeugt.",
|
"JULIA_EXPLANATION": "nutzt dieselbe Formel wie Mandelbrot, fixiert jedoch den Parameter 'c' und variiert den Startwert. Je nach Wahl von 'c' entstehen filigrane, wolkenartige Gebilde oder zusammenhanglose 'Staubwolken'. Vorteil: Ermöglicht eine enorme ästiehetische Varianz, da jede Koordinate der Mandelbrot-Menge ein völlig eigenes, einzigartiges Julia-Fraktal erzeugt.",
|
||||||
"NEWTON_EXPLANATION": "entsteht durch die Visualisierung des Newton-Verfahrens zur Nullstellen-Suche einer komplexen Funktion. Jeder Pixel wird danach eingefärbt, zu welcher Nullstelle der Algorithmus konvergiert. Vorteil: Erzeugt faszinierende, sternförmige Symmetrien und komplexe Grenzen, an denen sich die Einzugsgebiete der Nullstellen auf chaotische Weise treffen.",
|
"NEWTON_EXPLANATION": "entsteht durch die Visualisierung des Newton-Verfahrens zur Nullstellen-Suche einer komplexen Funktion. Jeder Pixel wird danach eingefärbt, zu welcher Nullstelle der Algorithmus konvergiert. Vorteil: Erzeugt faszinierende, sternförmige Symmetrien und komplexe Grenzen, an denen sich die Einzugsgebiete der Nullstellen auf chaotische Weise treffen.",
|
||||||
"BURNING_SHIP_EXPLANATION": "ist eine Variation des Mandelbrots, bei der vor jedem Iterationsschritt der Absolutbetrag der Real- und Imaginärteile genommen wird: '(|Re(z)| + i|Im(z)|)^2 + c'. Vorteil: Erzeugt eine markante, asymmetrische Struktur, die einem brennenden Schiff mit Segeln ähnelt. Das Fraktal wirkt düsterer und 'mechanischer' als die klassischen Mengen.",
|
"BURNING_SHIP_EXPLANATION": "ist eine variation des Mandelbrots, bei der vor jedem Iterationsschritt der Absolutbetrag der Real- und Imaginärteile genommen wird: '(|Re(z)| + i|Im(z)|)^2 + c'. Vorteil: Erzeugt eine markante, asymmetrische Struktur, die einem brennenden Schiff mit Segeln ähnelt. Das Fraktal wirkt düsterer und 'mechanischer' als die klassischen Mengen.",
|
||||||
"DISCLAIMER": "Alle diese Fraktale basieren auf dem Prinzip der Iteration und dem Chaos-Effekt. Das bedeutet für deine Visualisierung:",
|
"DISCLAIMER": "Alle diese Fraktale basieren auf dem Prinzip der Iteration und dem Chaos-Effekt. Das bedeutet für deine Visualisierung:",
|
||||||
"DISCLAIMER_1": "Unendliche Tiefe: Egal wie weit du hineinzoomst, es erscheinen immer neue, komplexe Strukturen, die dem Ganzen oft ähneln (Selbstähnlichkeit).",
|
"DISCLAIMER_1": "Unendliche Tiefe: Egal wie weit du hineinzoomst, es erscheinen immer neue, komplexe Strukturen, die dem Ganzen oft ähneln (Selbstähnlichkeit).",
|
||||||
"DISCLAIMER_2": "Fluchtzeit-Algorithmus: Die Farben geben meist an, wie schnell eine Folge einen bestimmten Schwellenwert überschreitet – je schneller, desto 'heißer' oder heller die Farbe.",
|
"DISCLAIMER_2": "Fluchtzeit-Algorithmus: Die Farben geben meist an, wie schnell eine Folge einen bestimmten Schwellenwert überschreitet – je schneller, desto 'heißer' oder heller die Farbe.",
|
||||||
@@ -478,6 +537,9 @@
|
|||||||
"WIND_OFF": "Wind Ausschalten",
|
"WIND_OFF": "Wind Ausschalten",
|
||||||
"OUTLINE_ON": "Mesh anzeigen",
|
"OUTLINE_ON": "Mesh anzeigen",
|
||||||
"OUTLINE_OFF": "Mesh ausschalten",
|
"OUTLINE_OFF": "Mesh ausschalten",
|
||||||
|
"STIFFNESS": "Steifigkeit",
|
||||||
|
"ELONGATION": "Dehnung",
|
||||||
|
"RESTART_SIMULATION": "Simulation neu starten",
|
||||||
"EXPLANATION": {
|
"EXPLANATION": {
|
||||||
"TITLE": "Echtzeit-Stoffsimulation auf der GPU",
|
"TITLE": "Echtzeit-Stoffsimulation auf der GPU",
|
||||||
"CLOTH_SIMULATION_EXPLANATION_TITLE": "Stoffsimulation",
|
"CLOTH_SIMULATION_EXPLANATION_TITLE": "Stoffsimulation",
|
||||||
@@ -490,11 +552,15 @@
|
|||||||
"DATA_STRUCTURES_EXPLANATION": "Für maximale GPU-Performance müssen Daten speicherfreundlich ausgerichtet werden (16-Byte-Alignment). Anstatt viele einzelne Variablen zu nutzen, packt man Informationen clever in 4er-Blöcke (vec4). Ein Vertex speichert so z. B. [X, Y, Z, Inverse_Masse]. Hat ein Punkt die inverse Masse 0.0, wird er vom Algorithmus ignoriert und schwebt unbeweglich in der Luft – ein eleganter Trick für Aufhängungen ohne extra Wenn-Dann-Abfragen.",
|
"DATA_STRUCTURES_EXPLANATION": "Für maximale GPU-Performance müssen Daten speicherfreundlich ausgerichtet werden (16-Byte-Alignment). Anstatt viele einzelne Variablen zu nutzen, packt man Informationen clever in 4er-Blöcke (vec4). Ein Vertex speichert so z. B. [X, Y, Z, Inverse_Masse]. Hat ein Punkt die inverse Masse 0.0, wird er vom Algorithmus ignoriert und schwebt unbeweglich in der Luft – ein eleganter Trick für Aufhängungen ohne extra Wenn-Dann-Abfragen.",
|
||||||
"DISCLAIMER": "XPBD vs. Masse-Feder-Systeme: In der physikalischen Simulation gibt es grundlegende Architektur-Unterschiede beim Lösen der Gleichungen:",
|
"DISCLAIMER": "XPBD vs. Masse-Feder-Systeme: In der physikalischen Simulation gibt es grundlegende Architektur-Unterschiede beim Lösen der Gleichungen:",
|
||||||
"DISCLAIMER_1": "Klassische Masse-Feder-Systeme: Hier werden Kräfte (Hookesches Gesetz) berechnet, die zu Beschleunigungen und schließlich zu neuen Positionen führen. Es gibt zwei Wege, diese mathematisch in die Zukunft zu rechnen (Integration):",
|
"DISCLAIMER_1": "Klassische Masse-Feder-Systeme: Hier werden Kräfte (Hookesches Gesetz) berechnet, die zu Beschleunigungen und schließlich zu neuen Positionen führen. Es gibt zwei Wege, diese mathematisch in die Zukunft zu rechnen (Integration):",
|
||||||
"DISCLAIMER_2": "Explizite Löser (z.B. Forward Euler): Sie berechnen den nächsten Schritt stur aus dem aktuellen Zustand. Sie sind leicht zu programmieren, aber bei steifen Stoffen extrem instabil. Die Kräfte schaukeln sich auf und die Simulation 'explodiert', sofern man keine winzigen, sehr leistungsfressenden Zeitschritte wählt.",
|
"DISCLAIMER_2": "Explizite Löser (z.B. Forward Euler): Sie berechnen den nächsten Schritt stur aus dem aktuellen Zustand. Sie sind leicht zu programmieren, aber bei steifen Stoffen extrem instabil. Die Kräfte schaukeln sich auf und die simulation 'explodiert', sofern man keine winzigen, sehr leistungsfressenden Zeitschritte wählt.",
|
||||||
"DISCLAIMER_3": "Implizite Löser (z.B. Backward Euler): Sie berechnen den nächsten Schritt basierend auf dem zukünftigen Zustand. Das ist mathematisch enorm stabil, erfordert aber das Lösen riesiger globaler Matrix-Gleichungssysteme in jedem Frame. Dies ist auf der GPU schwerer zu parallelisieren und bricht zusammen, wenn sich die Struktur ändert (z. B. durch Zerschneiden des Stoffs).",
|
"DISCLAIMER_3": "Implizite Löser (z.B. Backward Euler): Sie berechnen den nächsten Schritt basierend auf dem zukünftigen Zustand. Das ist mathematisch enorm stabil, erfordert aber das Lösen riesiger globaler Matrix-Gleichungssysteme in jedem Frame. Dies ist auf der GPU schwerer zu parallelisieren und bricht zusammen, wenn sich die Struktur ändert (z. B. durch Zerschneiden des Stoffs).",
|
||||||
"DISCLAIMER_4": "Der XPBD-Kompromiss: XPBD umgeht dieses komplexe Matrix-Problem völlig, indem es als lokaler Löser arbeitet. Es kombiniert die unbedingte Stabilität eines impliziten Lösers mit der enormen Geschwindigkeit, Parallelisierbarkeit und dynamischen Anpassungsfähigkeit eines expliziten Systems."
|
"DISCLAIMER_4": "Der XPBD-Kompromiss: XPBD umgeht dieses komplexe Matrix-Problem völlig, indem es als lokaler Löser arbeitet. Es kombiniert die unbedingte Stabilität eines impliziten Lösers mit der enormen Geschwindigkeit, Parallelisierbarkeit und dynamischen Anpassungsfähigkeit eines expliziten Systems."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"GPU": {
|
||||||
|
"WEBGL_FALLBACK": "WebGPU ist nicht verfügbar. WebGL wird als Fallback verwendet. Die Leistung kann eingeschränkt sein.",
|
||||||
|
"NOT_SUPPORTED": "Weder WebGPU noch WebGL konnten initialisiert werden. GPU-Visualisierungen sind nicht verfügbar."
|
||||||
|
},
|
||||||
"ALGORITHM": {
|
"ALGORITHM": {
|
||||||
"TITLE": "Algorithmen",
|
"TITLE": "Algorithmen",
|
||||||
"PATHFINDING": {
|
"PATHFINDING": {
|
||||||
@@ -513,6 +579,10 @@
|
|||||||
"TITLE": "Labyrinth-Erzeugung",
|
"TITLE": "Labyrinth-Erzeugung",
|
||||||
"DESCRIPTION": "Visualisierung verschiedener Laybrinth-Erzeugungs-Algorithmen."
|
"DESCRIPTION": "Visualisierung verschiedener Laybrinth-Erzeugungs-Algorithmen."
|
||||||
},
|
},
|
||||||
|
"FOUR_COLOR": {
|
||||||
|
"TITLE": "Vier-Farben-Satz",
|
||||||
|
"DESCRIPTION": "Der Vier-Farben-Satz besagt, dass jede Landkarte in der Ebene mit maximal vier Farben so eingefärbt werden kann, dass keine zwei aneinandergrenzenden Gebiete dieselbe Farbe besitzen."
|
||||||
|
},
|
||||||
"FRACTAL": {
|
"FRACTAL": {
|
||||||
"TITLE": "Fraktale",
|
"TITLE": "Fraktale",
|
||||||
"DESCRIPTION": "Visualisierung von komplexe, geometrische Mustern, die sich selbst in immer kleineren Maßstäben ähneln (Selbstähnlichkeit)."
|
"DESCRIPTION": "Visualisierung von komplexe, geometrische Mustern, die sich selbst in immer kleineren Maßstäben ähneln (Selbstähnlichkeit)."
|
||||||
@@ -532,5 +602,85 @@
|
|||||||
"NOTE": "HINWEIS",
|
"NOTE": "HINWEIS",
|
||||||
"GRID_HEIGHT": "Höhe",
|
"GRID_HEIGHT": "Höhe",
|
||||||
"GRID_WIDTH": "Beite"
|
"GRID_WIDTH": "Beite"
|
||||||
|
},
|
||||||
|
"FOUR_COLOR": {
|
||||||
|
"TITLE": "Vier-Farben-Satz",
|
||||||
|
"GENERATE": "Neue Karte generieren",
|
||||||
|
"SOLVE": "Automatisch lösen",
|
||||||
|
"CLEAR": "Farben zurücksetzen",
|
||||||
|
"COLOR_1": "Farbe 1",
|
||||||
|
"COLOR_2": "Farbe 2",
|
||||||
|
"COLOR_3": "Farbe 3",
|
||||||
|
"COLOR_4": "Farbe 4",
|
||||||
|
"EXECUTION_TIME": "Ausführungszeit",
|
||||||
|
"STATUS": {
|
||||||
|
"LABEL": "Status",
|
||||||
|
"INCOMPLETE": "Die Karte ist noch nicht vollständig eingefärbt.",
|
||||||
|
"SOLVED": "Glückwunsch! Du hast die Karte korrekt gelöst!",
|
||||||
|
"CONFLICTS": "Achtung: Benachbarte Regionen haben die gleiche Farbe!",
|
||||||
|
"INVALID": "Karte ist vollständig, enthält aber Fehler."
|
||||||
|
},
|
||||||
|
"EXPLANATION": {
|
||||||
|
"TITLE": "Der Vier-Farben-Satz",
|
||||||
|
"EXPLANATION": "Der Vier-Farben-Satz besagt, dass jede Landkarte in der Ebene mit maximal vier Farben so eingefärbt werden kann, dass keine zwei aneinandergrenzenden Gebiete dieselbe Farbe besitzen.",
|
||||||
|
"DISCLAIMER": "Dieser Algorithmus verwendet Backtracking, um eine gültige Färbung für die generierten Regionen zu finden.",
|
||||||
|
"DISCLAIMER_1": "Kartengenerierung: Regionen werden mittels eines zufälligen Seed-Wachstumsalgorithmus (Voronoi-ähnlich) auf einem Gitter erzeugt.",
|
||||||
|
"DISCLAIMER_2": "Adjazenz: Zwei Regionen gelten als benachbart, wenn sie mindestens eine gemeinsame Kante im Gitter teilen.",
|
||||||
|
"DISCLAIMER_3": "Backtracking: Der Löser probiert die Farben 1-4 für jede Region aus und macht Schritte rückgängig (Backtracking), wenn ein Konflikt auftritt.",
|
||||||
|
"DISCLAIMER_4": "Interaktiv: Sie können auch auf Regionen klicken, um manuell durch die Farben zu wechseln.",
|
||||||
|
"DISCLAIMER_BOTTOM": "Zum einfärben in die Zelle klicken. Durch erneutes klicken ändert sich die Farbe."
|
||||||
|
},
|
||||||
|
"ALERT": {
|
||||||
|
"NO_SOLUTION": "Keine Lösung gefunden (das sollte bei einer planaren Karte nicht passieren!)."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"STOPWATCH": {
|
||||||
|
"TITLE": "Stoppuhr",
|
||||||
|
"START": "Start",
|
||||||
|
"PAUSE": "Pause",
|
||||||
|
"STOP": "Stopp",
|
||||||
|
"RESET": "Zurücksetzen",
|
||||||
|
"INTERVAL_ENABLED": "Signalton bei Intervall",
|
||||||
|
"FIRST_INTERVAL": "Erstes Intervall",
|
||||||
|
"INTERVAL_MINUTES": "Minuten",
|
||||||
|
"INTERVAL_SECONDS": "Sekunden",
|
||||||
|
"SECOND_INTERVAL_ENABLED": "Zweites, abwechselndes Intervall hinzufügen",
|
||||||
|
"INTERVAL_HINT": "Spielt einen kurzen Ton ab, sobald ein Intervall abgelaufen ist. Mit aktiviertem zweiten Intervall wechseln sich beide ab, das zweite startet direkt nach dem ersten.",
|
||||||
|
"INTERVAL_SCHEDULE": "Nächste Signaltöne bei:"
|
||||||
|
},
|
||||||
|
"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 Softwareentwickler und Architekt aus München: Erfahrung, Fähigkeiten und Projekte in Full-Stack-Entwicklung, 3D-Simulation, Algorithmen und KI-gestütztem Engineering."
|
||||||
|
},
|
||||||
|
"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."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,41 +1,58 @@
|
|||||||
{
|
{
|
||||||
"APP": {
|
"APP": {
|
||||||
"TITLE": "Playground",
|
"TITLE": "Playground",
|
||||||
"COPYRIGHT": "Images and code protected by copyright, no use without permission!"
|
"COPYRIGHT": "Images and code protected by copyright, no use without permission!",
|
||||||
|
"AI_NOTICE": "Built with an AI-assisted engineering workflow.",
|
||||||
|
"AI_NOTICE_LINK": "How I build with AI"
|
||||||
},
|
},
|
||||||
"TOPBAR": {
|
"TOPBAR": {
|
||||||
"ABOUT": "About me",
|
"ABOUT": "About me",
|
||||||
"IMPRINT": "Impressum",
|
"IMPRINT": "Impressum",
|
||||||
"PROJECTS": "Projects",
|
"PROJECTS": "Projects",
|
||||||
"ALGORITHMS": "Algorithms",
|
"ALGORITHMS": "Algorithms",
|
||||||
|
"STOPWATCH": "Stopwatch",
|
||||||
"SETTINGS": "Settings",
|
"SETTINGS": "Settings",
|
||||||
"LANGUAGE": "Language",
|
"LANGUAGE": "Language",
|
||||||
"APPEARANCE": "Appearance"
|
"APPEARANCE": "Appearance"
|
||||||
},
|
},
|
||||||
"THEME": { "LIGHT": "Light", "DARK": "Dark" },
|
"THEME": {
|
||||||
"LANG": { "LABEL": "Language", "EN": "English", "DE": "Deutsch" },
|
"LIGHT": "Light",
|
||||||
|
"DARK": "Dark"
|
||||||
|
},
|
||||||
|
"LANG": {
|
||||||
|
"LABEL": "Language",
|
||||||
|
"EN": "English",
|
||||||
|
"DE": "Deutsch"
|
||||||
|
},
|
||||||
"ABOUT": {
|
"ABOUT": {
|
||||||
"ALT": { "PROFILE": "Profile photo of Andreas Dahm" },
|
"ALT": {
|
||||||
|
"PROFILE": "Profile photo of Andreas Dahm"
|
||||||
|
},
|
||||||
"HELLO": "Hello, I’m Andreas.",
|
"HELLO": "Hello, I’m Andreas.",
|
||||||
"LEAD": "I first discovered my interest in software development during my training as an application developer. After completing my degree in Applied Computer Science at FH Bingen, I have been working continuously as a software developer since 2010. During this time, I have gained experience in various areas, including 3D simulation and modern web applications. I also enjoy working on personal software projects, particularly in game development and algorithmic topics. Thank you for stopping by!",
|
"LEAD": "I have been working as a software developer since 2010. Most of that time went into two fairly different corners. One is real-time 3D simulation: cloth physics, collision detection, GPU shaders. The other is cloud architecture for larger enterprise platforms. I led a small backend team for a few years, planned the move of a big legacy monolith towards microservices, and released a game on Steam together with my brother. Lately a lot of my time goes into tooling around AI-assisted development, mostly into making the output reviewable instead of just fast. In my free time I build games and mess around with algorithms. Most of that ends up somewhere on this site.",
|
||||||
"ROLE": "Senior Software Developer / Full-Stack Developer / Software Architect",
|
"ROLE": "Senior Software Developer / Full-Stack Developer / Software Architect",
|
||||||
"LOCATION": "Munich · Remote",
|
"LOCATION": "Munich · Remote",
|
||||||
"DOWNLOAD_CV": "Download CV",
|
|
||||||
"VIEW_PROJECTS": "View projects",
|
"VIEW_PROJECTS": "View projects",
|
||||||
"CONTACT_ME": "Contact me",
|
"CONTACT_ME": "Contact me",
|
||||||
"SECTION": {
|
"SECTION": {
|
||||||
"SKILLS": "Skills & Stack",
|
"SKILLS": "Skills & Stack",
|
||||||
"BACKEND_ARCH": "Backend & Architecture",
|
"LANGUAGES": "Languages & Frameworks",
|
||||||
"INFRA_CLOUD": "Infrastructure & Cloud",
|
"ARCH_CLOUD": "Architecture & Cloud",
|
||||||
"SIM_ALGO": "Simulation & Algorithms",
|
"SIM_ALGO": "Simulation & Algorithms",
|
||||||
|
"AI_ENG": "AI-Assisted Engineering",
|
||||||
"EXPERIENCE": "Experience",
|
"EXPERIENCE": "Experience",
|
||||||
"PROJECTS": "Projects",
|
"PROJECTS": "Projects",
|
||||||
"EDUCATION": "Education"
|
"EDUCATION": "Education",
|
||||||
|
"SHOW_EARLIER_XP": "Show earlier positions",
|
||||||
|
"SHOW_EARLIER_EDU": "Show earlier education",
|
||||||
|
"SHOW_LESS": "Show less"
|
||||||
},
|
},
|
||||||
"SKILLS": {
|
"SKILLS": {
|
||||||
"JAVA": "Java 8/21+",
|
"JAVA": "Java",
|
||||||
"SPRING": "Spring Boot 2/3",
|
"SPRING": "Spring Boot",
|
||||||
"ANGULAR": "Angular 19+",
|
"ANGULAR": "Angular",
|
||||||
|
"VUE": "Vue.js",
|
||||||
|
"REACT": "React",
|
||||||
"DOCKER": "Docker",
|
"DOCKER": "Docker",
|
||||||
"UNITY": "Unity",
|
"UNITY": "Unity",
|
||||||
"PYTHON": "Python",
|
"PYTHON": "Python",
|
||||||
@@ -47,7 +64,12 @@
|
|||||||
"ENG_SIM": "3D Simulation",
|
"ENG_SIM": "3D Simulation",
|
||||||
"ENG_GPU": "WebGPU / OpenGL / GLSL",
|
"ENG_GPU": "WebGPU / OpenGL / GLSL",
|
||||||
"ENG_PERF": "Performance Optimization",
|
"ENG_PERF": "Performance Optimization",
|
||||||
"ENG_3D": "3D-Scanner Tech"
|
"ENG_3D": "3D-Scanner Tech",
|
||||||
|
"AI_AGENTS": "Claude Code / Agent SDK",
|
||||||
|
"AI_MCP": "MCP servers",
|
||||||
|
"AI_CONTEXT": "Context & prompt engineering",
|
||||||
|
"AI_HARNESS": "Agent harness design",
|
||||||
|
"AI_GATES": "Automated review gates"
|
||||||
},
|
},
|
||||||
"TOOLS": {
|
"TOOLS": {
|
||||||
"GIT": "Git",
|
"GIT": "Git",
|
||||||
@@ -61,10 +83,20 @@
|
|||||||
"DOCKER": "Docker"
|
"DOCKER": "Docker"
|
||||||
},
|
},
|
||||||
"XP": {
|
"XP": {
|
||||||
|
"COMPANY9": {
|
||||||
|
"COMPANY": "ColorDigital GmbH",
|
||||||
|
"ROLE": "Senior Software Engineer - DMIxOS Team",
|
||||||
|
"TIME": "March 2026 – present",
|
||||||
|
"HIGHLIGHTS": {
|
||||||
|
"P1": "Designed and implemented the core cloud architecture.",
|
||||||
|
"P2": "Full-stack development and optimization of new and existing system components.",
|
||||||
|
"P3": "Technical integration of partner companies into the proprietary ecosystem."
|
||||||
|
}
|
||||||
|
},
|
||||||
"COMPANY8": {
|
"COMPANY8": {
|
||||||
"COMPANY": "Teraport GmbH",
|
"COMPANY": "Teraport GmbH",
|
||||||
"ROLE": "Senior Software Developer / Architect",
|
"ROLE": "Senior Software Developer / Architect",
|
||||||
"TIME": "Feb. 2024 – now",
|
"TIME": "Feb. 2024 – Feb. 2026",
|
||||||
"HIGHLIGHTS": {
|
"HIGHLIGHTS": {
|
||||||
"P1": "Architecture and implementation of database connectivity using Hibernate 6.x.",
|
"P1": "Architecture and implementation of database connectivity using Hibernate 6.x.",
|
||||||
"P2": "Design and development of a full-stack web application for collision analysis (Angular + Spring Boot + Docker).",
|
"P2": "Design and development of a full-stack web application for collision analysis (Angular + Spring Boot + Docker).",
|
||||||
@@ -133,7 +165,7 @@
|
|||||||
},
|
},
|
||||||
"COMPANY1": {
|
"COMPANY1": {
|
||||||
"COMPANY": "TH Bingen (University of Applied Sciences)",
|
"COMPANY": "TH Bingen (University of Applied Sciences)",
|
||||||
"ROLE": "Research Asssitent",
|
"ROLE": "Research Assistant",
|
||||||
"TIME": "Oct. 2007 – Apr. 2008",
|
"TIME": "Oct. 2007 – Apr. 2008",
|
||||||
"HIGHLIGHTS": {
|
"HIGHLIGHTS": {
|
||||||
"P1": "Evaluation of various Web 2.0 technologies.",
|
"P1": "Evaluation of various Web 2.0 technologies.",
|
||||||
@@ -147,12 +179,22 @@
|
|||||||
"TIME": "Jul. 2002 – Jun. 2005",
|
"TIME": "Jul. 2002 – Jun. 2005",
|
||||||
"HIGHLIGHTS": {
|
"HIGHLIGHTS": {
|
||||||
"P1": "Development in PERL, PHP and ASP.",
|
"P1": "Development in PERL, PHP and ASP.",
|
||||||
"P2": "Portierung, Wartung und Reengineering von bestender Software.",
|
"P2": "Porting, maintenance and reengineering of existing software.",
|
||||||
"P3": "Regular performance of system tests and quality controls, as well as their documentation."
|
"P3": "Regular performance of system tests and quality controls, as well as their documentation."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"PROJECT": {
|
"PROJECT": {
|
||||||
|
"CLAUDE_BOX": {
|
||||||
|
"TITLE": "Claude-in-a-Box",
|
||||||
|
"DESCRIPTION": "A development setup in a container where an AI coding agent works under a fixed set of rules rather than whatever I happen to type that day. It picks up the rules for whatever stack it finds in the repository, and a git hook stops the commit if the build or the tests are red.",
|
||||||
|
"LINK_INTERNAL": "Project details",
|
||||||
|
"HIGHLIGHTS": {
|
||||||
|
"P1": "The agent loads the rules for the stack it finds in the repo, so a Java service and a frontend get different ones.",
|
||||||
|
"P2": "A commit hook runs the build and the tests and blocks the commit when they fail.",
|
||||||
|
"P3": "MCP servers for code search and read-only Jira access, plus separate review agents that go over the diff."
|
||||||
|
}
|
||||||
|
},
|
||||||
"P2": {
|
"P2": {
|
||||||
"TITLE": "Playground",
|
"TITLE": "Playground",
|
||||||
"DESCRIPTION": "This project is, as the name suggests, a personal playground that will grow over time. It showcases various private projects I’m working on and documents their progress, key ideas, and milestones.",
|
"DESCRIPTION": "This project is, as the name suggests, a personal playground that will grow over time. It showcases various private projects I’m working on and documents their progress, key ideas, and milestones.",
|
||||||
@@ -297,7 +339,7 @@
|
|||||||
"GAME_JAMS": {
|
"GAME_JAMS": {
|
||||||
"TITLE": "Rapid Prototyping & Game Jams",
|
"TITLE": "Rapid Prototyping & Game Jams",
|
||||||
"SHORT_DESCRIPTION": "Collection of innovative game concepts, created in under 48 hours.",
|
"SHORT_DESCRIPTION": "Collection of innovative game concepts, created in under 48 hours.",
|
||||||
"INTRODUCTION": "Participation in international competitions (e.g., Ludum Dare). The focus is on creating functional and fun prototypes under extreme time pressure.",
|
"INTRODUCTION": "Participation in national competitions (e.g. BeansJam). The focus is on creating functional and fun prototypes under extreme time pressure.",
|
||||||
"BULLET_1": "Focus on 'Core Game Loop' and fast feedback.",
|
"BULLET_1": "Focus on 'Core Game Loop' and fast feedback.",
|
||||||
"BULLET_2": "Collaborative development in small, agile teams.",
|
"BULLET_2": "Collaborative development in small, agile teams.",
|
||||||
"BULLET_3": "Effective time management and scope control.",
|
"BULLET_3": "Effective time management and scope control.",
|
||||||
@@ -312,17 +354,31 @@
|
|||||||
"SHORT_DESCRIPTION": "Real-time collision detection for complex, flexible 3D objects.",
|
"SHORT_DESCRIPTION": "Real-time collision detection for complex, flexible 3D objects.",
|
||||||
"INTRODUCTION": "Research work in the field of computer graphics. Development of an algorithm for physically correct simulation of fabrics and clothing in real-time.",
|
"INTRODUCTION": "Research work in the field of computer graphics. Development of an algorithm for physically correct simulation of fabrics and clothing in real-time.",
|
||||||
"BULLET_1": "Mathematical modeling of mass-spring systems.",
|
"BULLET_1": "Mathematical modeling of mass-spring systems.",
|
||||||
"BULLET_2": "Low-level programming with C++, OpenGL, and shader code.",
|
"BULLET_2": "Low-level programming with Java.",
|
||||||
"BULLET_3": "Optimization through spatial data structures (AABB Trees, Bounding Spheres).",
|
"BULLET_3": "Optimization through spatial data structures (AABB Trees, Bounding Spheres).",
|
||||||
"BULLET_4": "Scientific evaluation of simulation precision.",
|
"BULLET_4": "Scientific evaluation of simulation precision.",
|
||||||
"CHALLENGE_1": "Handling 'self-collisions' in high-resolution meshes without performance loss.",
|
"CHALLENGE_1": "Handling 'self-collisions' in high-resolution meshes without performance loss.",
|
||||||
"CHALLENGE_2": "Mathematical stabilization of integration methods under high force impacts.",
|
"CHALLENGE_2": "Mathematical stabilization of integration methods under high force impacts.",
|
||||||
"LEARNING_1": "Transferring theoretical research into productive, high-performance code.",
|
"LEARNING_1": "Transferring theoretical research into productive, high-performance code.",
|
||||||
"LEARNING_2": "Precise working and documentation according to scientific standards."
|
"LEARNING_2": "Precise working and documentation according to scientific standards."
|
||||||
|
},
|
||||||
|
"CLAUDE_BOX": {
|
||||||
|
"TITLE": "Claude-in-a-Box",
|
||||||
|
"SHORT_DESCRIPTION": "A container setup for working with an AI coding agent under a fixed set of rules.",
|
||||||
|
"INTRODUCTION": "An AI coding agent will produce code all day without complaining. The trouble is that what comes out usually looks reasonable even when it is wrong, so all the cost lands in review. I built a Docker setup that puts the agent under the same constraints as anyone else working on the repo. It reads the architecture rules of whatever project it is in, and the build has to pass before a commit goes through. The hook that enforces that last part is the piece I keep coming back to, because it is the one thing the agent has no way around.",
|
||||||
|
"BULLET_1": "Container with fixed toolchains: JDKs and Maven through SDKMAN, Node, and a home volume that survives a rebuild.",
|
||||||
|
"BULLET_2": "Rules are loaded by detection. A repo with a pom.xml gets the Java and Spring rules, one with a package.json gets the TypeScript rules, and repos with their own architecture notes get those on top.",
|
||||||
|
"BULLET_3": "A commit hook works out the stack, runs the matching build and tests, and refuses the commit if they fail.",
|
||||||
|
"BULLET_4": "MCP servers for code graph queries and read-only Jira lookups, plus review agents that check a finished diff before it counts as done.",
|
||||||
|
"CHALLENGE_1": "Keeping the rules short enough to stay useful. Long general guidelines just get ignored, so they are split into per-stack and per-repo files that only load when they apply.",
|
||||||
|
"CHALLENGE_2": "Making the quality gate something the agent cannot talk its way past. The check runs as a git hook outside the agent, so a failing build blocks the commit no matter what the agent believes about its own work.",
|
||||||
|
"CHALLENGE_3": "Getting the toolchains installed in a container that has no root. Builds, browsers and language runtimes all had to go into volumes owned by the development user.",
|
||||||
|
"LEARNING_1": "Prompt wording mattered far less than I expected. Almost all of the difference came from the scaffolding.",
|
||||||
|
"LEARNING_2": "Reviewing and gating the agent's output the same way you would a new colleague's pull request turned out to be the most useful rule of the lot."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"IMPRINT": {
|
"IMPRINT": {
|
||||||
"TITLE" : "Imprint",
|
"TITLE": "Imprint",
|
||||||
"PARAGRAPH": "Information pursuant to Section 5 DDG",
|
"PARAGRAPH": "Information pursuant to Section 5 DDG",
|
||||||
"COUNTRY": "Germany",
|
"COUNTRY": "Germany",
|
||||||
"CONTACT": "Contact"
|
"CONTACT": "Contact"
|
||||||
@@ -359,13 +415,17 @@
|
|||||||
"START": "Start Sorting",
|
"START": "Start Sorting",
|
||||||
"RESET": "Reset",
|
"RESET": "Reset",
|
||||||
"GENERATE_NEW_ARRAY": "Generate New Array",
|
"GENERATE_NEW_ARRAY": "Generate New Array",
|
||||||
|
"SOUND_ON": "Sound On",
|
||||||
|
"SOUND_OFF": "Sound Off",
|
||||||
"EXECUTION_TIME": "Execution Time",
|
"EXECUTION_TIME": "Execution Time",
|
||||||
"ARRAY_SIZE": "Number of Bars",
|
"ARRAY_SIZE": "Number of Bars",
|
||||||
"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.",
|
||||||
"DISCLAIMER": "The choice of the \"best\" sorting algorithm depends heavily on the data and the constraints. In computer science, three scenarios are often considered:",
|
"DISCLAIMER": "The choice of the \"best\" sorting algorithm depends heavily on the data and the constraints. In computer science, three scenarios are often considered:",
|
||||||
"DISCLAIMER_1": "Best Case: The data is already nearly sorted (Bubble Sort shines here, for example).",
|
"DISCLAIMER_1": "Best Case: The data is already nearly sorted (Bubble Sort shines here, for example).",
|
||||||
"DISCLAIMER_2": "Average Case: The statistical norm.",
|
"DISCLAIMER_2": "Average Case: The statistical norm.",
|
||||||
@@ -477,6 +537,9 @@
|
|||||||
"WIND_OFF": "Wind Off",
|
"WIND_OFF": "Wind Off",
|
||||||
"OUTLINE_ON": "Show Mesh",
|
"OUTLINE_ON": "Show Mesh",
|
||||||
"OUTLINE_OFF": "Hide Mesh",
|
"OUTLINE_OFF": "Hide Mesh",
|
||||||
|
"STIFFNESS": "Stiffness",
|
||||||
|
"ELONGATION": "Elongation",
|
||||||
|
"RESTART_SIMULATION": "Restart Simulation",
|
||||||
"EXPLANATION": {
|
"EXPLANATION": {
|
||||||
"TITLE": "Real-time Cloth Simulation on the GPU",
|
"TITLE": "Real-time Cloth Simulation on the GPU",
|
||||||
"CLOTH_SIMULATION_EXPLANATION_TITLE": "Cloth Simulation",
|
"CLOTH_SIMULATION_EXPLANATION_TITLE": "Cloth Simulation",
|
||||||
@@ -494,6 +557,10 @@
|
|||||||
"DISCLAIMER_4": "The XPBD Compromise: XPBD completely bypasses this complex matrix problem by acting as a local solver. It combines the absolute stability of an implicit solver with the enormous speed, parallelizability, and dynamic adaptability of an explicit system."
|
"DISCLAIMER_4": "The XPBD Compromise: XPBD completely bypasses this complex matrix problem by acting as a local solver. It combines the absolute stability of an implicit solver with the enormous speed, parallelizability, and dynamic adaptability of an explicit system."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"GPU": {
|
||||||
|
"WEBGL_FALLBACK": "WebGPU is not available. Using WebGL as fallback. Performance may be reduced.",
|
||||||
|
"NOT_SUPPORTED": "Neither WebGPU nor WebGL could be initialized. GPU visualizations are unavailable."
|
||||||
|
},
|
||||||
"ALGORITHM": {
|
"ALGORITHM": {
|
||||||
"TITLE": "Algorithms",
|
"TITLE": "Algorithms",
|
||||||
"PATHFINDING": {
|
"PATHFINDING": {
|
||||||
@@ -512,6 +579,10 @@
|
|||||||
"TITLE": "Maze Generation",
|
"TITLE": "Maze Generation",
|
||||||
"DESCRIPTION": "Visualizing various maze generation algorithms."
|
"DESCRIPTION": "Visualizing various maze generation algorithms."
|
||||||
},
|
},
|
||||||
|
"FOUR_COLOR": {
|
||||||
|
"TITLE": "Four Color Theorem",
|
||||||
|
"DESCRIPTION": "The four color theorem states that any map in a plane can be colored using at most four colors in such a way that regions sharing a common boundary (other than a single point) do not share the same color."
|
||||||
|
},
|
||||||
"FRACTAL": {
|
"FRACTAL": {
|
||||||
"TITLE": "Fractals",
|
"TITLE": "Fractals",
|
||||||
"DESCRIPTION": "Visualisation of complex geometric patterns that resemble each other on increasingly smaller scales (self-similarity)."
|
"DESCRIPTION": "Visualisation of complex geometric patterns that resemble each other on increasingly smaller scales (self-similarity)."
|
||||||
@@ -531,5 +602,85 @@
|
|||||||
"NOTE": "Note",
|
"NOTE": "Note",
|
||||||
"GRID_HEIGHT": "Height",
|
"GRID_HEIGHT": "Height",
|
||||||
"GRID_WIDTH": "Width"
|
"GRID_WIDTH": "Width"
|
||||||
|
},
|
||||||
|
"FOUR_COLOR": {
|
||||||
|
"TITLE": "Four Color Theorem",
|
||||||
|
"GENERATE": "Generate Map",
|
||||||
|
"SOLVE": "Auto Solve",
|
||||||
|
"CLEAR": "Clear Colors",
|
||||||
|
"COLOR_1": "Color 1",
|
||||||
|
"COLOR_2": "Color 2",
|
||||||
|
"COLOR_3": "Color 3",
|
||||||
|
"COLOR_4": "Color 4",
|
||||||
|
"EXECUTION_TIME": "Execution Time",
|
||||||
|
"STATUS": {
|
||||||
|
"LABEL": "Status",
|
||||||
|
"INCOMPLETE": "Map is not fully colored yet.",
|
||||||
|
"SOLVED": "Congratulations! You solved the map correctly!",
|
||||||
|
"CONFLICTS": "Warning: Adjacent regions have the same color!",
|
||||||
|
"INVALID": "Map is fully colored, but contains conflicts."
|
||||||
|
},
|
||||||
|
"EXPLANATION": {
|
||||||
|
"TITLE": "Four Color Theorem",
|
||||||
|
"EXPLANATION": "The four color theorem states that any map in a plane can be colored using at most four colors in such a way that regions sharing a common boundary (other than a single point) do not share the same color.",
|
||||||
|
"DISCLAIMER": "This algorithm uses backtracking to find a valid coloring for the generated regions.",
|
||||||
|
"DISCLAIMER_1": "Map Generation: Regions are generated using a random seed growth algorithm (Voronoi-like) on a grid.",
|
||||||
|
"DISCLAIMER_2": "Adjacency: Two regions are considered neighbors if they share at least one edge in the grid.",
|
||||||
|
"DISCLAIMER_3": "Backtracking: The solver tries colors 1-4 for each region, backtracking when a conflict is found.",
|
||||||
|
"DISCLAIMER_4": "Interactive: You can also click on regions to cycle through colors manually.",
|
||||||
|
"DISCLAIMER_BOTTOM": "Click to color the region. Click again to change the color."
|
||||||
|
},
|
||||||
|
"ALERT": {
|
||||||
|
"NO_SOLUTION": "No solution found (this should not happen for a planar map!)."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"STOPWATCH": {
|
||||||
|
"TITLE": "Stopwatch",
|
||||||
|
"START": "Start",
|
||||||
|
"PAUSE": "Pause",
|
||||||
|
"STOP": "Stop",
|
||||||
|
"RESET": "Reset",
|
||||||
|
"INTERVAL_ENABLED": "Beep at interval",
|
||||||
|
"FIRST_INTERVAL": "First interval",
|
||||||
|
"INTERVAL_MINUTES": "Minutes",
|
||||||
|
"INTERVAL_SECONDS": "Seconds",
|
||||||
|
"SECOND_INTERVAL_ENABLED": "Add a second, alternating interval",
|
||||||
|
"INTERVAL_HINT": "Plays a short tone every time an interval elapses. With a second interval enabled both alternate, so the second one starts right after the first.",
|
||||||
|
"INTERVAL_SCHEDULE": "Next beeps at:"
|
||||||
|
},
|
||||||
|
"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, algorithms and AI-assisted engineering."
|
||||||
|
},
|
||||||
|
"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 |
Binary file not shown.
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 5.0 KiB |
BIN
src/assets/logos/logo-wolf.png
Normal file
BIN
src/assets/logos/logo-wolf.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
109
src/assets/projects/claude-in-a-box/1.svg
Normal file
109
src/assets/projects/claude-in-a-box/1.svg
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 630" width="1200" height="630" role="img" aria-labelledby="title desc">
|
||||||
|
<title id="title">Claude-in-a-Box</title>
|
||||||
|
<desc id="desc">A containerised agent: repository detection routes stack rules into the box, and a commit gate blocks anything with a failing build.</desc>
|
||||||
|
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#1e2530"/>
|
||||||
|
<stop offset="100%" stop-color="#15181d"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="accent" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stop-color="#26c6da"/>
|
||||||
|
<stop offset="100%" stop-color="#ffa726"/>
|
||||||
|
</linearGradient>
|
||||||
|
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
|
||||||
|
<path d="M 0 0 L 10 5 L 0 10 z" fill="#4a5563"/>
|
||||||
|
</marker>
|
||||||
|
<marker id="arrowCyan" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
|
||||||
|
<path d="M 0 0 L 10 5 L 0 10 z" fill="#26c6da"/>
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; }
|
||||||
|
.sans { font-family: Roboto, Arial, sans-serif; }
|
||||||
|
.lbl { fill: #8b97a6; font-size: 17px; letter-spacing: .04em; }
|
||||||
|
.chip { fill: #cdd6e0; font-size: 19px; }
|
||||||
|
.cap { fill: #6c7887; font-size: 15px; letter-spacing: .18em; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<rect width="1200" height="630" fill="url(#bg)"/>
|
||||||
|
<rect x="0" y="0" width="1200" height="4" fill="url(#accent)"/>
|
||||||
|
|
||||||
|
<text x="64" y="72" class="mono" fill="#26c6da" font-size="26" font-weight="600">claude-in-a-box</text>
|
||||||
|
<text x="64" y="102" class="sans lbl">the harness, not the prompt</text>
|
||||||
|
|
||||||
|
<!-- ── Left: repository detection ─────────────────────────── -->
|
||||||
|
<text x="64" y="176" class="sans cap">detect</text>
|
||||||
|
|
||||||
|
<g>
|
||||||
|
<rect x="64" y="196" width="228" height="62" rx="10" fill="#212a35" stroke="#2f3a47"/>
|
||||||
|
<text x="86" y="223" class="mono chip">pom.xml</text>
|
||||||
|
<text x="86" y="245" class="sans lbl" font-size="15">→ java / spring rules</text>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="64" y="278" width="228" height="62" rx="10" fill="#212a35" stroke="#2f3a47"/>
|
||||||
|
<text x="86" y="305" class="mono chip">package.json</text>
|
||||||
|
<text x="86" y="327" class="sans lbl" font-size="15">→ typescript rules</text>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="64" y="360" width="228" height="62" rx="10" fill="#212a35" stroke="#2f3a47"/>
|
||||||
|
<text x="86" y="387" class="mono chip">pyproject.toml</text>
|
||||||
|
<text x="86" y="409" class="sans lbl" font-size="15">→ python rules</text>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="64" y="442" width="228" height="62" rx="10" fill="#212a35" stroke="#2f3a47" stroke-dasharray="4 4"/>
|
||||||
|
<text x="86" y="469" class="mono chip"><repo>.md</text>
|
||||||
|
<text x="86" y="491" class="sans lbl" font-size="15">→ architecture profile</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<path d="M 300 227 H 360" stroke="#4a5563" stroke-width="2" marker-end="url(#arrow)"/>
|
||||||
|
<path d="M 300 309 H 360" stroke="#4a5563" stroke-width="2" marker-end="url(#arrow)"/>
|
||||||
|
<path d="M 300 391 H 360" stroke="#4a5563" stroke-width="2" marker-end="url(#arrow)"/>
|
||||||
|
<path d="M 300 473 H 360" stroke="#4a5563" stroke-width="2" marker-end="url(#arrow)"/>
|
||||||
|
|
||||||
|
<!-- ── Centre: the container ──────────────────────────────── -->
|
||||||
|
<rect x="376" y="150" width="330" height="380" rx="18" fill="#1a222c" stroke="#26c6da" stroke-width="2"/>
|
||||||
|
<text x="400" y="184" class="sans cap" fill="#26c6da">container</text>
|
||||||
|
|
||||||
|
<rect x="404" y="212" width="274" height="96" rx="12" fill="#243040" stroke="#33475c"/>
|
||||||
|
<circle cx="440" cy="248" r="14" fill="none" stroke="#26c6da" stroke-width="2"/>
|
||||||
|
<circle cx="440" cy="248" r="5" fill="#26c6da"/>
|
||||||
|
<text x="470" y="246" class="sans" fill="#e3e9f0" font-size="21" font-weight="600">agent</text>
|
||||||
|
<text x="470" y="270" class="sans lbl" font-size="15">scoped context only</text>
|
||||||
|
<text x="424" y="296" class="mono" fill="#6c7887" font-size="14">/workspace · no root</text>
|
||||||
|
|
||||||
|
<text x="404" y="348" class="sans cap">pinned toolchain</text>
|
||||||
|
<g class="mono" font-size="16" fill="#9aa7b5">
|
||||||
|
<text x="404" y="380">sdkman · jdk · maven</text>
|
||||||
|
<text x="404" y="408">node · npm</text>
|
||||||
|
<text x="404" y="436">mcp: codegraph, jira (ro)</text>
|
||||||
|
</g>
|
||||||
|
<rect x="404" y="462" width="274" height="44" rx="10" fill="#2a2118" stroke="#5c4326"/>
|
||||||
|
<text x="424" y="490" class="mono" fill="#ffa726" font-size="16">review subagents</text>
|
||||||
|
|
||||||
|
<path d="M 714 340 H 776" stroke="#26c6da" stroke-width="2" marker-end="url(#arrowCyan)"/>
|
||||||
|
|
||||||
|
<!-- ── Right: the gate ────────────────────────────────────── -->
|
||||||
|
<text x="792" y="176" class="sans cap">gate</text>
|
||||||
|
|
||||||
|
<rect x="792" y="196" width="344" height="288" rx="14" fill="#1c242e" stroke="#2f3a47"/>
|
||||||
|
|
||||||
|
<g class="mono" font-size="17">
|
||||||
|
<circle cx="822" cy="243" r="7" fill="#4caf50"/>
|
||||||
|
<text x="844" y="249" fill="#cdd6e0">lint</text>
|
||||||
|
<circle cx="822" cy="291" r="7" fill="#4caf50"/>
|
||||||
|
<text x="844" y="297" fill="#cdd6e0">build</text>
|
||||||
|
<circle cx="822" cy="339" r="7" fill="#ef5350"/>
|
||||||
|
<text x="844" y="345" fill="#cdd6e0">tests</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<rect x="812" y="378" width="304" height="76" rx="10" fill="#2b1b1c" stroke="#7d3a3c"/>
|
||||||
|
<text x="836" y="408" class="mono" fill="#ef8b8d" font-size="17">git commit — blocked</text>
|
||||||
|
<text x="836" y="434" class="sans lbl" font-size="15">hook runs outside the agent</text>
|
||||||
|
|
||||||
|
<text x="792" y="524" class="sans lbl" font-size="16">a failing build cannot be argued with</text>
|
||||||
|
|
||||||
|
<line x1="64" y1="566" x2="1136" y2="566" stroke="#2a333e"/>
|
||||||
|
<text x="64" y="598" class="mono" fill="#5b6673" font-size="15">docker · claude code · mcp · hooks</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5.8 KiB |
@@ -2,15 +2,45 @@
|
|||||||
<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">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The LCP image lives in a lazily loaded route chunk, so the browser cannot
|
||||||
|
discover it until Angular has booted. Preloading it here is what makes it
|
||||||
|
fetch in parallel with the bundle instead of after it.
|
||||||
|
-->
|
||||||
|
<link rel="preload" as="image" href="assets/me.webp" fetchpriority="high">
|
||||||
|
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<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&display=swap" rel="stylesheet">
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined&display=swap" rel="stylesheet">
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<app-root></app-root>
|
<app-root class="flex flex-col min-h-screen"></app-root>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user