Compare commits
98 Commits
085201913f
...
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 | |||
| ab3bca4395 | |||
| 12411e58bf | |||
| 14d7a78ac4 | |||
| ed0e370e9d | |||
| f656206691 | |||
| 728dbc047f | |||
| 746022c48d | |||
| 954211b3cf | |||
| 885e609082 | |||
| b61eb4eb73 | |||
| 96d4659652 | |||
| ad43459173 | |||
| 32ecfcb621 | |||
| aceb0ea24e | |||
| c160fb4bc8 | |||
| 45c11e42cd | |||
| e66206c518 | |||
| d0c4ad770b | |||
| 5f8b1de20f | |||
| 30965afcbd | |||
| 6330d45b4e | |||
| c6edc922fe | |||
| 0e78e6b471 | |||
| 8f21b0e6b0 | |||
| cb2ffa2d80 | |||
| 5ebd1d19ea | |||
| eed7e8c0fa | |||
| 24d6d9cdbe | |||
| 34148aade2 | |||
| 5721b2e48e | |||
| 2bfa8ba9a1 | |||
| 13f99ac7ae | |||
| 66df3a7f88 | |||
| 598013a7d0 | |||
| f499b78fd5 | |||
| 0d2e7c97ec | |||
| 13b59d0b36 | |||
| 55ece27e1c | |||
| 68e21489ea | |||
| 796fdf4a79 | |||
| 270716551d | |||
| a494c8156d | |||
| 5691cb408d | |||
| c2ad2ae992 | |||
| 1e8ba020e2 | |||
| 4685a94f1d | |||
| d45b651d2d | |||
| c409cd08b1 | |||
| cc6997e732 | |||
| ea15e66c50 |
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.
|
||||||
|
|
||||||
|
|||||||
125
README.md
125
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
|
||||||
│ └─ index.html
|
│ │ └─ 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
|
||||||
|
├─ 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
10038
package-lock.json
generated
10038
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
66
package.json
66
package.json
@@ -7,40 +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/animations": "~21.1.0",
|
"@angular-slider/ngx-slider": "^21.0.0",
|
||||||
"@angular/cdk": "~21.1.0",
|
"@angular/animations": "~21.2.21",
|
||||||
"@angular/common": "~21.1.0",
|
"@angular/cdk": "~21.2.6",
|
||||||
"@angular/compiler": "~21.1.0",
|
"@angular/common": "~21.2.21",
|
||||||
"@angular/core": "~21.1.0",
|
"@angular/compiler": "~21.2.21",
|
||||||
"@angular/forms": "~21.1.0",
|
"@angular/core": "~21.2.21",
|
||||||
"@angular/material": "~21.1.0",
|
"@angular/forms": "~21.2.21",
|
||||||
"@angular/platform-browser": "~21.1.0",
|
"@angular/material": "~21.2.6",
|
||||||
"@angular/router": "~21.1.0",
|
"@angular/platform-browser": "~21.2.21",
|
||||||
"@babylonjs/core": "^8.50.5",
|
"@angular/router": "~21.2.21",
|
||||||
"@ngx-translate/core": "~17.0.0",
|
"@angular/service-worker": "~21.2.21",
|
||||||
"@ngx-translate/http-loader": "~17.0.0",
|
"@babylonjs/core": "^9.22.0",
|
||||||
"inquirer": "^13.2.2",
|
"@ngx-translate/core": "^18.0.0",
|
||||||
|
"@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,18 +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, loadComponent: () => import('./pages/algorithms/pendulum/pendulum.component').then(m => m.default), data: { seo: 'ALGORITHMS' } },
|
||||||
|
{ path: RouterConstants.CLOTH.PATH, loadComponent: () => import('./pages/algorithms/cloth/cloth.component').then(m => m.ClothComponent), data: { seo: 'ALGORITHMS' } },
|
||||||
|
{ path: RouterConstants.FOUR_COLOR.PATH, loadComponent: () => import('./pages/algorithms/four-color/four-color.component').then(m => m.FourColorComponent), data: { seo: 'ALGORITHMS' } },
|
||||||
|
{ path: RouterConstants.STOPWATCH.PATH, loadComponent: () => import('./pages/stopwatch/stopwatch.component').then(m => m.StopwatchComponent), data: { seo: 'STOPWATCH' } },
|
||||||
|
{ path: '**', loadComponent: () => import('./pages/not-found/not-found.component').then(m => m.NotFoundComponent), data: { seo: 'NOT_FOUND' } },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
@@ -15,6 +15,10 @@ export class AssetsConstants {
|
|||||||
static readonly DIPLOMA = '/assets/projects/diploma/Dahm2010-Diplomarbeit.pdf';
|
static readonly DIPLOMA = '/assets/projects/diploma/Dahm2010-Diplomarbeit.pdf';
|
||||||
|
|
||||||
//project images
|
//project images
|
||||||
|
static readonly PLAYGROUND_IMAGES = [
|
||||||
|
'/assets/projects/playground/1.png'
|
||||||
|
];
|
||||||
|
|
||||||
static readonly EL_MUCHO_IMAGES = [
|
static readonly EL_MUCHO_IMAGES = [
|
||||||
'/assets/projects/el-mucho/1.jpg',
|
'/assets/projects/el-mucho/1.jpg',
|
||||||
'/assets/projects/el-mucho/2.jpg',
|
'/assets/projects/el-mucho/2.jpg',
|
||||||
@@ -37,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,74 +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';
|
|
||||||
|
|
||||||
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 = {
|
||||||
|
PATH: 'algorithms/pendulum',
|
||||||
|
LINK: '/algorithms/pendulum',
|
||||||
|
};
|
||||||
|
|
||||||
|
static readonly CLOTH = {
|
||||||
|
PATH: 'algorithms/cloth',
|
||||||
|
LINK: '/algorithms/cloth',
|
||||||
|
};
|
||||||
|
|
||||||
|
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',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
export class UrlConstants {
|
export class UrlConstants {
|
||||||
static readonly LINKED_IN = 'https://www.linkedin.com/in/andreas-dahm-2395991ba';
|
static readonly LINKED_IN = 'https://www.linkedin.com/in/andreas-dahm-2395991ba';
|
||||||
static readonly GIT_HUB = 'https://github.com/LoboTheDark';
|
static readonly CODEBERG = 'https://codeberg.org/LoboTheDark';
|
||||||
static readonly DIJKSTRA_WIKI = 'https://de.wikipedia.org/wiki/Dijkstra-Algorithmus'
|
static readonly DIJKSTRA_WIKI = 'https://de.wikipedia.org/wiki/Dijkstra-Algorithmus'
|
||||||
static readonly ASTAR_WIKI = 'https://de.wikipedia.org/wiki/A*-Algorithmus'
|
static readonly ASTAR_WIKI = 'https://de.wikipedia.org/wiki/A*-Algorithmus'
|
||||||
static readonly BUBBLE_SORT_WIKI = 'https://de.wikipedia.org/wiki/Bubblesort'
|
static readonly BUBBLE_SORT_WIKI = 'https://de.wikipedia.org/wiki/Bubblesort'
|
||||||
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'
|
||||||
@@ -17,4 +18,11 @@
|
|||||||
static readonly MANDELBULB_WIKI = 'https://de.wikipedia.org/wiki/Mandelknolle'
|
static readonly MANDELBULB_WIKI = 'https://de.wikipedia.org/wiki/Mandelknolle'
|
||||||
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 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-topbar />
|
<app-topbar />
|
||||||
|
<main class="w-full max-w-app mx-auto mt-4 grow text-app-fg transition-colors duration-220">
|
||||||
<main class="container app-surface">
|
|
||||||
<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,10 +1 @@
|
|||||||
.container { max-width: 1100px; margin: 0 auto; padding: 1rem; }
|
|
||||||
.app-surface {
|
|
||||||
background: var(--app-bg);
|
|
||||||
color: var(--app-fg);
|
|
||||||
transition: background-color 220ms ease, color 220ms ease;
|
|
||||||
}
|
|
||||||
.foot {
|
|
||||||
border-top: 1px solid rgba(0,0,0,.08);
|
|
||||||
padding: 1rem; text-align: center; opacity: .8;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,16 +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 {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],
|
|
||||||
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,31 +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"
|
<img class="w-[clamp(36px,10vw,48px)] h-auto rounded-full" src="{{AssetsConstants.LOGO}}" width="117" height="128" alt="" aria-hidden="true" draggable="false"
|
||||||
src="{{AssetsConstants.LOGO}}"
|
oncontextmenu="return false;">
|
||||||
alt="" aria-hidden="true"
|
<span class="font-semibold tracking-[0.2px] text-[clamp(1rem,3vw,1.2rem)]">{{ 'APP.TITLE' | translate }}</span>
|
||||||
draggable="false"
|
|
||||||
oncontextmenu="return false;"
|
|
||||||
>
|
|
||||||
<span class="brand-text">{{ '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
|
<button mat-icon-button class="hidden mobile:inline-flex" [matMenuTriggerFor]="navMenu" aria-label="Open navigation">
|
||||||
mat-icon-button
|
|
||||||
class="nav-menu-btn"
|
|
||||||
[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">
|
||||||
@@ -38,15 +36,19 @@
|
|||||||
<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" matTooltip="{{ 'TOPBAR.SETTINGS' | translate }}">
|
<button mat-icon-button [matMenuTriggerFor]="settingsMenu" aria-label="Open settings"
|
||||||
|
matTooltip="{{ 'TOPBAR.SETTINGS' | translate }}">
|
||||||
<mat-icon>tune</mat-icon>
|
<mat-icon>tune</mat-icon>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -58,7 +60,7 @@
|
|||||||
<span>{{ 'LANG.DE' | translate }}</span>
|
<span>{{ 'LANG.DE' | translate }}</span>
|
||||||
@if (lang.lang() === 'de')
|
@if (lang.lang() === 'de')
|
||||||
{
|
{
|
||||||
<mat-icon >check</mat-icon>
|
<mat-icon>check</mat-icon>
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
<button mat-menu-item (click)="setLang('en')">
|
<button mat-menu-item (click)="setLang('en')">
|
||||||
@@ -66,7 +68,7 @@
|
|||||||
<span>{{ 'LANG.EN' | translate }}</span>
|
<span>{{ 'LANG.EN' | translate }}</span>
|
||||||
@if (lang.lang() === 'en')
|
@if (lang.lang() === 'en')
|
||||||
{
|
{
|
||||||
<mat-icon>check</mat-icon>
|
<mat-icon>check</mat-icon>
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,83 +1,6 @@
|
|||||||
.topbar {
|
:host {
|
||||||
position: sticky; top: 0; z-index: 100;
|
position: sticky;
|
||||||
backdrop-filter: saturate(1.1) blur(8px);
|
top: 0;
|
||||||
background:
|
z-index: 100;
|
||||||
color-mix(in oklab, var(--app-topbar-bg) 80%, transparent);
|
display: block;
|
||||||
border-bottom: 1px solid rgba(0,0,0,.08);
|
|
||||||
|
|
||||||
.brand {
|
|
||||||
display:flex; align-items:center; gap:.6rem;
|
|
||||||
color: inherit; text-decoration: none;
|
|
||||||
.logo-dot {
|
|
||||||
width: 48px; height: 48px; border-radius: 50%;
|
|
||||||
}
|
|
||||||
.brand-text { font-weight: 600; letter-spacing:.2px; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav { display:flex; gap:.25rem; margin-left:.5rem; }
|
|
||||||
|
|
||||||
.spacer { flex: 1; }
|
|
||||||
|
|
||||||
.flag-icon { width: 18px; height: 18px; border-radius: 2px; margin-right:.5rem; }
|
|
||||||
|
|
||||||
.menu-section { padding:.25rem .5rem .5rem; }
|
|
||||||
.menu-title { font-size:.75rem; opacity:.75; padding:.25rem .75rem .5rem; }
|
|
||||||
|
|
||||||
.kbd {
|
|
||||||
margin-left:auto; font-size:.7rem; opacity:.65; border:1px solid currentColor;
|
|
||||||
border-radius:4px; padding:0 .35rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
::ng-deep .mat-mdc-menu-item .mdc-list-item__primary-text {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: .5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
::ng-deep .mat-mdc-menu-item .kbd {
|
|
||||||
margin-left: auto;
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
|
|
||||||
font-size: 11px;
|
|
||||||
line-height: 1.6;
|
|
||||||
padding: 0 .35rem;
|
|
||||||
border: 0px solid currentColor;
|
|
||||||
border-radius: 4px;
|
|
||||||
opacity: .65;
|
|
||||||
}
|
|
||||||
|
|
||||||
::ng-deep .mat-mdc-menu-item .mat-icon {
|
|
||||||
width: 20px; height: 20px; font-size: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::ng-deep .mat-mdc-menu-item .flag-icon {
|
|
||||||
width: 20px !important;
|
|
||||||
height: 14px !important;
|
|
||||||
object-fit: cover;
|
|
||||||
border-radius: 2px;
|
|
||||||
margin-right: .5rem;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
::ng-deep .mat-mdc-menu-panel {
|
|
||||||
border-radius: 10px !important;
|
|
||||||
border: 1px solid rgba(0,0,0,.14);
|
|
||||||
}
|
|
||||||
.dark ::ng-deep .mat-mdc-menu-panel {
|
|
||||||
border-color: rgba(255,255,255,.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive: Collapse navigation to icon if width is smaller than 760px */
|
|
||||||
.nav-menu-btn {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
|
||||||
.topbar .nav {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-menu-btn {
|
|
||||||
display: inline-flex;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,179 +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="photo">
|
<div class="flex flex-wrap gap-fluid-lg p-fluid-md items-start">
|
||||||
<img
|
<div class="flex-[1_1_min(100%,425px)] max-w-full flex justify-center">
|
||||||
[ngSrc]="AssetsConstants.ME"
|
<img class="block w-full h-auto max-w-[425px] rounded-xl shadow-[0_6px_24px_rgba(0,0,0,0.25)] object-cover"
|
||||||
width="421" height="512"
|
[ngSrc]="AssetsConstants.ME" width="421" height="512" alt="{{ 'ABOUT.ALT.PROFILE' | translate }}"
|
||||||
alt="{{ 'ABOUT.ALT.PROFILE' | translate }}"
|
draggable="false" oncontextmenu="return false;" priority />
|
||||||
draggable="false"
|
</div>
|
||||||
oncontextmenu="return false;"
|
|
||||||
priority />
|
|
||||||
</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 class="flex items-center flex-wrap gap-[0.4rem]">
|
||||||
|
<mat-icon aria-hidden="true">data_object</mat-icon>
|
||||||
|
<a href="{{UrlConstants.CODEBERG}}" target="_blank" rel="noopener">Codeberg</a>
|
||||||
|
<span>·</span>
|
||||||
|
<mat-icon svgIcon="linkedin" aria-hidden="true"></mat-icon>
|
||||||
|
<a href="{{UrlConstants.LINKED_IN}}" target="_blank" rel="noopener">LinkedIn</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
|
||||||
<mat-icon svgIcon="github"></mat-icon>
|
<div class="flex flex-wrap gap-fluid-sm">
|
||||||
<a href="{{UrlConstants.GIT_HUB}}" target="_blank" rel="noopener">GitHub</a>
|
<a mat-flat-button [routerLink]="['/projects']">
|
||||||
<span>·</span>
|
<mat-icon aria-hidden="true">grid_view</mat-icon>
|
||||||
<mat-icon svgIcon="linkedin"></mat-icon>
|
{{ 'ABOUT.VIEW_PROJECTS' | translate }}
|
||||||
<a href="{{UrlConstants.LINKED_IN}}" target="_blank" rel="noopener">LinkedIn</a>
|
</a>
|
||||||
</div>
|
</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.PRIMARY' | translate }}</h3>
|
<h3 class="my-1 text-[0.95rem] opacity-85">{{ group.titleKey | translate }}</h3>
|
||||||
<mat-chip-set aria-label="Primary skills">
|
<mat-chip-set [attr.aria-label]="group.titleKey | translate">
|
||||||
@for (s of primarySkills; 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.TOOLSET' | translate }}</h3>
|
|
||||||
<mat-chip-set aria-label="Toolset">
|
|
||||||
@for (t of toolset; track t) {
|
|
||||||
<mat-chip>{{ t | translate }}</mat-chip>
|
|
||||||
}
|
|
||||||
</mat-chip-set>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</mat-card>
|
|
||||||
|
|
||||||
<mat-card class="experdience">
|
|
||||||
<h2 style="margin-left: 0.5rem;">{{ '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">
|
|
||||||
{{ (entry.key + '.COMPANY') | translate }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
<div class="highlights">
|
|
||||||
<ul>
|
|
||||||
<li>{{ entry.key + '.HIGHLIGHTS.P1' | translate }}</li>
|
|
||||||
<li>{{ entry.key + '.HIGHLIGHTS.P2' | translate }}</li>
|
|
||||||
<li>{{ entry.key + '.HIGHLIGHTS.P3' | translate }}</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if(entry.key !== xpKeys.at(xpKeys.length-1)?.key)
|
|
||||||
{
|
|
||||||
<mat-divider></mat-divider>
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</div>
|
</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)]">
|
||||||
@for (entry of projectKeys; track entry.key) {
|
<h2 class="mt-1 ml-2 text-[clamp(1.2rem,4vw,1.8rem)]">{{ 'ABOUT.SECTION.EXPERIENCE' | translate }}</h2>
|
||||||
<div class="xp-item">
|
|
||||||
<div class="head-row">
|
<div class="relative ml-2">
|
||||||
<strong>{{ (entry.key + '.TITLE') | translate }}</strong>
|
<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 }}
|
||||||
</div>
|
</div>
|
||||||
<div class="company-row">
|
} @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>
|
||||||
|
|
||||||
|
<ul class="mt-[0.4rem]">
|
||||||
|
@for (highlight of highlightKeys(entry); track highlight) {
|
||||||
|
<li class="my-1 text-[clamp(0.9rem,2vw,1rem)]">{{ highlight | translate }}</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
@if (isLastRoleAtCompany(i) && !$last) {
|
||||||
|
<mat-divider class="mt-fluid-sm!"></mat-divider>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button mat-button type="button" class="mt-1 ml-2" data-testid="toggle-experience" (click)="toggleEarlierExperience()"
|
||||||
|
[attr.aria-expanded]="showEarlierExperience()">
|
||||||
|
<mat-icon aria-hidden="true">{{ showEarlierExperience() ? 'expand_less' : 'expand_more' }}</mat-icon>
|
||||||
|
{{ (showEarlierExperience() ? 'ABOUT.SECTION.SHOW_LESS' : 'ABOUT.SECTION.SHOW_EARLIER_XP') | translate }}
|
||||||
|
</button>
|
||||||
|
</mat-card>
|
||||||
|
|
||||||
|
<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) {
|
||||||
|
<div>
|
||||||
|
<div class="flex flex-wrap items-baseline gap-x-4 gap-y-1">
|
||||||
|
<strong class="text-[clamp(0.95rem,2.5vw,1.1rem)]">{{ (entry.key + '.TITLE') | translate }}</strong>
|
||||||
|
</div>
|
||||||
|
<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"
|
<a class="inline-flex items-center gap-[0.35rem] leading-none" href="{{entry.externalLink}}" target="_blank" rel="noopener noreferrer">
|
||||||
href="{{entry.externalLink}}"
|
<mat-icon class="text-[18px]! w-[18px]! h-[18px]!" aria-hidden="true">open_in_new</mat-icon>
|
||||||
target="_blank"
|
{{ (entry.key + '.LINK_EXTERNAL') | translate }}
|
||||||
rel="noopener noreferrer">
|
</a>
|
||||||
<mat-icon>open_in_new</mat-icon>
|
</div>
|
||||||
{{ (entry.key + '.LINK_EXTERNAL') | translate }}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
<div class="link-row">
|
<div class="mt-[0.1rem] opacity-85">
|
||||||
<a class="link-with-icon"
|
<a class="inline-flex items-center gap-[0.35rem] leading-none" [routerLink]="['/projects']" [queryParams]="{ project: entry.identifier }"
|
||||||
[routerLink]="['/projects']"
|
rel="noopener noreferrer">
|
||||||
[queryParams]="{ project: entry.identifier }"
|
<mat-icon class="text-[18px]! w-[18px]! h-[18px]!" aria-hidden="true">link</mat-icon>
|
||||||
rel="noopener noreferrer">
|
|
||||||
<mat-icon>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>
|
||||||
|
|||||||
@@ -1,183 +0,0 @@
|
|||||||
.about {
|
|
||||||
display: grid;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hero block: Photo + Intro */
|
|
||||||
.hero {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 425px 1fr;
|
|
||||||
gap: 1.25rem;
|
|
||||||
border-radius: 16px;
|
|
||||||
background: var(--app-card-background);
|
|
||||||
|
|
||||||
.photo {
|
|
||||||
align-items:flex-start; justify-content:center;
|
|
||||||
img {
|
|
||||||
display:block;
|
|
||||||
width: 100%; height: auto;
|
|
||||||
max-width: 425px;
|
|
||||||
border-radius: 12px;
|
|
||||||
box-shadow: 0 6px 24px rgba(0,0,0,.25);
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.intro {
|
|
||||||
display:flex; flex-direction:column; gap:.5rem;
|
|
||||||
h1 { margin-top: .25rem }
|
|
||||||
.lead { opacity:.9; margin: .25rem 0 0.5rem; }
|
|
||||||
|
|
||||||
.meta {
|
|
||||||
display:flex; flex-direction:column; gap:.25rem; margin-bottom: 0.5rem;
|
|
||||||
.row {
|
|
||||||
display:flex; align-items:center; gap:.4rem;
|
|
||||||
a { color: inherit; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions {
|
|
||||||
display:flex; gap:.5rem; flex-wrap:wrap; margin-top:.5rem;
|
|
||||||
.mat-icon { margin-right:.25rem; }
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Skills block */
|
|
||||||
.skills {
|
|
||||||
padding: 5px;
|
|
||||||
h2 { margin-top: .25rem; margin-left: .25rem; }
|
|
||||||
.chip-groups {
|
|
||||||
margin-left: .25rem;
|
|
||||||
display:grid; gap:1rem;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
margin-bottom: .5rem;
|
|
||||||
h3 { margin: .2rem 0 .4rem; font-size: .95rem; opacity:.85; }
|
|
||||||
mat-chip-set {
|
|
||||||
display:flex; flex-wrap:wrap; gap:.4rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Experience block */
|
|
||||||
.experience {
|
|
||||||
padding: 5px;
|
|
||||||
h2 { margin-top: .25rem; margin-left: .25rem; }
|
|
||||||
.xp-item {
|
|
||||||
.xp-head {
|
|
||||||
display:flex; align-items:baseline; gap:.5rem;
|
|
||||||
.time { opacity:.75; font-size:.9rem; }
|
|
||||||
}
|
|
||||||
.xp-sub { opacity:.9; margin-bottom:.25rem; }
|
|
||||||
ul { margin: .25rem 0 .5rem 1.15rem; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Experience block */
|
|
||||||
.projects {
|
|
||||||
padding: 5px;
|
|
||||||
h2 { margin-top: .25rem;margin-left: .25rem; }
|
|
||||||
.xp-list {
|
|
||||||
margin-left: .25rem;
|
|
||||||
display: grid; gap: .75rem;
|
|
||||||
}
|
|
||||||
.xp-item {
|
|
||||||
.xp-head {
|
|
||||||
display:flex; align-items:baseline; gap:.5rem;
|
|
||||||
.time { opacity:.75; font-size:.9rem; }
|
|
||||||
}
|
|
||||||
.xp-sub { opacity:.9; margin-bottom:.25rem; }
|
|
||||||
ul { margin: .25rem 0 .5rem 1.15rem; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Experience block */
|
|
||||||
.education {
|
|
||||||
padding: 5px;
|
|
||||||
h2 { margin-top: .25rem;margin-left: .25rem; }
|
|
||||||
.xp-list {
|
|
||||||
margin-left: .25rem;
|
|
||||||
display: grid; gap: .75rem;
|
|
||||||
}
|
|
||||||
.xp-item {
|
|
||||||
.xp-head {
|
|
||||||
display:flex; align-items:baseline; gap:.5rem;
|
|
||||||
.time { opacity:.75; font-size:.9rem; }
|
|
||||||
}
|
|
||||||
.xp-sub { opacity:.9; margin-bottom:.25rem; }
|
|
||||||
ul { margin: .25rem 0 .5rem 1.15rem; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive */
|
|
||||||
@media (max-width: 900px) {
|
|
||||||
.hero { grid-template-columns: 1fr; }
|
|
||||||
.hero .photo { justify-content: flex-start; }
|
|
||||||
.skills .chip-groups { grid-template-columns: 1fr; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.xp-head-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: calc(48px + .75rem) 1fr; /* 1: Logo, 2: Text */
|
|
||||||
grid-template-rows: auto auto; /* 1: Role/Time, 2: Company */
|
|
||||||
column-gap: .75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-wrap {
|
|
||||||
grid-row: 1 / span 2;
|
|
||||||
grid-column: 1;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.company-logo {
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
object-fit: contain;
|
|
||||||
opacity: .9;
|
|
||||||
border-radius: 10%;
|
|
||||||
background-color: var(--app-logo-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.head-row {
|
|
||||||
grid-row: 1;
|
|
||||||
grid-column: 2;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: .5rem 1rem;
|
|
||||||
|
|
||||||
strong {
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
.time {
|
|
||||||
opacity: .75; font-size: .9rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.company-row {
|
|
||||||
grid-row: 2;
|
|
||||||
grid-column: 2;
|
|
||||||
margin-top: .1rem;
|
|
||||||
opacity: .85;
|
|
||||||
}
|
|
||||||
|
|
||||||
.highlights {
|
|
||||||
margin-top: .4rem;
|
|
||||||
margin-left: .75rem;
|
|
||||||
padding-left: 1.2rem;
|
|
||||||
|
|
||||||
li {
|
|
||||||
margin: .2rem 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.highlights-noMargin {
|
|
||||||
margin-top: .4rem;
|
|
||||||
|
|
||||||
li {
|
|
||||||
margin: .2rem 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
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,51 +150,114 @@ export class AboutComponent {
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
educationKeys = [
|
readonly recentEducationKeys = [
|
||||||
|
'ABOUT.EDUCATION.E6',
|
||||||
|
'ABOUT.EDUCATION.E5',
|
||||||
|
'ABOUT.EDUCATION.E4'
|
||||||
|
];
|
||||||
|
|
||||||
|
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[] = [
|
||||||
{
|
{
|
||||||
key: 'ABOUT.EDUCATION.E6'
|
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'
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'ABOUT.EDUCATION.E5'
|
titleKey: 'ABOUT.SECTION.ARCH_CLOUD',
|
||||||
|
skillKeys: [
|
||||||
|
'ABOUT.SKILLS.ARCH_MICROSERVICES',
|
||||||
|
'ABOUT.SKILLS.ARCH_CLOUD',
|
||||||
|
'ABOUT.TOOLS.DOCKER',
|
||||||
|
'ABOUT.TOOLS.K8S',
|
||||||
|
'ABOUT.TOOLS.POSTGRES',
|
||||||
|
'ABOUT.TOOLS.MONGO',
|
||||||
|
'ABOUT.TOOLS.GITLAB',
|
||||||
|
'ABOUT.TOOLS.JENKINS'
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'ABOUT.EDUCATION.E4'
|
titleKey: 'ABOUT.SECTION.SIM_ALGO',
|
||||||
|
skillKeys: [
|
||||||
|
'ABOUT.SKILLS.ENG_ALGO',
|
||||||
|
'ABOUT.SKILLS.ENG_SIM',
|
||||||
|
'ABOUT.SKILLS.ENG_GPU',
|
||||||
|
'ABOUT.SKILLS.ENG_PERF',
|
||||||
|
'ABOUT.SKILLS.UNITY'
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'ABOUT.EDUCATION.E3'
|
titleKey: 'ABOUT.SECTION.AI_ENG',
|
||||||
},
|
skillKeys: [
|
||||||
{
|
'ABOUT.SKILLS.AI_AGENTS',
|
||||||
key: 'ABOUT.EDUCATION.E2'
|
'ABOUT.SKILLS.AI_MCP',
|
||||||
},
|
'ABOUT.SKILLS.AI_CONTEXT',
|
||||||
{
|
'ABOUT.SKILLS.AI_HARNESS',
|
||||||
key: 'ABOUT.EDUCATION.E1'
|
'ABOUT.SKILLS.AI_GATES'
|
||||||
},
|
]
|
||||||
{
|
|
||||||
key: 'ABOUT.EDUCATION.E0'
|
|
||||||
}
|
}
|
||||||
]
|
|
||||||
|
|
||||||
primarySkills = [
|
|
||||||
'ABOUT.SKILLS.JAVA',
|
|
||||||
'ABOUT.SKILLS.SPRING',
|
|
||||||
'ABOUT.SKILLS.ANGULAR',
|
|
||||||
'ABOUT.SKILLS.DOCKER',
|
|
||||||
'ABOUT.SKILLS.UNITY',
|
|
||||||
'ABOUT.SKILLS.PYTHON',
|
|
||||||
'ABOUT.SKILLS.CSHARP',
|
|
||||||
'ABOUT.SKILLS.TYPESCRIPT'
|
|
||||||
];
|
];
|
||||||
|
|
||||||
toolset = [
|
readonly visibleExperience = computed(() => {
|
||||||
'ABOUT.TOOLS.GIT',
|
if (this.showEarlierExperience()) {
|
||||||
'ABOUT.TOOLS.GITHUB',
|
return [...this.recentExperience, ...this.earlierExperience];
|
||||||
'ABOUT.TOOLS.GITLAB',
|
}
|
||||||
'ABOUT.TOOLS.JENKINS',
|
return this.recentExperience;
|
||||||
'ABOUT.TOOLS.K8S',
|
});
|
||||||
'ABOUT.TOOLS.POSTGRES',
|
|
||||||
'ABOUT.TOOLS.MONGO',
|
readonly visibleEducationKeys = computed(() => {
|
||||||
'ABOUT.TOOLS.GRAFANA',
|
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;
|
||||||
|
|||||||
@@ -3,4 +3,5 @@ export interface AlgorithmCategory {
|
|||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
routerLink: string;
|
routerLink: string;
|
||||||
|
icon: string;
|
||||||
}
|
}
|
||||||
@@ -1,15 +1,16 @@
|
|||||||
<div class="container">
|
<div class="grid gap-fluid-md grid-cols-[repeat(auto-fill,minmax(min(100%,450px),1fr))] max-w-app mx-4 mt-auto">
|
||||||
<h1>{{ 'ALGORITHM.TITLE' |translate }}</h1>
|
<h1 class="m-0 mb-2 text-[clamp(1.4rem,4vw,2rem)]">{{ 'ALGORITHM.TITLE' | translate }}</h1>
|
||||||
<div class="category-cards">
|
</div>
|
||||||
@for (category of categories$ | async; track category.id) {
|
<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 [routerLink]="[category.routerLink]">
|
@for (category of categories; track category.id) {
|
||||||
<mat-card-header>
|
<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-title>{{ category.title | translate }}</mat-card-title>
|
|
||||||
</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>
|
</div>
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
.container {
|
|
||||||
padding: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.category-cards {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 1rem;
|
|
||||||
margin-top: 2rem;
|
|
||||||
|
|
||||||
mat-card {
|
|
||||||
cursor: pointer;
|
|
||||||
min-width: 450px;
|
|
||||||
max-width: 450px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,25 +1,19 @@
|
|||||||
import { Component, OnInit, inject } from '@angular/core';
|
import { Component, inject } from '@angular/core';
|
||||||
import { AlgorithmsService } from './service/algorithms.service';
|
import { AlgorithmsService } from './algorithms.service';
|
||||||
import { AlgorithmCategory } from './models/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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
79
src/app/pages/algorithms/algorithms.service.ts
Normal file
79
src/app/pages/algorithms/algorithms.service.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { AlgorithmCategory } from './algorithm-category';
|
||||||
|
import {RouterConstants} from '../../constants/RouterConstants';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class AlgorithmsService {
|
||||||
|
|
||||||
|
private readonly categories: AlgorithmCategory[] = [
|
||||||
|
{
|
||||||
|
id: 'pathfinding',
|
||||||
|
title: 'ALGORITHM.PATHFINDING.TITLE',
|
||||||
|
description: 'ALGORITHM.PATHFINDING.DESCRIPTION',
|
||||||
|
routerLink: RouterConstants.PATHFINDING.LINK,
|
||||||
|
icon: 'route'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sorting',
|
||||||
|
title: 'ALGORITHM.SORTING.TITLE',
|
||||||
|
description: 'ALGORITHM.SORTING.DESCRIPTION',
|
||||||
|
routerLink: RouterConstants.SORTING.LINK,
|
||||||
|
icon: 'sort'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gameOfLife',
|
||||||
|
title: 'ALGORITHM.GOL.TITLE',
|
||||||
|
description: 'ALGORITHM.GOL.DESCRIPTION',
|
||||||
|
routerLink: RouterConstants.GOL.LINK,
|
||||||
|
icon: 'grid_on'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'labyrinth',
|
||||||
|
title: 'ALGORITHM.LABYRINTH.TITLE',
|
||||||
|
description: 'ALGORITHM.LABYRINTH.DESCRIPTION',
|
||||||
|
routerLink: RouterConstants.LABYRINTH.LINK,
|
||||||
|
icon: 'grid_view'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fractal',
|
||||||
|
title: 'ALGORITHM.FRACTAL.TITLE',
|
||||||
|
description: 'ALGORITHM.FRACTAL.DESCRIPTION',
|
||||||
|
routerLink: RouterConstants.FRACTAL.LINK,
|
||||||
|
icon: 'blur_on'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fractal3d',
|
||||||
|
title: 'ALGORITHM.FRACTAL3D.TITLE',
|
||||||
|
description: 'ALGORITHM.FRACTAL3D.DESCRIPTION',
|
||||||
|
routerLink: RouterConstants.FRACTAL3d.LINK,
|
||||||
|
icon: 'view_in_ar'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pendulum',
|
||||||
|
title: 'ALGORITHM.PENDULUM.TITLE',
|
||||||
|
description: 'ALGORITHM.PENDULUM.DESCRIPTION',
|
||||||
|
routerLink: RouterConstants.PENDULUM.LINK,
|
||||||
|
icon: 'rotate_right'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cloth',
|
||||||
|
title: 'ALGORITHM.CLOTH.TITLE',
|
||||||
|
description: 'ALGORITHM.CLOTH.DESCRIPTION',
|
||||||
|
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(): AlgorithmCategory[] {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
`;
|
||||||
32
src/app/pages/algorithms/cloth/cloth.component.html
Normal file
32
src/app/pages/algorithms/cloth/cloth.component.html
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<mat-card class="w-full max-w-[1920px] p-5">
|
||||||
|
<mat-card-header>
|
||||||
|
<mat-card-title>{{ 'CLOTH.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-raised-button color="primary" (click)="toggleWind()">
|
||||||
|
{{ isWindActive ? ('CLOTH.WIND_OFF' | translate) : ('CLOTH.WIND_ON' | translate) }}
|
||||||
|
</button>
|
||||||
|
<button mat-raised-button color="primary" (click)="toggleMesh()">
|
||||||
|
{{ isOutlineActive ? ('CLOTH.OUTLINE_OFF' | translate) : ('CLOTH.OUTLINE_ON' | translate) }}
|
||||||
|
</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>
|
||||||
|
<app-babylon-canvas
|
||||||
|
[config]="renderConfig"
|
||||||
|
(sceneReady)="onSceneReady($event)"
|
||||||
|
(sceneResized)="onSceneReady($event)"
|
||||||
|
/>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
0
src/app/pages/algorithms/cloth/cloth.component.scss
Normal file
0
src/app/pages/algorithms/cloth/cloth.component.scss
Normal file
158
src/app/pages/algorithms/cloth/cloth.component.ts
Normal file
158
src/app/pages/algorithms/cloth/cloth.component.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import {Component} from '@angular/core';
|
||||||
|
import {FormsModule} from '@angular/forms';
|
||||||
|
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
|
||||||
|
import {MatSliderModule} from '@angular/material/slider';
|
||||||
|
import {TranslatePipe} from '@ngx-translate/core';
|
||||||
|
import {BabylonCanvas, RenderConfig, SceneEventData} from '../../../shared/components/render-canvas/babylon-canvas.component';
|
||||||
|
import {MatButton} from '@angular/material/button';
|
||||||
|
import {ClothConfig} from './cloth.model';
|
||||||
|
import {Information} from '../information/information';
|
||||||
|
import {AlgorithmInformation} from '../information/information.models';
|
||||||
|
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({
|
||||||
|
selector: 'app-cloth',
|
||||||
|
imports: [
|
||||||
|
MatCard,
|
||||||
|
MatCardContent,
|
||||||
|
MatCardHeader,
|
||||||
|
MatCardTitle,
|
||||||
|
TranslatePipe,
|
||||||
|
BabylonCanvas,
|
||||||
|
MatButton,
|
||||||
|
MatSliderModule,
|
||||||
|
FormsModule,
|
||||||
|
Information
|
||||||
|
],
|
||||||
|
templateUrl: './cloth.component.html',
|
||||||
|
styleUrl: './cloth.component.scss',
|
||||||
|
})
|
||||||
|
export class ClothComponent {
|
||||||
|
private currentSceneData: SceneEventData | null = null;
|
||||||
|
private simulationTime: number = 0;
|
||||||
|
private strategy: ClothSimulationStrategy | null = null;
|
||||||
|
|
||||||
|
public isWindActive: boolean = false;
|
||||||
|
public isOutlineActive: boolean = false;
|
||||||
|
public stiffness: number = 80;
|
||||||
|
public elongation: number = 1.0;
|
||||||
|
|
||||||
|
public renderConfig: RenderConfig = {
|
||||||
|
mode: '3D',
|
||||||
|
initialViewSize: 20
|
||||||
|
};
|
||||||
|
|
||||||
|
algoInformation: AlgorithmInformation = {
|
||||||
|
title: 'CLOTH.EXPLANATION.TITLE',
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
name: 'CLOTH.EXPLANATION.CLOTH_SIMULATION_EXPLANATION_TITLE',
|
||||||
|
description: 'CLOTH.EXPLANATION.CLOTH_SIMULATION_EXPLANATION',
|
||||||
|
link: UrlConstants.CLOTH_SIMULATION_WIKI,
|
||||||
|
translateName: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'CLOTH.EXPLANATION.XPBD_EXPLANATION_TITLE',
|
||||||
|
description: 'CLOTH.EXPLANATION.XPBD_EXPLANATION',
|
||||||
|
link: UrlConstants.XPBD_WIKI,
|
||||||
|
translateName: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'CLOTH.EXPLANATION.GPU_PARALLELIZATION_EXPLANATION_TITLE',
|
||||||
|
description: 'CLOTH.EXPLANATION.GPU_PARALLELIZATION_EXPLANATION',
|
||||||
|
link: UrlConstants.GPU_COMPUTING_WIKI,
|
||||||
|
translateName: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'CLOTH.EXPLANATION.DATA_STRUCTURES_EXPLANATION_TITLE',
|
||||||
|
description: 'CLOTH.EXPLANATION.DATA_STRUCTURES_EXPLANATION',
|
||||||
|
link: UrlConstants.DATA_STRUCTURE_WIKI,
|
||||||
|
translateName: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
disclaimer: 'CLOTH.EXPLANATION.DISCLAIMER',
|
||||||
|
disclaimerBottom: '',
|
||||||
|
disclaimerListEntry: ['CLOTH.EXPLANATION.DISCLAIMER_1', 'CLOTH.EXPLANATION.DISCLAIMER_2', 'CLOTH.EXPLANATION.DISCLAIMER_3', 'CLOTH.EXPLANATION.DISCLAIMER_4']
|
||||||
|
};
|
||||||
|
|
||||||
|
public onSceneReady(event: SceneEventData): void {
|
||||||
|
this.currentSceneData = event;
|
||||||
|
this.createSimulation();
|
||||||
|
}
|
||||||
|
|
||||||
|
public toggleWind(): void {
|
||||||
|
this.isWindActive = !this.isWindActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
public toggleMesh(): void {
|
||||||
|
this.isOutlineActive = !this.isOutlineActive;
|
||||||
|
const mesh = this.strategy?.getMesh();
|
||||||
|
if (!mesh?.material) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mesh.material.wireframe = this.isOutlineActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
public restartSimulation(): void {
|
||||||
|
this.simulationTime = 0;
|
||||||
|
this.createSimulation();
|
||||||
|
}
|
||||||
|
|
||||||
|
private createSimulation(): void {
|
||||||
|
if (!this.currentSceneData) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {engine, scene, gpuTier} = this.currentSceneData;
|
||||||
|
const config = this.getClothConfig();
|
||||||
|
|
||||||
|
if (this.strategy) {
|
||||||
|
this.strategy.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.strategy = gpuTier === 'webgpu'
|
||||||
|
? new ClothGpuStrategy()
|
||||||
|
: new ClothCpuStrategy();
|
||||||
|
|
||||||
|
this.strategy.init(scene, engine, config);
|
||||||
|
this.startParamUpdateLoop(scene, engine);
|
||||||
|
}
|
||||||
|
|
||||||
|
private startParamUpdateLoop(scene: any, engine: any): void {
|
||||||
|
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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private getClothConfig(): ClothConfig {
|
||||||
|
const gridWidth = 100;
|
||||||
|
const gridHeight = 100;
|
||||||
|
const spacing = 0.05;
|
||||||
|
const density = 1.0;
|
||||||
|
const particleArea = spacing * spacing;
|
||||||
|
const particleMass = density * particleArea;
|
||||||
|
|
||||||
|
return {
|
||||||
|
gridWidth,
|
||||||
|
gridHeight,
|
||||||
|
spacing,
|
||||||
|
density,
|
||||||
|
numVertices: gridWidth * gridHeight,
|
||||||
|
particleInvMass: 1.0 / particleMass
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/app/pages/algorithms/cloth/cloth.model.ts
Normal file
16
src/app/pages/algorithms/cloth/cloth.model.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export interface ClothConfig {
|
||||||
|
gridWidth: number;
|
||||||
|
gridHeight: number;
|
||||||
|
spacing: number;
|
||||||
|
density: number;
|
||||||
|
numVertices: number;
|
||||||
|
particleInvMass: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClothData {
|
||||||
|
positions: Float32Array;
|
||||||
|
prevPositions: Float32Array;
|
||||||
|
velocities: Float32Array;
|
||||||
|
constraints: number[][];
|
||||||
|
params: Float32Array;
|
||||||
|
}
|
||||||
215
src/app/pages/algorithms/cloth/cloth.shader.ts
Normal file
215
src/app/pages/algorithms/cloth/cloth.shader.ts
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
/**
|
||||||
|
* File: cloth.shader.ts
|
||||||
|
* Description: WGSL shaders for cloth simulation and rendering.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// --- SHARED DATA STRUCTURES ---
|
||||||
|
export const CLOTH_SHARED_STRUCTS = `
|
||||||
|
struct Params {
|
||||||
|
dt: f32,
|
||||||
|
gravity_y: f32,
|
||||||
|
compliance: f32,
|
||||||
|
numVertices: f32,
|
||||||
|
wind_x: f32,
|
||||||
|
wind_y: f32,
|
||||||
|
wind_z: f32,
|
||||||
|
time: f32,
|
||||||
|
elongation: f32
|
||||||
|
};
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// VERTEX SHADER
|
||||||
|
// ==========================================
|
||||||
|
export const CLOTH_VERTEX_SHADER_WGSL = `
|
||||||
|
attribute uv : vec2<f32>;
|
||||||
|
var<storage, read> positions : array<vec4<f32>>;
|
||||||
|
|
||||||
|
uniform viewProjection : mat4x4<f32>;
|
||||||
|
|
||||||
|
varying vUV : vec2<f32>;
|
||||||
|
varying vWorldPos : vec3<f32>;
|
||||||
|
|
||||||
|
@vertex
|
||||||
|
fn main(input : VertexInputs) -> FragmentInputs {
|
||||||
|
var output : FragmentInputs;
|
||||||
|
|
||||||
|
let worldPos = positions[input.vertexIndex].xyz;
|
||||||
|
output.position = uniforms.viewProjection * vec4<f32>(worldPos, 1.0);
|
||||||
|
|
||||||
|
output.vUV = input.uv;
|
||||||
|
output.vWorldPos = worldPos;
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// FRAGMENT SHADER
|
||||||
|
// ==========================================
|
||||||
|
export const CLOTH_FRAGMENT_SHADER_WGSL = `
|
||||||
|
varying vUV : vec2<f32>;
|
||||||
|
varying vWorldPos : vec3<f32>;
|
||||||
|
|
||||||
|
@fragment
|
||||||
|
fn main(input: FragmentInputs) -> FragmentOutputs {
|
||||||
|
var output: FragmentOutputs;
|
||||||
|
|
||||||
|
let dx = dpdx(input.vWorldPos);
|
||||||
|
let dy = dpdy(input.vWorldPos);
|
||||||
|
let normal = normalize(cross(dx, dy));
|
||||||
|
let lightDir = normalize(vec3<f32>(1.0, 1.0, 0.5));
|
||||||
|
let diffuse = max(0.0, abs(dot(normal, lightDir)));
|
||||||
|
let ambient = 0.3;
|
||||||
|
let lightIntensity = ambient + (diffuse * 0.7);
|
||||||
|
let grid = (floor(input.vUV.x * 20.0) + floor(input.vUV.y * 20.0)) % 2.0;
|
||||||
|
let baseColor = mix(vec3<f32>(0.8, 0.4, 0.15), vec3<f32>(0.9, 0.5, 0.2), grid);
|
||||||
|
let finalColor = baseColor * lightIntensity;
|
||||||
|
|
||||||
|
output.color = vec4<f32>(finalColor, 1.0);
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// PASS 1: INTEGRATION (Apply Forces & Predict Positions)
|
||||||
|
// =====================================================================
|
||||||
|
export const CLOTH_INTEGRATE_COMPUTE_WGSL = CLOTH_SHARED_STRUCTS + `
|
||||||
|
@group(0) @binding(0) var<storage, read> p : Params;
|
||||||
|
@group(0) @binding(1) var<storage, read_write> positions : array<vec4<f32>>;
|
||||||
|
@group(0) @binding(2) var<storage, read_write> prev_positions : array<vec4<f32>>;
|
||||||
|
@group(0) @binding(3) var<storage, read_write> velocities : array<vec4<f32>>;
|
||||||
|
|
||||||
|
@compute @workgroup_size(64)
|
||||||
|
fn main(@builtin(global_invocation_id) global_id : vec3<u32>) {
|
||||||
|
let idx = global_id.x;
|
||||||
|
if (f32(idx) >= p.numVertices) { return; }
|
||||||
|
|
||||||
|
var pos = positions[idx];
|
||||||
|
var vel = velocities[idx];
|
||||||
|
let invMass = pos.w;
|
||||||
|
|
||||||
|
if (invMass > 0.0) {
|
||||||
|
vel.y = vel.y + (p.gravity_y * p.dt);
|
||||||
|
|
||||||
|
let flutter = sin(pos.x * 2.0 + p.time * 5.0) * cos(pos.y * 2.0 + p.time * 3.0);
|
||||||
|
|
||||||
|
let windForce = vec3<f32>(
|
||||||
|
p.wind_x + (flutter * p.wind_x * 0.8),
|
||||||
|
p.wind_y + (flutter * 2.0), // Leichter Auftrieb durchs Flattern
|
||||||
|
p.wind_z + (flutter * p.wind_z * 0.8)
|
||||||
|
);
|
||||||
|
|
||||||
|
vel.x = vel.x + (windForce.x * p.dt);
|
||||||
|
vel.y = vel.y + (windForce.y * p.dt);
|
||||||
|
vel.z = vel.z + (windForce.z * p.dt);
|
||||||
|
|
||||||
|
prev_positions[idx] = pos;
|
||||||
|
|
||||||
|
pos.x = pos.x + vel.x * p.dt;
|
||||||
|
pos.y = pos.y + vel.y * p.dt;
|
||||||
|
pos.z = pos.z + vel.z * p.dt;
|
||||||
|
|
||||||
|
positions[idx] = pos;
|
||||||
|
velocities[idx] = vel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// PASS 2: SOLVE CONSTRAINTS (The core of XPBD)
|
||||||
|
// =====================================================================
|
||||||
|
export const CLOTH_SOLVE_COMPUTE_WGSL = CLOTH_SHARED_STRUCTS + `
|
||||||
|
@group(0) @binding(0) var<storage, read> p : Params;
|
||||||
|
@group(0) @binding(1) var<storage, read_write> positions : array<vec4<f32>>;
|
||||||
|
@group(0) @binding(2) var<storage, read> constraints : array<vec4<f32>>; // <--- Read-only as we do not modify them here
|
||||||
|
|
||||||
|
@compute @workgroup_size(64)
|
||||||
|
fn main(@builtin(global_invocation_id) global_id : vec3<u32>) {
|
||||||
|
let idx = global_id.x;
|
||||||
|
|
||||||
|
// Query the GPU directly for the length of the passed array
|
||||||
|
if (idx >= arrayLength(&constraints)) { return; }
|
||||||
|
|
||||||
|
let constraint = constraints[idx];
|
||||||
|
|
||||||
|
// constraint.w: 0.0 = inactive, 1.0 = horizontal/diagonal, 2.0 = vertical
|
||||||
|
if (constraint.w < 0.5) { return; }
|
||||||
|
|
||||||
|
let idA = u32(constraint.x);
|
||||||
|
let idB = u32(constraint.y);
|
||||||
|
|
||||||
|
// constraint.w encodes type: 1.0 = horizontal/diagonal, 2.0 = vertical (elongation applies)
|
||||||
|
let restLength =constraint.z * p.elongation;
|
||||||
|
|
||||||
|
var pA = positions[idA];
|
||||||
|
var pB = positions[idB];
|
||||||
|
|
||||||
|
let wA = pA.w;
|
||||||
|
let wB = pB.w;
|
||||||
|
let wSum = wA + wB;
|
||||||
|
|
||||||
|
if (wSum <= 0.0) { return; }
|
||||||
|
|
||||||
|
let dir = pA.xyz - pB.xyz;
|
||||||
|
let dist = length(dir);
|
||||||
|
|
||||||
|
if (dist < 0.0001) { return; }
|
||||||
|
|
||||||
|
let n = dir / dist;
|
||||||
|
let C = dist - restLength;
|
||||||
|
|
||||||
|
let alpha = p.compliance / (p.dt * p.dt);
|
||||||
|
let lambda = -C / (wSum + alpha);
|
||||||
|
|
||||||
|
let corrA = n * (lambda * wA);
|
||||||
|
let corrB = n * (-lambda * wB);
|
||||||
|
|
||||||
|
// This is because we are using graph coloring to be thread safe
|
||||||
|
if (wA > 0.0) {
|
||||||
|
positions[idA].x = positions[idA].x + corrA.x;
|
||||||
|
positions[idA].y = positions[idA].y + corrA.y;
|
||||||
|
positions[idA].z = positions[idA].z + corrA.z;
|
||||||
|
}
|
||||||
|
if (wB > 0.0) {
|
||||||
|
positions[idB].x = positions[idB].x + corrB.x;
|
||||||
|
positions[idB].y = positions[idB].y + corrB.y;
|
||||||
|
positions[idB].z = positions[idB].z + corrB.z;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// PASS 3: VELOCITY UPDATE (Derive velocity from position changes)
|
||||||
|
// =====================================================================
|
||||||
|
export const CLOTH_VELOCITY_COMPUTE_WGSL = CLOTH_SHARED_STRUCTS + `
|
||||||
|
@group(0) @binding(0) var<storage, read> p : Params;
|
||||||
|
@group(0) @binding(1) var<storage, read_write> positions : array<vec4<f32>>;
|
||||||
|
@group(0) @binding(2) var<storage, read_write> prev_positions : array<vec4<f32>>;
|
||||||
|
@group(0) @binding(3) var<storage, read_write> velocities : array<vec4<f32>>;
|
||||||
|
|
||||||
|
@compute @workgroup_size(64)
|
||||||
|
fn main(@builtin(global_invocation_id) global_id : vec3<u32>) {
|
||||||
|
let idx = global_id.x;
|
||||||
|
if (f32(idx) >= p.numVertices) { return; }
|
||||||
|
|
||||||
|
let pos = positions[idx];
|
||||||
|
let prev = prev_positions[idx];
|
||||||
|
let invMass = pos.w;
|
||||||
|
|
||||||
|
if (invMass > 0.0) {
|
||||||
|
var vel = velocities[idx];
|
||||||
|
|
||||||
|
// v = (p - p_prev) / dt
|
||||||
|
vel.x = (pos.x - prev.x) / p.dt;
|
||||||
|
vel.y = (pos.y - prev.y) / p.dt;
|
||||||
|
vel.z = (pos.z - prev.z) / p.dt;
|
||||||
|
|
||||||
|
// Optional: Add simple damping here
|
||||||
|
// vel = vel * 0.99;
|
||||||
|
|
||||||
|
velocities[idx] = vel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
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="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="grid-size">
|
<div class="flex gap-3 items-center flex-wrap">
|
||||||
<mat-form-field appearance="outline" class="grid-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="grid-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="grid-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,55 +1,45 @@
|
|||||||
<mat-card class="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-panel">
|
<div class="flex flex-col mb-4">
|
||||||
<mat-form-field appearance="fill">
|
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
|
||||||
<mat-label>{{ 'FRACTAL.ALGORITHM' | translate }}</mat-label>
|
<mat-form-field class="w-[200px]" appearance="fill">
|
||||||
<mat-select [(ngModel)]="selectedAlgorithm"
|
<mat-label>{{ 'FRACTAL.ALGORITHM' | translate }}</mat-label>
|
||||||
(selectionChange)="onAlgorithmChange()">
|
<mat-select [value]="'Mandelbrot'" (selectionChange)="onAlgorithmChange($event.value)">
|
||||||
@for (algo of algoInformation.entries; track algo.name) {
|
<mat-option value="Mandelbrot">Mandelbrot</mat-option>
|
||||||
<mat-option [value]="algo.name">{{ algo.name }}</mat-option>
|
<mat-option value="Julia">Julia</mat-option>
|
||||||
}
|
<mat-option value="Burning Ship">Burning Ship</mat-option>
|
||||||
</mat-select>
|
<mat-option value="Newton">Newton</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field class="w-[200px]" appearance="fill">
|
||||||
|
<mat-label>{{ 'FRACTAL.COLOR_SCHEME' | translate }}</mat-label>
|
||||||
|
<mat-select [value]="'Blue-Gold'" (selectionChange)="onColorChanged($event.value)">
|
||||||
|
<mat-option value="Blue-Gold">Blue-Gold</mat-option>
|
||||||
|
<mat-option value="Greyscale">Greyscale</mat-option>
|
||||||
|
<mat-option value="Fire">Fire</mat-option>
|
||||||
|
<mat-option value="Rainbow">Rainbow</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
</mat-form-field>
|
<button mat-raised-button color="primary" (click)="onReset()">
|
||||||
<mat-form-field appearance="fill">
|
<mat-icon>undo</mat-icon> {{ 'FRACTAL.RESET' | translate }}
|
||||||
<mat-label>{{ 'FRACTAL.COLOR_SCHEME' | translate }}</mat-label>
|
</button>
|
||||||
<mat-select [(ngModel)]="selectedColorScheme"
|
</div>
|
||||||
(selectionChange)="onColorChanged()"
|
<div style="display: flex; align-items: center; gap: 10px;">
|
||||||
[disabled]="selectedAlgorithm === 'Newton'">
|
<mat-icon>zoom_out</mat-icon>
|
||||||
@for (name of FRACTAL_COLOR_SCHEMES; track name) {
|
<ngx-slider [(value)]="sliderValue" [options]="options" (valueChange)="onSliderChange($event)" ></ngx-slider>
|
||||||
<mat-option [value]="name">{{ name }}</mat-option>
|
<mat-icon>zoom_in</mat-icon>
|
||||||
}
|
</div>
|
||||||
</mat-select>
|
</div>
|
||||||
|
|
||||||
</mat-form-field>
|
<app-babylon-canvas
|
||||||
<mat-form-field appearance="outline">
|
[config]="renderConfig"
|
||||||
<mat-label>{{ 'FRACTAL.MAX_ITERATION' | translate }}</mat-label>
|
[renderCallback]="onRender"
|
||||||
<input
|
(sceneReady)="onSceneReady($event)"
|
||||||
matInput
|
/>
|
||||||
type="number"
|
|
||||||
[disabled]="selectedAlgorithm === 'Newton'"
|
|
||||||
[min]="MIN_ITERATION"
|
|
||||||
[max]="MAX_ITERATION"
|
|
||||||
[(ngModel)]="currentIteration"
|
|
||||||
(blur)="onIterationChanged()"
|
|
||||||
(keyup.enter)="onIterationChanged()"
|
|
||||||
/>
|
|
||||||
</mat-form-field>
|
|
||||||
</div>
|
|
||||||
<div class="canvas-container">
|
|
||||||
<canvas #fractalCanvas
|
|
||||||
width="1000"
|
|
||||||
height="1000"
|
|
||||||
(mousedown)="onMouseDown($event)"
|
|
||||||
(mousemove)="onMouseMove($event)"
|
|
||||||
(mouseup)="onMouseUp()"
|
|
||||||
(mouseleave)="onMouseUp()"
|
|
||||||
(wheel)="onWheel($event)">
|
|
||||||
</canvas>
|
|
||||||
</div>
|
|
||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
import {AfterViewInit, Component, ElementRef, inject, ViewChild} from '@angular/core';
|
import {Component, OnInit, signal} from '@angular/core';
|
||||||
import {Information} from '../information/information';
|
import {Information} from '../information/information';
|
||||||
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
|
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
|
||||||
import {TranslatePipe} from '@ngx-translate/core';
|
import {TranslatePipe} from '@ngx-translate/core';
|
||||||
import {MatFormField, MatInput, MatLabel} from '@angular/material/input';
|
import {MatFormField, MatLabel} from '@angular/material/input';
|
||||||
import {MatOption} from '@angular/material/core';
|
import {MatOption} from '@angular/material/core';
|
||||||
import {MatSelect} from '@angular/material/select';
|
import {MatSelect} from '@angular/material/select';
|
||||||
import {AlgorithmInformation} from '../information/information.models';
|
import {AlgorithmInformation} from '../information/information.models';
|
||||||
import {UrlConstants} from '../../../constants/UrlConstants';
|
import {UrlConstants} from '../../../constants/UrlConstants';
|
||||||
import {FormsModule} from '@angular/forms';
|
import {FormsModule} from '@angular/forms';
|
||||||
import {FractalService} from './service/fractal.service';
|
import {BabylonCanvas, RenderCallback, RenderConfig, SceneEventData} from '../../../shared/components/render-canvas/babylon-canvas.component';
|
||||||
import {DEFAULT_ITERATION, FRACTAL_COLOR_SCHEMES, FractalConfig, MAX_ITERATION, MIN_ITERATION} from './fractal.model';
|
import {FRACTAL2D_FRAGMENT, FRACTAL2D_VERTEX} from './fractal.shader';
|
||||||
|
import {PointerEventTypes, PointerInfo, ShaderMaterial, Vector2} from '@babylonjs/core';
|
||||||
|
import {MatButton} from '@angular/material/button';
|
||||||
|
import {MatIcon} from '@angular/material/icon';
|
||||||
|
import {NgxSliderModule, Options} from '@angular-slider/ngx-slider';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-fractal',
|
selector: 'app-fractal',
|
||||||
@@ -25,12 +29,15 @@ import {DEFAULT_ITERATION, FRACTAL_COLOR_SCHEMES, FractalConfig, MAX_ITERATION,
|
|||||||
MatOption,
|
MatOption,
|
||||||
MatSelect,
|
MatSelect,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
MatInput
|
BabylonCanvas,
|
||||||
|
MatButton,
|
||||||
|
MatIcon,
|
||||||
|
NgxSliderModule
|
||||||
],
|
],
|
||||||
templateUrl: './fractal.component.html',
|
templateUrl: './fractal.component.html',
|
||||||
styleUrl: './fractal.component.scss',
|
styleUrl: './fractal.component.scss',
|
||||||
})
|
})
|
||||||
export class FractalComponent implements AfterViewInit {
|
export class FractalComponent implements OnInit {
|
||||||
algoInformation: AlgorithmInformation = {
|
algoInformation: AlgorithmInformation = {
|
||||||
title: 'FRACTAL.EXPLANATION.TITLE',
|
title: 'FRACTAL.EXPLANATION.TITLE',
|
||||||
entries: [
|
entries: [
|
||||||
@@ -56,7 +63,7 @@ export class FractalComponent implements AfterViewInit {
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
disclaimer: 'FRACTAL.EXPLANATION.DISCLAIMER',
|
disclaimer: 'FRACTAL.EXPLANATION.DISCLAIMER',
|
||||||
disclaimerBottom: '',
|
disclaimerBottom: 'FRACTAL.EXPLANATION.DISCLAIMER_BOTTOM',
|
||||||
disclaimerListEntry: [
|
disclaimerListEntry: [
|
||||||
'FRACTAL.EXPLANATION.DISCLAIMER_1',
|
'FRACTAL.EXPLANATION.DISCLAIMER_1',
|
||||||
'FRACTAL.EXPLANATION.DISCLAIMER_2',
|
'FRACTAL.EXPLANATION.DISCLAIMER_2',
|
||||||
@@ -64,126 +71,195 @@ export class FractalComponent implements AfterViewInit {
|
|||||||
'FRACTAL.EXPLANATION.DISCLAIMER_4'
|
'FRACTAL.EXPLANATION.DISCLAIMER_4'
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
private readonly fractalService = inject(FractalService);
|
|
||||||
|
|
||||||
config: FractalConfig = {
|
renderConfig: RenderConfig = {
|
||||||
algorithm: 'Mandelbrot',
|
mode: '2D',
|
||||||
width: 1000,
|
initialViewSize: 100,
|
||||||
height: 1000,
|
vertexShader: FRACTAL2D_VERTEX,
|
||||||
maxIterations: DEFAULT_ITERATION,
|
fragmentShader: FRACTAL2D_FRAGMENT,
|
||||||
zoom: 1,
|
uniformNames: ["worldViewProjection", "time", "targetPosition","center", "zoom", "maxIterations", "algorithm", "colorScheme", "juliaC"]
|
||||||
offsetX: -0.5,
|
|
||||||
offsetY: 0,
|
|
||||||
colorScheme: FRACTAL_COLOR_SCHEMES[0]
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- State ---
|
||||||
|
readonly minZoomValue = 0.2;
|
||||||
|
readonly maxZoomValue = 64000;
|
||||||
private isDragging = false;
|
private isDragging = false;
|
||||||
private dragStartX = 0;
|
private dragStartPoint: { x: number, y: number } | null = null;
|
||||||
private dragStartY = 0;
|
|
||||||
|
|
||||||
@ViewChild('fractalCanvas') canvasRef!: ElementRef<HTMLCanvasElement>;
|
selectedAlgorithm = 0;
|
||||||
|
selectedColorScheme = 0;
|
||||||
|
|
||||||
selectedAlgorithm: string = this.config.algorithm;
|
sliderValue = signal(0);
|
||||||
currentIteration: number = this.config.maxIterations;
|
options: Options = {
|
||||||
selectedColorScheme: string = 'Blue-Gold';
|
floor: this.minZoomValue,
|
||||||
|
ceil: this.maxZoomValue,
|
||||||
|
logScale: true,
|
||||||
|
step: 0.01,
|
||||||
|
showTicks: false,
|
||||||
|
hideLimitLabels: true,
|
||||||
|
hidePointerLabels: true
|
||||||
|
};
|
||||||
|
zoom = 0;
|
||||||
|
offsetX = 0;
|
||||||
|
offsetY = 0;
|
||||||
|
maxIterations = 0;
|
||||||
|
|
||||||
ngAfterViewInit(): void {
|
juliaReal = -0.7;
|
||||||
this.draw();
|
juliaImag = 0.27015;
|
||||||
|
|
||||||
|
// --- Render Callback ---
|
||||||
|
onRender: RenderCallback = (material: ShaderMaterial) => {
|
||||||
|
material.setVector2("center", new Vector2(this.offsetX, this.offsetY));
|
||||||
|
material.setFloat("zoom", this.zoom);
|
||||||
|
material.setInt("maxIterations", this.maxIterations);
|
||||||
|
material.setInt("algorithm", this.selectedAlgorithm);
|
||||||
|
material.setInt("colorScheme", this.selectedColorScheme);
|
||||||
|
material.setVector2("juliaC", new Vector2(this.juliaReal, this.juliaImag));
|
||||||
|
};
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.onReset();
|
||||||
}
|
}
|
||||||
|
|
||||||
resetView(): void{
|
onAlgorithmChange(algoName: string): void {
|
||||||
this.isDragging = false;
|
switch(algoName) {
|
||||||
this.dragStartX = 0;
|
case 'Mandelbrot': this.selectedAlgorithm = 0; break;
|
||||||
this.dragStartY = 0;
|
case 'Julia': this.selectedAlgorithm = 1; break;
|
||||||
this.config.offsetX = -0.5;
|
case 'Burning Ship': this.selectedAlgorithm = 2; break;
|
||||||
this.config.offsetY = 0;
|
case 'Newton': this.selectedAlgorithm = 3; break;
|
||||||
this.config.zoom = 1;
|
}
|
||||||
|
this.onReset()
|
||||||
}
|
}
|
||||||
|
|
||||||
onAlgorithmChange(): void {
|
onColorChanged(schemeName: string): void {
|
||||||
this.config.algorithm = this.selectedAlgorithm as any;
|
switch(schemeName) {
|
||||||
this.resetView();
|
case 'Blue-Gold': this.selectedColorScheme = 0; break;
|
||||||
this.draw();
|
case 'Greyscale': this.selectedColorScheme = 1; break;
|
||||||
}
|
case 'Fire': this.selectedColorScheme = 2; break;
|
||||||
|
case 'Rainbow': this.selectedColorScheme = 3; break;
|
||||||
onColorChanged(): void {
|
|
||||||
this.config.colorScheme = this.selectedColorScheme as any;
|
|
||||||
this.draw();
|
|
||||||
}
|
|
||||||
|
|
||||||
onIterationChanged(): void {
|
|
||||||
this.config.maxIterations = Math.max(Math.min(this.currentIteration, MAX_ITERATION), MIN_ITERATION );
|
|
||||||
this.draw();
|
|
||||||
}
|
|
||||||
|
|
||||||
private draw(): void {
|
|
||||||
const canvas = this.canvasRef.nativeElement;
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
if (ctx) {
|
|
||||||
this.fractalService.draw(ctx, this.config);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//movement
|
onReset(): void {
|
||||||
onMouseDown(event: MouseEvent): void {
|
|
||||||
|
this.offsetY = 0.0;
|
||||||
|
this.zoom = 0.2
|
||||||
|
this.maxIterations = 100;
|
||||||
|
|
||||||
|
switch(this.selectedAlgorithm) {
|
||||||
|
case 0: this.offsetX = -0.5; break;
|
||||||
|
case 1: this.offsetX = 0; break;
|
||||||
|
case 2: this.offsetX = -1.75; this.zoom = 8; this.offsetY = -0.03;break;
|
||||||
|
case 3: this.offsetX = 0; break;
|
||||||
|
default: this.offsetX = 0.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onSceneReady(event: SceneEventData): void {
|
||||||
|
event.scene.onPointerObservable.add((pointerInfo) => {
|
||||||
|
switch (pointerInfo.type) {
|
||||||
|
|
||||||
|
case PointerEventTypes.POINTERDOWN:
|
||||||
|
this.onPointerDown(pointerInfo);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case PointerEventTypes.POINTERUP:
|
||||||
|
this.onPointerUp();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case PointerEventTypes.POINTERMOVE:
|
||||||
|
this.onPointerMove(pointerInfo);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case PointerEventTypes.POINTERWHEEL:
|
||||||
|
this.onPointerWheel(pointerInfo);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private onPointerDown(info: PointerInfo): void {
|
||||||
|
if (info.event.button !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.isDragging = true;
|
this.isDragging = true;
|
||||||
this.dragStartX = event.clientX;
|
this.dragStartPoint = { x: info.event.clientX, y: info.event.clientY };
|
||||||
this.dragStartY = event.clientY;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMouseUp(): void {
|
private onPointerUp(): void {
|
||||||
this.isDragging = false;
|
this.isDragging = false;
|
||||||
|
this.dragStartPoint = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
onMouseMove(event: MouseEvent): void {
|
private onPointerMove(info: PointerInfo): void {
|
||||||
if (!this.isDragging) return;
|
if (!this.isDragging || !this.dragStartPoint) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const deltaX = event.clientX - this.dragStartX;
|
const event = info.event as PointerEvent;
|
||||||
const deltaY = event.clientY - this.dragStartY;
|
|
||||||
|
|
||||||
const reScale = 4 / (this.config.width * this.config.zoom);
|
const deltaX = event.clientX - this.dragStartPoint.x;
|
||||||
const imScale = 4 / (this.config.height * this.config.zoom);
|
const deltaY = event.clientY - this.dragStartPoint.y;
|
||||||
|
|
||||||
this.config.offsetX -= deltaX * reScale;
|
const element = event.target as HTMLElement;
|
||||||
this.config.offsetY -= deltaY * imScale;
|
const height = element.clientHeight;
|
||||||
|
|
||||||
this.dragStartX = event.clientX;
|
const scaleFactor = 1 / (height * this.zoom);
|
||||||
this.dragStartY = event.clientY;
|
|
||||||
|
|
||||||
this.draw();
|
this.offsetX += deltaX * scaleFactor;
|
||||||
|
this.offsetY += deltaY * scaleFactor;
|
||||||
|
|
||||||
|
this.dragStartPoint = { x: event.clientX, y: event.clientY };
|
||||||
}
|
}
|
||||||
|
|
||||||
onWheel(event: WheelEvent): void {
|
private onPointerWheel(info: PointerInfo): void {
|
||||||
|
const event = info.event as WheelEvent;
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
|
const element = event.target as HTMLElement;
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
|
||||||
|
const mouseXPixels = -(event.clientX - rect.left - rect.width / 2);
|
||||||
|
const mouseYPixels = -(event.clientY - rect.top - rect.height / 2);
|
||||||
|
|
||||||
|
const mouseXView = mouseXPixels / rect.height;
|
||||||
|
const mouseYView = mouseYPixels / rect.height;
|
||||||
|
|
||||||
|
const mouseXWorld = mouseXView / this.zoom + this.offsetX;
|
||||||
|
const mouseYWorld = mouseYView / this.zoom + this.offsetY;
|
||||||
|
|
||||||
const zoomFactor = 1.1;
|
const zoomFactor = 1.1;
|
||||||
const zoomIn = event.deltaY < 0;
|
if (event.deltaY < 0) {
|
||||||
|
this.zoom *= zoomFactor;
|
||||||
const rect = this.canvasRef.nativeElement.getBoundingClientRect();
|
|
||||||
const mouseX = event.clientX - rect.left;
|
|
||||||
const mouseY = event.clientY - rect.top;
|
|
||||||
|
|
||||||
const reScale = 4 / (this.config.width * this.config.zoom);
|
|
||||||
const imScale = 4 / (this.config.height * this.config.zoom);
|
|
||||||
|
|
||||||
const mouseRe = (mouseX - this.config.width / 2) * reScale + this.config.offsetX;
|
|
||||||
const mouseIm = (mouseY - this.config.height / 2) * imScale + this.config.offsetY;
|
|
||||||
|
|
||||||
if (zoomIn) {
|
|
||||||
this.config.zoom *= zoomFactor;
|
|
||||||
} else {
|
} else {
|
||||||
this.config.zoom /= zoomFactor;
|
this.zoom /= zoomFactor;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newReScale = 4 / (this.config.width * this.config.zoom);
|
this.zoom = Math.max(Math.min(this.zoom, this.maxZoomValue), this.minZoomValue);
|
||||||
const newImScale = 4 / (this.config.height * this.config.zoom);
|
this.sliderValue.set(this.zoom);
|
||||||
|
const optimalIterations = this.getIterationsForZoom(this.zoom);
|
||||||
|
this.maxIterations = Math.min(optimalIterations, 3000);
|
||||||
|
|
||||||
this.config.offsetX = mouseRe - (mouseX - this.config.width / 2) * newReScale;
|
this.offsetX = mouseXWorld - mouseXView / this.zoom;
|
||||||
this.config.offsetY = mouseIm - (mouseY - this.config.height / 2) * newImScale;
|
this.offsetY = mouseYWorld - mouseYView / this.zoom;
|
||||||
|
|
||||||
this.draw();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected readonly MIN_ITERATION = MIN_ITERATION;
|
onSliderChange(newValue: number): void {
|
||||||
protected readonly MAX_ITERATION = MAX_ITERATION;
|
|
||||||
protected readonly FRACTAL_COLOR_SCHEMES = FRACTAL_COLOR_SCHEMES;
|
this.zoom = newValue;
|
||||||
|
this.zoom = Math.max(Math.min(this.zoom, this.maxZoomValue), this.minZoomValue);
|
||||||
|
this.maxIterations = this.getIterationsForZoom(this.zoom);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getIterationsForZoom(zoom: number): number {
|
||||||
|
const baseIterations = 100;
|
||||||
|
const factor = 200;
|
||||||
|
|
||||||
|
if (zoom <= 1) {
|
||||||
|
return baseIterations;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.floor(baseIterations + Math.log10(zoom) * factor);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
export interface FractalConfig {
|
|
||||||
algorithm: 'Mandelbrot' | 'Julia' | 'Burning Ship' | 'Newton';
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
maxIterations: number;
|
|
||||||
zoom: number;
|
|
||||||
offsetX: number;
|
|
||||||
offsetY: number;
|
|
||||||
cReal?: number;
|
|
||||||
cImag?: number;
|
|
||||||
colorScheme: FractalColorScheme;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FRACTAL_COLOR_SCHEMES = [
|
|
||||||
'Blue-Gold',
|
|
||||||
'Fire',
|
|
||||||
'Rainbow',
|
|
||||||
'Greyscale',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export type FractalColorScheme = typeof FRACTAL_COLOR_SCHEMES[number];
|
|
||||||
export class ComplexNumber {
|
|
||||||
constructor(public re: number, public im: number) {}
|
|
||||||
|
|
||||||
add(other: ComplexNumber): ComplexNumber {
|
|
||||||
return new ComplexNumber(this.re + other.re, this.im + other.im);
|
|
||||||
}
|
|
||||||
// Für Newton brauchen wir später auch Multiplikation und Division
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DEFAULT_ITERATION = 100;
|
|
||||||
export const MIN_ITERATION = 20;
|
|
||||||
export const MAX_ITERATION = 1000;
|
|
||||||
155
src/app/pages/algorithms/fractal/fractal.shader.ts
Normal file
155
src/app/pages/algorithms/fractal/fractal.shader.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
export const FRACTAL2D_VERTEX = `
|
||||||
|
precision highp float;
|
||||||
|
attribute vec3 position;
|
||||||
|
attribute vec2 uv;
|
||||||
|
uniform mat4 worldViewProjection;
|
||||||
|
varying vec2 vUV;
|
||||||
|
void main() {
|
||||||
|
gl_Position = worldViewProjection * vec4(position, 1.0);
|
||||||
|
vUV = uv;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const FRACTAL2D_FRAGMENT = `
|
||||||
|
precision highp float;
|
||||||
|
varying vec2 vUV;
|
||||||
|
|
||||||
|
uniform vec2 resolution;
|
||||||
|
uniform vec2 center; // OffsetX, OffsetY
|
||||||
|
uniform float zoom;
|
||||||
|
uniform int maxIterations;
|
||||||
|
uniform int algorithm; // 0:Mandel, 1:Julia, 2:Ship, 3:Newton
|
||||||
|
uniform int colorScheme; // 0:BlueGold, 1:Greyscale, 2:Fire, 3:Rainbow
|
||||||
|
uniform vec2 juliaC;
|
||||||
|
|
||||||
|
vec3 hsv2rgb(vec3 c) {
|
||||||
|
vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0);
|
||||||
|
vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
|
||||||
|
return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Color Schemes ---
|
||||||
|
vec3 getColor(float t, int iter, int maxIter, int root) {
|
||||||
|
if (iter >= maxIter) return vec3(0.0);
|
||||||
|
|
||||||
|
// special newton coloring
|
||||||
|
if (algorithm == 3) {
|
||||||
|
float val = 1.0 - (float(iter) / 20.0); // Weicheres Shading für Newton
|
||||||
|
val = clamp(val, 0.0, 1.0);
|
||||||
|
if (root == 1) return vec3(val, 0.0, 0.0); // Rot
|
||||||
|
if (root == 2) return vec3(0.0, val, 0.0); // Grün
|
||||||
|
if (root == 3) return vec3(0.0, 0.0, val); // Blau
|
||||||
|
return vec3(0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// default color
|
||||||
|
if (colorScheme == 0) { // Blue-Gold
|
||||||
|
return vec3(
|
||||||
|
9.0 * (1.0-t)*t*t*t,
|
||||||
|
15.0 * (1.0-t)*(1.0-t)*t*t,
|
||||||
|
8.5 * (1.0-t)*(1.0-t)*(1.0-t)*t
|
||||||
|
) * 3.0;
|
||||||
|
}
|
||||||
|
if (colorScheme == 1) { // Greyscale
|
||||||
|
return vec3(t);
|
||||||
|
}
|
||||||
|
if (colorScheme == 2) { // Fire
|
||||||
|
float f = sqrt(t);
|
||||||
|
return vec3(f * 2.0, (f - 0.3) * 3.0, (f - 0.6) * 6.0);
|
||||||
|
}
|
||||||
|
if (colorScheme == 3) { // Rainbow
|
||||||
|
return hsv2rgb(vec3(t * 5.0, 1.0, 1.0));
|
||||||
|
}
|
||||||
|
return vec3(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main(void) {
|
||||||
|
float aspect = resolution.x / resolution.y;
|
||||||
|
vec2 uv = (vUV - 0.5) * vec2(aspect, 1.0);
|
||||||
|
vec2 c = uv / zoom + center;
|
||||||
|
|
||||||
|
vec2 z = c;
|
||||||
|
// For Julia is c fix, z changes. For Mandel is z=0, c changes.
|
||||||
|
if (algorithm == 1) {
|
||||||
|
z = c;
|
||||||
|
c = juliaC;
|
||||||
|
} else if (algorithm != 3) {
|
||||||
|
z = vec2(0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
int iter = 0;
|
||||||
|
int root = 0;
|
||||||
|
|
||||||
|
// --- Algorithms ---
|
||||||
|
if (algorithm == 3) { // Newton: z^3 - 1
|
||||||
|
z = c;
|
||||||
|
for(int i=0; i<100; i++) {
|
||||||
|
if (i >= maxIterations) break;
|
||||||
|
|
||||||
|
// z^3 - 1
|
||||||
|
// z_new = z - (z^3 - 1) / (3*z^2)
|
||||||
|
// simplified: z_new = (2*z^3 + 1) / (3*z^2)
|
||||||
|
|
||||||
|
float zx2 = z.x * z.x;
|
||||||
|
float zy2 = z.y * z.y;
|
||||||
|
float denom = 3.0 * (zx2 + zy2) * (zx2 + zy2); // |3z^2|^2 simplified
|
||||||
|
|
||||||
|
// z -= (z^3-1)/(3z^2)
|
||||||
|
|
||||||
|
vec2 z2 = vec2(z.x*z.x - z.y*z.y, 2.0*z.x*z.y);
|
||||||
|
vec2 z3 = vec2(z2.x*z.x - z2.y*z.y, z2.x*z.y + z2.y*z.x);
|
||||||
|
|
||||||
|
vec2 num = z3 - vec2(1.0, 0.0);
|
||||||
|
vec2 den = 3.0 * z2;
|
||||||
|
|
||||||
|
// Division Complex: (a+bi)/(c+di) = ((ac+bd) + (bc-ad)i) / (c^2+d^2)
|
||||||
|
float d = den.x*den.x + den.y*den.y;
|
||||||
|
if(d < 0.000001) { iter=maxIterations; break; }
|
||||||
|
|
||||||
|
vec2 div = vec2(
|
||||||
|
(num.x*den.x + num.y*den.y)/d,
|
||||||
|
(num.y*den.x - num.x*den.y)/d
|
||||||
|
);
|
||||||
|
|
||||||
|
z -= div;
|
||||||
|
iter++;
|
||||||
|
|
||||||
|
// Roots check
|
||||||
|
// 1. (1, 0)
|
||||||
|
if (distance(z, vec2(1.0, 0.0)) < 0.001) { root = 1; break; }
|
||||||
|
// 2. (-0.5, 0.866)
|
||||||
|
if (distance(z, vec2(-0.5, 0.866)) < 0.001) { root = 2; break; }
|
||||||
|
// 3. (-0.5, -0.866)
|
||||||
|
if (distance(z, vec2(-0.5, -0.866)) < 0.001) { root = 3; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else { // Mandelbrot (0), Julia (1), Burning Ship (2)
|
||||||
|
for(int i=0; i<2000; i++) {
|
||||||
|
if (i >= maxIterations) break;
|
||||||
|
|
||||||
|
float x2 = z.x * z.x;
|
||||||
|
float y2 = z.y * z.y;
|
||||||
|
|
||||||
|
if (x2 + y2 > 4.0) {
|
||||||
|
iter = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
iter = i;
|
||||||
|
|
||||||
|
if (algorithm == 2) { // Burning Ship
|
||||||
|
z.y = abs(z.y);
|
||||||
|
z.x = abs(z.x);
|
||||||
|
}
|
||||||
|
|
||||||
|
// z = z^2 + c
|
||||||
|
float nextX = x2 - y2 + c.x;
|
||||||
|
z.y = 2.0 * z.x * z.y + c.y;
|
||||||
|
z.x = nextX;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
float t = float(iter) / float(maxIterations);
|
||||||
|
vec3 color = getColor(t, iter, maxIterations, root);
|
||||||
|
gl_FragColor = vec4(color, 1.0);
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -1,256 +0,0 @@
|
|||||||
import { Injectable } from '@angular/core';
|
|
||||||
import {FractalColorScheme, FractalConfig} from '../fractal.model';
|
|
||||||
|
|
||||||
@Injectable({
|
|
||||||
providedIn: 'root'
|
|
||||||
})
|
|
||||||
export class FractalService {
|
|
||||||
|
|
||||||
private currentPalette: Uint8ClampedArray = new Uint8ClampedArray(0);
|
|
||||||
private lastScheme: FractalColorScheme | null = null;
|
|
||||||
private lastMaxIter: number = 0;
|
|
||||||
|
|
||||||
draw(ctx: CanvasRenderingContext2D, config: FractalConfig): void {
|
|
||||||
const width = config.width;
|
|
||||||
const height = config.height;
|
|
||||||
|
|
||||||
this.updateColorPalette(config);
|
|
||||||
|
|
||||||
const imageData = ctx.createImageData(width, height);
|
|
||||||
const data = imageData.data;
|
|
||||||
|
|
||||||
for (let x = 0; x < width; x++) {
|
|
||||||
for (let y = 0; y < height; y++) {
|
|
||||||
|
|
||||||
const re = (x - width / 2) * (4 / width / config.zoom) + config.offsetX;
|
|
||||||
const im = (y - height / 2) * (4 / height / config.zoom) + config.offsetY;
|
|
||||||
|
|
||||||
let iterations = 0;
|
|
||||||
const pixelIndex = (y * width + x) * 4;
|
|
||||||
switch (config.algorithm) {
|
|
||||||
case 'Mandelbrot':
|
|
||||||
iterations = this.calculateMandelbrot(re, im, config.maxIterations);
|
|
||||||
this.colorizePixel(data, pixelIndex, iterations);
|
|
||||||
break;
|
|
||||||
case 'Julia':
|
|
||||||
{ const cRe = config.cReal ?? -0.7;
|
|
||||||
const cIm = config.cImag ?? 0.27015;
|
|
||||||
iterations = this.calculateJulia(re, im, cRe, cIm, config.maxIterations);
|
|
||||||
this.colorizePixel(data, pixelIndex, iterations);
|
|
||||||
break; }
|
|
||||||
case 'Burning Ship':
|
|
||||||
iterations = this.calculateBurningShip(re, im, config.maxIterations);
|
|
||||||
this.colorizePixel(data, pixelIndex, iterations);
|
|
||||||
break;
|
|
||||||
case 'Newton':
|
|
||||||
this.handleNewtonFractal(re, im, pixelIndex, config, data);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.putImageData(imageData, 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleNewtonFractal(re: number, im: number, pixelIndex:number, config: FractalConfig, data: Uint8ClampedArray<ArrayBuffer>) {
|
|
||||||
|
|
||||||
const result = this.calculateNewton(re, im, config.maxIterations);
|
|
||||||
if (result === config.maxIterations) {
|
|
||||||
data[pixelIndex] = 0;
|
|
||||||
data[pixelIndex + 1] = 0;
|
|
||||||
data[pixelIndex + 2] = 0;
|
|
||||||
} else if (result >= 2000) {
|
|
||||||
const light = 255 - (result - 2000) * 10;
|
|
||||||
data[pixelIndex] = 0;
|
|
||||||
data[pixelIndex + 1] = 0;
|
|
||||||
data[pixelIndex + 2] = Math.max(0, light);
|
|
||||||
} else if (result >= 1000) {
|
|
||||||
const light = 255 - (result - 1000) * 10;
|
|
||||||
data[pixelIndex] = 0;
|
|
||||||
data[pixelIndex + 1] = Math.max(0, light);
|
|
||||||
data[pixelIndex + 2] = 0;
|
|
||||||
} else {
|
|
||||||
const light = 255 - result * 10;
|
|
||||||
data[pixelIndex] = Math.max(0, light);
|
|
||||||
data[pixelIndex + 1] = 0;
|
|
||||||
data[pixelIndex + 2] = 0;
|
|
||||||
}
|
|
||||||
data[pixelIndex + 3] = 255;
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateColorPalette(config: FractalConfig) {
|
|
||||||
if (this.lastScheme !== config.colorScheme || this.lastMaxIter !== config.maxIterations) {
|
|
||||||
this.currentPalette = this.generatePalette(config.colorScheme, config.maxIterations);
|
|
||||||
this.lastScheme = config.colorScheme;
|
|
||||||
this.lastMaxIter = config.maxIterations;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private calculateMandelbrot(cRe: number, cIm: number, maxIter: number): number {
|
|
||||||
let zRe = 0;
|
|
||||||
let zIm = 0;
|
|
||||||
let n = 0;
|
|
||||||
|
|
||||||
while (zRe * zRe + zIm * zIm <= 4 && n < maxIter) {
|
|
||||||
const zReNew = zRe * zRe - zIm * zIm + cRe;
|
|
||||||
zIm = 2 * zRe * zIm + cIm;
|
|
||||||
zRe = zReNew;
|
|
||||||
n++;
|
|
||||||
}
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
|
|
||||||
private calculateJulia(zRe: number, zIm: number, cRe: number, cIm: number, maxIter: number): number {
|
|
||||||
let n = 0;
|
|
||||||
while (zRe * zRe + zIm * zIm <= 4 && n < maxIter) {
|
|
||||||
const zReNew = zRe * zRe - zIm * zIm + cRe;
|
|
||||||
zIm = 2 * zRe * zIm + cIm;
|
|
||||||
zRe = zReNew;
|
|
||||||
n++;
|
|
||||||
}
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
|
|
||||||
private calculateBurningShip(cRe: number, cIm: number, maxIter: number): number {
|
|
||||||
let zRe = 0;
|
|
||||||
let zIm = 0;
|
|
||||||
let n = 0;
|
|
||||||
|
|
||||||
while (zRe * zRe + zIm * zIm <= 4 && n < maxIter) {
|
|
||||||
const zReAbs = Math.abs(zRe);
|
|
||||||
const zImAbs = Math.abs(zIm);
|
|
||||||
|
|
||||||
const zReNew = zReAbs * zReAbs - zImAbs * zImAbs + cRe;
|
|
||||||
zIm = 2 * zReAbs * zImAbs + cIm;
|
|
||||||
zRe = zReNew;
|
|
||||||
n++;
|
|
||||||
}
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
|
|
||||||
private colorizePixel(data: Uint8ClampedArray, pixelIndex: number, iterations: number): void {
|
|
||||||
const paletteIndex = iterations * 4;
|
|
||||||
data[pixelIndex] = this.currentPalette[paletteIndex]; // R
|
|
||||||
data[pixelIndex + 1] = this.currentPalette[paletteIndex + 1]; // G
|
|
||||||
data[pixelIndex + 2] = this.currentPalette[paletteIndex + 2]; // B
|
|
||||||
data[pixelIndex + 3] = 255;
|
|
||||||
}
|
|
||||||
|
|
||||||
// z^3 - 1
|
|
||||||
// 1. 1 + 0i (right)
|
|
||||||
// 2. -0.5 + 0.866i (upper left)
|
|
||||||
// 3. -0.5 - 0.866i (lower left)
|
|
||||||
private calculateNewton(x0: number, y0: number, maxIter: number): number {
|
|
||||||
let x = x0;
|
|
||||||
let y = y0;
|
|
||||||
const tolerance = 0.000001;
|
|
||||||
|
|
||||||
for (let i = 0; i < maxIter; i++) {
|
|
||||||
const x2 = x * x;
|
|
||||||
const y2 = y * y;
|
|
||||||
|
|
||||||
if (x2 + y2 < 0.0000001) return maxIter;
|
|
||||||
|
|
||||||
const oldX = x;
|
|
||||||
const oldY = y;
|
|
||||||
|
|
||||||
const z3Re = x*x*x - 3*x*y*y;
|
|
||||||
const z3Im = 3*x*x*y - y*y*y;
|
|
||||||
|
|
||||||
const fRe = z3Re - 1;
|
|
||||||
const fIm = z3Im;
|
|
||||||
|
|
||||||
const fPrimeRe = 3 * (x2 - y2);
|
|
||||||
const fPrimeIm = 3 * (2 * x * y);
|
|
||||||
|
|
||||||
const divDenom = fPrimeRe * fPrimeRe + fPrimeIm * fPrimeIm;
|
|
||||||
if (divDenom === 0) return maxIter;
|
|
||||||
|
|
||||||
const divRe = (fRe * fPrimeRe + fIm * fPrimeIm) / divDenom;
|
|
||||||
const divIm = (fIm * fPrimeRe - fRe * fPrimeIm) / divDenom;
|
|
||||||
|
|
||||||
x = oldX - divRe;
|
|
||||||
y = oldY - divIm;
|
|
||||||
|
|
||||||
if ((x - 1)*(x - 1) + y*y < tolerance) return i;
|
|
||||||
|
|
||||||
if ((x + 0.5)*(x + 0.5) + (y - 0.866)*(y - 0.866) < tolerance) return i + 1000;
|
|
||||||
|
|
||||||
if ((x + 0.5)*(x + 0.5) + (y + 0.866)*(y + 0.866) < tolerance) return i + 2000;
|
|
||||||
}
|
|
||||||
|
|
||||||
return maxIter;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Paletten-Generator ---
|
|
||||||
private generatePalette(scheme: FractalColorScheme, maxIter: number): Uint8ClampedArray {
|
|
||||||
const palette = new Uint8ClampedArray((maxIter + 1) * 4);
|
|
||||||
|
|
||||||
for (let i = 0; i <= maxIter; i++) {
|
|
||||||
let r = 0, g = 0, b = 0;
|
|
||||||
|
|
||||||
if (i === maxIter) {
|
|
||||||
r = 0; g = 0; b = 0;
|
|
||||||
} else {
|
|
||||||
const t = i / maxIter;
|
|
||||||
|
|
||||||
switch (scheme) {
|
|
||||||
case 'Greyscale':
|
|
||||||
r = g = b = t * 255;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'Blue-Gold':
|
|
||||||
r = 9 * (1 - t) * t * t * t * 255;
|
|
||||||
g = 15 * (1 - t) * (1 - t) * t * t * 255;
|
|
||||||
b = 8.5 * (1 - t) * (1 - t) * (1 - t) * t * 255;
|
|
||||||
r = Math.min(255, r * 2);
|
|
||||||
g = Math.min(255, g * 2);
|
|
||||||
b = Math.min(255, b * 4) + 40;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'Fire':
|
|
||||||
if (i === maxIter) {
|
|
||||||
r = 0; g = 0; b = 0;
|
|
||||||
} else {
|
|
||||||
const t = Math.sqrt(i / maxIter);
|
|
||||||
|
|
||||||
r = t * 255 * 2;
|
|
||||||
g = (t - 0.3) * 255 * 3;
|
|
||||||
b = (t - 0.6) * 255 * 6;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'Rainbow':
|
|
||||||
{ const hue = (i * 5) % 360;
|
|
||||||
const rgb = this.hsvToRgb(hue, 1, 1);
|
|
||||||
r = rgb[0]; g = rgb[1]; b = rgb[2];
|
|
||||||
break; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const index = i * 4;
|
|
||||||
palette[index] = Math.min(255, Math.max(0, r));
|
|
||||||
palette[index + 1] = Math.min(255, Math.max(0, g));
|
|
||||||
palette[index + 2] = Math.min(255, Math.max(0, b));
|
|
||||||
palette[index + 3] = 255; // Alpha
|
|
||||||
}
|
|
||||||
|
|
||||||
return palette;
|
|
||||||
}
|
|
||||||
|
|
||||||
private hsvToRgb(h: number, s: number, v: number): [number, number, number] {
|
|
||||||
let r = 0, g = 0, b = 0;
|
|
||||||
const c = v * s;
|
|
||||||
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
|
||||||
const m = v - c;
|
|
||||||
|
|
||||||
if (h >= 0 && h < 60) { r = c; g = x; b = 0; }
|
|
||||||
else if (h >= 60 && h < 120) { r = x; g = c; b = 0; }
|
|
||||||
else if (h >= 120 && h < 180) { r = 0; g = c; b = x; }
|
|
||||||
else if (h >= 180 && h < 240) { r = 0; g = x; b = c; }
|
|
||||||
else if (h >= 240 && h < 300) { r = x; g = 0; b = c; }
|
|
||||||
else if (h >= 300 && h < 360) { r = c; g = 0; b = x; }
|
|
||||||
|
|
||||||
return [(r + m) * 255, (g + m) * 255, (b + m) * 255];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +1,19 @@
|
|||||||
<mat-card class="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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="canvas-container">
|
<app-babylon-canvas
|
||||||
<canvas #renderCanvas></canvas>
|
[config]="fractalConfig"
|
||||||
</div>
|
[renderCallback]="onRender"
|
||||||
|
/>
|
||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
.canvas-container { width: 100%; height: 1000px; }
|
|
||||||
canvas { width: 100%; height: 100%; touch-action: none; border-width: 0; border-color: transparent; border-style: hidden; }
|
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import {AfterViewInit, Component, ElementRef, inject, NgZone, OnDestroy, ViewChild} from '@angular/core';
|
import {Component} from '@angular/core';
|
||||||
import {ArcRotateCamera, Engine, MeshBuilder, Scene, ShaderMaterial, Vector2, Vector3} from '@babylonjs/core';
|
import {ArcRotateCamera, Camera, ShaderMaterial} from '@babylonjs/core';
|
||||||
import {MANDELBULB_FRAGMENT, MANDELBULB_VERTEX} from './fractal.shader';
|
import {MANDELBULB_FRAGMENT, MANDELBULB_VERTEX} from './fractal3d.shader';
|
||||||
import {Information} from '../information/information';
|
import {Information} from '../information/information';
|
||||||
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
|
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
|
||||||
import {TranslatePipe} from '@ngx-translate/core';
|
import {TranslatePipe} from '@ngx-translate/core';
|
||||||
import {AlgorithmInformation} from '../information/information.models';
|
import {AlgorithmInformation} from '../information/information.models';
|
||||||
import {UrlConstants} from '../../../constants/UrlConstants';
|
import {UrlConstants} from '../../../constants/UrlConstants';
|
||||||
import {MatButton} from '@angular/material/button';
|
import {MatButton} from '@angular/material/button';
|
||||||
|
import {BabylonCanvas, RenderCallback, RenderConfig} from '../../../shared/components/render-canvas/babylon-canvas.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-fractal3d',
|
selector: 'app-fractal3d',
|
||||||
@@ -17,15 +18,13 @@ import {MatButton} from '@angular/material/button';
|
|||||||
MatCardHeader,
|
MatCardHeader,
|
||||||
MatCardTitle,
|
MatCardTitle,
|
||||||
TranslatePipe,
|
TranslatePipe,
|
||||||
MatButton
|
MatButton,
|
||||||
|
BabylonCanvas
|
||||||
],
|
],
|
||||||
templateUrl: './fractal3d.component.html',
|
templateUrl: './fractal3d.component.html',
|
||||||
styleUrl: './fractal3d.component.scss',
|
styleUrl: './fractal3d.component.scss',
|
||||||
})
|
})
|
||||||
export class Fractal3dComponent implements AfterViewInit, OnDestroy {
|
export class Fractal3dComponent {
|
||||||
readonly ngZone = inject(NgZone);
|
|
||||||
|
|
||||||
@ViewChild('renderCanvas') canvasRef!: ElementRef<HTMLCanvasElement>;
|
|
||||||
|
|
||||||
algoInformation: AlgorithmInformation = {
|
algoInformation: AlgorithmInformation = {
|
||||||
title: 'FRACTAL3D.EXPLANATION.TITLE',
|
title: 'FRACTAL3D.EXPLANATION.TITLE',
|
||||||
@@ -51,96 +50,36 @@ export class Fractal3dComponent implements AfterViewInit, OnDestroy {
|
|||||||
disclaimerListEntry: ['FRACTAL3D.EXPLANATION.DISCLAIMER_1', 'FRACTAL3D.EXPLANATION.DISCLAIMER_2', 'FRACTAL3D.EXPLANATION.DISCLAIMER_3', 'FRACTAL3D.EXPLANATION.DISCLAIMER_4']
|
disclaimerListEntry: ['FRACTAL3D.EXPLANATION.DISCLAIMER_1', 'FRACTAL3D.EXPLANATION.DISCLAIMER_2', 'FRACTAL3D.EXPLANATION.DISCLAIMER_3', 'FRACTAL3D.EXPLANATION.DISCLAIMER_4']
|
||||||
};
|
};
|
||||||
|
|
||||||
|
fractalConfig: RenderConfig = {
|
||||||
|
mode: '3D',
|
||||||
|
initialViewSize: 4,
|
||||||
|
vertexShader: MANDELBULB_VERTEX,
|
||||||
|
fragmentShader: MANDELBULB_FRAGMENT,
|
||||||
|
uniformNames: ["time", "power", "fractalType"]
|
||||||
|
};
|
||||||
|
|
||||||
private readonly fractalPower = 8;
|
private readonly fractalPower = 8;
|
||||||
private engine!: Engine;
|
|
||||||
private scene!: Scene;
|
|
||||||
private shaderMaterial!: ShaderMaterial;
|
|
||||||
private time = 0;
|
private time = 0;
|
||||||
private triggerCamUpdate = false;
|
private oldType = 0;
|
||||||
private cameraPosition: number = 3.5;
|
|
||||||
public currentFractalType = 0;
|
public currentFractalType = 0;
|
||||||
|
|
||||||
|
|
||||||
ngAfterViewInit(): void {
|
onRender: RenderCallback = (material: ShaderMaterial, camera: Camera) => {
|
||||||
this.ngZone.runOutsideAngular(() => {
|
this.time += 0.005;
|
||||||
this.initBabylon();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private initBabylon(): void {
|
if (this.oldType != this.currentFractalType && camera instanceof ArcRotateCamera) {
|
||||||
const canvas = this.canvasRef.nativeElement;
|
this.oldType = this.currentFractalType;
|
||||||
this.engine = new Engine(canvas, true);
|
camera.radius = this.currentFractalType == 1 ? 15 : 4;
|
||||||
this.scene = new Scene(this.engine);
|
}
|
||||||
|
|
||||||
const camera = new ArcRotateCamera("Camera", 0, Math.PI / 2, 4, Vector3.Zero(), this.scene);
|
material.setFloat("time", this.time);
|
||||||
camera.wheelPrecision = 100;
|
material.setFloat("power", this.fractalPower);
|
||||||
camera.minZ = 0.1;
|
material.setInt("fractalType", this.currentFractalType);
|
||||||
camera.maxZ = 100;
|
};
|
||||||
camera.lowerRadiusLimit = 1.5;
|
|
||||||
camera.upperRadiusLimit = 20;
|
|
||||||
camera.attachControl(this.canvasRef.nativeElement, true);
|
|
||||||
|
|
||||||
const plane = MeshBuilder.CreatePlane("plane", { size: 10 }, this.scene);
|
|
||||||
plane.parent = camera;
|
|
||||||
plane.position.z = 1;
|
|
||||||
plane.alwaysSelectAsActiveMesh = true;
|
|
||||||
|
|
||||||
this.shaderMaterial = new ShaderMaterial(
|
|
||||||
"mandelbulbShader",
|
|
||||||
this.scene,
|
|
||||||
{
|
|
||||||
vertexSource: MANDELBULB_VERTEX,
|
|
||||||
fragmentSource: MANDELBULB_FRAGMENT
|
|
||||||
},
|
|
||||||
{
|
|
||||||
attributes: ["position", "uv"],
|
|
||||||
uniforms: ["time", "resolution", "cameraPosition", "targetPosition", "power", "fractalType"]
|
|
||||||
}
|
|
||||||
);
|
|
||||||
this.shaderMaterial.disableDepthWrite = true;
|
|
||||||
this.shaderMaterial.backFaceCulling = false;
|
|
||||||
|
|
||||||
plane.material = this.shaderMaterial;
|
|
||||||
|
|
||||||
this.engine.runRenderLoop(() => {
|
|
||||||
this.time += 0.005;
|
|
||||||
|
|
||||||
if (this.triggerCamUpdate)
|
|
||||||
{
|
|
||||||
this.triggerCamUpdate = false;
|
|
||||||
camera.radius = this.cameraPosition;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.shaderMaterial) {
|
|
||||||
this.shaderMaterial.setFloat("time", this.time);
|
|
||||||
this.shaderMaterial.setVector2("resolution", new Vector2(canvas.width, canvas.height));
|
|
||||||
this.shaderMaterial.setVector3("cameraPosition", camera.position);
|
|
||||||
this.shaderMaterial.setVector3("targetPosition", camera.target);
|
|
||||||
this.shaderMaterial.setFloat("power", this.fractalPower);
|
|
||||||
this.shaderMaterial.setInt("fractalType", this.currentFractalType);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.scene.render();
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener('resize', () => this.engine.resize());
|
|
||||||
}
|
|
||||||
|
|
||||||
onFractalTypeChange(type: number): void {
|
onFractalTypeChange(type: number): void {
|
||||||
this.currentFractalType = type;
|
this.currentFractalType = type;
|
||||||
if (type === 0 ||type === 2)
|
|
||||||
{
|
|
||||||
this.cameraPosition = 4;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
this.cameraPosition = 15;
|
|
||||||
}
|
|
||||||
this.triggerCamUpdate = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
|
||||||
if (this.engine) {
|
|
||||||
this.engine.dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
<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>{{ algo.name }}</strong> {{ algo.description | translate }}
|
<strong>
|
||||||
|
@if(algo.translateName){
|
||||||
|
{{ algo.name | translate}}
|
||||||
|
} @else {
|
||||||
|
{{ algo.name }}
|
||||||
|
}
|
||||||
|
</strong>
|
||||||
|
{{ algo.description | translate }}
|
||||||
<a href="{{algo.link}}" target="_blank" rel="noopener noreferrer">Wikipedia</a>
|
<a href="{{algo.link}}" target="_blank" rel="noopener noreferrer">Wikipedia</a>
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
@@ -13,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)
|
||||||
|
|||||||
@@ -10,5 +10,5 @@ export interface AlgorithmEntry {
|
|||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
link: string;
|
link: string;
|
||||||
|
translateName?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,27 @@
|
|||||||
<mat-card class="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,80 +1,80 @@
|
|||||||
<mat-card class="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="grid-size">
|
<div class="flex gap-3 items-center flex-wrap">
|
||||||
<mat-form-field appearance="outline" class="grid-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
|
||||||
type="number"
|
type="number"
|
||||||
[min]="MIN_GRID_SIZE"
|
[min]="MIN_GRID_SIZE"
|
||||||
[max]="MAX_GRID_SIZE"
|
[max]="MAX_GRID_SIZE"
|
||||||
[(ngModel)]="gridRows"
|
[(ngModel)]="gridRows"
|
||||||
(ngModelChange)="genericGridComponent.gridRows = gridRows; genericGridComponent.applyGridSize()"
|
(ngModelChange)="genericGridComponent.gridRows = gridRows; genericGridComponent.applyGridSize()"
|
||||||
/> </mat-form-field>
|
/> </mat-form-field>
|
||||||
|
|
||||||
<mat-form-field appearance="outline" class="grid-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
|
||||||
type="number"
|
type="number"
|
||||||
[min]="MIN_GRID_SIZE"
|
[min]="MIN_GRID_SIZE"
|
||||||
[max]="MAX_GRID_SIZE"
|
[max]="MAX_GRID_SIZE"
|
||||||
[(ngModel)]="gridCols"
|
[(ngModel)]="gridCols"
|
||||||
(ngModelChange)="genericGridComponent.gridCols = gridCols; genericGridComponent.applyGridSize()"
|
(ngModelChange)="genericGridComponent.gridCols = gridCols; genericGridComponent.applyGridSize()"
|
||||||
/> </mat-form-field>
|
/> </mat-form-field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-4 items-center text-[0.9em]">
|
||||||
|
<span><span class="legend-swatch bg-green-600"></span> {{ 'PATHFINDING.START_NODE' | translate }}</span>
|
||||||
|
<span><span class="legend-swatch bg-red-600"></span> {{ 'PATHFINDING.END_NODE' | translate }}</span>
|
||||||
|
<span><span class="legend-swatch bg-black"></span> {{ 'PATHFINDING.WALL' | translate }}</span>
|
||||||
|
<span><span class="legend-swatch bg-sky-300"></span> {{ 'PATHFINDING.VISITED' | translate }}</span>
|
||||||
|
<span><span class="legend-swatch bg-[gold]"></span> {{ 'PATHFINDING.PATH' | translate }}</span>
|
||||||
|
</div>
|
||||||
|
<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.EXECUTION_TIME' | translate }}: {{ executionTime | number:'1.2-2' }} ms</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="legend">
|
<app-generic-grid
|
||||||
<span><span class="legend-color start"></span> {{ 'PATHFINDING.START_NODE' | translate }}</span>
|
[gridRows]="gridRows"
|
||||||
<span><span class="legend-color end"></span> {{ 'PATHFINDING.END_NODE' | translate }}</span>
|
[gridCols]="gridCols"
|
||||||
<span><span class="legend-color wall"></span> {{ 'PATHFINDING.WALL' | translate }}</span>
|
[minGridSize]="MIN_GRID_SIZE"
|
||||||
<span><span class="legend-color visited"></span> {{ 'PATHFINDING.VISITED' | translate }}</span>
|
[maxGridSize]="MAX_GRID_SIZE"
|
||||||
<span><span class="legend-color path"></span> {{ 'PATHFINDING.PATH' | translate }}</span>
|
[maxGridPx]="MAX_GRID_PX"
|
||||||
</div>
|
[createNodeFn]="createPathfindingNode"
|
||||||
<div class="controls-panel">
|
[getNodeColorFn]="getPathfindingNodeColor"
|
||||||
<p>{{ 'PATHFINDING.PATH_LENGTH' | translate }}: {{ pathLength }}</p>
|
[applySelectionFn]="applyPathfindingSelection"
|
||||||
<p>{{ 'PATHFINDING.EXECUTION_TIME' | translate }}: {{ executionTime | number:'1.2-2' }} ms</p>
|
[backgroundColor]="'lightgray'"
|
||||||
</div>
|
(gridChange)="grid = $event"
|
||||||
</div>
|
></app-generic-grid>
|
||||||
|
</mat-card-content>
|
||||||
<app-generic-grid
|
</mat-card>
|
||||||
[gridRows]="gridRows"
|
|
||||||
[gridCols]="gridCols"
|
|
||||||
[minGridSize]="MIN_GRID_SIZE"
|
|
||||||
[maxGridSize]="MAX_GRID_SIZE"
|
|
||||||
[maxGridPx]="MAX_GRID_PX"
|
|
||||||
[createNodeFn]="createPathfindingNode"
|
|
||||||
[getNodeColorFn]="getPathfindingNodeColor"
|
|
||||||
[applySelectionFn]="applyPathfindingSelection"
|
|
||||||
[backgroundColor]="'lightgray'"
|
|
||||||
(gridChange)="grid = $event"
|
|
||||||
></app-generic-grid>
|
|
||||||
</mat-card-content>
|
|
||||||
</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);
|
||||||
|
}
|
||||||
|
`;
|
||||||
67
src/app/pages/algorithms/pendulum/pendulum.component.html
Normal file
67
src/app/pages/algorithms/pendulum/pendulum.component.html
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<mat-card class="w-full max-w-[1920px] p-5">
|
||||||
|
<mat-card-header>
|
||||||
|
<mat-card-title>{{ 'PENDULUM.TITLE' | translate }}</mat-card-title>
|
||||||
|
</mat-card-header>
|
||||||
|
<mat-card-content>
|
||||||
|
<app-information [algorithmInformation]="algoInformation"/>
|
||||||
|
<div class="flex flex-col mb-4">
|
||||||
|
<div class="grid grid-cols-2 tablet:grid-cols-1 tablet:gap-4 mb-6">
|
||||||
|
<div class="flex items-center gap-4 mr-4">
|
||||||
|
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.TRAIL_DECAY_TIME' | translate }}</p>
|
||||||
|
<ngx-slider class="grow" [(value)]="simParams.trailDecay" [options]="trailDecayOptions" ></ngx-slider>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-4 mr-4">
|
||||||
|
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.ATTRACTION' | translate }}</p>
|
||||||
|
<ngx-slider class="grow" [(value)]="simParams.g" [options]="gravityOptions" ></ngx-slider>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-4 mr-4">
|
||||||
|
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.L1_LENGTH' | translate }}</p>
|
||||||
|
<ngx-slider class="grow" [(value)]="simParams.l1" [options]="lengthOptions" ></ngx-slider>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-4 mr-4">
|
||||||
|
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.L2_LENGTH' | translate }}</p>
|
||||||
|
<ngx-slider class="grow" [(value)]="simParams.l2" [options]="lengthOptions" ></ngx-slider>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-4 mr-4">
|
||||||
|
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.M1_MASS' | translate }}</p>
|
||||||
|
<ngx-slider class="grow" [(value)]="simParams.m1" [options]="massOptions" ></ngx-slider>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-4 mr-4">
|
||||||
|
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.M2_MASS' | translate }}</p>
|
||||||
|
<ngx-slider class="grow" [(value)]="simParams.m2" [options]="massOptions" ></ngx-slider>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-4 mr-4 col-span-full">
|
||||||
|
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.DAMPING' | translate }}</p>
|
||||||
|
<ngx-slider class="grow" [(value)]="simParams.damping" [options]="dampingOptions" ></ngx-slider>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-3 mb-4">
|
||||||
|
<button mat-raised-button color="primary" (click)="pushPendulum(true)">
|
||||||
|
{{ 'PENDULUM.POKE_M1' | translate }}
|
||||||
|
</button>
|
||||||
|
<button mat-raised-button color="primary" (click)="pushPendulum(false)">
|
||||||
|
{{ 'PENDULUM.POKE_M2' | translate }}
|
||||||
|
</button>
|
||||||
|
<button mat-raised-button color="primary" (click)="resetPendulum()">
|
||||||
|
{{ 'PENDULUM.RESET' | translate }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-4 items-center text-[0.9em] mt-2.5">
|
||||||
|
<span><span class="legend-swatch bg-yellow-400"></span> L1</span>
|
||||||
|
<span><span class="legend-swatch bg-fuchsia-500"></span> L2</span>
|
||||||
|
<span><span class="legend-swatch bg-red-600"></span> M1</span>
|
||||||
|
<span><span class="legend-swatch bg-green-600"></span> M2</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<app-babylon-canvas
|
||||||
|
[config]="renderConfig"
|
||||||
|
(sceneReady)="onSceneReady($event)"
|
||||||
|
(sceneResized)="onSceneReady($event)"
|
||||||
|
/>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
183
src/app/pages/algorithms/pendulum/pendulum.component.ts
Normal file
183
src/app/pages/algorithms/pendulum/pendulum.component.ts
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
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 {FormsModule} from '@angular/forms';
|
||||||
|
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 {TranslatePipe} from '@ngx-translate/core';
|
||||||
|
import {MatButton} from '@angular/material/button';
|
||||||
|
import {Information} from '../information/information';
|
||||||
|
import {AlgorithmInformation} from '../information/information.models';
|
||||||
|
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({
|
||||||
|
selector: 'app-pendulum',
|
||||||
|
imports: [
|
||||||
|
BabylonCanvas,
|
||||||
|
MatCard,
|
||||||
|
MatCardContent,
|
||||||
|
MatCardHeader,
|
||||||
|
MatCardTitle,
|
||||||
|
FormsModule,
|
||||||
|
NgxSliderModule,
|
||||||
|
TranslatePipe,
|
||||||
|
MatButton,
|
||||||
|
Information,
|
||||||
|
],
|
||||||
|
templateUrl: './pendulum.component.html',
|
||||||
|
styleUrl: './pendulum.component.scss',
|
||||||
|
})
|
||||||
|
class PendulumComponent {
|
||||||
|
|
||||||
|
algoInformation: AlgorithmInformation = {
|
||||||
|
title: 'PENDULUM.EXPLANATION.TITLE',
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
name: '',
|
||||||
|
description: 'PENDULUM.EXPLANATION.EXPLANATION',
|
||||||
|
link: UrlConstants.DOUBLE_PENDULUM_WIKI
|
||||||
|
}
|
||||||
|
],
|
||||||
|
disclaimer: 'PENDULUM.EXPLANATION.DISCLAIMER',
|
||||||
|
disclaimerBottom: 'PENDULUM.EXPLANATION.DISCLAIMER_BOTTOM',
|
||||||
|
disclaimerListEntry: ['PENDULUM.EXPLANATION.DISCLAIMER_1', 'PENDULUM.EXPLANATION.DISCLAIMER_2', 'PENDULUM.EXPLANATION.DISCLAIMER_3', 'PENDULUM.EXPLANATION.DISCLAIMER_4']
|
||||||
|
};
|
||||||
|
|
||||||
|
renderConfig: RenderConfig = {
|
||||||
|
mode: '2D',
|
||||||
|
initialViewSize: 2
|
||||||
|
};
|
||||||
|
|
||||||
|
trailDecayOptions: Options = {
|
||||||
|
floor: MIN_TRAIL_DECAY,
|
||||||
|
ceil: MAX_TRAIL_DECAY,
|
||||||
|
logScale: false,
|
||||||
|
step: 0.001,
|
||||||
|
showTicks: false,
|
||||||
|
hideLimitLabels: false,
|
||||||
|
hidePointerLabels: false
|
||||||
|
};
|
||||||
|
|
||||||
|
gravityOptions: Options = {
|
||||||
|
floor: MIN_G,
|
||||||
|
ceil: MAX_G,
|
||||||
|
logScale: false,
|
||||||
|
step: 0.01,
|
||||||
|
showTicks: false,
|
||||||
|
hideLimitLabels: false,
|
||||||
|
hidePointerLabels: false
|
||||||
|
};
|
||||||
|
|
||||||
|
dampingOptions: Options = {
|
||||||
|
floor: MAX_DAMPING,
|
||||||
|
ceil: MIN_DAMPING,
|
||||||
|
logScale: false,
|
||||||
|
step: 0.001,
|
||||||
|
showTicks: false,
|
||||||
|
hideLimitLabels: false,
|
||||||
|
hidePointerLabels: false
|
||||||
|
};
|
||||||
|
|
||||||
|
lengthOptions: Options = {
|
||||||
|
floor: MIN_LENGTH,
|
||||||
|
ceil: MAX_LENGTH,
|
||||||
|
logScale: false,
|
||||||
|
step: 0.1,
|
||||||
|
showTicks: false,
|
||||||
|
hideLimitLabels: false,
|
||||||
|
hidePointerLabels: false
|
||||||
|
};
|
||||||
|
|
||||||
|
massOptions: Options = {
|
||||||
|
floor: MIN_MASS,
|
||||||
|
ceil: MAX_MASS,
|
||||||
|
logScale: false,
|
||||||
|
step: 0.1,
|
||||||
|
showTicks: false,
|
||||||
|
hideLimitLabels: false,
|
||||||
|
hidePointerLabels: false
|
||||||
|
};
|
||||||
|
|
||||||
|
readonly simParams = {
|
||||||
|
time: 0,
|
||||||
|
dt: 0.015,
|
||||||
|
g: DEFAULT_G,
|
||||||
|
m1: DEFAULT_M1_MASS,
|
||||||
|
m2: DEFAULT_M2_MASS,
|
||||||
|
l1: DEFAULT_L1_LENGTH,
|
||||||
|
l2: DEFAULT_L2_LENGTH,
|
||||||
|
damping: DEFAULT_DAMPING,
|
||||||
|
trailDecay: DEFAULT_TRAIL_DECAY,
|
||||||
|
impulseM1: 0.0,
|
||||||
|
impulseM2: 0.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
private currentSceneData: SceneEventData | null = null;
|
||||||
|
private strategy: PendulumSimulationStrategy | null = null;
|
||||||
|
|
||||||
|
onSceneReady(event: SceneEventData) {
|
||||||
|
this.currentSceneData = event;
|
||||||
|
this.createSimulation();
|
||||||
|
}
|
||||||
|
|
||||||
|
private createSimulation() {
|
||||||
|
if (!this.currentSceneData) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {engine, scene, gpuTier} = this.currentSceneData;
|
||||||
|
|
||||||
|
if (this.strategy) {
|
||||||
|
this.strategy.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.strategy = gpuTier === 'webgpu'
|
||||||
|
? new PendulumGpuStrategy()
|
||||||
|
: new PendulumCpuStrategy();
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
if (this.strategy) {
|
||||||
|
this.strategy.updateParams({...this.simParams});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.resetImpulses();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private resetImpulses() {
|
||||||
|
if (this.simParams.impulseM1 !== 0.0) {
|
||||||
|
this.simParams.impulseM1 = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.simParams.impulseM2 !== 0.0) {
|
||||||
|
this.simParams.impulseM2 = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pushPendulum(m1: boolean) {
|
||||||
|
if (m1) {
|
||||||
|
this.simParams.impulseM1 = IMPULSE_M1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.simParams.impulseM2 = IMPULSE_M2;
|
||||||
|
}
|
||||||
|
|
||||||
|
resetPendulum() {
|
||||||
|
this.createSimulation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PendulumComponent;
|
||||||
24
src/app/pages/algorithms/pendulum/pendulum.model.ts
Normal file
24
src/app/pages/algorithms/pendulum/pendulum.model.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
export const DEFAULT_G = 9.81;
|
||||||
|
export const MIN_G = 2;
|
||||||
|
export const MAX_G = 15;
|
||||||
|
|
||||||
|
export const DEFAULT_DAMPING = 0.999;
|
||||||
|
export const MIN_DAMPING = 1;
|
||||||
|
export const MAX_DAMPING = 0.7;
|
||||||
|
|
||||||
|
export const DEFAULT_TRAIL_DECAY = 0.96;
|
||||||
|
export const MIN_TRAIL_DECAY = 0.2;
|
||||||
|
export const MAX_TRAIL_DECAY = 0.9999;
|
||||||
|
|
||||||
|
export const DEFAULT_L1_LENGTH = 1.5;
|
||||||
|
export const DEFAULT_L2_LENGTH = 1.2;
|
||||||
|
export const MIN_LENGTH = 0.2;
|
||||||
|
export const MAX_LENGTH = 3;
|
||||||
|
|
||||||
|
export const DEFAULT_M1_MASS = 2;
|
||||||
|
export const DEFAULT_M2_MASS = 1;
|
||||||
|
export const MIN_MASS = 0.1;
|
||||||
|
export const MAX_MASS = 5;
|
||||||
|
|
||||||
|
export const IMPULSE_M1 = 7;
|
||||||
|
export const IMPULSE_M2 = 15;
|
||||||
236
src/app/pages/algorithms/pendulum/pendulum.shader.ts
Normal file
236
src/app/pages/algorithms/pendulum/pendulum.shader.ts
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
//Simple Pass-Through Shader
|
||||||
|
export const PENDULUM_VERTEX_SHADER_WGSL = `
|
||||||
|
attribute position : vec3<f32>;
|
||||||
|
|
||||||
|
@vertex
|
||||||
|
fn main(input : VertexInputs) -> FragmentInputs {
|
||||||
|
var output : FragmentInputs;
|
||||||
|
output.position = vec4<f32>(input.position, 1.0);
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// --- SHARED DATA STRUCTURES ---
|
||||||
|
// These structs map exactly to the Float32Array in the TypeScript code.
|
||||||
|
const SHARED_STRUCTS = `
|
||||||
|
struct Params {
|
||||||
|
width: f32,
|
||||||
|
height: f32,
|
||||||
|
time: f32,
|
||||||
|
dt: f32,
|
||||||
|
g: f32,
|
||||||
|
m1: f32,
|
||||||
|
m2: f32,
|
||||||
|
l1: f32,
|
||||||
|
l2: f32,
|
||||||
|
damping: f32,
|
||||||
|
trailDecay: f32,
|
||||||
|
impulseM1: f32,
|
||||||
|
impulseM2: f32,
|
||||||
|
pad: f32 // <-- Padding for safe 16-byte memory alignment
|
||||||
|
};
|
||||||
|
|
||||||
|
struct State {
|
||||||
|
theta1: f32,
|
||||||
|
theta2: f32,
|
||||||
|
v1: f32,
|
||||||
|
v2: f32
|
||||||
|
};
|
||||||
|
`;
|
||||||
|
|
||||||
|
//Fragment Shader to display the pixel buffer
|
||||||
|
export const PENDULUM_FRAGMENT_SHADER_WGSL = SHARED_STRUCTS + `
|
||||||
|
var<storage, read> pixelBuffer : array<f32>;
|
||||||
|
var<storage, read> p : Params;
|
||||||
|
|
||||||
|
@fragment
|
||||||
|
fn main(input : FragmentInputs) -> FragmentOutputs {
|
||||||
|
let width = u32(p.width);
|
||||||
|
let height = u32(p.height);
|
||||||
|
|
||||||
|
if (width == 0u || height == 0u) {
|
||||||
|
fragmentOutputs.color = vec4<f32>(0.5, 0.0, 0.0, 1.0);
|
||||||
|
return fragmentOutputs;
|
||||||
|
}
|
||||||
|
|
||||||
|
let x = u32(input.position.x);
|
||||||
|
let y = u32(input.position.y);
|
||||||
|
|
||||||
|
if (x >= width || y >= height) {
|
||||||
|
fragmentOutputs.color = vec4<f32>(0.0, 0.0, 0.0, 1.0);
|
||||||
|
return fragmentOutputs;
|
||||||
|
}
|
||||||
|
|
||||||
|
let index = y * width + x;
|
||||||
|
|
||||||
|
// --- THE MAGIC DECODING ---
|
||||||
|
var val = pixelBuffer[index];
|
||||||
|
var isLine1 = false;
|
||||||
|
var isLine2 = false;
|
||||||
|
|
||||||
|
// 1. Check for overlays (Lines)
|
||||||
|
if (val >= 20.0) {
|
||||||
|
isLine2 = true;
|
||||||
|
val = val - 20.0;
|
||||||
|
} else if (val >= 10.0) {
|
||||||
|
isLine1 = true;
|
||||||
|
val = val - 10.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check which trail it is
|
||||||
|
var isTrail2 = false;
|
||||||
|
if (val >= 2.0) {
|
||||||
|
isTrail2 = true;
|
||||||
|
val = val - 2.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. What remains is purely the fading intensity (0.0 to 1.0)
|
||||||
|
let trailIntensity = val;
|
||||||
|
|
||||||
|
// --- COLORS ---
|
||||||
|
let bgColor = vec3<f32>(0.1, 0.1, 0.15);
|
||||||
|
let mass1Color = vec3<f32>(1.0, 0.0, 0.0); // Red
|
||||||
|
let mass2Color = vec3<f32>(0.0, 1.0, 0.0); // Green
|
||||||
|
let line1Color = vec3<f32>(1.0, 1.0, 0.0); // Yellow
|
||||||
|
let line2Color = vec3<f32>(1.0, 0.0, 1.0); // Magenta
|
||||||
|
|
||||||
|
var massColor = mass1Color;
|
||||||
|
if (isTrail2) {
|
||||||
|
massColor = mass2Color;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate background blending with the trail
|
||||||
|
var finalColor = mix(bgColor, massColor, clamp(trailIntensity, 0.0, 1.0));
|
||||||
|
|
||||||
|
// Overwrite with the line colors if necessary
|
||||||
|
if (isLine1) { finalColor = line1Color; }
|
||||||
|
if (isLine2) { finalColor = line2Color; }
|
||||||
|
|
||||||
|
fragmentOutputs.color = vec4<f32>(finalColor, 1.0);
|
||||||
|
return fragmentOutputs;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
//Math for the double pendulum
|
||||||
|
//https://en.wikipedia.org/wiki/Double_pendulum
|
||||||
|
export const PENDULUM_PHYSIC_COMPUTE_SHADER_WGSL = SHARED_STRUCTS + `
|
||||||
|
@group(0) @binding(0) var<storage, read_write> state : State;
|
||||||
|
@group(0) @binding(1) var<storage, read> p : Params;
|
||||||
|
|
||||||
|
@compute @workgroup_size(1)
|
||||||
|
fn main() {
|
||||||
|
let t1 = state.theta1;
|
||||||
|
let t2 = state.theta2;
|
||||||
|
let v1 = state.v1;
|
||||||
|
let v2 = state.v2;
|
||||||
|
|
||||||
|
let delta_t = t1 - t2;
|
||||||
|
|
||||||
|
let num1 = -p.g * (2.0 * p.m1 + p.m2) * sin(t1)
|
||||||
|
- p.m2 * p.g * sin(t1 - 2.0 * t2)
|
||||||
|
- 2.0 * sin(delta_t) * p.m2 * (v2 * v2 * p.l2 + v1 * v1 * p.l1 * cos(delta_t));
|
||||||
|
let den1 = p.l1 * (2.0 * p.m1 + p.m2 - p.m2 * cos(2.0 * delta_t));
|
||||||
|
let a1 = num1 / den1;
|
||||||
|
|
||||||
|
let num2 = 2.0 * sin(delta_t) * (v1 * v1 * p.l1 * (p.m1 + p.m2) + p.g * (p.m1 + p.m2) * cos(t1) + v2 * v2 * p.l2 * p.m2 * cos(delta_t));
|
||||||
|
let den2 = p.l2 * (2.0 * p.m1 + p.m2 - p.m2 * cos(2.0 * delta_t));
|
||||||
|
let a2 = num2 / den2;
|
||||||
|
|
||||||
|
let new_v1 = (v1 + a1 * p.dt) * p.damping + p.impulseM1;
|
||||||
|
let new_v2 = (v2 + a2 * p.dt) * p.damping + p.impulseM2;
|
||||||
|
|
||||||
|
state.v1 = new_v1;
|
||||||
|
state.v2 = new_v2;
|
||||||
|
state.theta1 = t1 + new_v1 * p.dt;
|
||||||
|
state.theta2 = t2 + new_v2 * p.dt;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
//Pixel data to visualize the pendulum
|
||||||
|
export const PENDULUM_RENDER_COMPUTE_SHADER_WGSL = SHARED_STRUCTS + `
|
||||||
|
@group(0) @binding(0) var<storage, read_write> pixelBuffer : array<f32>;
|
||||||
|
@group(0) @binding(1) var<storage, read> p : Params;
|
||||||
|
@group(0) @binding(2) var<storage, read> state : State;
|
||||||
|
|
||||||
|
fn sdSegment(point: vec2<f32>, a: vec2<f32>, b: vec2<f32>) -> f32 {
|
||||||
|
let pa = point - a;
|
||||||
|
let ba = b - a;
|
||||||
|
let h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0);
|
||||||
|
return length(pa - ba * h);
|
||||||
|
}
|
||||||
|
|
||||||
|
@compute @workgroup_size(64)
|
||||||
|
fn main(@builtin(global_invocation_id) global_id : vec3<u32>) {
|
||||||
|
let index = global_id.x;
|
||||||
|
let width = u32(p.width);
|
||||||
|
let height = u32(p.height);
|
||||||
|
|
||||||
|
if (index >= width * height) { return; }
|
||||||
|
|
||||||
|
let x = f32(index % width);
|
||||||
|
let y = f32(index / width);
|
||||||
|
let uv = vec2<f32>(x / p.width, y / p.height);
|
||||||
|
|
||||||
|
let aspect = p.width / p.height;
|
||||||
|
let uv_corr = vec2<f32>(uv.x * aspect, uv.y);
|
||||||
|
|
||||||
|
// --- 1. EXTRACT & DECAY OLD MEMORY ---
|
||||||
|
var memory = pixelBuffer[index];
|
||||||
|
|
||||||
|
// Strip line overlays from the previous frame
|
||||||
|
if (memory >= 20.0) { memory = memory - 20.0; }
|
||||||
|
else if (memory >= 10.0) { memory = memory - 10.0; }
|
||||||
|
|
||||||
|
// Check if the memory belongs to Trail 2
|
||||||
|
var isTrail2 = false;
|
||||||
|
if (memory >= 2.0) {
|
||||||
|
isTrail2 = true;
|
||||||
|
memory = memory - 2.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply decay to the pure intensity
|
||||||
|
memory = memory * p.trailDecay;
|
||||||
|
|
||||||
|
// --- 2. CALCULATE GEOMETRY ---
|
||||||
|
let origin = vec2<f32>(0.5 * aspect, 0.3);
|
||||||
|
let displayScale = 0.15;
|
||||||
|
|
||||||
|
let p1 = origin + vec2<f32>(sin(state.theta1), cos(state.theta1)) * p.l1 * displayScale;
|
||||||
|
let p2 = p1 + vec2<f32>(sin(state.theta2), cos(state.theta2)) * p.l2 * displayScale;
|
||||||
|
|
||||||
|
let dLine1 = sdSegment(uv_corr, origin, p1);
|
||||||
|
let dLine2 = sdSegment(uv_corr, p1, p2);
|
||||||
|
let dMass1 = length(uv_corr - p1);
|
||||||
|
let dMass2 = length(uv_corr - p2);
|
||||||
|
|
||||||
|
// --- 3. SMART LAYERING ---
|
||||||
|
var baseVal = 0.0;
|
||||||
|
|
||||||
|
// Base Layer (Masses & Trails)
|
||||||
|
if (dMass1 < 0.02) {
|
||||||
|
baseVal = 1.0; // Mass 1 = 1.0 (Trail 1 Max)
|
||||||
|
} else if (dMass2 < 0.02) {
|
||||||
|
baseVal = 3.0; // Mass 2 = 2.0 (Flag) + 1.0 (Trail 2 Max)
|
||||||
|
} else {
|
||||||
|
// Write fading memory back
|
||||||
|
if (isTrail2) {
|
||||||
|
baseVal = memory + 2.0;
|
||||||
|
} else {
|
||||||
|
baseVal = memory;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overlay Layer (Lines)
|
||||||
|
var overlay = 0.0;
|
||||||
|
// Don't draw lines over the masses (Clean Z-Index)
|
||||||
|
if (dMass1 < 0.02 || dMass2 < 0.02) {
|
||||||
|
overlay = 0.0;
|
||||||
|
} else if (dLine1 < 0.003) {
|
||||||
|
overlay = 10.0;
|
||||||
|
} else if (dLine2 < 0.003) {
|
||||||
|
overlay = 20.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
pixelBuffer[index] = baseVal + overlay;
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import { Injectable } from '@angular/core';
|
|
||||||
import { AlgorithmCategory } from '../models/algorithm-category';
|
|
||||||
import { Observable, of } from 'rxjs';
|
|
||||||
import {RouterConstants} from '../../../constants/RouterConstants';
|
|
||||||
|
|
||||||
@Injectable({
|
|
||||||
providedIn: 'root'
|
|
||||||
})
|
|
||||||
export class AlgorithmsService {
|
|
||||||
|
|
||||||
private readonly categories: AlgorithmCategory[] = [
|
|
||||||
{
|
|
||||||
id: 'pathfinding',
|
|
||||||
title: 'ALGORITHM.PATHFINDING.TITLE',
|
|
||||||
description: 'ALGORITHM.PATHFINDING.DESCRIPTION',
|
|
||||||
routerLink: RouterConstants.PATHFINDING.LINK
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'sorting',
|
|
||||||
title: 'ALGORITHM.SORTING.TITLE',
|
|
||||||
description: 'ALGORITHM.SORTING.DESCRIPTION',
|
|
||||||
routerLink: RouterConstants.SORTING.LINK
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'gameOfLife',
|
|
||||||
title: 'ALGORITHM.GOL.TITLE',
|
|
||||||
description: 'ALGORITHM.GOL.DESCRIPTION',
|
|
||||||
routerLink: RouterConstants.GOL.LINK
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'labyrinth',
|
|
||||||
title: 'ALGORITHM.LABYRINTH.TITLE',
|
|
||||||
description: 'ALGORITHM.LABYRINTH.DESCRIPTION',
|
|
||||||
routerLink: RouterConstants.LABYRINTH.LINK
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'fractal',
|
|
||||||
title: 'ALGORITHM.FRACTAL.TITLE',
|
|
||||||
description: 'ALGORITHM.FRACTAL.DESCRIPTION',
|
|
||||||
routerLink: RouterConstants.FRACTAL.LINK
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'fractal3d',
|
|
||||||
title: 'ALGORITHM.FRACTAL3D.TITLE',
|
|
||||||
description: 'ALGORITHM.FRACTAL3D.DESCRIPTION',
|
|
||||||
routerLink: RouterConstants.FRACTAL3d.LINK
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
getCategories(): Observable<AlgorithmCategory[]> {
|
|
||||||
return of(this.categories);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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="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>
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
.sorting-card {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 1200px;
|
|
||||||
padding: 20px;
|
|
||||||
|
|
||||||
.controls-panel {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
|
|
||||||
mat-form-field {
|
|
||||||
width: 200px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.visualization-area {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
height: 300px; /* Max height for bars */
|
|
||||||
border-bottom: 1px solid #ccc;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
gap: 1px;
|
|
||||||
background-color: #f0f0f0;
|
|
||||||
|
|
||||||
.bar {
|
|
||||||
flex-grow: 1;
|
|
||||||
background-color: #424242; /* Default unsorted color */
|
|
||||||
transition: height 0.05s ease-in-out, background-color 0.05s ease-in-out;
|
|
||||||
width: 10px; /* Default width, flex-grow will adjust */
|
|
||||||
min-width: 1px; /* Ensure bars are always visible */
|
|
||||||
|
|
||||||
&.unsorted {
|
|
||||||
background-color: #424242;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.comparing {
|
|
||||||
background-color: #ffeb3b; /* Yellow for comparing */
|
|
||||||
}
|
|
||||||
|
|
||||||
&.sorted {
|
|
||||||
background-color: #4caf50; /* Green for sorted */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-panel {
|
|
||||||
margin-top: 10px;
|
|
||||||
font-size: 0.9em;
|
|
||||||
color: #FFFFFF;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
.imprint {
|
|
||||||
display: grid;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.imprint-card {
|
|
||||||
padding: 1.25rem 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.imprint-title {
|
|
||||||
margin: 0 0 1rem;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.imprint-section {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.25rem;
|
|
||||||
|
|
||||||
&:not(:last-child) {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.imprint-label {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
opacity: 0.7;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: var(--mat-primary);
|
|
||||||
text-decoration: none;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
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,63 +1,90 @@
|
|||||||
<h2 mat-dialog-title>{{ project.title | translate }}</h2>
|
<h2 mat-dialog-title>{{ project.title | translate }}</h2>
|
||||||
<mat-dialog-content #dialogContent>
|
<mat-dialog-content #dialogContent>
|
||||||
<p>{{ project.introduction | translate }}</p>
|
<div class="flex flex-col gap-6">
|
||||||
|
<div>
|
||||||
|
<p class="text-lg leading-relaxed opacity-90 mb-4">{{ project.introduction | translate }}</p>
|
||||||
|
|
||||||
<ul>
|
<div class="mb-6">
|
||||||
@for(bullet of project.bulletPoints; track bullet) {
|
<ul class="pl-5">
|
||||||
<li>{{ bullet | translate }}</li>
|
@for(bullet of project.bulletPoints; track bullet) {
|
||||||
}
|
<li class="mb-2">{{ bullet | translate }}</li>
|
||||||
</ul>
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
@if (project.images.length > 0)
|
<div class="grid grid-cols-[repeat(auto-fit,minmax(300px,1fr))] sm-dialog:grid-cols-1 gap-4 mb-4">
|
||||||
{
|
<div class="p-5 rounded-xl bg-black/3 border border-black/5 dark:bg-white/5 dark:border-white/10">
|
||||||
<swiper-container
|
<div class="flex items-center gap-3 mb-3 text-link">
|
||||||
class="my-swiper"
|
<mat-icon class="text-[24px]! w-6! h-6!">settings_suggest</mat-icon>
|
||||||
[attr.slides-per-view]="1.2"
|
<h3 class="m-0 text-sm uppercase tracking-wider font-semibold">{{ 'PROJECTS.SECTION.TECHNICAL' | translate }}</h3>
|
||||||
[attr.space-between]="12"
|
</div>
|
||||||
[attr.navigation]="true"
|
<ul class="m-0 pl-5 text-[0.95rem] leading-relaxed opacity-85">
|
||||||
[attr.pagination]="true"
|
@for(challenge of project.challenges; track challenge) {
|
||||||
[attr.keyboard]="true"
|
<li class="mb-1">{{ challenge | translate }}</li>
|
||||||
style="width: 100%;"
|
}
|
||||||
>
|
</ul>
|
||||||
@for (img of project.images; track img) {
|
</div>
|
||||||
<swiper-slide>
|
|
||||||
<img
|
<div class="p-5 rounded-xl bg-black/3 border border-black/5 dark:bg-white/5 dark:border-white/10">
|
||||||
class="slide-img"
|
<div class="flex items-center gap-3 mb-3 text-link">
|
||||||
[src]="img.url"
|
<mat-icon class="text-[24px]! w-6! h-6!">psychology</mat-icon>
|
||||||
[alt]="project.title | translate"
|
<h3 class="m-0 text-sm uppercase tracking-wider font-semibold">{{ 'PROJECTS.SECTION.LEARNINGS' | translate }}</h3>
|
||||||
/>
|
</div>
|
||||||
|
<ul class="m-0 pl-5 text-[0.95rem] leading-relaxed opacity-85">
|
||||||
|
@for(learning of project.learnings; track learning) {
|
||||||
|
<li class="mb-1">{{ learning | translate }}</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (project.images.length > 0)
|
||||||
|
{
|
||||||
|
<div class="my-4 rounded-xl overflow-hidden bg-black">
|
||||||
|
<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%;">
|
||||||
|
@for (img of project.images; track img) {
|
||||||
|
<swiper-slide class="rounded-xl overflow-hidden flex flex-col bg-[#222]">
|
||||||
|
<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>
|
||||||
}
|
}
|
||||||
</swiper-slide>
|
</swiper-slide>
|
||||||
}
|
}
|
||||||
</swiper-container>
|
</swiper-container>
|
||||||
}
|
</div>
|
||||||
|
|
||||||
<mat-chip-set aria-label="Technologies">
|
|
||||||
@for(tech of project.technologies; track tech) {
|
|
||||||
<mat-chip>{{tech}}</mat-chip>
|
|
||||||
}
|
|
||||||
</mat-chip-set>
|
|
||||||
|
|
||||||
<div class="link-section">
|
|
||||||
@for(link of project.links; track link)
|
|
||||||
{
|
|
||||||
<a mat-button href="{{link.url}}" target="_blank" rel="noopener noreferrer">
|
|
||||||
<mat-icon>open_in_new</mat-icon>
|
|
||||||
{{ link.name | translate }}
|
|
||||||
</a>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@if(project.assets)
|
<div class="flex flex-col gap-4 pt-4 border-t border-black/10 dark:border-white/10">
|
||||||
{
|
<div class="flex flex-wrap">
|
||||||
<a mat-button href="{{project.assets}}" rel="noopener noreferrer">
|
<mat-chip-set aria-label="Technologies">
|
||||||
<mat-icon>download</mat-icon>
|
@for(tech of project.technologies; track tech) {
|
||||||
{{ 'PROJECTS.DOWNLOAD' | translate}}
|
<mat-chip>{{tech}}</mat-chip>
|
||||||
</a>
|
}
|
||||||
}
|
</mat-chip-set>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-fluid-sm mt-6">
|
||||||
|
@for(link of project.links; track link)
|
||||||
|
{
|
||||||
|
<a mat-button href="{{link.url}}" target="_blank" rel="noopener noreferrer">
|
||||||
|
<mat-icon>open_in_new</mat-icon>
|
||||||
|
{{ link.name | translate }}
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if(project.assets)
|
||||||
|
{
|
||||||
|
<a mat-button href="{{project.assets}}" rel="noopener noreferrer">
|
||||||
|
<mat-icon>download</mat-icon>
|
||||||
|
{{ 'PROJECTS.DOWNLOAD' | translate}}
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</mat-dialog-content>
|
</mat-dialog-content>
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
.my-swiper::part(button-prev),
|
|
||||||
.my-swiper::part(button-next) {
|
|
||||||
width: 35px;
|
|
||||||
height: 35px;
|
|
||||||
padding: 5px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: rgba(0,0,0,.5);
|
|
||||||
color: white;
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.my-swiper::part(button-prev):hover,
|
|
||||||
.my-swiper::part(button-next):hover {
|
|
||||||
background: rgba(0,0,0,.75);
|
|
||||||
}
|
|
||||||
|
|
||||||
.my-swiper {
|
|
||||||
border-radius: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.my-swiper::part(pagination) {
|
|
||||||
bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
swiper-slide {
|
|
||||||
border-radius: 12px;
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background-color: #222;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slide-img {
|
|
||||||
width: 100%;
|
|
||||||
height: auto;
|
|
||||||
max-height: 512px !important;
|
|
||||||
object-fit: contain;
|
|
||||||
display: block;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slide-source {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: #aaa;
|
|
||||||
background: #2a2a2a;
|
|
||||||
padding: 0.5rem;
|
|
||||||
text-align: right;
|
|
||||||
border-top: 1px solid #444;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul {
|
|
||||||
padding-left: 20px;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
li {
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
mat-chip-set {
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-section {
|
|
||||||
display: flex;
|
|
||||||
gap: 1rem;
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
|
|
||||||
a {
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mat-dialog-actions {
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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="project-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>
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
.project-grid {
|
|
||||||
display: grid;
|
|
||||||
gap: 1.5rem;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-card {
|
|
||||||
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
transform: translateY(-5px);
|
|
||||||
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.featured {
|
|
||||||
grid-column: 1 / -1; // Span full width
|
|
||||||
}
|
|
||||||
|
|
||||||
mat-card-header {
|
|
||||||
padding-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
mat-card-content {
|
|
||||||
flex-grow: 1; // Ensure content area expands
|
|
||||||
padding-top: 1rem;
|
|
||||||
padding-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
mat-chip-set {
|
|
||||||
padding-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
mat-card-actions {
|
|
||||||
margin-top: auto; // Push actions to the bottom
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-container {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
height: 200px; /* Or a height that fits your design */
|
|
||||||
background-color: #f0f0f0; /* A light background for the icon */
|
|
||||||
}
|
|
||||||
|
|
||||||
.fallback-icon {
|
|
||||||
font-size: 4rem;
|
|
||||||
width: 4rem;
|
|
||||||
height: 4rem;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure images don't exceed the card width and maintain aspect ratio
|
|
||||||
img[mat-card-image] {
|
|
||||||
width: 100%;
|
|
||||||
height: 250px;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import {Component, computed, inject, CUSTOM_ELEMENTS_SCHEMA, OnDestroy, OnInit} from '@angular/core';
|
import { Component, computed, inject, CUSTOM_ELEMENTS_SCHEMA, OnDestroy, OnInit } from '@angular/core';
|
||||||
import {ActivatedRoute, Router} from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import {Subscription} from "rxjs";
|
import { Subscription } from "rxjs";
|
||||||
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 {MatIcon} from "@angular/material/icon";
|
import { MatIcon } from "@angular/material/icon";
|
||||||
import {TranslatePipe} from "@ngx-translate/core";
|
import { TranslatePipe } from "@ngx-translate/core";
|
||||||
import {MatButtonModule} from "@angular/material/button";
|
import { MatButtonModule } from "@angular/material/button";
|
||||||
import {MatDialog} from "@angular/material/dialog";
|
import { MatDialog } from "@angular/material/dialog";
|
||||||
import {ProjectDialogComponent} from "./dialog/project-dialog.component";
|
import { ProjectDialogComponent } from "./dialog/project-dialog.component";
|
||||||
import {AssetsConstants} from "../../constants/AssetsConstants";
|
import { AssetsConstants } from "../../constants/AssetsConstants";
|
||||||
|
|
||||||
export interface Projects {
|
export interface Projects {
|
||||||
identifier: string;
|
identifier: string;
|
||||||
@@ -26,13 +26,14 @@ export interface Projects {
|
|||||||
url: string
|
url: string
|
||||||
}[],
|
}[],
|
||||||
bulletPoints: string[],
|
bulletPoints: string[],
|
||||||
|
challenges: string[],
|
||||||
|
learnings: string[],
|
||||||
isFeatured: boolean,
|
isFeatured: boolean,
|
||||||
technologies: string[]
|
technologies: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-projects',
|
selector: 'app-projects',
|
||||||
standalone: true,
|
|
||||||
imports: [
|
imports: [
|
||||||
MatCardModule,
|
MatCardModule,
|
||||||
MatChipsModule,
|
MatChipsModule,
|
||||||
@@ -46,124 +47,192 @@ export interface Projects {
|
|||||||
})
|
})
|
||||||
export class ProjectsComponent implements OnInit, OnDestroy {
|
export class ProjectsComponent implements OnInit, OnDestroy {
|
||||||
|
|
||||||
private readonly route = inject(ActivatedRoute);
|
private readonly route = inject(ActivatedRoute);
|
||||||
private readonly dialog = inject(MatDialog);
|
private readonly dialog = inject(MatDialog);
|
||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
private queryParamSub: Subscription | undefined;
|
private queryParamSub: Subscription | undefined;
|
||||||
|
|
||||||
allProjects: Projects[] = [
|
allProjects: Projects[] = [
|
||||||
{
|
{
|
||||||
identifier: "playground",
|
identifier: "claude-in-a-box",
|
||||||
title: 'PROJECTS.PLAYGROUND.TITLE',
|
title: 'PROJECTS.CLAUDE_BOX.TITLE',
|
||||||
shortDescription: 'PROJECTS.PLAYGROUND.SHORT_DESCRIPTION',
|
shortDescription: 'PROJECTS.CLAUDE_BOX.SHORT_DESCRIPTION',
|
||||||
introduction: 'PROJECTS.PLAYGROUND.INTRODUCTION',
|
introduction: 'PROJECTS.CLAUDE_BOX.INTRODUCTION',
|
||||||
images: [],
|
images: AssetsConstants.CLAUDE_BOX_IMAGES.map(url => ({ url, source: '' })),
|
||||||
icon: 'web',
|
icon: 'smart_toy',
|
||||||
assets: '',
|
assets: '',
|
||||||
links: [{name: 'PROJECTS.LINK_TO_PROJECT', url: 'https://andreas-dahm.eu'}],
|
links: [],
|
||||||
bulletPoints: [
|
bulletPoints: [
|
||||||
'PROJECTS.PLAYGROUND.BULLET_1',
|
'PROJECTS.CLAUDE_BOX.BULLET_1',
|
||||||
'PROJECTS.PLAYGROUND.BULLET_2',
|
'PROJECTS.CLAUDE_BOX.BULLET_2',
|
||||||
'PROJECTS.PLAYGROUND.BULLET_3',
|
'PROJECTS.CLAUDE_BOX.BULLET_3',
|
||||||
'PROJECTS.PLAYGROUND.BULLET_4',
|
'PROJECTS.CLAUDE_BOX.BULLET_4',
|
||||||
],
|
],
|
||||||
isFeatured: false,
|
challenges: [
|
||||||
technologies: ['Angular', 'TypeScript', 'SCSS', 'HTML', 'GitHub Actions', 'Docker']
|
'PROJECTS.CLAUDE_BOX.CHALLENGE_1',
|
||||||
},
|
'PROJECTS.CLAUDE_BOX.CHALLENGE_2',
|
||||||
{
|
'PROJECTS.CLAUDE_BOX.CHALLENGE_3',
|
||||||
identifier: "elmucho",
|
],
|
||||||
title: 'PROJECTS.EL_MUCHO.TITLE',
|
learnings: [
|
||||||
shortDescription: 'PROJECTS.EL_MUCHO.SHORT_DESCRIPTION',
|
'PROJECTS.CLAUDE_BOX.LEARNING_1',
|
||||||
introduction: 'PROJECTS.EL_MUCHO.INTRODUCTION',
|
'PROJECTS.CLAUDE_BOX.LEARNING_2',
|
||||||
images: AssetsConstants.EL_MUCHO_IMAGES.map(url => ({ url, source: '' })),
|
],
|
||||||
icon: 'sports_esports',
|
isFeatured: false,
|
||||||
assets: '',
|
technologies: ['Claude Code', 'MCP', 'Docker', 'Bash', 'Node.js']
|
||||||
links: [{name: 'PROJECTS.LINK_TO_PROJECT', url: 'https://store.steampowered.com/app/1532640/El_Mucho/'}],
|
},
|
||||||
bulletPoints: [
|
{
|
||||||
'PROJECTS.EL_MUCHO.BULLET_1',
|
identifier: "playground",
|
||||||
'PROJECTS.EL_MUCHO.BULLET_2',
|
title: 'PROJECTS.PLAYGROUND.TITLE',
|
||||||
'PROJECTS.EL_MUCHO.BULLET_3',
|
shortDescription: 'PROJECTS.PLAYGROUND.SHORT_DESCRIPTION',
|
||||||
'PROJECTS.EL_MUCHO.BULLET_4',
|
introduction: 'PROJECTS.PLAYGROUND.INTRODUCTION',
|
||||||
],
|
images: AssetsConstants.PLAYGROUND_IMAGES.map(url => ({ url, source: '' })),
|
||||||
isFeatured: true,
|
icon: 'web',
|
||||||
technologies: ['Unity', 'C#', 'Steamworks', 'Git']
|
assets: '',
|
||||||
},
|
links: [{ name: 'PROJECTS.LINK_TO_PROJECT', url: 'https://andreas-dahm.eu' }],
|
||||||
{
|
bulletPoints: [
|
||||||
identifier: "gamejams",
|
'PROJECTS.PLAYGROUND.BULLET_1',
|
||||||
title: 'PROJECTS.GAME_JAMS.TITLE',
|
'PROJECTS.PLAYGROUND.BULLET_2',
|
||||||
shortDescription: 'PROJECTS.GAME_JAMS.SHORT_DESCRIPTION',
|
'PROJECTS.PLAYGROUND.BULLET_3',
|
||||||
introduction: 'PROJECTS.GAME_JAMS.INTRODUCTION',
|
'PROJECTS.PLAYGROUND.BULLET_4',
|
||||||
images: AssetsConstants.GAME_JAMS_IMAGES.map(url => ({ url, source: '' })),
|
],
|
||||||
icon: 'videogame_asset',
|
challenges: [
|
||||||
assets: '',
|
'PROJECTS.PLAYGROUND.CHALLENGE_1',
|
||||||
links: [{name: 'PROJECTS.LINK_TO_PROJECT', url: 'https://itch.io/c/6628860/lobos-collection'}],
|
'PROJECTS.PLAYGROUND.CHALLENGE_2',
|
||||||
bulletPoints: [
|
],
|
||||||
'PROJECTS.GAME_JAMS.BULLET_1',
|
learnings: [
|
||||||
'PROJECTS.GAME_JAMS.BULLET_2',
|
'PROJECTS.PLAYGROUND.LEARNING_1',
|
||||||
'PROJECTS.GAME_JAMS.BULLET_3',
|
'PROJECTS.PLAYGROUND.LEARNING_2',
|
||||||
'PROJECTS.GAME_JAMS.BULLET_4',
|
],
|
||||||
],
|
isFeatured: false,
|
||||||
isFeatured: false,
|
technologies: ['Angular', 'TypeScript', 'SCSS', 'HTML', 'GitHub Actions', 'Docker']
|
||||||
technologies: ['Unity', 'C#', 'Git']
|
},
|
||||||
},
|
{
|
||||||
{
|
identifier: "elmucho",
|
||||||
identifier: "diploma",
|
title: 'PROJECTS.EL_MUCHO.TITLE',
|
||||||
title: 'PROJECTS.DIPLOMA.TITLE',
|
shortDescription: 'PROJECTS.EL_MUCHO.SHORT_DESCRIPTION',
|
||||||
shortDescription: 'PROJECTS.DIPLOMA.SHORT_DESCRIPTION',
|
introduction: 'PROJECTS.EL_MUCHO.INTRODUCTION',
|
||||||
introduction: 'PROJECTS.DIPLOMA.INTRODUCTION',
|
images: AssetsConstants.EL_MUCHO_IMAGES.map(url => ({ url, source: '' })),
|
||||||
images: AssetsConstants.DIPLOMA_IMAGES.map(url => ({ url, source: '' })),
|
icon: 'sports_esports',
|
||||||
icon: 'history_edu',
|
assets: '',
|
||||||
assets: AssetsConstants.DIPLOMA,
|
links: [{ name: 'PROJECTS.LINK_TO_PROJECT', url: 'https://store.steampowered.com/app/1532640/El_Mucho/' }],
|
||||||
links: [{name: 'PROJECTS.LINK_TO_PROJECT', url: 'https://www.th-bingen.de'}],
|
bulletPoints: [
|
||||||
bulletPoints: [
|
'PROJECTS.EL_MUCHO.BULLET_1',
|
||||||
'PROJECTS.DIPLOMA.BULLET_1',
|
'PROJECTS.EL_MUCHO.BULLET_2',
|
||||||
'PROJECTS.DIPLOMA.BULLET_2',
|
'PROJECTS.EL_MUCHO.BULLET_3',
|
||||||
'PROJECTS.DIPLOMA.BULLET_3',
|
'PROJECTS.EL_MUCHO.BULLET_4',
|
||||||
'PROJECTS.DIPLOMA.BULLET_4',
|
],
|
||||||
],
|
challenges: [
|
||||||
isFeatured: false,
|
'PROJECTS.EL_MUCHO.CHALLENGE_1',
|
||||||
technologies: ['C++', 'OpenGL', 'Qt', '3D-Scanner']
|
'PROJECTS.EL_MUCHO.CHALLENGE_2',
|
||||||
},
|
'PROJECTS.EL_MUCHO.CHALLENGE_3',
|
||||||
{
|
],
|
||||||
identifier: "tribble-the-homeserver",
|
learnings: [
|
||||||
title: 'PROJECTS.TRIBBLE.TITLE',
|
'PROJECTS.EL_MUCHO.LEARNING_1',
|
||||||
shortDescription: 'PROJECTS.TRIBBLE.SHORT_DESCRIPTION',
|
'PROJECTS.EL_MUCHO.LEARNING_2',
|
||||||
introduction: 'PROJECTS.TRIBBLE.INTRODUCTION',
|
],
|
||||||
images: [
|
isFeatured: true,
|
||||||
{ url: AssetsConstants.TRIBBLE_IMAGES[0], source: 'https://upload.wikimedia.org/wikipedia/commons/0/03/Hostinger_Logo.png'},
|
technologies: ['Unity', 'C#', 'Steamworks', 'Git']
|
||||||
{ url: AssetsConstants.TRIBBLE_IMAGES[1], source: 'https://dashboardicons.com/icons/docker-engine'},
|
},
|
||||||
{ url: AssetsConstants.TRIBBLE_IMAGES[2], source: 'https://dashboardicons.com/icons/gitea'},
|
{
|
||||||
{ url: AssetsConstants.TRIBBLE_IMAGES[3], source: 'https://commons.wikimedia.org/wiki/File:Traefik.logo.png'}
|
identifier: "gamejams",
|
||||||
],
|
title: 'PROJECTS.GAME_JAMS.TITLE',
|
||||||
icon: 'dns',
|
shortDescription: 'PROJECTS.GAME_JAMS.SHORT_DESCRIPTION',
|
||||||
assets: '',
|
introduction: 'PROJECTS.GAME_JAMS.INTRODUCTION',
|
||||||
links: [
|
images: AssetsConstants.GAME_JAMS_IMAGES.map(url => ({ url, source: '' })),
|
||||||
{name: 'Ubuntu Server', url: 'https://ubuntu.com/server'},
|
icon: 'videogame_asset',
|
||||||
{name: 'Docker', url: 'https://www.docker.com/'},
|
assets: '',
|
||||||
{name: 'Traefik', url: 'https://traefik.io/'},
|
links: [{ name: 'PROJECTS.LINK_TO_PROJECT', url: 'https://itch.io/c/6628860/lobos-collection' }],
|
||||||
{name: 'Gitea', url: 'https://gitea.io/'},
|
bulletPoints: [
|
||||||
{name: 'Jellyfin', url: 'https://jellyfin.org/'},
|
'PROJECTS.GAME_JAMS.BULLET_1',
|
||||||
{name: 'AdGuard Home', url: 'https://adguard.com/en/adguard-home/overview.html'},
|
'PROJECTS.GAME_JAMS.BULLET_2',
|
||||||
{name: 'Paperless-ngx', url: 'https://paperless-ngx.com/'},
|
'PROJECTS.GAME_JAMS.BULLET_3',
|
||||||
{name: 'Tailscale', url: 'https://tailscale.com/'}
|
'PROJECTS.GAME_JAMS.BULLET_4',
|
||||||
],
|
],
|
||||||
bulletPoints: [
|
challenges: [
|
||||||
'PROJECTS.TRIBBLE.BULLET_1',
|
'PROJECTS.GAME_JAMS.CHALLENGE_1',
|
||||||
'PROJECTS.TRIBBLE.BULLET_2',
|
'PROJECTS.GAME_JAMS.CHALLENGE_2',
|
||||||
'PROJECTS.TRIBBLE.BULLET_3',
|
],
|
||||||
'PROJECTS.TRIBBLE.BULLET_4',
|
learnings: [
|
||||||
],
|
'PROJECTS.GAME_JAMS.LEARNING_1',
|
||||||
isFeatured: false,
|
'PROJECTS.GAME_JAMS.LEARNING_2',
|
||||||
technologies: ['Ubuntu Server', 'Docker', 'Traefik', 'Gitea', 'Jellyfin', 'AdGuard Home', 'Paperless-ngx', 'Tailscale']
|
],
|
||||||
}
|
isFeatured: false,
|
||||||
]
|
technologies: ['Unity', 'C#', 'Git']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
identifier: "diploma",
|
||||||
|
title: 'PROJECTS.DIPLOMA.TITLE',
|
||||||
|
shortDescription: 'PROJECTS.DIPLOMA.SHORT_DESCRIPTION',
|
||||||
|
introduction: 'PROJECTS.DIPLOMA.INTRODUCTION',
|
||||||
|
images: AssetsConstants.DIPLOMA_IMAGES.map(url => ({ url, source: '' })),
|
||||||
|
icon: 'history_edu',
|
||||||
|
assets: AssetsConstants.DIPLOMA,
|
||||||
|
links: [{ name: 'PROJECTS.LINK_TO_PROJECT', url: 'https://www.th-bingen.de' }],
|
||||||
|
bulletPoints: [
|
||||||
|
'PROJECTS.DIPLOMA.BULLET_1',
|
||||||
|
'PROJECTS.DIPLOMA.BULLET_2',
|
||||||
|
'PROJECTS.DIPLOMA.BULLET_3',
|
||||||
|
'PROJECTS.DIPLOMA.BULLET_4',
|
||||||
|
],
|
||||||
|
challenges: [
|
||||||
|
'PROJECTS.DIPLOMA.CHALLENGE_1',
|
||||||
|
'PROJECTS.DIPLOMA.CHALLENGE_2',
|
||||||
|
],
|
||||||
|
learnings: [
|
||||||
|
'PROJECTS.DIPLOMA.LEARNING_1',
|
||||||
|
'PROJECTS.DIPLOMA.LEARNING_2',
|
||||||
|
],
|
||||||
|
isFeatured: false,
|
||||||
|
technologies: ['Java', 'Performance', 'Algorithm', 'Simulation']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
identifier: "tribble-the-homeserver",
|
||||||
|
title: 'PROJECTS.TRIBBLE.TITLE',
|
||||||
|
shortDescription: 'PROJECTS.TRIBBLE.SHORT_DESCRIPTION',
|
||||||
|
introduction: 'PROJECTS.TRIBBLE.INTRODUCTION',
|
||||||
|
images: [
|
||||||
|
{ url: AssetsConstants.TRIBBLE_IMAGES[0], source: 'https://upload.wikimedia.org/wikipedia/commons/0/03/Hostinger_Logo.png' },
|
||||||
|
{ url: AssetsConstants.TRIBBLE_IMAGES[1], source: 'https://dashboardicons.com/icons/docker-engine' },
|
||||||
|
{ url: AssetsConstants.TRIBBLE_IMAGES[2], source: 'https://dashboardicons.com/icons/gitea' },
|
||||||
|
{ url: AssetsConstants.TRIBBLE_IMAGES[3], source: 'https://dashboardicons.com/icons/traefik' }
|
||||||
|
],
|
||||||
|
icon: 'dns',
|
||||||
|
assets: '',
|
||||||
|
links: [
|
||||||
|
{ name: 'Ubuntu Server', url: 'https://ubuntu.com/server' },
|
||||||
|
{ name: 'Docker', url: 'https://www.docker.com/' },
|
||||||
|
{ name: 'Traefik', url: 'https://traefik.io/' },
|
||||||
|
{ name: 'Gitea', url: 'https://gitea.io/' },
|
||||||
|
{ name: 'Jellyfin', url: 'https://jellyfin.org/' },
|
||||||
|
{ name: 'AdGuard Home', url: 'https://adguard.com/en/adguard-home/overview.html' },
|
||||||
|
{ name: 'Paperless-ngx', url: 'https://paperless-ngx.com/' },
|
||||||
|
{ name: 'Tailscale', url: 'https://tailscale.com/' }
|
||||||
|
],
|
||||||
|
bulletPoints: [
|
||||||
|
'PROJECTS.TRIBBLE.BULLET_1',
|
||||||
|
'PROJECTS.TRIBBLE.BULLET_2',
|
||||||
|
'PROJECTS.TRIBBLE.BULLET_3',
|
||||||
|
'PROJECTS.TRIBBLE.BULLET_4',
|
||||||
|
],
|
||||||
|
challenges: [
|
||||||
|
'PROJECTS.TRIBBLE.CHALLENGE_1',
|
||||||
|
'PROJECTS.TRIBBLE.CHALLENGE_2',
|
||||||
|
],
|
||||||
|
learnings: [
|
||||||
|
'PROJECTS.TRIBBLE.LEARNING_1',
|
||||||
|
'PROJECTS.TRIBBLE.LEARNING_2',
|
||||||
|
],
|
||||||
|
isFeatured: false,
|
||||||
|
technologies: ['Ubuntu Server', 'Docker', 'Traefik', 'Gitea', 'Jellyfin', 'AdGuard Home', 'Paperless-ngx', 'Tailscale']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
featuredProject = computed(() => this.allProjects.find(p => p.isFeatured));
|
featuredProject = computed(() => this.allProjects.find(p => p.isFeatured));
|
||||||
otherProjects = computed(() => this.allProjects.filter(p => !p.isFeatured));
|
otherProjects = computed(() => this.allProjects.filter(p => !p.isFeatured));
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
setTimeout(() =>{ this.dialogOpenFunction(); },10);
|
setTimeout(() => { this.dialogOpenFunction(); }, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
@@ -172,8 +241,7 @@ export class ProjectsComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private dialogOpenFunction() : void
|
private dialogOpenFunction(): void {
|
||||||
{
|
|
||||||
this.queryParamSub = this.route.queryParamMap.subscribe(params => {
|
this.queryParamSub = this.route.queryParamMap.subscribe(params => {
|
||||||
const projectIdentifier = params.get('project');
|
const projectIdentifier = params.get('project');
|
||||||
if (projectIdentifier) {
|
if (projectIdentifier) {
|
||||||
|
|||||||
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user