Compare commits
150 Commits
afe0670098
...
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 | |||
| 085201913f | |||
| 525bec30fe | |||
| d01a6b0c6b | |||
| 159d82d602 | |||
| ba3dc4d928 | |||
| 12ebbb09ce | |||
| 07b6296294 | |||
| 6cb033bd9f | |||
| 42c86ecb70 | |||
| 5d162b57ab | |||
| dab7c51b90 | |||
| e8354bfecd | |||
| bbec113f5d | |||
| 950ec75f07 | |||
| 854d558e6b | |||
| bc740af0bf | |||
| f6ed2057a4 | |||
| a6d8405916 | |||
| 41ec7a862f | |||
| b9f6564771 | |||
| d8611b0968 | |||
| 4d27643d74 | |||
| 48e74ed3e8 | |||
| e1b1643eb2 | |||
| 0e520ead26 | |||
| 70ed047059 | |||
| 16cc8afd4a | |||
| 2a808c1d96 | |||
| 5d48118add | |||
| bf46c57db0 | |||
| 930f0110b0 | |||
| 3795090cea | |||
| 59148db295 | |||
| a22dd17869 | |||
| da43213808 | |||
| ff1ee9b5f6 | |||
| 52f0e371ee | |||
| f385a154d5 | |||
| 38bf7edd53 | |||
| b52790c9a1 | |||
| e3d835300b | |||
| 6b64c5f4e9 | |||
| 6f282a004b | |||
| e1680426ad | |||
| 3cb3a86b75 | |||
| beb5bb7db1 | |||
| a10f62f2dd | |||
| 450ab0b837 | |||
| cbc46cf858 | |||
| 2aca34fb9c | |||
| 24bf329628 | |||
| e195e93f1a |
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
|
||||||
|
|||||||
@@ -1,12 +1,87 @@
|
|||||||
name: Build & Push Frontend A
|
name: Build, Test & Push Frontend
|
||||||
|
run-name: ${{ gitea.actor }} build and test Angular 🚀
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
docker:
|
# ------------------------------------------------------------------
|
||||||
|
# JOB 1: Code integrity and quality gates (CI)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
quality-check:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout Code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install Linux Libs
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y \
|
||||||
|
libnss3 \
|
||||||
|
libnspr4 \
|
||||||
|
libatk1.0-0 \
|
||||||
|
libatk-bridge2.0-0 \
|
||||||
|
libcups2t64 \
|
||||||
|
libdrm2 \
|
||||||
|
libxkbcommon0 \
|
||||||
|
libxcomposite1 \
|
||||||
|
libxdamage1 \
|
||||||
|
libxfixes3 \
|
||||||
|
libxrandr2 \
|
||||||
|
libgbm1 \
|
||||||
|
libasound2t64 \
|
||||||
|
libpango-1.0-0 \
|
||||||
|
libcairo2
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
# 1. Linting (Code-Style)
|
||||||
|
- name: Lint & Type Check
|
||||||
|
run: npm run lint --if-present
|
||||||
|
|
||||||
|
# 2. i18n key sync (en/de must share the same keys)
|
||||||
|
- name: i18n Key Check
|
||||||
|
run: npm run i18n:check
|
||||||
|
|
||||||
|
# 3. Unit Tests (Logic) — browser/launcher configured in karma.conf.js
|
||||||
|
- name: Unit Tests
|
||||||
|
run: npx ng test --watch=false
|
||||||
|
|
||||||
|
# 4. Build Production (necessary for lighthouse)
|
||||||
|
- name: Build Production
|
||||||
|
run: npx ng build --configuration production
|
||||||
|
|
||||||
|
# 5. Lighthouse Audit (Performance & SEO)
|
||||||
|
# Puppeteer is a devDependency, so `npm ci` already installed it and
|
||||||
|
# downloaded Chrome. executablePath() is async in Puppeteer v25, so await it.
|
||||||
|
- name: Lighthouse CI
|
||||||
|
run: |
|
||||||
|
CHROME_PATH=$(node -e 'require("puppeteer").executablePath().then(p => console.log(p))')
|
||||||
|
export CHROME_PATH=$CHROME_PATH
|
||||||
|
npx lhci autorun
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# JOB 2: Docker Build & Push (CD)
|
||||||
|
# Runs only if 'quality-check' are successfully and we are on branch main.
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
docker:
|
||||||
|
needs: quality-check
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
@@ -20,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
|
||||||
@@ -33,14 +114,13 @@ 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 (only on main)
|
- name: Also push moving main tag
|
||||||
if: ${{ github.ref == 'refs/heads/main' }}
|
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
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
|
||||||
|
|||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -41,3 +41,10 @@ __screenshots__/
|
|||||||
# System files
|
# System files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# Lighthouse
|
||||||
|
.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 . .
|
||||||
|
|||||||
79
GEMINI.md
Normal file
79
GEMINI.md
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# Gemini CLI Context for playground-frontend
|
||||||
|
|
||||||
|
## 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:** GitHub Actions
|
||||||
|
|
||||||
|
## 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
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
|
To run the unit tests:
|
||||||
|
```bash
|
||||||
|
ng test
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linting
|
||||||
|
|
||||||
|
To lint the codebase:
|
||||||
|
```bash
|
||||||
|
ng lint
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
To build and run the application using Docker locally:
|
||||||
|
|
||||||
|
1. **Build Docker image:**
|
||||||
|
```bash
|
||||||
|
docker build -t playground-frontend:local .
|
||||||
|
```
|
||||||
|
2. **Run Docker container:**
|
||||||
|
```bash
|
||||||
|
docker run -p 8080:80 playground-frontend:local
|
||||||
|
```
|
||||||
|
Then open `http://localhost:8080` in your browser.
|
||||||
|
|
||||||
|
## Development Conventions
|
||||||
|
|
||||||
|
* **Language:** TypeScript
|
||||||
|
* **Framework:** Angular
|
||||||
|
* **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).
|
||||||
|
* **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`).
|
||||||
121
README.md
121
README.md
@@ -1,9 +1,8 @@
|
|||||||
## 📗 `playground-frontend/README.md`
|
|
||||||
|
|
||||||
# 🎨 Playground Frontend
|
# 🎨 Playground Frontend
|
||||||
|
|
||||||
This is the **frontend** of the Playground project.
|
This is the **frontend** of the Playground project.
|
||||||
Built with **Angular 21** and **Angular Material**, including a simple light/dark theme toggle and multi-language support via **ngx-translate**.
|
Built with **Angular 21** and **Angular Material**, including a light/dark theme toggle and
|
||||||
|
multi-language support (EN/DE) via **ngx-translate**.
|
||||||
|
|
||||||
The app is built as a static **Single Page Application (SPA)** served by **NGINX**,
|
The app is built as a static **Single Page Application (SPA)** served by **NGINX**,
|
||||||
deployed at:
|
deployed at:
|
||||||
@@ -12,84 +11,136 @@ deployed at:
|
|||||||
---
|
---
|
||||||
|
|
||||||
## 🧩 Tech Stack
|
## 🧩 Tech Stack
|
||||||
|
|
||||||
| Component | Purpose |
|
| Component | Purpose |
|
||||||
|------------|----------|
|
| --------- | ------- |
|
||||||
| Angular 21 | Frontend framework |
|
| Angular 21 | Frontend framework (standalone components, signals, zoneless change detection) |
|
||||||
| Angular Material | UI components & theming |
|
| Angular Material 21 | UI components & theming |
|
||||||
| ngx-translate | i18n / instant translation |
|
| Tailwind CSS 4 | Utility-first styling (CSS-first config, no `tailwind.config.js`) |
|
||||||
|
| ngx-translate 18 | i18n / instant translation |
|
||||||
|
| Babylon.js 9 | WebGL/WebGPU rendering for the algorithm demos |
|
||||||
|
| Swiper 14 | Image carousels in the project dialogs |
|
||||||
|
| Angular Service Worker | PWA / offline caching |
|
||||||
| NGINX | Serves the compiled SPA |
|
| NGINX | Serves the compiled SPA |
|
||||||
| Docker + GitHub Actions | Automated build & image publishing |
|
| Docker + Gitea Actions | Automated build, test & image publishing |
|
||||||
|
|
||||||
|
Requires **Node.js 22**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📁 Project Structure
|
## 📁 Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
playground-frontend/
|
playground-frontend/
|
||||||
├─ src/
|
├─ src/
|
||||||
│ ├─ app/
|
│ ├─ app/
|
||||||
│ │ ├─ app.component.ts
|
│ │ ├─ layout/ # app shell, topbar, dialogs
|
||||||
│ │ ├─ theme.service.ts
|
│ │ ├─ pages/ # about, projects, algorithms, imprint, stopwatch, not-found
|
||||||
│ │ └─ ...
|
│ │ ├─ service/ # theme, language, SEO, GPU capability
|
||||||
│ ├─ assets/i18n/
|
│ │ ├─ shared/ # reusable components & helpers
|
||||||
│ │ ├─ en.json
|
│ │ ├─ constants/
|
||||||
│ │ └─ de.json
|
│ │ ├─ app.config.ts
|
||||||
|
│ │ └─ app.routes.ts
|
||||||
|
│ ├─ assets/
|
||||||
|
│ │ ├─ i18n/ # en.json / de.json (keys must stay in sync)
|
||||||
|
│ │ ├─ logos/, flags/, icons/
|
||||||
|
│ │ └─ projects/ # project screenshots
|
||||||
|
│ ├─ tailwind.css # Tailwind entry + @theme tokens
|
||||||
|
│ ├─ styles.scss # Material theme setup & component overrides
|
||||||
│ └─ index.html
|
│ └─ index.html
|
||||||
|
├─ scripts/check-i18n.mjs # fails the build on i18n key drift
|
||||||
├─ Dockerfile
|
├─ Dockerfile
|
||||||
├─ nginx.conf
|
├─ nginx.conf
|
||||||
└─ .github/workflows/docker.yml
|
├─ ngsw-config.json # service worker caching rules
|
||||||
|
├─ lighthouserc.json
|
||||||
|
└─ .gitea/workflows/build-Frontend-a.yml
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🚀 Local Development
|
## 🚀 Local Development
|
||||||
|
|
||||||
# 1. Install dependencies
|
**1. Install dependencies**
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
# 2. Start development server
|
**2. Start development server**
|
||||||
```bash
|
```bash
|
||||||
ng serve --open
|
ng serve --open
|
||||||
```
|
```
|
||||||
|
|
||||||
App runs at http://localhost:4200
|
App runs at http://localhost:4200
|
||||||
|
|
||||||
|
**3. Docker build (local)**
|
||||||
# 3. 🐳 Docker Build (local)
|
|
||||||
```bash
|
```bash
|
||||||
docker build -t playground-frontend:local .
|
docker build -t playground-frontend:local .
|
||||||
docker run -p 8080:80 playground-frontend:local
|
docker run -p 8080:80 playground-frontend:local
|
||||||
```
|
```
|
||||||
|
|
||||||
Then open http://localhost:8080
|
Then open http://localhost:8080
|
||||||
|
|
||||||
## ⚙️ GitHub Actions (CI/CD)
|
---
|
||||||
|
|
||||||
On every push to main, GitHub Actions will:
|
## 🧪 Scripts
|
||||||
|
|
||||||
Build the Angular project
|
| Command | Purpose |
|
||||||
|
| ------- | ------- |
|
||||||
|
| `npm start` | Dev server |
|
||||||
|
| `npm run build` | Production build |
|
||||||
|
| `npm run watch` | Rebuild on change (development configuration) |
|
||||||
|
| `npm test` | Unit tests (Karma + Jasmine, headless Chrome) |
|
||||||
|
| `npm run lint` | ESLint over `src/**/*.ts` and `src/**/*.html` |
|
||||||
|
| `npm run i18n:check` | Verifies `en.json` and `de.json` share the exact same keys |
|
||||||
|
|
||||||
1. Create a Docker image
|
---
|
||||||
2. Push it to Docker Hub (docker.io/andreasdahm/playground-frontend:main)
|
|
||||||
3. Workflow file: .github/workflows/docker.yml
|
|
||||||
|
|
||||||
Required repository secrets:
|
## ⚙️ Gitea Actions (CI/CD)
|
||||||
|
|
||||||
Name Description
|
CI/CD runs on a **self-hosted Gitea instance** at `git.andreas-dahm.eu`.
|
||||||
DOCKERHUB_USERNAME Your Docker Hub username
|
Workflow file: `.gitea/workflows/build-Frontend-a.yml`
|
||||||
DOCKERHUB_TOKEN Personal access token for Docker Hub
|
|
||||||
|
Triggered on every push to `main` and on pull requests targeting `main`.
|
||||||
|
|
||||||
|
**Job 1 — `quality-check`** (runs on both pushes and PRs):
|
||||||
|
|
||||||
|
1. Lint & type check — `npm run lint`
|
||||||
|
2. i18n key sync — `npm run i18n:check`
|
||||||
|
3. Unit tests — `npx ng test --watch=false`
|
||||||
|
4. Production build — `npx ng build --configuration production`
|
||||||
|
5. Lighthouse audit — `npx lhci autorun` (performance & SEO)
|
||||||
|
|
||||||
|
**Job 2 — `docker`** (only on a push to `main`, and only if `quality-check` passed):
|
||||||
|
|
||||||
|
Builds the image and pushes it to the Gitea container registry at `git.andreas-dahm.eu`
|
||||||
|
with these tags:
|
||||||
|
|
||||||
|
```
|
||||||
|
git.andreas-dahm.eu/<owner>/playground:frontend-a-<branch>
|
||||||
|
git.andreas-dahm.eu/<owner>/playground:frontend-a-<branch>-<short-sha>
|
||||||
|
git.andreas-dahm.eu/<owner>/playground:frontend-a-main
|
||||||
|
```
|
||||||
|
|
||||||
|
**Required repository secret:**
|
||||||
|
|
||||||
|
| Name | Description |
|
||||||
|
| ---- | ----------- |
|
||||||
|
| `TOKEN_GITEA` | Gitea access token used to authenticate against the container registry |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 🌐 Deployment
|
## 🌐 Deployment
|
||||||
|
|
||||||
The built image is deployed via
|
The built image is deployed via the `playground-deploy` repository on the Hostinger KVM
|
||||||
https://github.com/lobothedark/playground-deploy
|
server using **Traefik**:
|
||||||
|
|
||||||
on the Hostinger KVM server using Traefik.
|
|
||||||
|
|
||||||
|
```bash
|
||||||
docker compose pull
|
docker compose pull
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
✅ Live site at https://app.andreas-dahm.eu
|
✅ Live site at https://app.andreas-dahm.eu
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 🪄 Maintainer
|
## 🪄 Maintainer
|
||||||
|
|
||||||
Andreas Dahm
|
Andreas Dahm
|
||||||
|
|||||||
11
angular.json
11
angular.json
@@ -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"
|
||||||
]
|
]
|
||||||
@@ -49,8 +51,8 @@
|
|||||||
"budgets": [
|
"budgets": [
|
||||||
{
|
{
|
||||||
"type": "initial",
|
"type": "initial",
|
||||||
"maximumWarning": "1MB",
|
"maximumWarning": "8MB",
|
||||||
"maximumError": "1MB"
|
"maximumError": "15MB"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "anyComponentStyle",
|
"type": "anyComponentStyle",
|
||||||
@@ -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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ module.exports = defineConfig([
|
|||||||
],
|
],
|
||||||
processor: angular.processInlineTemplates,
|
processor: angular.processInlineTemplates,
|
||||||
rules: {
|
rules: {
|
||||||
|
"@typescript-eslint/no-inferrable-types": "off",
|
||||||
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
|
"@typescript-eslint/prefer-for-of": "off",
|
||||||
"@angular-eslint/directive-selector": [
|
"@angular-eslint/directive-selector": [
|
||||||
"error",
|
"error",
|
||||||
{
|
{
|
||||||
|
|||||||
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,
|
||||||
|
});
|
||||||
|
};
|
||||||
14
lighthouserc.json
Normal file
14
lighthouserc.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"ci": {
|
||||||
|
"collect": {
|
||||||
|
"numberOfRuns": 1,
|
||||||
|
"staticDistDir": "./dist/playground-frontend/browser",
|
||||||
|
"settings": {
|
||||||
|
"chromeFlags": "--no-sandbox --headless --disable-gpu --disable-dev-shm-usage"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"upload": {
|
||||||
|
"target": "temporary-public-storage"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
9601
package-lock.json
generated
9601
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
68
package.json
68
package.json
@@ -1,40 +1,64 @@
|
|||||||
{
|
{
|
||||||
"name": "playground-frontend",
|
"name": "playground-frontend",
|
||||||
"version": "0.2.0",
|
"version": "1.0.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
"start": "ng serve",
|
"start": "ng serve",
|
||||||
"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",
|
||||||
"@ngx-translate/core": "~17.0.0",
|
"@angular/router": "~21.2.21",
|
||||||
"@ngx-translate/http-loader": "~17.0.0",
|
"@angular/service-worker": "~21.2.21",
|
||||||
|
"@babylonjs/core": "^9.22.0",
|
||||||
|
"@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",
|
||||||
"@types/jasmine": "~5.1.15",
|
"@eslint/js": "~10.0.1",
|
||||||
"angular-eslint": "21.2.0",
|
"@lhci/cli": "^0.15.1",
|
||||||
"eslint": "^9.39.2",
|
"@puppeteer/browsers": "3.2.1",
|
||||||
"jasmine-core": "~6.0.1",
|
"@tailwindcss/postcss": "^4.3.3",
|
||||||
|
"@types/jasmine": "~6.0.0",
|
||||||
|
"@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": {
|
||||||
|
"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,13 +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.IMPRINT.PATH, component: RouterConstants.IMPRINT.COMPONENT},
|
{ path: RouterConstants.SORTING.PATH, loadComponent: () => import('./pages/algorithms/sorting/sorting.component').then(m => m.SortingComponent), data: { seo: 'ALGORITHMS' } },
|
||||||
|
{ path: RouterConstants.IMPRINT.PATH, loadComponent: () => import('./pages/imprint/imprint.component').then(m => m.ImprintComponent), data: { seo: 'IMPRINT' } },
|
||||||
|
{ path: RouterConstants.GOL.PATH, loadComponent: () => import('./pages/algorithms/conway-gol/conway-gol.component').then(m => m.ConwayGolComponent), data: { seo: 'ALGORITHMS' } },
|
||||||
|
{ path: RouterConstants.LABYRINTH.PATH, loadComponent: () => import('./pages/algorithms/pathfinding/labyrinth/labyrinth.component').then(m => m.LabyrinthComponent), data: { seo: 'ALGORITHMS' } },
|
||||||
|
{ path: RouterConstants.FRACTAL.PATH, loadComponent: () => import('./pages/algorithms/fractal/fractal.component').then(m => m.FractalComponent), data: { seo: 'ALGORITHMS' } },
|
||||||
|
{ path: RouterConstants.FRACTAL3d.PATH, loadComponent: () => import('./pages/algorithms/fractal3d/fractal3d.component').then(m => m.Fractal3dComponent), data: { seo: 'ALGORITHMS' } },
|
||||||
|
{ path: RouterConstants.PENDULUM.PATH, loadComponent: () => import('./pages/algorithms/pendulum/pendulum.component').then(m => m.default), data: { seo: 'ALGORITHMS' } },
|
||||||
|
{ path: RouterConstants.CLOTH.PATH, loadComponent: () => import('./pages/algorithms/cloth/cloth.component').then(m => m.ClothComponent), data: { seo: 'ALGORITHMS' } },
|
||||||
|
{ path: RouterConstants.FOUR_COLOR.PATH, loadComponent: () => import('./pages/algorithms/four-color/four-color.component').then(m => m.FourColorComponent), data: { seo: 'ALGORITHMS' } },
|
||||||
|
{ path: RouterConstants.STOPWATCH.PATH, loadComponent: () => import('./pages/stopwatch/stopwatch.component').then(m => m.StopwatchComponent), data: { seo: 'STOPWATCH' } },
|
||||||
|
{ path: '**', loadComponent: () => import('./pages/not-found/not-found.component').then(m => m.NotFoundComponent), data: { seo: 'NOT_FOUND' } },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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,39 +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';
|
|
||||||
|
|
||||||
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 = {
|
||||||
|
PATH: 'algorithms/sorting',
|
||||||
|
LINK: '/algorithms/sorting',
|
||||||
|
};
|
||||||
|
|
||||||
|
static readonly GOL = {
|
||||||
|
PATH: 'algorithms/gol',
|
||||||
|
LINK: '/algorithms/gol',
|
||||||
|
};
|
||||||
|
|
||||||
|
static readonly LABYRINTH = {
|
||||||
|
PATH: 'algorithms/labyrinth',
|
||||||
|
LINK: '/algorithms/labyrinth',
|
||||||
|
};
|
||||||
|
|
||||||
|
static readonly FRACTAL = {
|
||||||
|
PATH: 'algorithms/fractal',
|
||||||
|
LINK: '/algorithms/fractal',
|
||||||
|
};
|
||||||
|
|
||||||
|
static readonly FRACTAL3d = {
|
||||||
|
PATH: 'algorithms/fractal3d',
|
||||||
|
LINK: '/algorithms/fractal3d',
|
||||||
|
};
|
||||||
|
|
||||||
|
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,6 +1,28 @@
|
|||||||
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 QUICK_SORT_WIKI = 'https://de.wikipedia.org/wiki/Quicksort'
|
||||||
|
static readonly HEAP_SORT_WIKI = 'https://de.wikipedia.org/wiki/Heapsort'
|
||||||
|
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 PRIMS_WIKI = 'https://de.wikipedia.org/wiki/Algorithmus_von_Prim'
|
||||||
|
static readonly KRUSKAL_WIKI = 'https://de.wikipedia.org/wiki/Algorithmus_von_Kruskal'
|
||||||
|
static readonly MANDELBROT_WIKI = 'https://de.wikipedia.org/wiki/Mandelbrot-Menge'
|
||||||
|
static readonly JULIA_WIKI = 'https://de.wikipedia.org/wiki/Julia-Menge'
|
||||||
|
static readonly NEWTON_FRACTAL_WIKI = 'https://de.wikipedia.org/wiki/Newtonfraktal'
|
||||||
|
static readonly BURNING_SHIP_WIKI = 'https://de.wikipedia.org/wiki/Burning_ship_(Fraktal)'
|
||||||
|
static readonly MANDELBULB_WIKI = 'https://de.wikipedia.org/wiki/Mandelknolle'
|
||||||
|
static readonly MANDELBOX_WIKI = 'https://de.wikipedia.org/wiki/Mandelbox'
|
||||||
|
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>
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
|
||||||
oncontextmenu="return false;"
|
|
||||||
priority />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="intro">
|
<div class="flex-[999_1_min(100%,400px)]">
|
||||||
<h1>{{ 'ABOUT.HELLO' | translate }}</h1>
|
<h1 class="mt-0 mb-2 text-[clamp(1.5rem,5vw,2.5rem)]">{{ 'ABOUT.HELLO' | translate }}</h1>
|
||||||
<p class="lead">
|
<p class="opacity-90 my-2 mb-4 text-[clamp(1rem,2.5vw,1.15rem)]">
|
||||||
{{ 'ABOUT.LEAD' | translate }}
|
{{ 'ABOUT.LEAD' | translate }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="meta">
|
<div class="flex flex-col gap-1 mb-4">
|
||||||
<div class="row">
|
<div class="flex items-center flex-wrap gap-[0.4rem]">
|
||||||
<mat-icon aria-hidden="true">work</mat-icon>
|
<mat-icon aria-hidden="true">work</mat-icon>
|
||||||
<span>{{ 'ABOUT.ROLE' | translate }}</span>
|
<span>{{ 'ABOUT.ROLE' | translate }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="flex items-center flex-wrap gap-[0.4rem]">
|
||||||
<mat-icon aria-hidden="true">location_on</mat-icon>
|
<mat-icon aria-hidden="true">location_on</mat-icon>
|
||||||
<span>{{ 'ABOUT.LOCATION' | translate }}</span>
|
<span>{{ 'ABOUT.LOCATION' | translate }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="flex items-center flex-wrap gap-[0.4rem]">
|
||||||
<mat-icon aria-hidden="true">email</mat-icon>
|
<mat-icon aria-hidden="true">email</mat-icon>
|
||||||
<a href="" (click)="SharedFunctions.openMail($event)">
|
<button type="button" class="link-button" data-testid="contact-mail"
|
||||||
|
(click)="SharedFunctions.openMail($event)">
|
||||||
{{ 'ABOUT.CONTACT_ME' | translate }}
|
{{ 'ABOUT.CONTACT_ME' | translate }}
|
||||||
</a>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="flex items-center flex-wrap gap-[0.4rem]">
|
||||||
<mat-icon svgIcon="github"></mat-icon>
|
<mat-icon aria-hidden="true">data_object</mat-icon>
|
||||||
<a href="{{UrlConstants.GIT_HUB}}" target="_blank" rel="noopener">GitHub</a>
|
<a href="{{UrlConstants.CODEBERG}}" target="_blank" rel="noopener">Codeberg</a>
|
||||||
<span>·</span>
|
<span>·</span>
|
||||||
<mat-icon svgIcon="linkedin"></mat-icon>
|
<mat-icon svgIcon="linkedin" aria-hidden="true"></mat-icon>
|
||||||
<a href="{{UrlConstants.LINKED_IN}}" target="_blank" rel="noopener">LinkedIn</a>
|
<a href="{{UrlConstants.LINKED_IN}}" target="_blank" rel="noopener">LinkedIn</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</mat-card>
|
|
||||||
|
|
||||||
<mat-card class="skills">
|
<div class="flex flex-wrap gap-fluid-sm">
|
||||||
<h2>{{ 'ABOUT.SECTION.SKILLS' | translate }}</h2>
|
<a mat-flat-button [routerLink]="['/projects']">
|
||||||
<div class="chip-groups">
|
<mat-icon aria-hidden="true">grid_view</mat-icon>
|
||||||
<div>
|
{{ 'ABOUT.VIEW_PROJECTS' | translate }}
|
||||||
<h3>{{ 'ABOUT.SECTION.PRIMARY' | translate }}</h3>
|
</a>
|
||||||
<mat-chip-set aria-label="Primary skills">
|
|
||||||
@for (s of primarySkills; track s) {
|
|
||||||
<mat-chip >{{ s | translate }}</mat-chip>
|
|
||||||
}
|
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
|
|
||||||
<mat-card class="experdience">
|
<mat-card class="p-[clamp(5px,2vw,15px)]">
|
||||||
<h2>{{ 'ABOUT.SECTION.EXPERIENCE' | translate }}</h2>
|
<h2 class="mt-1 ml-1 text-[clamp(1.2rem,4vw,1.8rem)]">{{ 'ABOUT.SECTION.SKILLS' | translate }}</h2>
|
||||||
<div class="xp-list">
|
<div class="grid grid-cols-[repeat(auto-fit,minmax(min(100%,250px),1fr))] gap-fluid-sm ml-1 mb-2">
|
||||||
@for (entry of xpKeys; track entry.key) {
|
@for (group of skillGroups; track group.titleKey) {
|
||||||
<div class="xp-item">
|
<div>
|
||||||
<div class="xp-head-grid">
|
<h3 class="my-1 text-[0.95rem] opacity-85">{{ group.titleKey | translate }}</h3>
|
||||||
<div class="logo-wrap">
|
<mat-chip-set [attr.aria-label]="group.titleKey | translate">
|
||||||
<img
|
@for (skillKey of group.skillKeys; track skillKey) {
|
||||||
src="{{entry.logo}}"
|
<mat-chip>{{ skillKey | translate }}</mat-chip>
|
||||||
alt=""
|
}
|
||||||
class="company-logo"
|
</mat-chip-set>
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="head-row">
|
}
|
||||||
<strong>{{ (entry.key + '.ROLE') | translate }}</strong>
|
|
||||||
<span class="time">{{ (entry.key + '.TIME') | translate }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
</mat-card>
|
||||||
|
|
||||||
<div class="company-row">
|
<mat-card class="p-[clamp(5px,2vw,15px)]">
|
||||||
|
<h2 class="mt-1 ml-2 text-[clamp(1.2rem,4vw,1.8rem)]">{{ 'ABOUT.SECTION.EXPERIENCE' | translate }}</h2>
|
||||||
|
|
||||||
|
<div class="relative ml-2">
|
||||||
|
<span class="absolute left-5 top-2 bottom-2 w-px bg-current opacity-15" aria-hidden="true"></span>
|
||||||
|
@for (entry of visibleExperience(); track entry.key; let i = $index) {
|
||||||
|
<div class="relative pl-14 pb-fluid-sm">
|
||||||
|
@if (isFirstRoleAtCompany(i)) {
|
||||||
|
<img src="{{entry.logo}}" alt="" aria-hidden="true"
|
||||||
|
class="absolute left-0 top-0 w-10 h-10 object-contain opacity-90 rounded-[10%] bg-logo-bg" />
|
||||||
|
<div class="font-semibold opacity-90 text-[clamp(0.9rem,2.2vw,1.05rem)] leading-10" data-testid="company">
|
||||||
{{ (entry.key + '.COMPANY') | translate }}
|
{{ (entry.key + '.COMPANY') | translate }}
|
||||||
</div>
|
</div>
|
||||||
|
} @else {
|
||||||
|
<span class="absolute left-5 top-[0.6rem] -translate-x-1/2 w-2 h-2 rounded-full bg-current opacity-30"
|
||||||
|
aria-hidden="true"></span>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-baseline gap-x-4 gap-y-1">
|
||||||
|
<strong class="text-[clamp(0.95rem,2.5vw,1.1rem)]" data-testid="role">{{ (entry.key + '.ROLE') | translate }}</strong>
|
||||||
|
<span class="opacity-75 text-[clamp(0.85rem,2vw,0.95rem)]">{{ (entry.key + '.TIME') | translate }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="highlights">
|
|
||||||
<ul>
|
<ul class="mt-[0.4rem]">
|
||||||
<li>{{ entry.key + '.HIGHLIGHTS.P1' | translate }}</li>
|
@for (highlight of highlightKeys(entry); track highlight) {
|
||||||
<li>{{ entry.key + '.HIGHLIGHTS.P2' | translate }}</li>
|
<li class="my-1 text-[clamp(0.9rem,2vw,1rem)]">{{ highlight | translate }}</li>
|
||||||
<li>{{ entry.key + '.HIGHLIGHTS.P3' | translate }}</li>
|
}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
@if (isLastRoleAtCompany(i) && !$last) {
|
||||||
|
<mat-divider class="mt-fluid-sm!"></mat-divider>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if(entry.key !== xpKeys.at(xpKeys.length-1)?.key)
|
<button mat-button type="button" class="mt-1 ml-2" data-testid="toggle-experience" (click)="toggleEarlierExperience()"
|
||||||
{
|
[attr.aria-expanded]="showEarlierExperience()">
|
||||||
<mat-divider></mat-divider>
|
<mat-icon aria-hidden="true">{{ showEarlierExperience() ? 'expand_less' : 'expand_more' }}</mat-icon>
|
||||||
}
|
{{ (showEarlierExperience() ? 'ABOUT.SECTION.SHOW_LESS' : 'ABOUT.SECTION.SHOW_EARLIER_XP') | translate }}
|
||||||
}
|
</button>
|
||||||
</div>
|
|
||||||
</mat-card>
|
</mat-card>
|
||||||
<mat-card class="projects">
|
|
||||||
<h2>{{ 'ABOUT.SECTION.PROJECTS' | translate }}</h2>
|
|
||||||
|
|
||||||
<div class="xp-list">
|
<mat-card class="p-[clamp(5px,2vw,15px)]">
|
||||||
|
<h2 class="mt-1 ml-1 text-[clamp(1.2rem,4vw,1.8rem)]">{{ 'ABOUT.SECTION.PROJECTS' | translate }}</h2>
|
||||||
|
|
||||||
|
<div class="ml-1 grid gap-fluid-sm">
|
||||||
@for (entry of projectKeys; track entry.key) {
|
@for (entry of projectKeys; track entry.key) {
|
||||||
<div class="xp-item">
|
<div>
|
||||||
<div class="head-row">
|
<div class="flex flex-wrap items-baseline gap-x-4 gap-y-1">
|
||||||
<strong>{{ (entry.key + '.TITLE') | translate }}</strong>
|
<strong class="text-[clamp(0.95rem,2.5vw,1.1rem)]">{{ (entry.key + '.TITLE') | translate }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="company-row">
|
<div class="mt-[0.1rem] opacity-85 text-[clamp(0.85rem,2vw,1rem)]">
|
||||||
{{ (entry.key + '.DESCRIPTION') | translate }}
|
{{ (entry.key + '.DESCRIPTION') | translate }}
|
||||||
</div>
|
</div>
|
||||||
@if (entry.externalLink) {
|
@if (entry.externalLink) {
|
||||||
<div class="link-row">
|
<div class="mt-[0.1rem] opacity-85">
|
||||||
<a class="link-with-icon"
|
<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"
|
|
||||||
rel="noopener noreferrer">
|
|
||||||
<mat-icon>open_in_new</mat-icon>
|
|
||||||
{{ (entry.key + '.LINK_EXTERNAL') | translate }}
|
{{ (entry.key + '.LINK_EXTERNAL') | translate }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<div class="link-row">
|
<div class="mt-[0.1rem] opacity-85">
|
||||||
<a class="link-with-icon"
|
<a class="inline-flex items-center gap-[0.35rem] leading-none" [routerLink]="['/projects']" [queryParams]="{ project: entry.identifier }"
|
||||||
[routerLink]="['/projects']"
|
|
||||||
[queryParams]="{ project: entry.identifier }"
|
|
||||||
rel="noopener noreferrer">
|
rel="noopener noreferrer">
|
||||||
<mat-icon>link</mat-icon>
|
<mat-icon class="text-[18px]! w-[18px]! h-[18px]!" aria-hidden="true">link</mat-icon>
|
||||||
{{ (entry.key + '.LINK_INTERNAL') | translate }}
|
{{ (entry.key + '.LINK_INTERNAL') | translate }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="highlights-noMargin">
|
<div>
|
||||||
<ul>
|
<ul>
|
||||||
<li>{{ entry.key + '.HIGHLIGHTS.P1' | translate }}</li>
|
<li class="my-1 text-[clamp(0.9rem,2vw,1rem)]">{{ entry.key + '.HIGHLIGHTS.P1' | translate }}</li>
|
||||||
<li>{{ entry.key + '.HIGHLIGHTS.P2' | translate }}</li>
|
<li class="my-1 text-[clamp(0.9rem,2vw,1rem)]">{{ entry.key + '.HIGHLIGHTS.P2' | translate }}</li>
|
||||||
<li>{{ entry.key + '.HIGHLIGHTS.P3' | translate }}</li>
|
<li class="my-1 text-[clamp(0.9rem,2vw,1rem)]">{{ entry.key + '.HIGHLIGHTS.P3' | translate }}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if(entry.key !== projectKeys.at(projectKeys.length-1)?.key)
|
@if (!$last) {
|
||||||
{
|
|
||||||
<mat-divider></mat-divider>
|
<mat-divider></mat-divider>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
|
|
||||||
<mat-card class="education">
|
<mat-card class="p-[clamp(5px,2vw,15px)]">
|
||||||
<h2>{{ 'ABOUT.SECTION.EDUCATION' | translate }}</h2>
|
<h2 class="mt-1 ml-1 text-[clamp(1.2rem,4vw,1.8rem)]">{{ 'ABOUT.SECTION.EDUCATION' | translate }}</h2>
|
||||||
|
|
||||||
<div class="xp-list">
|
<div class="ml-1 grid gap-fluid-sm">
|
||||||
<div class="xp-item">
|
<div>
|
||||||
@for (entry of educationKeys; track entry.key) {
|
@for (key of visibleEducationKeys(); track key) {
|
||||||
<div class="head-row">
|
<div class="flex flex-wrap items-baseline gap-x-4 gap-y-1">
|
||||||
<strong>{{ (entry.key + '.WHERE') | translate }}</strong>
|
<strong class="text-[clamp(0.95rem,2.5vw,1.1rem)]" data-testid="education-where">{{ (key + '.WHERE') | translate }}</strong>
|
||||||
<span class="time">{{ (entry.key + '.WHEN') | translate }}</span>
|
<span class="opacity-75 text-[clamp(0.85rem,2vw,0.95rem)]">{{ (key + '.WHEN') | translate }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="company-row">
|
<div class="mt-[0.1rem] opacity-85 text-[clamp(0.85rem,2vw,1rem)]">
|
||||||
{{ (entry.key + '.WHAT') | translate }}
|
{{ (key + '.WHAT') | translate }}
|
||||||
</div>
|
</div>
|
||||||
|
@if (!$last) {
|
||||||
@if(entry.key !== educationKeys.at(educationKeys.length-1)?.key)
|
<mat-divider class="my-2!"></mat-divider>
|
||||||
{
|
|
||||||
<mat-divider style="margin-top: .5rem; margin-bottom: .5rem"></mat-divider>
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button mat-button type="button" class="mt-1" data-testid="toggle-education" (click)="toggleEarlierEducation()"
|
||||||
|
[attr.aria-expanded]="showEarlierEducation()">
|
||||||
|
<mat-icon aria-hidden="true">{{ showEarlierEducation() ? 'expand_less' : 'expand_more' }}</mat-icon>
|
||||||
|
{{ (showEarlierEducation() ? 'ABOUT.SECTION.SHOW_LESS' : 'ABOUT.SECTION.SHOW_EARLIER_EDU') | translate }}
|
||||||
|
</button>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,188 +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-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 */
|
|
||||||
.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,52 +150,115 @@ export class AboutComponent {
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
educationKeys = [
|
readonly recentEducationKeys = [
|
||||||
{
|
'ABOUT.EDUCATION.E6',
|
||||||
key: 'ABOUT.EDUCATION.E6'
|
'ABOUT.EDUCATION.E5',
|
||||||
},
|
'ABOUT.EDUCATION.E4'
|
||||||
{
|
|
||||||
key: 'ABOUT.EDUCATION.E5'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'ABOUT.EDUCATION.E4'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'ABOUT.EDUCATION.E3'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'ABOUT.EDUCATION.E2'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'ABOUT.EDUCATION.E1'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'ABOUT.EDUCATION.E0'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
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 earlierEducationKeys = [
|
||||||
'ABOUT.TOOLS.GIT',
|
'ABOUT.EDUCATION.E3',
|
||||||
'ABOUT.TOOLS.GITHUB',
|
'ABOUT.EDUCATION.E2',
|
||||||
'ABOUT.TOOLS.GITLAB',
|
'ABOUT.EDUCATION.E1',
|
||||||
'ABOUT.TOOLS.JENKINS',
|
'ABOUT.EDUCATION.E0'
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Heading and chips travel together so the two can no longer drift apart —
|
||||||
|
* they used to be declared in separate places and had ended up mismatched.
|
||||||
|
*/
|
||||||
|
readonly skillGroups: SkillGroup[] = [
|
||||||
|
{
|
||||||
|
titleKey: 'ABOUT.SECTION.LANGUAGES',
|
||||||
|
skillKeys: [
|
||||||
|
'ABOUT.SKILLS.JAVA',
|
||||||
|
'ABOUT.SKILLS.SPRING',
|
||||||
|
'ABOUT.SKILLS.TYPESCRIPT',
|
||||||
|
'ABOUT.SKILLS.ANGULAR',
|
||||||
|
'ABOUT.SKILLS.CSHARP',
|
||||||
|
'ABOUT.SKILLS.PYTHON',
|
||||||
|
'ABOUT.SKILLS.VUE',
|
||||||
|
'ABOUT.SKILLS.REACT'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titleKey: 'ABOUT.SECTION.ARCH_CLOUD',
|
||||||
|
skillKeys: [
|
||||||
|
'ABOUT.SKILLS.ARCH_MICROSERVICES',
|
||||||
|
'ABOUT.SKILLS.ARCH_CLOUD',
|
||||||
|
'ABOUT.TOOLS.DOCKER',
|
||||||
'ABOUT.TOOLS.K8S',
|
'ABOUT.TOOLS.K8S',
|
||||||
'ABOUT.TOOLS.POSTGRES',
|
'ABOUT.TOOLS.POSTGRES',
|
||||||
'ABOUT.TOOLS.MONGO',
|
'ABOUT.TOOLS.MONGO',
|
||||||
'ABOUT.TOOLS.GRAFANA',
|
'ABOUT.TOOLS.GITLAB',
|
||||||
|
'ABOUT.TOOLS.JENKINS'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titleKey: 'ABOUT.SECTION.AI_ENG',
|
||||||
|
skillKeys: [
|
||||||
|
'ABOUT.SKILLS.AI_AGENTS',
|
||||||
|
'ABOUT.SKILLS.AI_MCP',
|
||||||
|
'ABOUT.SKILLS.AI_CONTEXT',
|
||||||
|
'ABOUT.SKILLS.AI_HARNESS',
|
||||||
|
'ABOUT.SKILLS.AI_GATES'
|
||||||
|
]
|
||||||
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
readonly visibleExperience = computed(() => {
|
||||||
|
if (this.showEarlierExperience()) {
|
||||||
|
return [...this.recentExperience, ...this.earlierExperience];
|
||||||
|
}
|
||||||
|
return this.recentExperience;
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly visibleEducationKeys = computed(() => {
|
||||||
|
if (this.showEarlierEducation()) {
|
||||||
|
return [...this.recentEducationKeys, ...this.earlierEducationKeys];
|
||||||
|
}
|
||||||
|
return this.recentEducationKeys;
|
||||||
|
});
|
||||||
|
|
||||||
|
toggleEarlierExperience(): void {
|
||||||
|
this.showEarlierExperience.update(shown => !shown);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleEarlierEducation(): void {
|
||||||
|
this.showEarlierEducation.update(shown => !shown);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The employer header is drawn once per run of consecutive roles at the same company. */
|
||||||
|
isFirstRoleAtCompany(index: number): boolean {
|
||||||
|
if (index === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const entries = this.visibleExperience();
|
||||||
|
return entries[index - 1].company !== entries[index].company;
|
||||||
|
}
|
||||||
|
|
||||||
|
isLastRoleAtCompany(index: number): boolean {
|
||||||
|
const entries = this.visibleExperience();
|
||||||
|
if (index === entries.length - 1) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return entries[index + 1].company !== entries[index].company;
|
||||||
|
}
|
||||||
|
|
||||||
|
highlightKeys(entry: ExperienceEntry): string[] {
|
||||||
|
return Array.from({ length: entry.highlightCount }, (_, i) => `${entry.key}.HIGHLIGHTS.P${i + 1}`);
|
||||||
|
}
|
||||||
|
|
||||||
protected readonly UrlConstants = UrlConstants;
|
protected readonly UrlConstants = UrlConstants;
|
||||||
protected readonly AssetsConstants = AssetsConstants;
|
protected readonly AssetsConstants = AssetsConstants;
|
||||||
protected readonly SharedFunctions = SharedFunctions;
|
protected readonly SharedFunctions = SharedFunctions;
|
||||||
|
|||||||
@@ -3,4 +3,5 @@ export interface AlgorithmCategory {
|
|||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
routerLink: string;
|
routerLink: string;
|
||||||
|
icon: string;
|
||||||
}
|
}
|
||||||
@@ -1,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: 300px;
|
|
||||||
max-width: 400px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<mat-card class="w-full max-w-[1920px] p-5">
|
||||||
|
<mat-card-header>
|
||||||
|
<mat-card-title>{{ 'GOL.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 (click)="generate(Scenario.SIMPLE)">
|
||||||
|
<mat-icon>arrow_right</mat-icon> {{ 'GOL.SIMPLE_SCENE' | translate }}
|
||||||
|
</button>
|
||||||
|
<button mat-raised-button (click)="generate(Scenario.PULSAR)">
|
||||||
|
<mat-icon>arrow_right</mat-icon> {{ 'GOL.PULSAR_SCENE' | translate }}
|
||||||
|
</button>
|
||||||
|
<button mat-raised-button (click)="generate(Scenario.GUN)">
|
||||||
|
<mat-icon>arrow_right</mat-icon> {{ 'GOL.GUN_SCENE' | translate }}
|
||||||
|
</button>
|
||||||
|
<button mat-raised-button (click)="generate(Scenario.RANDOM)">
|
||||||
|
<mat-icon>shuffle</mat-icon> {{ 'GOL.RANDOM_SCENE' | translate }}
|
||||||
|
</button>
|
||||||
|
<button mat-raised-button (click)="generate(Scenario.EMPTY)">
|
||||||
|
<mat-icon>check_box_outline_blank</mat-icon> {{ 'GOL.EMPTY_SCENE' | translate }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
|
||||||
|
@if (gameStarted())
|
||||||
|
{
|
||||||
|
<button mat-raised-button (click)="pauseGame()">
|
||||||
|
<mat-icon>pause</mat-icon> {{ 'GOL.PAUSE' | translate }}
|
||||||
|
</button>
|
||||||
|
} @else {
|
||||||
|
<button mat-raised-button (click)="startGame()">
|
||||||
|
<mat-icon>play_arrow</mat-icon> {{ 'GOL.START' | translate }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
<p>{{ 'SORTING.EXECUTION_TIME' | translate }}: {{ executionTime }} ms</p>
|
||||||
|
</div>
|
||||||
|
<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"
|
||||||
|
[(ngModel)]="gridRows"
|
||||||
|
[min]="MIN_GRID_SIZE"
|
||||||
|
[max]="MAX_GRID_SIZE"
|
||||||
|
(ngModelChange)="pauseGame(); genericGridComponent.gridRows = gridRows; genericGridComponent.applyGridSize()"
|
||||||
|
/>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" class="w-[150px]">
|
||||||
|
<mat-label>{{ 'ALGORITHM.GRID_WIDTH' | translate }}</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
type="number"
|
||||||
|
[(ngModel)]="gridCols"
|
||||||
|
[min]="MIN_GRID_SIZE"
|
||||||
|
[max]="MAX_GRID_SIZE"
|
||||||
|
(ngModelChange)="pauseGame(); genericGridComponent.gridCols = gridCols; genericGridComponent.applyGridSize()"
|
||||||
|
/>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" class="w-[150px]">
|
||||||
|
<mat-label>{{ 'GOL.SPEED' | translate }}</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
type="number"
|
||||||
|
[(ngModel)]="lifeSpeed"
|
||||||
|
[min]="MIN_TIME_PER_GENERATION"
|
||||||
|
[max]="MAX_TIME_PER_GENERATION"
|
||||||
|
(blur)="applySpeed()"
|
||||||
|
(keyup.enter)="applySpeed()"
|
||||||
|
/>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-4 items-center text-[0.9em]">
|
||||||
|
<span><span class="legend-swatch bg-black"></span> {{ 'GOL.ALIVE' | translate }}</span>
|
||||||
|
<span><span class="legend-swatch bg-gray-300"></span> {{ 'GOL.DEAD' | translate }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<app-generic-grid
|
||||||
|
[gridRows]="gridRows"
|
||||||
|
[gridCols]="gridCols"
|
||||||
|
[minGridSize]="MIN_GRID_SIZE"
|
||||||
|
[maxGridSize]="MAX_GRID_SIZE"
|
||||||
|
[maxGridPx]="MAX_GRID_PX"
|
||||||
|
[createNodeFn]="createConwayNode"
|
||||||
|
[getNodeColorFn]="getConwayNodeColor"
|
||||||
|
[applySelectionFn]="applyConwaySelection"
|
||||||
|
[backgroundColor]="'lightgray'"
|
||||||
|
(gridChange)="readGrid = $event"
|
||||||
|
></app-generic-grid>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
259
src/app/pages/algorithms/conway-gol/conway-gol.component.ts
Normal file
259
src/app/pages/algorithms/conway-gol/conway-gol.component.ts
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
import {AfterViewInit, Component, signal, ViewChild} from '@angular/core';
|
||||||
|
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from "@angular/material/card";
|
||||||
|
import {TranslatePipe} from "@ngx-translate/core";
|
||||||
|
import {UrlConstants} from '../../../constants/UrlConstants';
|
||||||
|
import {Information} from '../information/information';
|
||||||
|
import {AlgorithmInformation} from '../information/information.models';
|
||||||
|
import {MatButton} from '@angular/material/button';
|
||||||
|
import {MatIcon} from '@angular/material/icon';
|
||||||
|
import {MatFormField, MatInput, MatLabel} from '@angular/material/input';
|
||||||
|
import {FormsModule, ReactiveFormsModule} from '@angular/forms';
|
||||||
|
import {DEFAULT_GRID_COLS, DEFAULT_GRID_ROWS, DEFAULT_TIME_PER_GENERATION, LIVE_SPAWN_PROBABILITY, MAX_GRID_PX, MAX_GRID_SIZE, MAX_TIME_PER_GENERATION, MIN_GRID_SIZE, MIN_TIME_PER_GENERATION, Node, Scenario} from './conway-gol.models';
|
||||||
|
import {GenericGridComponent, GridPos} from '../../../shared/components/generic-grid/generic-grid';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-conway-gol',
|
||||||
|
imports: [
|
||||||
|
MatCard,
|
||||||
|
MatCardContent,
|
||||||
|
MatCardHeader,
|
||||||
|
MatCardTitle,
|
||||||
|
TranslatePipe,
|
||||||
|
Information,
|
||||||
|
MatButton,
|
||||||
|
MatIcon,
|
||||||
|
MatFormField,
|
||||||
|
MatInput,
|
||||||
|
MatLabel,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
FormsModule,
|
||||||
|
GenericGridComponent
|
||||||
|
],
|
||||||
|
templateUrl: './conway-gol.component.html',
|
||||||
|
})
|
||||||
|
export class ConwayGolComponent implements AfterViewInit {
|
||||||
|
|
||||||
|
algoInformation: AlgorithmInformation = {
|
||||||
|
title: 'GOL.EXPLANATION.TITLE',
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
name: '',
|
||||||
|
description: 'GOL.EXPLANATION.EXPLANATION',
|
||||||
|
link: UrlConstants.CONWAYS_WIKI
|
||||||
|
}
|
||||||
|
],
|
||||||
|
disclaimer: 'GOL.EXPLANATION.DISCLAIMER',
|
||||||
|
disclaimerBottom: '',
|
||||||
|
disclaimerListEntry: ['GOL.EXPLANATION.DISCLAIMER_1', 'GOL.EXPLANATION.DISCLAIMER_2', 'GOL.EXPLANATION.DISCLAIMER_3', 'GOL.EXPLANATION.DISCLAIMER_4']
|
||||||
|
};
|
||||||
|
|
||||||
|
protected gridCols = DEFAULT_GRID_COLS;
|
||||||
|
protected gridRows = DEFAULT_GRID_ROWS;
|
||||||
|
protected lifeSpeed = DEFAULT_TIME_PER_GENERATION;
|
||||||
|
protected readonly MIN_GRID_SIZE = MIN_GRID_SIZE;
|
||||||
|
protected readonly MAX_GRID_SIZE = MAX_GRID_SIZE;
|
||||||
|
protected readonly MAX_GRID_PX = MAX_GRID_PX;
|
||||||
|
|
||||||
|
readGrid: Node[][] = [];
|
||||||
|
writeGrid: Node[][] = [];
|
||||||
|
executionTime = 0;
|
||||||
|
currentScenario: Scenario = Scenario.SIMPLE;
|
||||||
|
readonly gameStarted = signal(false);
|
||||||
|
|
||||||
|
@ViewChild(GenericGridComponent) genericGridComponent!: GenericGridComponent;
|
||||||
|
|
||||||
|
ngAfterViewInit(): void {
|
||||||
|
if (this.genericGridComponent) {
|
||||||
|
this.genericGridComponent.initializationFn = this.initializeConwayGrid;
|
||||||
|
this.genericGridComponent.createNodeFn = this.createConwayNode;
|
||||||
|
this.genericGridComponent.getNodeColorFn = this.getConwayNodeColor;
|
||||||
|
this.genericGridComponent.applySelectionFn = this.applyConwaySelection;
|
||||||
|
this.genericGridComponent.gridRows = this.gridRows;
|
||||||
|
this.genericGridComponent.gridCols = this.gridCols;
|
||||||
|
this.genericGridComponent.minGridSize = this.MIN_GRID_SIZE;
|
||||||
|
this.genericGridComponent.maxGridSize = this.MAX_GRID_SIZE;
|
||||||
|
this.genericGridComponent.maxGridPx = this.MAX_GRID_PX;
|
||||||
|
this.genericGridComponent.initializeGrid();
|
||||||
|
}
|
||||||
|
this.gameStarted.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
generate(scene: Scenario): void {
|
||||||
|
this.currentScenario = scene;
|
||||||
|
this.genericGridComponent.initializationFn = this.initializeConwayGrid;
|
||||||
|
this.genericGridComponent.initializeGrid();
|
||||||
|
}
|
||||||
|
|
||||||
|
applySpeed(): void {
|
||||||
|
this.lifeSpeed = Math.min(Math.max(this.lifeSpeed, MIN_TIME_PER_GENERATION), MAX_TIME_PER_GENERATION);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Callbacks for GenericGridComponent ---
|
||||||
|
createConwayNode = (row: number, col: number): Node => {
|
||||||
|
return {
|
||||||
|
row,
|
||||||
|
col,
|
||||||
|
alive: false
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
getConwayNodeColor = (node: Node): string => {
|
||||||
|
return node.alive ? 'black' : 'lightgray';
|
||||||
|
};
|
||||||
|
|
||||||
|
applyConwaySelection = (pos: GridPos, grid: Node[][]): void => {
|
||||||
|
this.readGrid = grid; // Keep internal grid in sync
|
||||||
|
const node = grid[pos.row][pos.col];
|
||||||
|
node.alive = !node.alive; // Toggle alive status
|
||||||
|
};
|
||||||
|
|
||||||
|
initializeConwayGrid = (grid: Node[][]): void => {
|
||||||
|
this.gameStarted.set(false);
|
||||||
|
this.readGrid = grid;
|
||||||
|
switch(this.currentScenario) {
|
||||||
|
case Scenario.RANDOM: this.setupRandomLives(); break;
|
||||||
|
case Scenario.SIMPLE: this.setupSimpleLive(); break;
|
||||||
|
case Scenario.PULSAR: this.setupPulsar(); break;
|
||||||
|
case Scenario.GUN: this.setupGliderGun(); break;
|
||||||
|
}
|
||||||
|
this.writeGrid = structuredClone(this.readGrid);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Conway-specific logic (kept local) ---
|
||||||
|
setupRandomLives(): void {
|
||||||
|
for (let row = 0; row < this.gridRows; row++) {
|
||||||
|
for (let col = 0; col < this.gridCols; col++) {
|
||||||
|
this.readGrid[row][col].alive = Math.random() <= LIVE_SPAWN_PROBABILITY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setupSimpleLive(): void {
|
||||||
|
this.readGrid[3][4].alive = true;
|
||||||
|
this.readGrid[4][5].alive = true;
|
||||||
|
this.readGrid[5][3].alive = true;
|
||||||
|
this.readGrid[5][4].alive = true;
|
||||||
|
this.readGrid[5][5].alive = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
setupPulsar(): void {
|
||||||
|
const centerRow = Math.floor(this.gridRows / 2);
|
||||||
|
const centerCol = Math.floor(this.gridCols / 2);
|
||||||
|
|
||||||
|
const rows = [-6, -1, 1, 6];
|
||||||
|
const offsets = [2, 3, 4];
|
||||||
|
|
||||||
|
rows.forEach(r => {
|
||||||
|
offsets.forEach(c => {
|
||||||
|
this.setAlive(centerRow + r, centerCol + c);
|
||||||
|
this.setAlive(centerRow + r, centerCol - c);
|
||||||
|
this.setAlive(centerRow + c, centerCol + r);
|
||||||
|
this.setAlive(centerRow - c, centerCol + r);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setupGliderGun(): void {
|
||||||
|
const r = 5;
|
||||||
|
const c = 5;
|
||||||
|
|
||||||
|
const dots = [
|
||||||
|
[r+4, c], [r+4, c+1], [r+5, c], [r+5, c+1], // Block links
|
||||||
|
[r+4, c+10], [r+5, c+10], [r+6, c+10], [r+3, c+11], [r+7, c+11],
|
||||||
|
[r+2, c+12], [r+8, c+12], [r+2, c+13], [r+8, c+13], [r+5, c+14],
|
||||||
|
[r+3, c+15], [r+7, c+15], [r+4, c+16], [r+5, c+16], [r+6, c+16], [r+5, c+17],
|
||||||
|
[r+2, c+20], [r+3, c+20], [r+4, c+20], [r+2, c+21], [r+3, c+21], [r+4, c+21],
|
||||||
|
[r+1, c+22], [r+5, c+22], [r+0, c+24], [r+1, c+24], [r+5, c+24], [r+6, c+24],
|
||||||
|
[r+2, c+34], [r+3, c+34], [r+2, c+35], [r+3, c+35]
|
||||||
|
];
|
||||||
|
|
||||||
|
dots.forEach(([row, col]) => this.setAlive(row, col));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- The rules of the game
|
||||||
|
|
||||||
|
pauseGame(): void {
|
||||||
|
this.gameStarted.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async startGame(): Promise<void> {
|
||||||
|
this.gameStarted.set(true);
|
||||||
|
while (this.gameStarted()){
|
||||||
|
const startTime = performance.now();
|
||||||
|
let lifeIsDead = true;
|
||||||
|
for (let row = 0; row < this.gridRows; row++) {
|
||||||
|
for (let col = 0; col < this.gridCols; col++) {
|
||||||
|
lifeIsDead = this.checkLifeRules(row, col, this.writeGrid) && lifeIsDead;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.swapGrids();
|
||||||
|
const endTime = performance.now();
|
||||||
|
this.executionTime = Number.parseFloat((endTime - startTime).toFixed(4));
|
||||||
|
if (lifeIsDead){
|
||||||
|
this.gameStarted.set(false);
|
||||||
|
}
|
||||||
|
const delta = Math.max(this.lifeSpeed - this.executionTime, 0);
|
||||||
|
await this.delay(delta);
|
||||||
|
}
|
||||||
|
this.executionTime = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private checkLifeRules(row: number, col: number, writeGrid: Node[][]): boolean {
|
||||||
|
const currentCell = this.readGrid[row][col];
|
||||||
|
const aliveNeighbors = this.howManyNeighborsAreLiving(row, col);
|
||||||
|
const oldLifeState = currentCell.alive;
|
||||||
|
|
||||||
|
const nextStateAlive = (currentCell.alive && (aliveNeighbors === 2 || aliveNeighbors === 3)) || (!currentCell.alive && aliveNeighbors === 3);
|
||||||
|
writeGrid[row][col].alive = nextStateAlive;
|
||||||
|
|
||||||
|
//only if at least one cell changes the game is still alive
|
||||||
|
return (nextStateAlive == oldLifeState);
|
||||||
|
}
|
||||||
|
|
||||||
|
private swapGrids() {
|
||||||
|
const tmp = this.readGrid;
|
||||||
|
this.readGrid = this.writeGrid;
|
||||||
|
this.writeGrid = tmp;
|
||||||
|
if (this.genericGridComponent) {
|
||||||
|
this.genericGridComponent.grid = this.readGrid;
|
||||||
|
this.genericGridComponent.drawGrid();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private howManyNeighborsAreLiving(row: number, col: number): number {
|
||||||
|
|
||||||
|
let aliveNeighborCount = 0;
|
||||||
|
const minRow = Math.max(row - 1, 0);
|
||||||
|
const minCol = Math.max(col - 1, 0);
|
||||||
|
const maxRow = Math.min(row + 1, this.gridRows - 1);
|
||||||
|
const maxCol = Math.min(col + 1, this.gridCols - 1);
|
||||||
|
|
||||||
|
for (let nRow = minRow; nRow <= maxRow; nRow++) {
|
||||||
|
for (let nCol = minCol; nCol <= maxCol; nCol++) {
|
||||||
|
if (nRow == row && nCol == col) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (this.readGrid[nRow][nCol].alive) {
|
||||||
|
aliveNeighborCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return aliveNeighborCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Other methods ---
|
||||||
|
protected readonly Scenario = Scenario;
|
||||||
|
protected readonly MIN_TIME_PER_GENERATION = MIN_TIME_PER_GENERATION;
|
||||||
|
protected readonly MAX_TIME_PER_GENERATION = MAX_TIME_PER_GENERATION;
|
||||||
|
|
||||||
|
delay(ms: number) {
|
||||||
|
return new Promise( resolve => setTimeout(resolve, ms) );
|
||||||
|
}
|
||||||
|
|
||||||
|
private setAlive(r: number, c: number): void {
|
||||||
|
if (r >= 0 && r < this.gridRows && c >= 0 && c < this.gridCols) {
|
||||||
|
this.readGrid[r][c].alive = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
26
src/app/pages/algorithms/conway-gol/conway-gol.models.ts
Normal file
26
src/app/pages/algorithms/conway-gol/conway-gol.models.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
export interface Node {
|
||||||
|
row: number;
|
||||||
|
col: number;
|
||||||
|
alive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum Scenario {
|
||||||
|
RANDOM = 0,
|
||||||
|
EMPTY = 1,
|
||||||
|
SIMPLE = 2,
|
||||||
|
PULSAR = 3,
|
||||||
|
GUN = 4
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_GRID_ROWS = 50;
|
||||||
|
export const DEFAULT_GRID_COLS = 50;
|
||||||
|
|
||||||
|
export const MIN_GRID_SIZE = 20;
|
||||||
|
export const MAX_GRID_SIZE = 200;
|
||||||
|
export const DEFAULT_TIME_PER_GENERATION = 30;
|
||||||
|
|
||||||
|
export const MIN_TIME_PER_GENERATION = 20;
|
||||||
|
export const MAX_TIME_PER_GENERATION = 200;
|
||||||
|
|
||||||
|
export const MAX_GRID_PX = 1000;
|
||||||
|
export const LIVE_SPAWN_PROBABILITY = 0.37;
|
||||||
@@ -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;
|
||||||
45
src/app/pages/algorithms/fractal/fractal.component.html
Normal file
45
src/app/pages/algorithms/fractal/fractal.component.html
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<mat-card class="w-full max-w-[1920px] p-5">
|
||||||
|
<mat-card-header>
|
||||||
|
<mat-card-title>{{ 'FRACTAL.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]">
|
||||||
|
<mat-form-field class="w-[200px]" appearance="fill">
|
||||||
|
<mat-label>{{ 'FRACTAL.ALGORITHM' | translate }}</mat-label>
|
||||||
|
<mat-select [value]="'Mandelbrot'" (selectionChange)="onAlgorithmChange($event.value)">
|
||||||
|
<mat-option value="Mandelbrot">Mandelbrot</mat-option>
|
||||||
|
<mat-option value="Julia">Julia</mat-option>
|
||||||
|
<mat-option value="Burning Ship">Burning Ship</mat-option>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<button mat-raised-button color="primary" (click)="onReset()">
|
||||||
|
<mat-icon>undo</mat-icon> {{ 'FRACTAL.RESET' | translate }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; align-items: center; gap: 10px;">
|
||||||
|
<mat-icon>zoom_out</mat-icon>
|
||||||
|
<ngx-slider [(value)]="sliderValue" [options]="options" (valueChange)="onSliderChange($event)" ></ngx-slider>
|
||||||
|
<mat-icon>zoom_in</mat-icon>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<app-babylon-canvas
|
||||||
|
[config]="renderConfig"
|
||||||
|
[renderCallback]="onRender"
|
||||||
|
(sceneReady)="onSceneReady($event)"
|
||||||
|
/>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
265
src/app/pages/algorithms/fractal/fractal.component.ts
Normal file
265
src/app/pages/algorithms/fractal/fractal.component.ts
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
import {Component, OnInit, signal} from '@angular/core';
|
||||||
|
import {Information} from '../information/information';
|
||||||
|
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
|
||||||
|
import {TranslatePipe} from '@ngx-translate/core';
|
||||||
|
import {MatFormField, MatLabel} from '@angular/material/input';
|
||||||
|
import {MatOption} from '@angular/material/core';
|
||||||
|
import {MatSelect} from '@angular/material/select';
|
||||||
|
import {AlgorithmInformation} from '../information/information.models';
|
||||||
|
import {UrlConstants} from '../../../constants/UrlConstants';
|
||||||
|
import {FormsModule} from '@angular/forms';
|
||||||
|
import {BabylonCanvas, RenderCallback, RenderConfig, SceneEventData} from '../../../shared/components/render-canvas/babylon-canvas.component';
|
||||||
|
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({
|
||||||
|
selector: 'app-fractal',
|
||||||
|
imports: [
|
||||||
|
Information,
|
||||||
|
MatCard,
|
||||||
|
MatCardContent,
|
||||||
|
MatCardHeader,
|
||||||
|
MatCardTitle,
|
||||||
|
TranslatePipe,
|
||||||
|
MatFormField,
|
||||||
|
MatLabel,
|
||||||
|
MatOption,
|
||||||
|
MatSelect,
|
||||||
|
FormsModule,
|
||||||
|
BabylonCanvas,
|
||||||
|
MatButton,
|
||||||
|
MatIcon,
|
||||||
|
NgxSliderModule
|
||||||
|
],
|
||||||
|
templateUrl: './fractal.component.html',
|
||||||
|
styleUrl: './fractal.component.scss',
|
||||||
|
})
|
||||||
|
export class FractalComponent implements OnInit {
|
||||||
|
algoInformation: AlgorithmInformation = {
|
||||||
|
title: 'FRACTAL.EXPLANATION.TITLE',
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
name: 'Mandelbrot',
|
||||||
|
description: 'FRACTAL.EXPLANATION.MANDELBROT_EXPLANATION',
|
||||||
|
link: UrlConstants.MANDELBROT_WIKI
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Julia',
|
||||||
|
description: 'FRACTAL.EXPLANATION.JULIA_EXPLANATION',
|
||||||
|
link: UrlConstants.JULIA_WIKI
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Burning Ship',
|
||||||
|
description: 'FRACTAL.EXPLANATION.BURNING_SHIP_EXPLANATION',
|
||||||
|
link: UrlConstants.BURNING_SHIP_WIKI
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Newton',
|
||||||
|
description: 'FRACTAL.EXPLANATION.NEWTON_EXPLANATION',
|
||||||
|
link: UrlConstants.NEWTON_FRACTAL_WIKI
|
||||||
|
}
|
||||||
|
],
|
||||||
|
disclaimer: 'FRACTAL.EXPLANATION.DISCLAIMER',
|
||||||
|
disclaimerBottom: 'FRACTAL.EXPLANATION.DISCLAIMER_BOTTOM',
|
||||||
|
disclaimerListEntry: [
|
||||||
|
'FRACTAL.EXPLANATION.DISCLAIMER_1',
|
||||||
|
'FRACTAL.EXPLANATION.DISCLAIMER_2',
|
||||||
|
'FRACTAL.EXPLANATION.DISCLAIMER_3',
|
||||||
|
'FRACTAL.EXPLANATION.DISCLAIMER_4'
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
renderConfig: RenderConfig = {
|
||||||
|
mode: '2D',
|
||||||
|
initialViewSize: 100,
|
||||||
|
vertexShader: FRACTAL2D_VERTEX,
|
||||||
|
fragmentShader: FRACTAL2D_FRAGMENT,
|
||||||
|
uniformNames: ["worldViewProjection", "time", "targetPosition","center", "zoom", "maxIterations", "algorithm", "colorScheme", "juliaC"]
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- State ---
|
||||||
|
readonly minZoomValue = 0.2;
|
||||||
|
readonly maxZoomValue = 64000;
|
||||||
|
private isDragging = false;
|
||||||
|
private dragStartPoint: { x: number, y: number } | null = null;
|
||||||
|
|
||||||
|
selectedAlgorithm = 0;
|
||||||
|
selectedColorScheme = 0;
|
||||||
|
|
||||||
|
sliderValue = signal(0);
|
||||||
|
options: Options = {
|
||||||
|
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;
|
||||||
|
|
||||||
|
juliaReal = -0.7;
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
onAlgorithmChange(algoName: string): void {
|
||||||
|
switch(algoName) {
|
||||||
|
case 'Mandelbrot': this.selectedAlgorithm = 0; break;
|
||||||
|
case 'Julia': this.selectedAlgorithm = 1; break;
|
||||||
|
case 'Burning Ship': this.selectedAlgorithm = 2; break;
|
||||||
|
case 'Newton': this.selectedAlgorithm = 3; break;
|
||||||
|
}
|
||||||
|
this.onReset()
|
||||||
|
}
|
||||||
|
|
||||||
|
onColorChanged(schemeName: string): void {
|
||||||
|
switch(schemeName) {
|
||||||
|
case 'Blue-Gold': this.selectedColorScheme = 0; break;
|
||||||
|
case 'Greyscale': this.selectedColorScheme = 1; break;
|
||||||
|
case 'Fire': this.selectedColorScheme = 2; break;
|
||||||
|
case 'Rainbow': this.selectedColorScheme = 3; break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onReset(): 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.dragStartPoint = { x: info.event.clientX, y: info.event.clientY };
|
||||||
|
}
|
||||||
|
|
||||||
|
private onPointerUp(): void {
|
||||||
|
this.isDragging = false;
|
||||||
|
this.dragStartPoint = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private onPointerMove(info: PointerInfo): void {
|
||||||
|
if (!this.isDragging || !this.dragStartPoint) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = info.event as PointerEvent;
|
||||||
|
|
||||||
|
const deltaX = event.clientX - this.dragStartPoint.x;
|
||||||
|
const deltaY = event.clientY - this.dragStartPoint.y;
|
||||||
|
|
||||||
|
const element = event.target as HTMLElement;
|
||||||
|
const height = element.clientHeight;
|
||||||
|
|
||||||
|
const scaleFactor = 1 / (height * this.zoom);
|
||||||
|
|
||||||
|
this.offsetX += deltaX * scaleFactor;
|
||||||
|
this.offsetY += deltaY * scaleFactor;
|
||||||
|
|
||||||
|
this.dragStartPoint = { x: event.clientX, y: event.clientY };
|
||||||
|
}
|
||||||
|
|
||||||
|
private onPointerWheel(info: PointerInfo): void {
|
||||||
|
const event = info.event as WheelEvent;
|
||||||
|
|
||||||
|
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;
|
||||||
|
if (event.deltaY < 0) {
|
||||||
|
this.zoom *= zoomFactor;
|
||||||
|
} else {
|
||||||
|
this.zoom /= zoomFactor;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.zoom = Math.max(Math.min(this.zoom, this.maxZoomValue), this.minZoomValue);
|
||||||
|
this.sliderValue.set(this.zoom);
|
||||||
|
const optimalIterations = this.getIterationsForZoom(this.zoom);
|
||||||
|
this.maxIterations = Math.min(optimalIterations, 3000);
|
||||||
|
|
||||||
|
this.offsetX = mouseXWorld - mouseXView / this.zoom;
|
||||||
|
this.offsetY = mouseYWorld - mouseYView / this.zoom;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSliderChange(newValue: number): void {
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
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);
|
||||||
|
}
|
||||||
|
`;
|
||||||
19
src/app/pages/algorithms/fractal3d/fractal3d.component.html
Normal file
19
src/app/pages/algorithms/fractal3d/fractal3d.component.html
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<mat-card class="w-full max-w-[1920px] p-5">
|
||||||
|
<mat-card-header>
|
||||||
|
<mat-card-title>{{ 'FRACTAL3D.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 matButton="filled" (click)="onFractalTypeChange(0)">{{ 'FRACTAL3D.MANDELBULB' | translate }}</button>
|
||||||
|
<button matButton="filled" (click)="onFractalTypeChange(1)">{{ 'FRACTAL3D.MANDELBOX' | translate }}</button>
|
||||||
|
<button matButton="filled" (click)="onFractalTypeChange(2)">{{ 'FRACTAL3D.JULIA' | translate }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<app-babylon-canvas
|
||||||
|
[config]="fractalConfig"
|
||||||
|
[renderCallback]="onRender"
|
||||||
|
/>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
85
src/app/pages/algorithms/fractal3d/fractal3d.component.ts
Normal file
85
src/app/pages/algorithms/fractal3d/fractal3d.component.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import {Component} from '@angular/core';
|
||||||
|
import {ArcRotateCamera, Camera, ShaderMaterial} from '@babylonjs/core';
|
||||||
|
import {MANDELBULB_FRAGMENT, MANDELBULB_VERTEX} from './fractal3d.shader';
|
||||||
|
import {Information} from '../information/information';
|
||||||
|
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
|
||||||
|
import {TranslatePipe} from '@ngx-translate/core';
|
||||||
|
import {AlgorithmInformation} from '../information/information.models';
|
||||||
|
import {UrlConstants} from '../../../constants/UrlConstants';
|
||||||
|
import {MatButton} from '@angular/material/button';
|
||||||
|
import {BabylonCanvas, RenderCallback, RenderConfig} from '../../../shared/components/render-canvas/babylon-canvas.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-fractal3d',
|
||||||
|
imports: [
|
||||||
|
Information,
|
||||||
|
MatCard,
|
||||||
|
MatCardContent,
|
||||||
|
MatCardHeader,
|
||||||
|
MatCardTitle,
|
||||||
|
TranslatePipe,
|
||||||
|
MatButton,
|
||||||
|
BabylonCanvas
|
||||||
|
],
|
||||||
|
templateUrl: './fractal3d.component.html',
|
||||||
|
styleUrl: './fractal3d.component.scss',
|
||||||
|
})
|
||||||
|
export class Fractal3dComponent {
|
||||||
|
|
||||||
|
algoInformation: AlgorithmInformation = {
|
||||||
|
title: 'FRACTAL3D.EXPLANATION.TITLE',
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
name: 'Mandel-Bulb',
|
||||||
|
description: 'FRACTAL3D.EXPLANATION.MANDELBULB_EXPLANATION',
|
||||||
|
link: UrlConstants.MANDELBULB_WIKI
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Mandelbox',
|
||||||
|
description: 'FRACTAL3D.EXPLANATION.MANDELBOX_EXPLANATION',
|
||||||
|
link: UrlConstants.MANDELBOX_WIKI
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Julia-Bulb',
|
||||||
|
description: 'FRACTAL3D.EXPLANATION.JULIA_EXPLANATION',
|
||||||
|
link: UrlConstants.JULIA3D_WIKI
|
||||||
|
}
|
||||||
|
],
|
||||||
|
disclaimer: 'FRACTAL3D.EXPLANATION.DISCLAIMER',
|
||||||
|
disclaimerBottom: '',
|
||||||
|
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 time = 0;
|
||||||
|
private oldType = 0;
|
||||||
|
public currentFractalType = 0;
|
||||||
|
|
||||||
|
|
||||||
|
onRender: RenderCallback = (material: ShaderMaterial, camera: Camera) => {
|
||||||
|
this.time += 0.005;
|
||||||
|
|
||||||
|
if (this.oldType != this.currentFractalType && camera instanceof ArcRotateCamera) {
|
||||||
|
this.oldType = this.currentFractalType;
|
||||||
|
camera.radius = this.currentFractalType == 1 ? 15 : 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
material.setFloat("time", this.time);
|
||||||
|
material.setFloat("power", this.fractalPower);
|
||||||
|
material.setInt("fractalType", this.currentFractalType);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
onFractalTypeChange(type: number): void {
|
||||||
|
this.currentFractalType = type;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
220
src/app/pages/algorithms/fractal3d/fractal3d.shader.ts
Normal file
220
src/app/pages/algorithms/fractal3d/fractal3d.shader.ts
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
export const MANDELBULB_VERTEX = /* glsl */`
|
||||||
|
precision highp float;
|
||||||
|
attribute vec3 position;
|
||||||
|
attribute vec2 uv;
|
||||||
|
varying vec2 vUV;
|
||||||
|
|
||||||
|
void main(void) {
|
||||||
|
gl_Position = vec4(position, 1.0);
|
||||||
|
vUV = uv;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const MANDELBULB_FRAGMENT = /* glsl */`
|
||||||
|
precision highp float;
|
||||||
|
|
||||||
|
uniform float time;
|
||||||
|
uniform vec2 resolution;
|
||||||
|
uniform vec3 cameraPosition;
|
||||||
|
uniform vec3 targetPosition;
|
||||||
|
uniform float power;
|
||||||
|
uniform int fractalType; // 0 = Bulb, 1 = Box, 2 = Julia
|
||||||
|
|
||||||
|
// --- Palettes ---
|
||||||
|
vec3 palette( in float t, in vec3 a, in vec3 b, in vec3 c, in vec3 d ) {
|
||||||
|
return a + b*cos( 6.28318*(c*t+d) );
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global trap for coloring
|
||||||
|
float minTrap = 1000.0;
|
||||||
|
|
||||||
|
// --- Shape 1: Mandelbulb ---
|
||||||
|
float mapMandelbulb(vec3 pos, out float trap) {
|
||||||
|
vec3 z = pos;
|
||||||
|
float dr = 1.0;
|
||||||
|
float r = 0.0;
|
||||||
|
trap = 1000.0;
|
||||||
|
|
||||||
|
for (int i = 0; i < 8; i++) {
|
||||||
|
r = length(z);
|
||||||
|
if (r > 100.0) break;
|
||||||
|
trap = min(trap, r);
|
||||||
|
|
||||||
|
float theta = acos(z.y / r);
|
||||||
|
float phi = atan(z.z, z.x);
|
||||||
|
dr = pow(r, power - 1.0) * power * dr + 1.0;
|
||||||
|
float zr = pow(r, power);
|
||||||
|
theta = theta * power;
|
||||||
|
phi = phi * power;
|
||||||
|
z = zr * vec3(sin(theta) * cos(phi), cos(theta), sin(theta) * sin(phi));
|
||||||
|
z += pos;
|
||||||
|
}
|
||||||
|
return 0.5 * log(r) * r / dr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Shape 2: Mandelbox ---
|
||||||
|
float mapMandelbox(vec3 pos, out float trap) {
|
||||||
|
vec3 z = pos;
|
||||||
|
float dr = 1.0;
|
||||||
|
float scale = 2.8; // Fixed scale for good look
|
||||||
|
trap = 1000.0;
|
||||||
|
|
||||||
|
for (int i = 0; i < 15; i++) {
|
||||||
|
// Box fold
|
||||||
|
z = clamp(z, -1.0, 1.0) * 2.0 - z;
|
||||||
|
|
||||||
|
// Sphere fold
|
||||||
|
float r2 = dot(z, z);
|
||||||
|
trap = min(trap, r2); // Trap based on sphere fold
|
||||||
|
|
||||||
|
if (r2 < 0.25) {
|
||||||
|
z = z * 4.0;
|
||||||
|
dr = dr * 4.0;
|
||||||
|
} else if (r2 < 1.0) {
|
||||||
|
z = z / r2;
|
||||||
|
dr = dr / r2;
|
||||||
|
}
|
||||||
|
|
||||||
|
z = z * scale + pos;
|
||||||
|
dr = dr * abs(scale) + 1.0;
|
||||||
|
}
|
||||||
|
return (length(z) - abs(scale - 1.0)) / dr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Shape 3: Julia Bulb ---
|
||||||
|
float mapJulia(vec3 pos, out float trap) {
|
||||||
|
vec3 z = pos;
|
||||||
|
float dr = 1.0;
|
||||||
|
float r = 0.0;
|
||||||
|
trap = 1000.0;
|
||||||
|
|
||||||
|
// Constant C for Julia set (animating slightly makes it alive)
|
||||||
|
vec3 c = vec3(0.35, 0.45, -0.1) + vec3(sin(time*0.1)*0.2);
|
||||||
|
|
||||||
|
for (int i = 0; i < 8; i++) {
|
||||||
|
r = length(z);
|
||||||
|
if (r > 100.0) break; // Higher escape radius for Julia
|
||||||
|
trap = min(trap, r);
|
||||||
|
|
||||||
|
float theta = acos(z.y / r);
|
||||||
|
float phi = atan(z.z, z.x);
|
||||||
|
dr = pow(r, power - 1.0) * power * dr + 1.0;
|
||||||
|
float zr = pow(r, power);
|
||||||
|
theta = theta * power;
|
||||||
|
phi = phi * power;
|
||||||
|
z = zr * vec3(sin(theta) * cos(phi), cos(theta), sin(theta) * sin(phi));
|
||||||
|
z += c; // Add C instead of pos
|
||||||
|
}
|
||||||
|
return 0.5 * log(r) * r / dr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Main Map Dispatcher ---
|
||||||
|
float map(vec3 pos) {
|
||||||
|
float d = 0.0;
|
||||||
|
float currentTrap = 0.0;
|
||||||
|
|
||||||
|
if (fractalType == 1) {
|
||||||
|
d = mapMandelbox(pos, currentTrap);
|
||||||
|
} else if (fractalType == 2) {
|
||||||
|
d = mapJulia(pos, currentTrap);
|
||||||
|
} else {
|
||||||
|
d = mapMandelbulb(pos, currentTrap);
|
||||||
|
}
|
||||||
|
|
||||||
|
minTrap = currentTrap; // Update global
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Raymarching ---
|
||||||
|
bool intersectSphere(vec3 ro, vec3 rd, vec3 c, float r, out float t0, out float t1) {
|
||||||
|
vec3 oc = ro - c;
|
||||||
|
float b = dot(oc, rd);
|
||||||
|
float c2 = dot(oc, oc) - r * r;
|
||||||
|
float h = b*b - c2;
|
||||||
|
if (h < 0.0) return false;
|
||||||
|
h = sqrt(h);
|
||||||
|
t0 = -b - h;
|
||||||
|
t1 = -b + h;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
float raymarch(vec3 ro, vec3 rd) {
|
||||||
|
// Bounding sphere around fractal center (here: origin)
|
||||||
|
vec3 center = vec3(0.0);
|
||||||
|
float radius = 6.0;
|
||||||
|
|
||||||
|
float tEnter, tExit;
|
||||||
|
if (!intersectSphere(ro, rd, center, radius, tEnter, tExit)) {
|
||||||
|
return -1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
float t = max(tEnter, 0.0);
|
||||||
|
float tMax = tExit;
|
||||||
|
|
||||||
|
for (int i = 0; i < 128; i++) {
|
||||||
|
vec3 pos = ro + t * rd;
|
||||||
|
float d = map(pos);
|
||||||
|
|
||||||
|
// distance-based epsilon is more stable for zoom-out
|
||||||
|
float eps = max(0.001, 0.0005 * t);
|
||||||
|
|
||||||
|
if (d < eps) return t;
|
||||||
|
t += d * 0.8; // safety factor against overshoot
|
||||||
|
if (t > tMax) break;
|
||||||
|
}
|
||||||
|
return -1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
vec3 getNormal(vec3 p) {
|
||||||
|
float d = map(p);
|
||||||
|
vec2 e = vec2(0.001, 0.0);
|
||||||
|
return normalize(vec3(
|
||||||
|
d - map(p - e.xyy),
|
||||||
|
d - map(p - e.yxy),
|
||||||
|
d - map(p - e.yyx)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
void main(void) {
|
||||||
|
vec2 uv = (gl_FragCoord.xy - 0.5 * resolution.xy) / resolution.y;
|
||||||
|
vec3 ro = cameraPosition;
|
||||||
|
vec3 ta = targetPosition;
|
||||||
|
|
||||||
|
vec3 fwd = normalize(ta - ro);
|
||||||
|
vec3 right = normalize(cross(vec3(0.0, 1.0, 0.0), fwd));
|
||||||
|
vec3 up = normalize(cross(fwd, right));
|
||||||
|
vec3 rd = normalize(fwd + uv.x * right + uv.y * up);
|
||||||
|
|
||||||
|
vec3 color = vec3(0.1);
|
||||||
|
|
||||||
|
float t = raymarch(ro, rd);
|
||||||
|
|
||||||
|
if(t > 0.0) {
|
||||||
|
vec3 pos = ro + t * rd;
|
||||||
|
vec3 nor = getNormal(pos);
|
||||||
|
|
||||||
|
// Different colors for different shapes
|
||||||
|
vec3 colParamsA = vec3(0.5, 0.5, 0.5);
|
||||||
|
vec3 colParamsB = vec3(0.5, 0.5, 0.5);
|
||||||
|
vec3 colParamsC = vec3(1.0, 1.0, 1.0);
|
||||||
|
vec3 colParamsD = vec3(0.80, 0.90, 0.30);
|
||||||
|
|
||||||
|
if (fractalType == 1) { // Box: Sci-Fi Blue/Grey
|
||||||
|
colParamsD = vec3(0.0, 0.1, 0.2);
|
||||||
|
}
|
||||||
|
if (fractalType == 2) { // Julia: Alien Green/Purple
|
||||||
|
colParamsD = vec3(0.8, 0.0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
vec3 materialColor = palette(minTrap, colParamsA, colParamsB, colParamsC, colParamsD);
|
||||||
|
|
||||||
|
float camLight = max(0.0, dot(nor, -rd));
|
||||||
|
float ambient = 0.4;
|
||||||
|
vec3 lighting = vec3(1.0) * (camLight * 0.7 + ambient);
|
||||||
|
color = materialColor * lighting;
|
||||||
|
}
|
||||||
|
|
||||||
|
color = pow(color, vec3(0.8));
|
||||||
|
gl_FragColor = vec4(color, 1.0);
|
||||||
|
}
|
||||||
|
`;
|
||||||
42
src/app/pages/algorithms/information/information.html
Normal file
42
src/app/pages/algorithms/information/information.html
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<div class="mb-4 py-3 px-4 border border-[#ddd] rounded-lg">
|
||||||
|
<h3 class="m-0 mb-2">{{ algorithmInformation.title | translate }}</h3>
|
||||||
|
|
||||||
|
@if(algorithmInformation.entries && algorithmInformation.entries.length > 0){
|
||||||
|
@for (algo of algorithmInformation.entries; track algo)
|
||||||
|
{
|
||||||
|
<p class="my-2">
|
||||||
|
<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>
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (algorithmInformation.disclaimer !== '')
|
||||||
|
{
|
||||||
|
<p class="my-2">
|
||||||
|
<strong>{{ 'ALGORITHM.NOTE' | translate}}</strong> {{ algorithmInformation.disclaimer | translate}}
|
||||||
|
</p>
|
||||||
|
@if (algorithmInformation.disclaimerListEntry && algorithmInformation.disclaimerListEntry.length > 0)
|
||||||
|
{
|
||||||
|
<ul>
|
||||||
|
@for (entry of algorithmInformation.disclaimerListEntry; track entry)
|
||||||
|
{
|
||||||
|
<li>{{ entry | translate}}</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
@if (algorithmInformation.disclaimerBottom !== '')
|
||||||
|
{
|
||||||
|
<p>
|
||||||
|
{{ algorithmInformation.disclaimerBottom | translate}}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
14
src/app/pages/algorithms/information/information.models.ts
Normal file
14
src/app/pages/algorithms/information/information.models.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
export interface AlgorithmInformation {
|
||||||
|
title: string;
|
||||||
|
entries: AlgorithmEntry[];
|
||||||
|
disclaimer: string;
|
||||||
|
disclaimerBottom: string;
|
||||||
|
disclaimerListEntry: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AlgorithmEntry {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
link: string;
|
||||||
|
translateName?: boolean;
|
||||||
|
}
|
||||||
16
src/app/pages/algorithms/information/information.ts
Normal file
16
src/app/pages/algorithms/information/information.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import {Component, Input} from '@angular/core';
|
||||||
|
import {TranslatePipe} from "@ngx-translate/core";
|
||||||
|
import {AlgorithmInformation} from './information.models';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-information',
|
||||||
|
imports: [
|
||||||
|
TranslatePipe
|
||||||
|
],
|
||||||
|
templateUrl: './information.html',
|
||||||
|
styleUrl: './information.scss',
|
||||||
|
})
|
||||||
|
export class Information {
|
||||||
|
|
||||||
|
@Input({ required: true }) algorithmInformation!: AlgorithmInformation;
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<mat-card class="w-full max-w-[1920px] p-5">
|
||||||
|
<mat-card-header>
|
||||||
|
<mat-card-title>{{ 'LABYRINTH.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 matButton="filled" [disabled]="isAnimationRunning()" (click)="visualize('dijkstra')">{{ 'PATHFINDING.DIJKSTRA' | translate }}</button>
|
||||||
|
<button matButton="filled" [disabled]="isAnimationRunning()" (click)="visualize('astar')">{{ 'PATHFINDING.ASTAR' | translate }}</button>
|
||||||
|
</div>
|
||||||
|
<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(false)">{{ 'LABYRINTH.KRUSKAL' | translate }}</button>
|
||||||
|
</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>
|
||||||
|
<app-generic-grid
|
||||||
|
[gridRows]="gridRows"
|
||||||
|
[gridCols]="gridCols"
|
||||||
|
[minGridSize]="MIN_GRID_SIZE"
|
||||||
|
[maxGridSize]="MAX_GRID_SIZE"
|
||||||
|
[maxGridPx]="MAX_GRID_PX"
|
||||||
|
[createNodeFn]="createMazeNode"
|
||||||
|
[getNodeColorFn]="getMazeColor"
|
||||||
|
[applySelectionFn]="applyNoSelection"
|
||||||
|
[backgroundColor]="'lightgray'"
|
||||||
|
(gridChange)="grid = $event"
|
||||||
|
></app-generic-grid>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
@@ -0,0 +1,458 @@
|
|||||||
|
import {AfterViewInit, Component, inject, signal, ViewChild} from '@angular/core';
|
||||||
|
import {Information} from '../../information/information';
|
||||||
|
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
|
||||||
|
import {TranslatePipe} from '@ngx-translate/core';
|
||||||
|
import {GenericGridComponent, GridPos} from '../../../../shared/components/generic-grid/generic-grid';
|
||||||
|
import {AlgorithmInformation} from '../../information/information.models';
|
||||||
|
import {UrlConstants} from '../../../../constants/UrlConstants';
|
||||||
|
import {Node} from '../pathfinding.models';
|
||||||
|
import {SharedFunctions} from '../../../../shared/SharedFunctions';
|
||||||
|
import {MatButton} from '@angular/material/button';
|
||||||
|
import {DecimalPipe} from '@angular/common';
|
||||||
|
import {PathfindingService} from '../service/pathfinding.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-labyrinth',
|
||||||
|
imports: [
|
||||||
|
Information,
|
||||||
|
MatCard,
|
||||||
|
MatCardContent,
|
||||||
|
MatCardHeader,
|
||||||
|
MatCardTitle,
|
||||||
|
TranslatePipe,
|
||||||
|
GenericGridComponent,
|
||||||
|
MatButton,
|
||||||
|
DecimalPipe
|
||||||
|
],
|
||||||
|
templateUrl: './labyrinth.component.html',
|
||||||
|
styleUrl: './labyrinth.component.scss',
|
||||||
|
})
|
||||||
|
export class LabyrinthComponent implements AfterViewInit {
|
||||||
|
|
||||||
|
protected readonly gridRows = 101;
|
||||||
|
protected readonly gridCols = 101;
|
||||||
|
protected readonly MAX_GRID_SIZE = 101;
|
||||||
|
protected readonly MAX_GRID_PX = 1000;
|
||||||
|
protected readonly MIN_GRID_SIZE = 101;
|
||||||
|
private readonly pathfindingService = inject(PathfindingService);
|
||||||
|
|
||||||
|
|
||||||
|
algoInformation: AlgorithmInformation = {
|
||||||
|
title: 'LABYRINTH.EXPLANATION.TITLE',
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
name: 'Prim’s',
|
||||||
|
description: 'LABYRINTH.EXPLANATION.PRIM_EXPLANATION',
|
||||||
|
link: UrlConstants.PRIMS_WIKI
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Kruskal’s',
|
||||||
|
description: 'LABYRINTH.EXPLANATION.KRUSKAL_EXPLANATION',
|
||||||
|
link: UrlConstants.KRUSKAL_WIKI
|
||||||
|
}
|
||||||
|
],
|
||||||
|
disclaimer: 'LABYRINTH.EXPLANATION.DISCLAIMER',
|
||||||
|
disclaimerBottom: '',
|
||||||
|
disclaimerListEntry: ['LABYRINTH.EXPLANATION.DISCLAIMER_1', 'LABYRINTH.EXPLANATION.DISCLAIMER_2', 'LABYRINTH.EXPLANATION.DISCLAIMER_3', 'LABYRINTH.EXPLANATION.DISCLAIMER_4']
|
||||||
|
};
|
||||||
|
|
||||||
|
@ViewChild(GenericGridComponent) genericGridComponent!: GenericGridComponent;
|
||||||
|
|
||||||
|
grid: Node[][] = [];
|
||||||
|
startNode: Node | null = null;
|
||||||
|
endNode: Node | null = null;
|
||||||
|
animationSpeed = 3;
|
||||||
|
mazeAnimationSpeed = 1;
|
||||||
|
pathLength = "0";
|
||||||
|
executionTime = 0;
|
||||||
|
|
||||||
|
private timeoutIds: number[] = [];
|
||||||
|
protected mazeNodesInOrder: Node[] = [];
|
||||||
|
readonly isAnimationRunning = signal(false);
|
||||||
|
|
||||||
|
ngAfterViewInit(): void {
|
||||||
|
if (this.genericGridComponent) {
|
||||||
|
this.genericGridComponent.initializationFn = this.initializeMazeGrid;
|
||||||
|
this.genericGridComponent.createNodeFn = this.createMazeNode;
|
||||||
|
this.genericGridComponent.getNodeColorFn = this.getMazeColor;
|
||||||
|
this.genericGridComponent.applySelectionFn = this.applyNoSelection;
|
||||||
|
this.genericGridComponent.gridRows = this.gridRows;
|
||||||
|
this.genericGridComponent.gridCols = this.gridCols;
|
||||||
|
this.genericGridComponent.minGridSize = this.MIN_GRID_SIZE;
|
||||||
|
this.genericGridComponent.maxGridSize = this.MAX_GRID_SIZE;
|
||||||
|
this.genericGridComponent.maxGridPx = 1000;
|
||||||
|
this.genericGridComponent.applyGridSize();
|
||||||
|
this.genericGridComponent.initializeGrid();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
initializeMazeGrid = (grid: Node[][]): void => {
|
||||||
|
this.grid = grid;
|
||||||
|
this.createRandom(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
createRandom(prim: boolean): void {
|
||||||
|
this.isAnimationRunning.set(true);
|
||||||
|
this.stopAnimations();
|
||||||
|
this.clearPath();
|
||||||
|
this.startNode = null;
|
||||||
|
this.endNode = null;
|
||||||
|
if (prim)
|
||||||
|
{
|
||||||
|
this.createPrimMaze();
|
||||||
|
}
|
||||||
|
else{
|
||||||
|
this.createKruskalMaze();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cleanupGrid();
|
||||||
|
this.genericGridComponent.drawGrid();
|
||||||
|
}
|
||||||
|
// ------- Kuskal -------
|
||||||
|
private createKruskalMaze(): void {
|
||||||
|
this.initKuskal();
|
||||||
|
this.mazeNodesInOrder = [];
|
||||||
|
const walls = this.findWallsWithADistanceOfTwoRooms();
|
||||||
|
SharedFunctions.shuffleArray(walls);
|
||||||
|
for (const wallInfo of walls) {
|
||||||
|
const { row, col, roomA, roomB } = wallInfo;
|
||||||
|
|
||||||
|
if (roomA.nodeData !== roomB.nodeData) {
|
||||||
|
const wallNode = this.grid[row][col];
|
||||||
|
wallNode.isWall = true;
|
||||||
|
this.mazeNodesInOrder.push(wallNode);
|
||||||
|
|
||||||
|
const oldId = roomB.nodeData;
|
||||||
|
const newId = roomA.nodeData;
|
||||||
|
|
||||||
|
this.mergeSets(oldId, newId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.setRandomStartAndEnd();
|
||||||
|
this.animateMazeGeneration();
|
||||||
|
}
|
||||||
|
|
||||||
|
private initKuskal() {
|
||||||
|
let roomId = 0;
|
||||||
|
|
||||||
|
for (let row = 0; row < this.gridRows; row++) {
|
||||||
|
for (let col = 0; col < this.gridCols; col++) {
|
||||||
|
const node = this.grid[row][col];
|
||||||
|
node.isStart = false;
|
||||||
|
node.isEnd = false;
|
||||||
|
|
||||||
|
if (row % 2 === 0 && col % 2 === 0) {
|
||||||
|
node.isWall = false;
|
||||||
|
node.nodeData = roomId++;
|
||||||
|
} else {
|
||||||
|
node.isWall = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mergeSets(oldId: number, newId: number): void {
|
||||||
|
for (let r = 0; r < this.gridRows; r += 2) {
|
||||||
|
for (let c = 0; c < this.gridCols; c += 2) {
|
||||||
|
if (this.grid[r][c].nodeData === oldId) {
|
||||||
|
this.grid[r][c].nodeData = newId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private findWallsWithADistanceOfTwoRooms() {
|
||||||
|
const walls: { row: number, col: number, roomA: Node, roomB: Node }[] = [];
|
||||||
|
for (let row = 0; row < this.gridRows; row++) {
|
||||||
|
for (let col = 0; col < this.gridCols; col++) {
|
||||||
|
if (row % 2 === 0 && col % 2 !== 0 && col > 0 && col < this.gridCols - 1) {
|
||||||
|
walls.push({
|
||||||
|
row, col,
|
||||||
|
roomA: this.grid[row][col - 1],
|
||||||
|
roomB: this.grid[row][col + 1]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (row % 2 !== 0 && col % 2 === 0 && row > 0 && row < this.gridRows - 1) {
|
||||||
|
walls.push({
|
||||||
|
row, col,
|
||||||
|
roomA: this.grid[row - 1][col],
|
||||||
|
roomB: this.grid[row + 1][col]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return walls;
|
||||||
|
}
|
||||||
|
|
||||||
|
private setRandomStartAndEnd(): void {
|
||||||
|
const lastRow = Math.floor((this.gridRows - 1) / 2) * 2;
|
||||||
|
const lastCol = Math.floor((this.gridCols - 1) / 2) * 2;
|
||||||
|
|
||||||
|
const corners = [
|
||||||
|
{ r: 0, c: 0 },
|
||||||
|
{ r: 0, c: lastCol },
|
||||||
|
{ r: lastRow, c: 0 },
|
||||||
|
{ r: lastRow, c: lastCol }
|
||||||
|
];
|
||||||
|
|
||||||
|
const startIndex = Math.floor(Math.random() * corners.length);
|
||||||
|
let endIndex = Math.floor(Math.random() * corners.length);
|
||||||
|
while (endIndex === startIndex) {
|
||||||
|
endIndex = Math.floor(Math.random() * corners.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = corners[startIndex];
|
||||||
|
const end = corners[endIndex];
|
||||||
|
|
||||||
|
this.startNode = this.grid[start.r][start.c];
|
||||||
|
this.startNode.isStart = true;
|
||||||
|
this.startNode.isWall = false;
|
||||||
|
|
||||||
|
this.endNode =this.grid[end.r][end.c];
|
||||||
|
this.endNode.isEnd = true;
|
||||||
|
this.endNode.isWall = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------- PRIM -------
|
||||||
|
private createPrimMaze(): void {
|
||||||
|
this.initPrim();
|
||||||
|
this.mazeNodesInOrder = [];
|
||||||
|
const frontier: Node[] = [];
|
||||||
|
|
||||||
|
const {startRow, startCol, startNode} = this.findStartNode();
|
||||||
|
this.mazeNodesInOrder.push(startNode);
|
||||||
|
|
||||||
|
this.getNeighborWalls(startRow, startCol, frontier);
|
||||||
|
|
||||||
|
while (frontier.length > 0) {
|
||||||
|
const randomIndex = SharedFunctions.randomIntFromInterval(0, frontier.length - 1);
|
||||||
|
const lastIndex = frontier.length - 1;
|
||||||
|
[frontier[randomIndex], frontier[lastIndex]] = [frontier[lastIndex], frontier[randomIndex]];
|
||||||
|
|
||||||
|
const wallNode = frontier.pop()!;
|
||||||
|
const target = wallNode.linkedNode;
|
||||||
|
|
||||||
|
if (!target || target.isVisited) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
wallNode.isVisited = true;
|
||||||
|
target.isVisited = true;
|
||||||
|
|
||||||
|
this.mazeNodesInOrder.push(wallNode, target);
|
||||||
|
|
||||||
|
this.getNeighborWalls(target.row, target.col, frontier);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setRandomStartAndEnd();
|
||||||
|
this.animateMazeGeneration();
|
||||||
|
}
|
||||||
|
|
||||||
|
private initPrim() {
|
||||||
|
for (let row = 0; row < this.grid.length; row++) {
|
||||||
|
for (let col = 0; col < this.grid[row].length; col++) {
|
||||||
|
this.grid[row][col].isWall = true;
|
||||||
|
this.grid[row][col].isStart = false;
|
||||||
|
this.grid[row][col].isEnd = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanupGrid() {
|
||||||
|
for (let row = 0; row < this.grid.length; row++) {
|
||||||
|
for (let col = 0; col < this.grid[row].length; col++) {
|
||||||
|
this.grid[row][col].isVisited = false;
|
||||||
|
this.grid[row][col].linkedNode = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private findStartNode() {
|
||||||
|
const startRow: number = SharedFunctions.randomEventIntFromInterval(this.gridRows - 1);
|
||||||
|
const startCol: number = SharedFunctions.randomEventIntFromInterval(this.gridCols - 1);
|
||||||
|
|
||||||
|
const startNode = this.grid[startRow][startCol];
|
||||||
|
startNode.isWall = false;
|
||||||
|
startNode.isVisited = true;
|
||||||
|
return {startRow, startCol, startNode};
|
||||||
|
}
|
||||||
|
|
||||||
|
visualize(algorithm: string): void {
|
||||||
|
this.stopAnimations();
|
||||||
|
this.clearPath();
|
||||||
|
|
||||||
|
const startTime = performance.now();
|
||||||
|
let result;
|
||||||
|
|
||||||
|
switch (algorithm) {
|
||||||
|
case 'dijkstra': result = this.pathfindingService.dijkstra(
|
||||||
|
this.grid,
|
||||||
|
this.grid[this.startNode!.row][this.startNode!.col],
|
||||||
|
this.grid[this.endNode!.row][this.endNode!.col]
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'astar': result = this.pathfindingService.aStar(
|
||||||
|
this.grid,
|
||||||
|
this.grid[this.startNode!.row][this.startNode!.col],
|
||||||
|
this.grid[this.endNode!.row][this.endNode!.col]
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const endTime = performance.now();
|
||||||
|
const lengthOfShortestPath = result.nodesInShortestPathOrder.length;
|
||||||
|
if (lengthOfShortestPath === 0)
|
||||||
|
{
|
||||||
|
this.pathLength = "∞"
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
this.pathLength = result.nodesInShortestPathOrder.length + "";
|
||||||
|
}
|
||||||
|
this.executionTime = endTime - startTime;
|
||||||
|
|
||||||
|
this.animateAlgorithm(result.visitedNodesInOrder, result.nodesInShortestPathOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
createMazeNode = (row: number, col: number): Node => {
|
||||||
|
return {
|
||||||
|
row,
|
||||||
|
col,
|
||||||
|
isStart: false,
|
||||||
|
isEnd: false,
|
||||||
|
isWall: false,
|
||||||
|
isVisited: false,
|
||||||
|
isPath: false,
|
||||||
|
nodeData: Infinity,
|
||||||
|
linkedNode: null,
|
||||||
|
hScore: 0,
|
||||||
|
fScore: Infinity,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
getMazeColor = (node: Node): string => {
|
||||||
|
if (node.isStart) return 'green';
|
||||||
|
if (node.isEnd) return 'red';
|
||||||
|
if (node.isPath) return 'gold';
|
||||||
|
if (node.isVisited) return 'skyblue';
|
||||||
|
if (node.isWall) return 'black';
|
||||||
|
return 'lightgray';
|
||||||
|
};
|
||||||
|
|
||||||
|
applyNoSelection = (pos: GridPos, grid: Node[][]): void => {
|
||||||
|
this.grid = grid;
|
||||||
|
//dont need a selection for the maze case
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Animation (adapted to use genericGridComponent for redraw) ---
|
||||||
|
private stopAnimations(): void {
|
||||||
|
for (const id of this.timeoutIds) {
|
||||||
|
clearTimeout(id);
|
||||||
|
}
|
||||||
|
this.timeoutIds = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearPath(): void {
|
||||||
|
for (let row = 0; row < this.gridRows; row++) {
|
||||||
|
for (let col = 0; col < this.gridCols; col++) {
|
||||||
|
const node = this.grid[row][col];
|
||||||
|
node.isVisited = false;
|
||||||
|
node.isPath = false;
|
||||||
|
node.nodeData = Infinity;
|
||||||
|
node.linkedNode = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.genericGridComponent?.drawGrid(); // Redraw the grid via generic grid component
|
||||||
|
}
|
||||||
|
|
||||||
|
private animateAlgorithm(visited: Node[], path: Node[]): void {
|
||||||
|
for (let i = 0; i <= visited.length; i++) {
|
||||||
|
if (i === visited.length) {
|
||||||
|
const id = globalThis.setTimeout(() => this.animateShortestPath(path), this.animationSpeed * i);
|
||||||
|
this.timeoutIds.push(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = visited[i];
|
||||||
|
const id = globalThis.setTimeout(() => {
|
||||||
|
if (!node.isStart && !node.isEnd) {
|
||||||
|
node.isVisited = true;
|
||||||
|
this.genericGridComponent?.drawNode(node);
|
||||||
|
}
|
||||||
|
}, this.animationSpeed * i);
|
||||||
|
|
||||||
|
this.timeoutIds.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private animateShortestPath(path: Node[]): void {
|
||||||
|
for (let i = 0; i < path.length; i++) {
|
||||||
|
const node = path[i];
|
||||||
|
const id = globalThis.setTimeout(() => {
|
||||||
|
if (!node.isStart && !node.isEnd) {
|
||||||
|
node.isPath = true;
|
||||||
|
this.genericGridComponent?.drawNode(node); // Redraw single node
|
||||||
|
}
|
||||||
|
}, this.animationSpeed * i);
|
||||||
|
|
||||||
|
this.timeoutIds.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private animateMazeGeneration(): void {
|
||||||
|
for (let i = 0; i < this.mazeNodesInOrder.length; i++) {
|
||||||
|
const id = globalThis.setTimeout(() => {
|
||||||
|
const node = this.mazeNodesInOrder[i];
|
||||||
|
node.isWall = false;
|
||||||
|
this.genericGridComponent?.drawNode(node);
|
||||||
|
if (i === this.mazeNodesInOrder.length - 1) {
|
||||||
|
this.cleanupGrid();
|
||||||
|
if (this.startNode) {
|
||||||
|
this.genericGridComponent?.drawNode(this.startNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.endNode) {
|
||||||
|
this.genericGridComponent?.drawNode(this.endNode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (i == this.mazeNodesInOrder.length - 1) {
|
||||||
|
this.isAnimationRunning.set(false);
|
||||||
|
}
|
||||||
|
}, this.mazeAnimationSpeed * i);
|
||||||
|
|
||||||
|
this.timeoutIds.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
//utility
|
||||||
|
private getNeighborWalls(row: number, col: number, frontier: Node[]): void{
|
||||||
|
|
||||||
|
const directions = [
|
||||||
|
[0, 2], [0, -2], [2, 0], [-2, 0]
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [dr, dc] of directions) {
|
||||||
|
const nextRow = row + dr;
|
||||||
|
const nextCol = col + dc;
|
||||||
|
|
||||||
|
|
||||||
|
if (this.isValid(nextRow, nextCol) && this.grid[nextRow][nextCol].isWall && !this.grid[nextRow][nextCol].isVisited) {
|
||||||
|
const wallRow = row + dr / 2;
|
||||||
|
const wallCol = col + dc / 2;
|
||||||
|
|
||||||
|
const node = this.grid[wallRow][wallCol];
|
||||||
|
node.linkedNode = this.grid[nextRow][nextCol];
|
||||||
|
frontier.push(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isValid = (row: number, col: number): boolean => {
|
||||||
|
return row >= 0 && row < this.gridRows && col >= 0 && col < this.gridCols;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,87 +1,80 @@
|
|||||||
<div class="container">
|
<mat-card class="w-full max-w-[1920px] p-5">
|
||||||
<h1>{{ 'PATHFINDING.TITLE' | translate }}</h1>
|
<mat-card-header>
|
||||||
|
<mat-card-title>{{ 'PATHFINDING.TITLE' | translate }}</mat-card-title>
|
||||||
|
</mat-card-header>
|
||||||
|
<mat-card-content>
|
||||||
|
<app-information [algorithmInformation]="algoInformation"/>
|
||||||
|
|
||||||
<div class="algo-info">
|
<div class="flex flex-col mb-4">
|
||||||
<h3>{{ 'PATHFINDING.EXPLANATION.TITLE' | translate }}</h3>
|
<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>
|
||||||
<p>
|
<button matButton="filled" (click)="visualize('astar')">{{ 'PATHFINDING.ASTAR' | translate }}</button>
|
||||||
<strong>Dijkstra</strong> {{ 'PATHFINDING.EXPLANATION.DIJKSTRA_EXPLANATION' | translate }}
|
</div>
|
||||||
<a href="{{UrlConstants.DIJKSTRA_WIKI}}" target="_blank" rel="noopener noreferrer">Wikipedia</a>
|
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
|
||||||
</p>
|
<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>
|
||||||
<p>
|
<button matButton="filled" (click)="createCase({withWalls: true, scenario: 'edge'})">{{ 'PATHFINDING.EDGE_CASE' | translate }}</button>
|
||||||
<strong>A*</strong> {{ 'PATHFINDING.EXPLANATION.ASTAR_EXPLANATION' | translate}}
|
<button matButton="filled" (click)="createCase({withWalls: false, scenario: 'normal'})">{{ 'PATHFINDING.CLEAR_BOARD' | translate }}</button>
|
||||||
<a href="{{UrlConstants.ASTAR_WIKI}}" target="_blank" rel="noopener noreferrer">Wikipedia</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
<strong>{{ 'PATHFINDING.EXPLANATION.NOTE' | translate}}</strong> {{ 'PATHFINDING.EXPLANATION.DISCLAIMER' | translate}}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="controls-container">
|
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
|
||||||
<div class="controls">
|
<mat-button-toggle-group class="rounded-sm overflow-hidden" [(ngModel)]="selectedNodeType" aria-label="Node Type Selection">
|
||||||
<button matButton="filled" (click)="visualizeDijkstra()">{{ 'PATHFINDING.DIJKSTRA' | translate }}</button>
|
|
||||||
<button matButton="filled" (click)="visualizeAStar()">{{ 'PATHFINDING.ASTAR' | translate }}</button>
|
|
||||||
</div>
|
|
||||||
<div class="controls">
|
|
||||||
<button matButton="filled" (click)="normalCase()">{{ 'PATHFINDING.NORMAL_CASE' | translate }}</button>
|
|
||||||
<button matButton="filled" (click)="edgeCase()">{{ 'PATHFINDING.EDGE_CASE' | translate }}</button>
|
|
||||||
<button matButton="filled" (click)="clearBoard()">{{ 'PATHFINDING.CLEAR_BOARD' | translate }}</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="controls">
|
|
||||||
<mat-button-toggle-group [(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="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
|
||||||
<div class="controls">
|
<div class="flex gap-3 items-center flex-wrap">
|
||||||
<div class="grid-size">
|
<mat-form-field appearance="outline" class="w-[150px]">
|
||||||
<mat-form-field appearance="outline" class="grid-field">
|
<mat-label>{{ 'ALGORITHM.GRID_HEIGHT' | translate }}</mat-label>
|
||||||
<mat-label>{{ 'ALGORITHM.PATHFINDING.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"
|
||||||
(blur)="applyGridSize()"
|
(ngModelChange)="genericGridComponent.gridRows = gridRows; genericGridComponent.applyGridSize()"
|
||||||
(keyup.enter)="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.PATHFINDING.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"
|
||||||
(blur)="applyGridSize()"
|
(ngModelChange)="genericGridComponent.gridCols = gridCols; genericGridComponent.applyGridSize()"
|
||||||
(keyup.enter)="applyGridSize()"
|
/> </mat-form-field>
|
||||||
/>
|
|
||||||
</mat-form-field>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="legend">
|
<div class="flex flex-wrap gap-4 items-center text-[0.9em]">
|
||||||
<span><span class="legend-color start"></span> {{ 'PATHFINDING.START_NODE' | translate }}</span>
|
<span><span class="legend-swatch bg-green-600"></span> {{ 'PATHFINDING.START_NODE' | translate }}</span>
|
||||||
<span><span class="legend-color end"></span> {{ 'PATHFINDING.END_NODE' | translate }}</span>
|
<span><span class="legend-swatch bg-red-600"></span> {{ 'PATHFINDING.END_NODE' | translate }}</span>
|
||||||
<span><span class="legend-color wall"></span> {{ 'PATHFINDING.WALL' | translate }}</span>
|
<span><span class="legend-swatch bg-black"></span> {{ 'PATHFINDING.WALL' | translate }}</span>
|
||||||
<span><span class="legend-color visited"></span> {{ 'PATHFINDING.VISITED' | translate }}</span>
|
<span><span class="legend-swatch bg-sky-300"></span> {{ 'PATHFINDING.VISITED' | translate }}</span>
|
||||||
<span><span class="legend-color path"></span> {{ 'PATHFINDING.PATH' | translate }}</span>
|
<span><span class="legend-swatch bg-[gold]"></span> {{ 'PATHFINDING.PATH' | translate }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
|
||||||
|
|
||||||
<div class="results-container">
|
|
||||||
<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>
|
||||||
|
|
||||||
<canvas #gridCanvas></canvas>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<app-generic-grid
|
||||||
|
[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>
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
.container {
|
|
||||||
padding: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.algo-info {
|
|
||||||
margin: 0 0 1rem 0;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
border-radius: 8px;
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
margin: 0 0 0.5rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin: 0.5rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
margin-left: 0.25rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 1rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
mat-button-toggle-group {
|
|
||||||
border-radius: 4px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid-size {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid-field {
|
|
||||||
width: 150px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.legend {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 1rem;
|
|
||||||
align-items: center;
|
|
||||||
font-size: 0.9em;
|
|
||||||
|
|
||||||
.legend-color {
|
|
||||||
display: inline-block;
|
|
||||||
width: 15px;
|
|
||||||
height: 15px;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
vertical-align: middle;
|
|
||||||
margin-right: 5px;
|
|
||||||
|
|
||||||
&.start { background-color: green; }
|
|
||||||
&.end { background-color: red; }
|
|
||||||
&.wall { background-color: black; }
|
|
||||||
&.visited { background-color: skyblue; }
|
|
||||||
&.path { background-color: gold; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
canvas {
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
display: block;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import {AfterViewInit, Component, ElementRef, inject, ViewChild} from '@angular/core';
|
import {AfterViewInit, Component, inject, ViewChild} from '@angular/core';
|
||||||
import {CommonModule} from '@angular/common';
|
import {CommonModule} from '@angular/common';
|
||||||
import {FormsModule} from '@angular/forms';
|
import {FormsModule} from '@angular/forms';
|
||||||
|
|
||||||
@@ -6,12 +6,18 @@ 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, 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';
|
||||||
import {UrlConstants} from '../../../constants/UrlConstants';
|
import {UrlConstants} from '../../../constants/UrlConstants';
|
||||||
|
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
|
||||||
|
import {Information} from '../information/information';
|
||||||
|
import {AlgorithmInformation} from '../information/information.models';
|
||||||
|
import {GenericGridComponent, GridPos} from '../../../shared/components/generic-grid/generic-grid';
|
||||||
|
import {SharedFunctions} from '../../../shared/SharedFunctions';
|
||||||
|
|
||||||
enum NodeType {
|
enum NodeType {
|
||||||
Start = 'start',
|
Start = 'start',
|
||||||
@@ -20,11 +26,8 @@ enum NodeType {
|
|||||||
None = 'none'
|
None = 'none'
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GridPos { row: number; col: number }
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-pathfinding',
|
selector: 'app-pathfinding',
|
||||||
standalone: true,
|
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
@@ -32,176 +35,116 @@ interface GridPos { row: number; col: number }
|
|||||||
MatButtonToggleModule,
|
MatButtonToggleModule,
|
||||||
MatFormFieldModule,
|
MatFormFieldModule,
|
||||||
MatInputModule,
|
MatInputModule,
|
||||||
TranslateModule
|
TranslatePipe,
|
||||||
|
MatCard,
|
||||||
|
MatCardHeader,
|
||||||
|
MatCardTitle,
|
||||||
|
MatCardContent,
|
||||||
|
Information,
|
||||||
|
GenericGridComponent
|
||||||
],
|
],
|
||||||
templateUrl: './pathfinding.component.html',
|
templateUrl: './pathfinding.component.html',
|
||||||
styleUrls: ['./pathfinding.component.scss']
|
|
||||||
})
|
})
|
||||||
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;
|
||||||
readonly MAX_GRID_SIZE = MAX_GRID_SIZE;
|
readonly MAX_GRID_SIZE = MAX_GRID_SIZE;
|
||||||
|
readonly MAX_GRID_PX = MAX_GRID_PX;
|
||||||
|
|
||||||
@ViewChild('gridCanvas', { static: true })
|
algoInformation: AlgorithmInformation = {
|
||||||
canvas!: ElementRef<HTMLCanvasElement>;
|
title: 'PATHFINDING.EXPLANATION.TITLE',
|
||||||
|
entries: [
|
||||||
private ctx!: CanvasRenderingContext2D;
|
{
|
||||||
|
name: 'Dijkstra',
|
||||||
|
description: 'PATHFINDING.EXPLANATION.DIJKSTRA_EXPLANATION',
|
||||||
|
link: UrlConstants.DIJKSTRA_WIKI
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'A*',
|
||||||
|
description: 'PATHFINDING.EXPLANATION.ASTAR_EXPLANATION',
|
||||||
|
link: UrlConstants.ASTAR_WIKI
|
||||||
|
}
|
||||||
|
],
|
||||||
|
disclaimer: 'PATHFINDING.EXPLANATION.DISCLAIMER',
|
||||||
|
disclaimerBottom: '',
|
||||||
|
disclaimerListEntry: []
|
||||||
|
};
|
||||||
|
|
||||||
gridRows = DEFAULT_GRID_ROWS;
|
gridRows = DEFAULT_GRID_ROWS;
|
||||||
gridCols = DEFAULT_GRID_COLS;
|
gridCols = DEFAULT_GRID_COLS;
|
||||||
nodeSize = 10;
|
|
||||||
|
|
||||||
grid: Node[][] = [];
|
grid: Node[][] = [];
|
||||||
startNode: Node | null = null;
|
startNode: Node | null = null;
|
||||||
endNode: Node | null = null;
|
endNode: Node | null = null;
|
||||||
|
|
||||||
selectedNodeType: NodeType = NodeType.None;
|
selectedNodeType: NodeType = NodeType.None;
|
||||||
|
private shouldAddWall = true; // Moved here
|
||||||
isDrawing = false;
|
|
||||||
private lastCell: GridPos | null = null;
|
|
||||||
private shouldAddWall = true;
|
|
||||||
|
|
||||||
animationSpeed = 3;
|
animationSpeed = 3;
|
||||||
pathLength = 0;
|
pathLength = "0";
|
||||||
executionTime = 0;
|
executionTime = 0;
|
||||||
|
|
||||||
private timeoutIds: number[] = [];
|
private timeoutIds: number[] = [];
|
||||||
|
|
||||||
|
@ViewChild(GenericGridComponent) genericGridComponent!: GenericGridComponent;
|
||||||
|
|
||||||
ngAfterViewInit(): void {
|
ngAfterViewInit(): void {
|
||||||
this.ctx = this.getContextOrThrow();
|
// Canvas logic is now handled by GenericGridComponent
|
||||||
this.applyGridSize(true);
|
// Ensure genericGridComponent is initialized
|
||||||
|
if (this.genericGridComponent) {
|
||||||
const el = this.canvas.nativeElement;
|
this.genericGridComponent.initializationFn = this.initializePathfindingGrid;
|
||||||
el.addEventListener('mousedown', (e) => this.onMouseDown(e));
|
this.genericGridComponent.createNodeFn = this.createPathfindingNode;
|
||||||
el.addEventListener('mousemove', (e) => this.onMouseMove(e));
|
this.genericGridComponent.getNodeColorFn = this.getPathfindingNodeColor;
|
||||||
el.addEventListener('mouseup', () => this.onMouseUp());
|
this.genericGridComponent.applySelectionFn = this.applyPathfindingSelection;
|
||||||
el.addEventListener('mouseleave', () => this.onMouseUp());
|
this.genericGridComponent.gridRows = this.gridRows;
|
||||||
|
this.genericGridComponent.gridCols = this.gridCols;
|
||||||
|
this.genericGridComponent.minGridSize = this.MIN_GRID_SIZE;
|
||||||
|
this.genericGridComponent.maxGridSize = this.MAX_GRID_SIZE;
|
||||||
|
this.genericGridComponent.maxGridPx = MAX_GRID_PX;
|
||||||
|
this.genericGridComponent.applyGridSize(); // Trigger initial grid setup
|
||||||
|
}
|
||||||
|
this.createCase({withWalls: true, scenario: "normal"});
|
||||||
}
|
}
|
||||||
|
|
||||||
applyGridSize(skipReset?: boolean): void {
|
// --- Callbacks for GenericGridComponent ---
|
||||||
this.gridRows = this.clampGridSize(this.gridRows, DEFAULT_GRID_ROWS);
|
createPathfindingNode = (row: number, col: number): Node => {
|
||||||
this.gridCols = this.clampGridSize(this.gridCols, DEFAULT_GRID_COLS);
|
return {
|
||||||
|
row,
|
||||||
|
col,
|
||||||
|
isStart: false,
|
||||||
|
isEnd: false,
|
||||||
|
isWall: false,
|
||||||
|
isVisited: false,
|
||||||
|
isPath: false,
|
||||||
|
nodeData: Infinity,
|
||||||
|
linkedNode: null,
|
||||||
|
hScore: 0,
|
||||||
|
fScore: Infinity,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
this.nodeSize = this.computeNodeSize(this.gridRows, this.gridCols);
|
getPathfindingNodeColor = (node: Node): string => {
|
||||||
this.resizeCanvas();
|
if (node.isStart) return 'green';
|
||||||
|
if (node.isEnd) return 'red';
|
||||||
|
if (node.isPath) return 'gold';
|
||||||
|
if (node.isVisited) return 'skyblue';
|
||||||
|
if (node.isWall) return 'black';
|
||||||
|
return 'lightgray';
|
||||||
|
};
|
||||||
|
|
||||||
if (skipReset) {
|
applyPathfindingSelection = (pos: GridPos, grid: Node[][]): void => {
|
||||||
this.initializeGrid(true, 'edge');
|
this.grid = grid; // Keep internal grid in sync
|
||||||
this.drawGrid();
|
const node = grid[pos.row][pos.col];
|
||||||
return;
|
|
||||||
|
// Determine if we should add or remove a wall
|
||||||
|
if (this.selectedNodeType === NodeType.Wall && this.genericGridComponent.isDrawing && this.genericGridComponent['lastCell'] === null) {
|
||||||
|
this.shouldAddWall = !node.isWall;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default after size changes: pick one consistent scenario
|
|
||||||
this.edgeCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scenarios (buttons)
|
|
||||||
normalCase(): void {
|
|
||||||
this.stopAnimations();
|
|
||||||
this.initializeGrid(true, 'normal');
|
|
||||||
this.drawGrid();
|
|
||||||
}
|
|
||||||
|
|
||||||
edgeCase(): void {
|
|
||||||
this.stopAnimations();
|
|
||||||
this.initializeGrid(true, 'edge');
|
|
||||||
this.drawGrid();
|
|
||||||
}
|
|
||||||
|
|
||||||
clearBoard(): void {
|
|
||||||
this.stopAnimations();
|
|
||||||
this.initializeGrid(false, 'edge');
|
|
||||||
this.drawGrid();
|
|
||||||
}
|
|
||||||
|
|
||||||
visualizeDijkstra(): void {
|
|
||||||
if (!this.ensureStartAndEnd()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.stopAnimations();
|
|
||||||
this.clearPath();
|
|
||||||
|
|
||||||
const startTime = performance.now();
|
|
||||||
const result = this.pathfindingService.dijkstra(
|
|
||||||
this.grid,
|
|
||||||
this.grid[this.startNode!.row][this.startNode!.col],
|
|
||||||
this.grid[this.endNode!.row][this.endNode!.col]
|
|
||||||
);
|
|
||||||
const endTime = performance.now();
|
|
||||||
|
|
||||||
this.pathLength = result.nodesInShortestPathOrder.length;
|
|
||||||
this.executionTime = endTime - startTime;
|
|
||||||
|
|
||||||
this.animateAlgorithm(result.visitedNodesInOrder, result.nodesInShortestPathOrder);
|
|
||||||
}
|
|
||||||
|
|
||||||
visualizeAStar(): void {
|
|
||||||
if (!this.ensureStartAndEnd()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.stopAnimations();
|
|
||||||
this.clearPath();
|
|
||||||
|
|
||||||
const startTime = performance.now();
|
|
||||||
const result = this.pathfindingService.aStar(
|
|
||||||
this.grid,
|
|
||||||
this.grid[this.startNode!.row][this.startNode!.col],
|
|
||||||
this.grid[this.endNode!.row][this.endNode!.col]
|
|
||||||
);
|
|
||||||
const endTime = performance.now();
|
|
||||||
|
|
||||||
this.pathLength = result.nodesInShortestPathOrder.length;
|
|
||||||
this.executionTime = endTime - startTime;
|
|
||||||
|
|
||||||
this.animateAlgorithm(result.visitedNodesInOrder, result.nodesInShortestPathOrder);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mouse interactions
|
|
||||||
private onMouseDown(event: MouseEvent): void {
|
|
||||||
this.stopAnimations();
|
|
||||||
this.clearPath();
|
|
||||||
const pos = this.getGridPosition(event);
|
|
||||||
if (!pos) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.shouldAddWall = this.shouldStartWallStroke(pos);
|
|
||||||
|
|
||||||
this.isDrawing = true;
|
|
||||||
this.lastCell = null;
|
|
||||||
this.applySelectionAt(pos);
|
|
||||||
}
|
|
||||||
|
|
||||||
private onMouseMove(event: MouseEvent): void {
|
|
||||||
if (!this.isDrawing) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pos = this.getGridPosition(event);
|
|
||||||
if (!pos) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.isSameCell(pos, this.lastCell)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.applySelectionAt(pos);
|
|
||||||
}
|
|
||||||
|
|
||||||
private onMouseUp(): void {
|
|
||||||
this.isDrawing = false;
|
|
||||||
this.lastCell = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private applySelectionAt(pos: GridPos): void {
|
|
||||||
const node = this.grid[pos.row][pos.col];
|
|
||||||
|
|
||||||
switch (this.selectedNodeType) {
|
switch (this.selectedNodeType) {
|
||||||
case NodeType.Start:
|
case NodeType.Start:
|
||||||
this.trySetStart(node);
|
this.trySetStart(node);
|
||||||
@@ -219,180 +162,67 @@ export class PathfindingComponent implements AfterViewInit {
|
|||||||
this.tryClearNode(node);
|
this.tryClearNode(node);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
this.lastCell = pos;
|
visualize(algorithm: string): void {
|
||||||
this.drawNode(node);
|
if (!this.ensureStartAndEnd()) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Grid init
|
this.stopAnimations();
|
||||||
private initializeGrid(withWalls: boolean, scenario: 'normal' | 'edge'): void {
|
this.clearPath();
|
||||||
this.grid = this.createEmptyGrid();
|
|
||||||
|
|
||||||
const { start, end } = this.getScenarioStartEnd(scenario);
|
const startTime = performance.now();
|
||||||
|
let result;
|
||||||
|
|
||||||
|
switch (algorithm) {
|
||||||
|
case 'dijkstra': result = this.pathfindingService.dijkstra(
|
||||||
|
this.grid,
|
||||||
|
this.grid[this.startNode!.row][this.startNode!.col],
|
||||||
|
this.grid[this.endNode!.row][this.endNode!.col]
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'astar': result = this.pathfindingService.aStar(
|
||||||
|
this.grid,
|
||||||
|
this.grid[this.startNode!.row][this.startNode!.col],
|
||||||
|
this.grid[this.endNode!.row][this.endNode!.col]
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const endTime = performance.now();
|
||||||
|
const lengthOfShortestPath = result.nodesInShortestPathOrder.length;
|
||||||
|
if (lengthOfShortestPath === 0)
|
||||||
|
{
|
||||||
|
this.pathLength = "∞"
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
this.pathLength = result.nodesInShortestPathOrder.length + "";
|
||||||
|
}
|
||||||
|
this.executionTime = endTime - startTime;
|
||||||
|
|
||||||
|
this.animateAlgorithm(result.visitedNodesInOrder, result.nodesInShortestPathOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
initializePathfindingGrid = (grid: Node[][]): void => {
|
||||||
|
this.grid = grid; // Update the component's grid reference
|
||||||
|
const {start, end} = this.getScenarioStartEnd('normal'); // Default scenario
|
||||||
this.startNode = this.grid[start.row][start.col];
|
this.startNode = this.grid[start.row][start.col];
|
||||||
this.endNode = this.grid[end.row][end.col];
|
this.endNode = this.grid[end.row][end.col];
|
||||||
|
|
||||||
this.startNode.isStart = true;
|
this.startNode.isStart = true;
|
||||||
this.endNode.isEnd = true;
|
this.endNode.isEnd = true;
|
||||||
|
|
||||||
if (withWalls) {
|
this.placeDefaultDiagonalWall('normal');
|
||||||
this.placeDefaultDiagonalWall();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private createEmptyGrid(): Node[][] {
|
|
||||||
const grid: Node[][] = [];
|
|
||||||
|
|
||||||
for (let row = 0; row < this.gridRows; row++) {
|
|
||||||
const currentRow: Node[] = [];
|
|
||||||
for (let col = 0; col < this.gridCols; col++) {
|
|
||||||
currentRow.push(this.createNode(row, col));
|
|
||||||
}
|
|
||||||
grid.push(currentRow);
|
|
||||||
}
|
|
||||||
|
|
||||||
return grid;
|
|
||||||
}
|
|
||||||
|
|
||||||
private createNode(row: number, col: number): Node {
|
|
||||||
return {
|
|
||||||
row,
|
|
||||||
col,
|
|
||||||
isStart: false,
|
|
||||||
isEnd: false,
|
|
||||||
isWall: false,
|
|
||||||
isVisited: false,
|
|
||||||
isPath: false,
|
|
||||||
distance: Infinity,
|
|
||||||
previousNode: null,
|
|
||||||
hScore: 0,
|
|
||||||
fScore: Infinity,
|
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
private getScenarioStartEnd(scenario: 'normal' | 'edge'): { start: GridPos; end: GridPos } {
|
// --- Helper methods for node manipulation (kept local) ---
|
||||||
if (scenario === 'edge') {
|
|
||||||
return {
|
|
||||||
start: { row: 0, col: 0 },
|
|
||||||
end: { row: this.gridRows - 1, col: this.gridCols - 1 }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// normal: mid-left -> mid-right
|
|
||||||
const midRow = Math.floor(this.gridRows / 2);
|
|
||||||
return {
|
|
||||||
start: { row: midRow, col: 0 },
|
|
||||||
end: { row: midRow, col: this.gridCols - 1 }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private placeDefaultDiagonalWall(): void {
|
|
||||||
// Diagonal-ish wall; avoids start/end
|
|
||||||
const len = Math.min(this.gridRows, this.gridCols);
|
|
||||||
const startCol = Math.floor((this.gridCols - len) / 2);
|
|
||||||
|
|
||||||
for (let i = 0; i < Math.max(0, len - 10); i++) {
|
|
||||||
const row = len - i - 1;
|
|
||||||
const col = startCol + i;
|
|
||||||
|
|
||||||
if (!this.isValidPosition(row, col)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const node = this.grid[row][col];
|
|
||||||
if (node.isStart || node.isEnd) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
node.isWall = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Path state
|
|
||||||
private clearPath(): void {
|
|
||||||
for (let row = 0; row < this.gridRows; row++) {
|
|
||||||
for (let col = 0; col < this.gridCols; col++) {
|
|
||||||
const node = this.grid[row][col];
|
|
||||||
node.isVisited = false;
|
|
||||||
node.isPath = false;
|
|
||||||
node.distance = Infinity;
|
|
||||||
node.previousNode = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.drawGrid();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Animation
|
|
||||||
private stopAnimations(): void {
|
|
||||||
for (const id of this.timeoutIds) {
|
|
||||||
clearTimeout(id);
|
|
||||||
}
|
|
||||||
this.timeoutIds = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
private animateAlgorithm(visited: Node[], path: Node[]): void {
|
|
||||||
for (let i = 0; i <= visited.length; i++) {
|
|
||||||
if (i === visited.length) {
|
|
||||||
const id = globalThis.setTimeout(() => this.animateShortestPath(path), this.animationSpeed * i);
|
|
||||||
this.timeoutIds.push(id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const node = visited[i];
|
|
||||||
const id = globalThis.setTimeout(() => {
|
|
||||||
if (!node.isStart && !node.isEnd) {
|
|
||||||
node.isVisited = true;
|
|
||||||
this.drawNode(node);
|
|
||||||
}
|
|
||||||
}, this.animationSpeed * i);
|
|
||||||
|
|
||||||
this.timeoutIds.push(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private animateShortestPath(path: Node[]): void {
|
|
||||||
for (let i = 0; i < path.length; i++) {
|
|
||||||
const node = path[i];
|
|
||||||
const id = globalThis.setTimeout(() => {
|
|
||||||
if (!node.isStart && !node.isEnd) {
|
|
||||||
node.isPath = true;
|
|
||||||
this.drawNode(node);
|
|
||||||
}
|
|
||||||
}, this.animationSpeed * i);
|
|
||||||
|
|
||||||
this.timeoutIds.push(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Drawing
|
|
||||||
private drawGrid(): void {
|
|
||||||
this.ctx.clearRect(0, 0, this.canvas.nativeElement.width, this.canvas.nativeElement.height);
|
|
||||||
|
|
||||||
for (let row = 0; row < this.gridRows; row++) {
|
|
||||||
for (let col = 0; col < this.gridCols; col++) {
|
|
||||||
this.drawNode(this.grid[row][col]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private drawNode(node: Node): void {
|
|
||||||
this.ctx.fillStyle = this.getNodeColor(node);
|
|
||||||
this.ctx.fillRect(node.col * this.nodeSize, node.row * this.nodeSize, this.nodeSize, this.nodeSize);
|
|
||||||
|
|
||||||
this.ctx.strokeStyle = '#ccc';
|
|
||||||
this.ctx.strokeRect(node.col * this.nodeSize, node.row * this.nodeSize, this.nodeSize, this.nodeSize);
|
|
||||||
}
|
|
||||||
|
|
||||||
private getNodeColor(node: Node): string {
|
|
||||||
if (node.isStart) return 'green';
|
|
||||||
if (node.isEnd) return 'red';
|
|
||||||
if (node.isPath) return 'gold';
|
|
||||||
if (node.isVisited) return 'skyblue';
|
|
||||||
if (node.isWall) return 'black';
|
|
||||||
return 'lightgray';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Placement rules (readability helpers)
|
|
||||||
private trySetStart(node: Node): void {
|
private trySetStart(node: Node): void {
|
||||||
if (!this.canBeStart(node)) {
|
if (!this.canBeStart(node)) {
|
||||||
return;
|
return;
|
||||||
@@ -400,7 +230,7 @@ export class PathfindingComponent implements AfterViewInit {
|
|||||||
|
|
||||||
if (this.startNode) {
|
if (this.startNode) {
|
||||||
this.startNode.isStart = false;
|
this.startNode.isStart = false;
|
||||||
this.drawNode(this.startNode);
|
this.genericGridComponent.drawNode(this.startNode); // Redraw old start node
|
||||||
}
|
}
|
||||||
|
|
||||||
node.isStart = true;
|
node.isStart = true;
|
||||||
@@ -414,7 +244,7 @@ export class PathfindingComponent implements AfterViewInit {
|
|||||||
|
|
||||||
if (this.endNode) {
|
if (this.endNode) {
|
||||||
this.endNode.isEnd = false;
|
this.endNode.isEnd = false;
|
||||||
this.drawNode(this.endNode);
|
this.genericGridComponent.drawNode(this.endNode); // Redraw old end node
|
||||||
}
|
}
|
||||||
|
|
||||||
node.isEnd = true;
|
node.isEnd = true;
|
||||||
@@ -458,75 +288,205 @@ export class PathfindingComponent implements AfterViewInit {
|
|||||||
return !node.isStart && !node.isEnd;
|
return !node.isStart && !node.isEnd;
|
||||||
}
|
}
|
||||||
|
|
||||||
private shouldStartWallStroke(pos: GridPos): boolean {
|
// --- Grid manipulation for scenarios (kept local) ---
|
||||||
if (this.selectedNodeType !== NodeType.Wall) {
|
createCase({withWalls, scenario}: { withWalls: boolean, scenario: "normal" | "edge" | "random" }): void {
|
||||||
return true;
|
this.stopAnimations();
|
||||||
|
// Reinitialize grid through the generic component
|
||||||
|
this.genericGridComponent.initializationFn = (grid) => {
|
||||||
|
this.grid = grid;
|
||||||
|
const {start, end} = this.getScenarioStartEnd(scenario);
|
||||||
|
this.startNode = this.grid[start.row][start.col];
|
||||||
|
this.endNode = this.grid[end.row][end.col];
|
||||||
|
this.startNode.isStart = true;
|
||||||
|
this.endNode.isEnd = true;
|
||||||
|
|
||||||
|
if (withWalls) {
|
||||||
|
this.placeDefaultDiagonalWall(scenario);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.genericGridComponent.initializeGrid(); // Trigger re-initialization and redraw
|
||||||
}
|
}
|
||||||
|
|
||||||
const node = this.grid[pos.row][pos.col];
|
private getScenarioStartEnd(scenario: 'normal' | 'edge' | 'random'): { start: GridPos; end: GridPos } {
|
||||||
return !node.isWall;
|
if (scenario === 'edge') {
|
||||||
|
return {
|
||||||
|
start: {row: 0, col: 0},
|
||||||
|
end: {row: this.gridRows - 1, col: this.gridCols - 1}
|
||||||
|
};
|
||||||
|
} else if (scenario === 'random') {
|
||||||
|
return this.createRandomStartEndPosition();
|
||||||
|
} else {
|
||||||
|
// normal: mid-left -> mid-right
|
||||||
|
const midRow = Math.floor(this.gridRows / 2);
|
||||||
|
return {
|
||||||
|
start: {row: midRow, col: 0},
|
||||||
|
end: {row: midRow, col: this.gridCols - 1}
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validation
|
private createRandomStartEndPosition(): { start: GridPos; end: GridPos } {
|
||||||
|
const midCol = Math.floor(this.gridCols / 2);
|
||||||
|
|
||||||
|
const startRow: number = SharedFunctions.randomIntFromInterval(0, this.gridRows - 1);
|
||||||
|
const startCol: number = SharedFunctions.randomIntFromInterval(0, this.gridCols - 1);
|
||||||
|
|
||||||
|
const endRow: number = SharedFunctions.randomIntFromInterval(0, this.gridRows - 1);
|
||||||
|
let endCol: number;
|
||||||
|
|
||||||
|
if (startCol <= midCol) {
|
||||||
|
endCol = SharedFunctions.randomIntFromInterval(midCol + 1, this.gridCols - 1);
|
||||||
|
} else {
|
||||||
|
endCol = SharedFunctions.randomIntFromInterval(0, midCol);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
start: {row: startRow, col: startCol},
|
||||||
|
end: {row: endRow, col: endCol}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private placeDefaultDiagonalWall(scenario: 'normal' | 'edge' | 'random'): void {
|
||||||
|
if (scenario === 'edge') {
|
||||||
|
this.createDiagonalWall();
|
||||||
|
} else if (scenario === 'normal') {
|
||||||
|
this.createVerticalWall();
|
||||||
|
} else if (scenario === 'random') {
|
||||||
|
this.createRandomWalls();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private createRandomWalls() {
|
||||||
|
const maxNumberOfWalls = Math.floor(MAX_RANDOM_WALLS_FACTORS * this.gridCols * this.gridRows);
|
||||||
|
|
||||||
|
for (let wall = 0; wall < maxNumberOfWalls; wall++) {
|
||||||
|
|
||||||
|
const row: number = SharedFunctions.randomIntFromInterval(0, this.gridRows - 1);
|
||||||
|
const col: number = SharedFunctions.randomIntFromInterval(0, this.gridCols - 1);
|
||||||
|
|
||||||
|
if (!this.grid[row][col]) { // Use the grid passed from GenericGrid
|
||||||
|
wall--;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = this.grid[row][col];
|
||||||
|
if (node.isStart || node.isEnd) {
|
||||||
|
wall--;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
node.isWall = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private createVerticalWall() {
|
||||||
|
const height = this.gridRows;
|
||||||
|
const startCol = Math.floor(this.gridCols / 2);
|
||||||
|
|
||||||
|
for (let i = 5; i < (height - 5); i++) {
|
||||||
|
const row = i;
|
||||||
|
|
||||||
|
if (!this.grid[row]?.[startCol]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = this.grid[row][startCol];
|
||||||
|
if (node.isStart || node.isEnd) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
node.isWall = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private createDiagonalWall() {
|
||||||
|
// Diagonal-ish wall; avoids start/end
|
||||||
|
const len = Math.min(this.gridRows, this.gridCols);
|
||||||
|
const startCol = Math.floor((this.gridCols - len) / 2);
|
||||||
|
|
||||||
|
for (let i = 0; i < Math.max(0, len - 10); i++) {
|
||||||
|
const row = len - i - 1;
|
||||||
|
const col = startCol + i;
|
||||||
|
|
||||||
|
if (!this.grid[row]?.[col]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = this.grid[row][col];
|
||||||
|
if (node.isStart || node.isEnd) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
node.isWall = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Animation (adapted to use genericGridComponent for redraw) ---
|
||||||
|
private stopAnimations(): void {
|
||||||
|
for (const id of this.timeoutIds) {
|
||||||
|
clearTimeout(id);
|
||||||
|
}
|
||||||
|
this.timeoutIds = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearPath(): void {
|
||||||
|
for (let row = 0; row < this.gridRows; row++) {
|
||||||
|
for (let col = 0; col < this.gridCols; col++) {
|
||||||
|
const node = this.grid[row][col];
|
||||||
|
node.isVisited = false;
|
||||||
|
node.isPath = false;
|
||||||
|
node.nodeData = Infinity;
|
||||||
|
node.linkedNode = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.genericGridComponent?.drawGrid(); // Redraw the grid via generic grid component
|
||||||
|
}
|
||||||
|
|
||||||
|
private animateAlgorithm(visited: Node[], path: Node[]): void {
|
||||||
|
for (let i = 0; i <= visited.length; i++) {
|
||||||
|
if (i === visited.length) {
|
||||||
|
const id = globalThis.setTimeout(() => this.animateShortestPath(path), this.animationSpeed * i);
|
||||||
|
this.timeoutIds.push(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = visited[i];
|
||||||
|
const id = globalThis.setTimeout(() => {
|
||||||
|
if (!node.isStart && !node.isEnd) {
|
||||||
|
node.isVisited = true;
|
||||||
|
this.genericGridComponent?.drawNode(node); // Redraw single node
|
||||||
|
}
|
||||||
|
}, this.animationSpeed * i);
|
||||||
|
|
||||||
|
this.timeoutIds.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private animateShortestPath(path: Node[]): void {
|
||||||
|
for (let i = 0; i < path.length; i++) {
|
||||||
|
const node = path[i];
|
||||||
|
const id = globalThis.setTimeout(() => {
|
||||||
|
if (!node.isStart && !node.isEnd) {
|
||||||
|
node.isPath = true;
|
||||||
|
this.genericGridComponent?.drawNode(node); // Redraw single node
|
||||||
|
}
|
||||||
|
}, this.animationSpeed * i);
|
||||||
|
|
||||||
|
this.timeoutIds.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Validation ---
|
||||||
private ensureStartAndEnd(): boolean {
|
private ensureStartAndEnd(): boolean {
|
||||||
if (this.startNode && this.endNode) {
|
if (this.startNode && this.endNode) {
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Grid sizing
|
|
||||||
private clampGridSize(value: number, fallback: number): number {
|
|
||||||
const parsed = Math.floor(Number(value));
|
|
||||||
const safe = Number.isFinite(parsed) ? parsed : fallback;
|
|
||||||
return Math.min(Math.max(MIN_GRID_SIZE, safe), MAX_GRID_SIZE);
|
|
||||||
}
|
|
||||||
|
|
||||||
private computeNodeSize(rows: number, cols: number): number {
|
|
||||||
const sizeByWidth = Math.floor(MAX_GRID_PX / cols);
|
|
||||||
const sizeByHeight = Math.floor(MAX_GRID_PX / rows);
|
|
||||||
return Math.max(1, Math.min(sizeByWidth, sizeByHeight));
|
|
||||||
}
|
|
||||||
|
|
||||||
private resizeCanvas(): void {
|
|
||||||
const el = this.canvas.nativeElement;
|
|
||||||
el.width = this.gridCols * this.nodeSize;
|
|
||||||
el.height = this.gridRows * this.nodeSize;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mouse -> grid cell
|
|
||||||
private getGridPosition(event: MouseEvent): GridPos | null {
|
|
||||||
const rect = this.canvas.nativeElement.getBoundingClientRect();
|
|
||||||
const x = event.clientX - rect.left;
|
|
||||||
const y = event.clientY - rect.top;
|
|
||||||
|
|
||||||
const col = Math.floor(x / this.nodeSize);
|
|
||||||
const row = Math.floor(y / this.nodeSize);
|
|
||||||
|
|
||||||
if (!this.isValidPosition(row, col)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { row, col };
|
|
||||||
}
|
|
||||||
|
|
||||||
private isValidPosition(row: number, col: number): boolean {
|
|
||||||
return row >= 0 && row < this.gridRows && col >= 0 && col < this.gridCols;
|
|
||||||
}
|
|
||||||
|
|
||||||
private isSameCell(a: GridPos, b: GridPos | null): boolean {
|
|
||||||
return !!b && a.row === b.row && a.col === b.col;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getContextOrThrow(): CanvasRenderingContext2D {
|
|
||||||
const ctx = this.canvas.nativeElement.getContext('2d');
|
|
||||||
if (!ctx) {
|
|
||||||
throw new Error('CanvasRenderingContext2D not available.');
|
|
||||||
}
|
|
||||||
return ctx;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected readonly UrlConstants = UrlConstants;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ export interface Node {
|
|||||||
isWall: boolean;
|
isWall: boolean;
|
||||||
isVisited: boolean;
|
isVisited: boolean;
|
||||||
isPath: boolean;
|
isPath: boolean;
|
||||||
distance: number;
|
nodeData: number; //can be used as distance or id or something
|
||||||
previousNode: Node | null;
|
linkedNode: Node | null;
|
||||||
fScore: number;
|
fScore: number;
|
||||||
hScore: number;
|
hScore: number;
|
||||||
}
|
}
|
||||||
@@ -20,3 +20,4 @@ export const MAX_GRID_SIZE = 150;
|
|||||||
|
|
||||||
// Canvas max size (px)
|
// Canvas max size (px)
|
||||||
export const MAX_GRID_PX = 1000;
|
export const MAX_GRID_PX = 1000;
|
||||||
|
export const MAX_RANDOM_WALLS_FACTORS = 0.3;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -25,7 +25,7 @@ export class PathfindingService {
|
|||||||
let currentNode: Node | null = endNode;
|
let currentNode: Node | null = endNode;
|
||||||
while (currentNode !== null) {
|
while (currentNode !== null) {
|
||||||
shortestPathNodes.unshift(currentNode);
|
shortestPathNodes.unshift(currentNode);
|
||||||
currentNode = currentNode.previousNode;
|
currentNode = currentNode.linkedNode;
|
||||||
}
|
}
|
||||||
return shortestPathNodes;
|
return shortestPathNodes;
|
||||||
}
|
}
|
||||||
@@ -33,7 +33,7 @@ export class PathfindingService {
|
|||||||
// Dijkstra's Algorithm
|
// Dijkstra's Algorithm
|
||||||
dijkstra(grid: Node[][], startNode: Node, endNode: Node): { visitedNodesInOrder: Node[], nodesInShortestPathOrder: Node[] } {
|
dijkstra(grid: Node[][], startNode: Node, endNode: Node): { visitedNodesInOrder: Node[], nodesInShortestPathOrder: Node[] } {
|
||||||
const visitedNodesInOrder: Node[] = [];
|
const visitedNodesInOrder: Node[] = [];
|
||||||
startNode.distance = 0;
|
startNode.nodeData = 0;
|
||||||
const unvisitedNodes: Node[] = this.getAllNodes(grid);
|
const unvisitedNodes: Node[] = this.getAllNodes(grid);
|
||||||
|
|
||||||
while (unvisitedNodes.length > 0) {
|
while (unvisitedNodes.length > 0) {
|
||||||
@@ -44,7 +44,7 @@ export class PathfindingService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isTrapped = closestNode.distance === Infinity;
|
const isTrapped = closestNode.nodeData === Infinity;
|
||||||
if (isTrapped)
|
if (isTrapped)
|
||||||
{
|
{
|
||||||
return { visitedNodesInOrder, nodesInShortestPathOrder: [] };
|
return { visitedNodesInOrder, nodesInShortestPathOrder: [] };
|
||||||
@@ -65,24 +65,24 @@ export class PathfindingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private sortNodesByDistance(unvisitedNodes: Node[]): void {
|
private sortNodesByDistance(unvisitedNodes: Node[]): void {
|
||||||
unvisitedNodes.sort((nodeA, nodeB) => nodeA.distance - nodeB.distance);
|
unvisitedNodes.sort((nodeA, nodeB) => nodeA.nodeData - nodeB.nodeData);
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateUnvisitedNeighbors(node: Node, grid: Node[][]): void {
|
private updateUnvisitedNeighbors(node: Node, grid: Node[][]): void {
|
||||||
const unvisitedNeighbors = this.getUnvisitedNeighbors(node, grid);
|
const unvisitedNeighbors = this.getUnvisitedNeighbors(node, grid);
|
||||||
for (const neighbor of unvisitedNeighbors) {
|
for (const neighbor of unvisitedNeighbors) {
|
||||||
neighbor.distance = node.distance + 1;
|
neighbor.nodeData = node.nodeData + 1;
|
||||||
neighbor.previousNode = node;
|
neighbor.linkedNode = node;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A* Search Algorithm
|
// A* Search Algorithm
|
||||||
aStar(grid: Node[][], startNode: Node, endNode: Node): { visitedNodesInOrder: Node[], nodesInShortestPathOrder: Node[] } {
|
aStar(grid: Node[][], startNode: Node, endNode: Node): { visitedNodesInOrder: Node[], nodesInShortestPathOrder: Node[] } {
|
||||||
const visitedNodesInOrder: Node[] = [];
|
const visitedNodesInOrder: Node[] = [];
|
||||||
startNode.distance = 0;
|
startNode.nodeData = 0;
|
||||||
startNode['hScore'] = this.calculateHeuristic(startNode, endNode);
|
startNode['hScore'] = this.calculateHeuristic(startNode, endNode);
|
||||||
// fScore = gScore + hScore
|
// fScore = gScore + hScore
|
||||||
startNode['fScore'] = startNode.distance + startNode['hScore'];
|
startNode['fScore'] = startNode.nodeData + startNode['hScore'];
|
||||||
|
|
||||||
const openSet: Node[] = [startNode];
|
const openSet: Node[] = [startNode];
|
||||||
const allNodes = this.getAllNodes(grid);
|
const allNodes = this.getAllNodes(grid);
|
||||||
@@ -97,7 +97,7 @@ export class PathfindingService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isTrapped = currentNode.distance === Infinity;
|
const isTrapped = currentNode.nodeData === Infinity;
|
||||||
if (isTrapped)
|
if (isTrapped)
|
||||||
{
|
{
|
||||||
return {visitedNodesInOrder, nodesInShortestPathOrder: []};
|
return {visitedNodesInOrder, nodesInShortestPathOrder: []};
|
||||||
@@ -114,9 +114,9 @@ export class PathfindingService {
|
|||||||
|
|
||||||
const neighbors = this.getUnvisitedNeighbors(currentNode, grid);
|
const neighbors = this.getUnvisitedNeighbors(currentNode, grid);
|
||||||
for (const neighbor of neighbors) {
|
for (const neighbor of neighbors) {
|
||||||
const tentativeGScore = currentNode.distance + 1; // Distance from start to neighbor
|
const tentativeGScore = currentNode.nodeData + 1; // Distance from start to neighbor
|
||||||
|
|
||||||
if (tentativeGScore < neighbor.distance) {
|
if (tentativeGScore < neighbor.nodeData) {
|
||||||
this.updateNeighborNode(neighbor, currentNode, tentativeGScore, endNode, openSet);
|
this.updateNeighborNode(neighbor, currentNode, tentativeGScore, endNode, openSet);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -136,11 +136,11 @@ export class PathfindingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private updateNeighborNode(neighbor: Node, currentNode: Node, tentativeGScore: number, endNode: Node, openSet: Node[]) {
|
private updateNeighborNode(neighbor: Node, currentNode: Node, tentativeGScore: number, endNode: Node, openSet: Node[]) {
|
||||||
neighbor.previousNode = currentNode;
|
neighbor.linkedNode = currentNode;
|
||||||
neighbor.distance = tentativeGScore;
|
neighbor.nodeData = tentativeGScore;
|
||||||
neighbor['distance'] = this.calculateHeuristic(neighbor, endNode);
|
neighbor['nodeData'] = this.calculateHeuristic(neighbor, endNode);
|
||||||
neighbor['hScore'] = this.calculateHeuristic(neighbor, endNode);
|
neighbor['hScore'] = this.calculateHeuristic(neighbor, endNode);
|
||||||
neighbor['fScore'] = neighbor.distance + neighbor['hScore'];
|
neighbor['fScore'] = neighbor.nodeData + neighbor['hScore'];
|
||||||
|
|
||||||
if (!openSet.includes(neighbor)) {
|
if (!openSet.includes(neighbor)) {
|
||||||
openSet.push(neighbor);
|
openSet.push(neighbor);
|
||||||
@@ -151,7 +151,7 @@ export class PathfindingService {
|
|||||||
for (const node of allNodes) {
|
for (const node of allNodes) {
|
||||||
if (node !== startNode) {
|
if (node !== startNode) {
|
||||||
node['fScore'] = Infinity;
|
node['fScore'] = Infinity;
|
||||||
node.distance = Infinity; // gScore
|
node.nodeData = Infinity; // gScore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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,23 +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
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
329
src/app/pages/algorithms/sorting/service/sorting.service.ts
Normal file
329
src/app/pages/algorithms/sorting/service/sorting.service.ts
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import {SortData, SortSnapshot} from '../sorting.models';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class SortingService {
|
||||||
|
|
||||||
|
private createSnapshot(array: SortData[]): SortSnapshot {
|
||||||
|
return {
|
||||||
|
array: array.map(item => ({ ...item }))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- BUBBLE SORT ---
|
||||||
|
bubbleSort(array: SortData[]): SortSnapshot[] {
|
||||||
|
const snapshots: SortSnapshot[] = [];
|
||||||
|
const arr = array.map(item => ({ ...item }));
|
||||||
|
const n = arr.length;
|
||||||
|
|
||||||
|
snapshots.push(this.createSnapshot(arr));
|
||||||
|
|
||||||
|
for (let i = 0; i < n - 1; i++) {
|
||||||
|
for (let j = 0; j < n - i - 1; j++) {
|
||||||
|
arr[j].state = 'comparing';
|
||||||
|
arr[j + 1].state = 'comparing';
|
||||||
|
snapshots.push(this.createSnapshot(arr));
|
||||||
|
|
||||||
|
if (arr[j].value > arr[j + 1].value) {
|
||||||
|
const temp = arr[j].value;
|
||||||
|
arr[j].value = arr[j + 1].value;
|
||||||
|
arr[j + 1].value = temp;
|
||||||
|
|
||||||
|
snapshots.push(this.createSnapshot(arr));
|
||||||
|
}
|
||||||
|
|
||||||
|
arr[j].state = 'unsorted';
|
||||||
|
arr[j + 1].state = 'unsorted';
|
||||||
|
}
|
||||||
|
arr[n - 1 - i].state = 'sorted';
|
||||||
|
snapshots.push(this.createSnapshot(arr));
|
||||||
|
}
|
||||||
|
|
||||||
|
arr[0].state = 'sorted';
|
||||||
|
snapshots.push(this.createSnapshot(arr));
|
||||||
|
|
||||||
|
return snapshots;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- COCKTAIL SORT ---
|
||||||
|
|
||||||
|
cocktailSort(array: SortData[]): SortSnapshot[] {
|
||||||
|
const snapshots: SortSnapshot[] = [];
|
||||||
|
const arr = array.map(item => ({ ...item }));
|
||||||
|
snapshots.push(this.createSnapshot(arr));
|
||||||
|
|
||||||
|
let start = -1;
|
||||||
|
let end = array.length-1;
|
||||||
|
let changed: boolean;
|
||||||
|
do {
|
||||||
|
changed = false;
|
||||||
|
start += 1;
|
||||||
|
for (let i = start; i < end; i++) {
|
||||||
|
changed = this.switchValuesIfCorrect(arr, i, snapshots, changed);
|
||||||
|
}
|
||||||
|
|
||||||
|
arr[end].state = 'sorted';
|
||||||
|
snapshots.push(this.createSnapshot(arr));
|
||||||
|
|
||||||
|
//DONE
|
||||||
|
if (!changed) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
changed = false;
|
||||||
|
end -= 1;
|
||||||
|
for (let i = end-1; i >= start; i--) {
|
||||||
|
changed = this.switchValuesIfCorrect(arr, i, snapshots, changed);
|
||||||
|
}
|
||||||
|
arr[start].state = 'sorted';
|
||||||
|
snapshots.push(this.createSnapshot(arr));
|
||||||
|
} while (changed);
|
||||||
|
return snapshots;
|
||||||
|
}
|
||||||
|
|
||||||
|
private switchValuesIfCorrect(arr: { value: number; state: "sorted" | "comparing" | "unsorted" }[], i: number, snapshots: SortSnapshot[], changed: boolean) {
|
||||||
|
const firstValue = arr[i];
|
||||||
|
const secondValue = arr[i + 1];
|
||||||
|
|
||||||
|
firstValue.state = 'comparing';
|
||||||
|
secondValue.state = 'comparing';
|
||||||
|
snapshots.push(this.createSnapshot(arr));
|
||||||
|
|
||||||
|
if (firstValue.value > secondValue.value) {
|
||||||
|
const temp = firstValue.value;
|
||||||
|
firstValue.value = secondValue.value;
|
||||||
|
secondValue.value = temp;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
firstValue.state = 'unsorted';
|
||||||
|
secondValue.state = 'unsorted';
|
||||||
|
snapshots.push(this.createSnapshot(arr));
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- QUICK SORT ---
|
||||||
|
quickSort(array: SortData[]): SortSnapshot[] {
|
||||||
|
const snapshots: SortSnapshot[] = [];
|
||||||
|
const arr = array.map(item => ({ ...item }));
|
||||||
|
snapshots.push(this.createSnapshot(arr));
|
||||||
|
|
||||||
|
this.quickSortHelper(arr, 0, arr.length - 1, snapshots);
|
||||||
|
|
||||||
|
arr.forEach(i => i.state = 'sorted');
|
||||||
|
snapshots.push(this.createSnapshot(arr));
|
||||||
|
|
||||||
|
return snapshots;
|
||||||
|
}
|
||||||
|
|
||||||
|
private quickSortHelper(arr: SortData[], low: number, high: number, snapshots: SortSnapshot[]) {
|
||||||
|
if (low < high) {
|
||||||
|
const pi = this.partition(arr, low, high, snapshots);
|
||||||
|
|
||||||
|
this.quickSortHelper(arr, low, pi - 1, snapshots);
|
||||||
|
this.quickSortHelper(arr, pi + 1, high, snapshots);
|
||||||
|
} else if (low >= 0 && high >= 0 && low === high) {
|
||||||
|
arr[low].state = 'sorted';
|
||||||
|
snapshots.push(this.createSnapshot(arr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private partition(arr: SortData[], low: number, high: number, snapshots: SortSnapshot[]): number {
|
||||||
|
const pivot = arr[high];
|
||||||
|
arr[high].state = 'comparing';
|
||||||
|
snapshots.push(this.createSnapshot(arr));
|
||||||
|
|
||||||
|
let i = (low - 1);
|
||||||
|
|
||||||
|
for (let j = low; j <= high - 1; j++) {
|
||||||
|
arr[j].state = 'comparing';
|
||||||
|
snapshots.push(this.createSnapshot(arr));
|
||||||
|
|
||||||
|
if (arr[j].value < pivot.value) {
|
||||||
|
i++;
|
||||||
|
this.swap(arr, i, j);
|
||||||
|
snapshots.push(this.createSnapshot(arr));
|
||||||
|
}
|
||||||
|
arr[j].state = 'unsorted';
|
||||||
|
}
|
||||||
|
this.swap(arr, i + 1, high);
|
||||||
|
|
||||||
|
arr[high].state = 'unsorted';
|
||||||
|
arr[i + 1].state = 'sorted';
|
||||||
|
snapshots.push(this.createSnapshot(arr));
|
||||||
|
|
||||||
|
return i + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- HEAP SORT ---
|
||||||
|
heapSort(array: SortData[]): SortSnapshot[] {
|
||||||
|
const snapshots: SortSnapshot[] = [];
|
||||||
|
const arr = array.map(item => ({ ...item }));
|
||||||
|
const n = arr.length;
|
||||||
|
|
||||||
|
snapshots.push(this.createSnapshot(arr));
|
||||||
|
|
||||||
|
for (let i = Math.floor(n / 2) - 1; i >= 0; i--) {
|
||||||
|
this.heapify(arr, n, i, snapshots);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = n - 1; i > 0; i--) {
|
||||||
|
arr[0].state = 'comparing';
|
||||||
|
arr[i].state = 'comparing';
|
||||||
|
snapshots.push(this.createSnapshot(arr));
|
||||||
|
|
||||||
|
this.swap(arr, 0, i);
|
||||||
|
|
||||||
|
arr[0].state = 'unsorted';
|
||||||
|
arr[i].state = 'sorted';
|
||||||
|
snapshots.push(this.createSnapshot(arr));
|
||||||
|
|
||||||
|
this.heapify(arr, i, 0, snapshots);
|
||||||
|
}
|
||||||
|
arr[0].state = 'sorted';
|
||||||
|
snapshots.push(this.createSnapshot(arr));
|
||||||
|
|
||||||
|
return snapshots;
|
||||||
|
}
|
||||||
|
|
||||||
|
private heapify(arr: SortData[], n: number, i: number, snapshots: SortSnapshot[]) {
|
||||||
|
let largest = i;
|
||||||
|
const l = 2 * i + 1;
|
||||||
|
const r = 2 * i + 2;
|
||||||
|
|
||||||
|
if (l < n) {
|
||||||
|
arr[l].state = 'comparing';
|
||||||
|
arr[largest].state = 'comparing';
|
||||||
|
snapshots.push(this.createSnapshot(arr));
|
||||||
|
|
||||||
|
if (arr[l].value > arr[largest].value) {
|
||||||
|
largest = l;
|
||||||
|
}
|
||||||
|
arr[l].state = 'unsorted';
|
||||||
|
arr[largest].state = 'unsorted';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (r < n) {
|
||||||
|
arr[r].state = 'comparing';
|
||||||
|
arr[largest].state = 'comparing';
|
||||||
|
snapshots.push(this.createSnapshot(arr));
|
||||||
|
|
||||||
|
if (arr[r].value > arr[largest].value) {
|
||||||
|
largest = r;
|
||||||
|
}
|
||||||
|
arr[r].state = 'unsorted';
|
||||||
|
arr[largest].state = 'unsorted';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (largest !== i) {
|
||||||
|
this.swap(arr, i, largest);
|
||||||
|
snapshots.push(this.createSnapshot(arr));
|
||||||
|
|
||||||
|
this.heapify(arr, n, largest, snapshots);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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) {
|
||||||
|
const temp = arr[i].value;
|
||||||
|
arr[i].value = arr[j].value;
|
||||||
|
arr[j].value = temp;
|
||||||
|
}
|
||||||
|
}
|
||||||
63
src/app/pages/algorithms/sorting/sorting.component.html
Normal file
63
src/app/pages/algorithms/sorting/sorting.component.html
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<mat-card class="w-full max-w-[1920px] p-[clamp(10px,3vw,20px)]">
|
||||||
|
<mat-card-header>
|
||||||
|
<mat-card-title>{{ 'SORTING.TITLE' | translate }}</mat-card-title>
|
||||||
|
</mat-card-header>
|
||||||
|
<mat-card-content>
|
||||||
|
<app-information [algorithmInformation]="algoInformation"/>
|
||||||
|
<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 class="w-[clamp(150px,20vw,200px)]" appearance="fill">
|
||||||
|
<mat-label>{{ 'SORTING.ALGORITHM' | translate }}</mat-label>
|
||||||
|
<mat-select [(ngModel)]="selectedAlgorithm">
|
||||||
|
@for (algo of algoInformation.entries; track algo.name) {
|
||||||
|
<mat-option [value]="algo.name">{{ algo.name }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field class="w-[clamp(150px,20vw,200px)]" appearance="outline">
|
||||||
|
<mat-label>{{ 'SORTING.ARRAY_SIZE' | translate }}</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
type="number"
|
||||||
|
[min]="MIN_ARRAY_SIZE"
|
||||||
|
[max]="MAX_ARRAY_SIZE"
|
||||||
|
[(ngModel)]="arraySize"
|
||||||
|
(blur)="newArraySizeSet()"
|
||||||
|
(keyup.enter)="newArraySizeSet()"
|
||||||
|
/>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
<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()">
|
||||||
|
<mat-icon>play_arrow</mat-icon> {{ 'SORTING.START' | translate }}
|
||||||
|
</button>
|
||||||
|
<button mat-raised-button color="warn" (click)="resetSorting()">
|
||||||
|
<mat-icon>refresh</mat-icon> {{ 'SORTING.RESET' | translate }}
|
||||||
|
</button>
|
||||||
|
<button mat-raised-button (click)="generateNewArray()">
|
||||||
|
<mat-icon>add_box</mat-icon> {{ 'SORTING.GENERATE_NEW_ARRAY' | translate }}
|
||||||
|
</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 class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
|
||||||
|
<p>{{ 'SORTING.EXECUTION_TIME' | translate }}: {{ executionTime }} ms</p>
|
||||||
|
</div>
|
||||||
|
<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) {
|
||||||
|
<div
|
||||||
|
class="grow w-2.5 min-w-px transition-all duration-50"
|
||||||
|
[style.height.px]="item.value * 3"
|
||||||
|
[ngClass]="{
|
||||||
|
'bg-[#424242]': item.state === 'unsorted',
|
||||||
|
'bg-[#ffeb3b]': item.state === 'comparing',
|
||||||
|
'bg-[#4caf50]': item.state === 'sorted'
|
||||||
|
}"
|
||||||
|
></div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
200
src/app/pages/algorithms/sorting/sorting.component.ts
Normal file
200
src/app/pages/algorithms/sorting/sorting.component.ts
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import {ChangeDetectorRef, Component, inject, OnInit} from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import {MatCardModule} from "@angular/material/card";
|
||||||
|
import {MatFormFieldModule} from "@angular/material/form-field";
|
||||||
|
import {MatSelectModule} from "@angular/material/select";
|
||||||
|
import {MatButtonModule} from "@angular/material/button";
|
||||||
|
import {MatIconModule} from "@angular/material/icon";
|
||||||
|
import {TranslatePipe} from "@ngx-translate/core";
|
||||||
|
import { SortingService } from './service/sorting.service';
|
||||||
|
import { SortingAudioService } from './service/sorting-audio.service';
|
||||||
|
import {SortData, SortSnapshot} from './sorting.models';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import {MatInput} from '@angular/material/input';
|
||||||
|
import {UrlConstants} from '../../../constants/UrlConstants';
|
||||||
|
import {AlgorithmInformation} from '../information/information.models';
|
||||||
|
import {Information} from '../information/information';
|
||||||
|
@Component({
|
||||||
|
selector: 'app-sorting',
|
||||||
|
imports: [CommonModule, MatCardModule, MatFormFieldModule, MatSelectModule, MatButtonModule, MatIconModule, TranslatePipe, FormsModule, MatInput, Information],
|
||||||
|
templateUrl: './sorting.component.html',
|
||||||
|
styleUrl: './sorting.component.scss'
|
||||||
|
})
|
||||||
|
export class SortingComponent implements OnInit {
|
||||||
|
|
||||||
|
private readonly sortingService: SortingService = inject(SortingService);
|
||||||
|
private readonly audioService: SortingAudioService = inject(SortingAudioService);
|
||||||
|
private readonly cdr: ChangeDetectorRef = inject(ChangeDetectorRef);
|
||||||
|
|
||||||
|
readonly MAX_ARRAY_SIZE: number = 200;
|
||||||
|
readonly MIN_ARRAY_SIZE: number = 20;
|
||||||
|
|
||||||
|
algoInformation: AlgorithmInformation = {
|
||||||
|
title: 'SORTING.EXPLANATION.TITLE',
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
name: 'Bubble Sort',
|
||||||
|
description: 'SORTING.EXPLANATION.BUBBLE_SORT_EXPLANATION',
|
||||||
|
link: UrlConstants.BUBBLE_SORT_WIKI
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Cocktail Sort',
|
||||||
|
description: 'SORTING.EXPLANATION.COCKTAIL_SORT_EXPLANATION',
|
||||||
|
link: UrlConstants.SHAKE_SORT_WIKI
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Quick Sort',
|
||||||
|
description: 'SORTING.EXPLANATION.QUICK_SORT_EXPLANATION',
|
||||||
|
link: UrlConstants.QUICK_SORT_WIKI
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Heap Sort',
|
||||||
|
description: 'SORTING.EXPLANATION.HEAP_SORT_EXPLANATION',
|
||||||
|
link: UrlConstants.HEAP_SORT_WIKI
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Tim Sort',
|
||||||
|
description: 'SORTING.EXPLANATION.TIMSORT_EXPLANATION',
|
||||||
|
link: UrlConstants.TIM_SORT_WIKI
|
||||||
|
}
|
||||||
|
],
|
||||||
|
disclaimer: 'SORTING.EXPLANATION.DISCLAIMER',
|
||||||
|
disclaimerBottom: 'SORTING.EXPLANATION.DISCLAIMER_4',
|
||||||
|
disclaimerListEntry: [
|
||||||
|
'SORTING.EXPLANATION.DISCLAIMER_1',
|
||||||
|
'SORTING.EXPLANATION.DISCLAIMER_2',
|
||||||
|
'SORTING.EXPLANATION.DISCLAIMER_3'
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
private timeoutIds: number[] = [];
|
||||||
|
sortArray: SortData[] = [];
|
||||||
|
unsortedArrayCopy: SortData[] = [];
|
||||||
|
arraySize = 50;
|
||||||
|
maxArrayValue = 100;
|
||||||
|
animationSpeed = 100; // Milliseconds per step
|
||||||
|
selectedAlgorithm: string = this.algoInformation.entries[0].name;
|
||||||
|
executionTime = 0;
|
||||||
|
isSoundEnabled = false;
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.generateNewArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
newArraySizeSet()
|
||||||
|
{
|
||||||
|
this.arraySize = Math.min(Math.max(this.arraySize, this.MIN_ARRAY_SIZE), this.MAX_ARRAY_SIZE);
|
||||||
|
if (this.arraySize == this.sortArray.length)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.generateNewArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
generateNewArray(): void {
|
||||||
|
this.resetSorting();
|
||||||
|
this.executionTime = 0;
|
||||||
|
this.unsortedArrayCopy = [];
|
||||||
|
this.sortArray = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < this.arraySize; i++) {
|
||||||
|
const randomValue = Math.floor(Math.random() * this.maxArrayValue) + 1;
|
||||||
|
this.sortArray.push({
|
||||||
|
value: randomValue,
|
||||||
|
state: 'unsorted'
|
||||||
|
});
|
||||||
|
|
||||||
|
this.unsortedArrayCopy.push({
|
||||||
|
value: randomValue,
|
||||||
|
state: 'unsorted'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private resetSortState() {
|
||||||
|
for (let i = 0; i < this.sortArray.length; i++) {
|
||||||
|
const element = this.sortArray[i];
|
||||||
|
const unsortedElement = this.unsortedArrayCopy[i];
|
||||||
|
element.value = unsortedElement.value;
|
||||||
|
element.state = 'unsorted';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleSound(): void {
|
||||||
|
this.isSoundEnabled = !this.isSoundEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
async startSorting(): Promise<void> {
|
||||||
|
this.resetSorting();
|
||||||
|
// Init the AudioContext on this user gesture so the browser allows sound
|
||||||
|
this.audioService.initOnUserGesture();
|
||||||
|
const startTime = performance.now();
|
||||||
|
let snapshots: SortSnapshot[] = [];
|
||||||
|
|
||||||
|
switch (this.selectedAlgorithm) {
|
||||||
|
case 'Bubble Sort':
|
||||||
|
snapshots = this.sortingService.bubbleSort(this.sortArray);
|
||||||
|
break;
|
||||||
|
case 'Quick Sort':
|
||||||
|
snapshots = this.sortingService.quickSort(this.sortArray);
|
||||||
|
break;
|
||||||
|
case 'Heap Sort':
|
||||||
|
snapshots = this.sortingService.heapSort(this.sortArray);
|
||||||
|
break;
|
||||||
|
case 'Cocktail Sort':
|
||||||
|
snapshots = this.sortingService.cocktailSort(this.sortArray);
|
||||||
|
break;
|
||||||
|
case 'Tim Sort':
|
||||||
|
snapshots = this.sortingService.timSort(this.sortArray);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const endTime = performance.now();
|
||||||
|
this.executionTime = Number.parseFloat((endTime - startTime).toFixed(4));
|
||||||
|
|
||||||
|
this.animateSorting(snapshots);
|
||||||
|
}
|
||||||
|
|
||||||
|
private animateSorting(snapshots: SortSnapshot[]): void {
|
||||||
|
snapshots.forEach((snapshot, index) => {
|
||||||
|
const id = setTimeout(() => {
|
||||||
|
for (let i = 0; i < this.sortArray.length; i++) {
|
||||||
|
if (snapshot.array[i]) {
|
||||||
|
this.sortArray[i].value = snapshot.array[i].value;
|
||||||
|
this.sortArray[i].state = snapshot.array[i].state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
if (index === snapshots.length - 1) {
|
||||||
|
this.sortArray.forEach(item => item.state = 'sorted');
|
||||||
|
this.cdr.detectChanges();
|
||||||
|
if (this.isSoundEnabled) {
|
||||||
|
this.audioService.playSortedSweep(this.sortArray.map(item => item.value), this.maxArrayValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, index * this.animationSpeed);
|
||||||
|
|
||||||
|
this.timeoutIds.push(id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopAnimations(): void {
|
||||||
|
this.timeoutIds.forEach(id => clearTimeout(id));
|
||||||
|
this.timeoutIds = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
resetSorting(): void {
|
||||||
|
this.stopAnimations();
|
||||||
|
this.resetSortState();
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/app/pages/algorithms/sorting/sorting.models.ts
Normal file
8
src/app/pages/algorithms/sorting/sorting.models.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export interface SortData {
|
||||||
|
value: number;
|
||||||
|
state: 'sorted' | 'comparing' | 'unsorted';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SortSnapshot {
|
||||||
|
array: SortData[];
|
||||||
|
}
|
||||||
@@ -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,48 +1,73 @@
|
|||||||
<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">
|
||||||
|
<ul class="pl-5">
|
||||||
@for(bullet of project.bulletPoints; track bullet) {
|
@for(bullet of project.bulletPoints; track bullet) {
|
||||||
<li>{{ bullet | translate }}</li>
|
<li class="mb-2">{{ bullet | translate }}</li>
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div 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">
|
||||||
|
<div class="flex items-center gap-3 mb-3 text-link">
|
||||||
|
<mat-icon class="text-[24px]! w-6! h-6!">settings_suggest</mat-icon>
|
||||||
|
<h3 class="m-0 text-sm uppercase tracking-wider font-semibold">{{ 'PROJECTS.SECTION.TECHNICAL' | translate }}</h3>
|
||||||
|
</div>
|
||||||
|
<ul class="m-0 pl-5 text-[0.95rem] leading-relaxed opacity-85">
|
||||||
|
@for(challenge of project.challenges; track challenge) {
|
||||||
|
<li class="mb-1">{{ challenge | translate }}</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-5 rounded-xl bg-black/3 border border-black/5 dark:bg-white/5 dark:border-white/10">
|
||||||
|
<div class="flex items-center gap-3 mb-3 text-link">
|
||||||
|
<mat-icon class="text-[24px]! w-6! h-6!">psychology</mat-icon>
|
||||||
|
<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)
|
@if (project.images.length > 0)
|
||||||
{
|
{
|
||||||
<swiper-container
|
<div class="my-4 rounded-xl overflow-hidden bg-black">
|
||||||
class="my-swiper"
|
<swiper-container class="my-swiper rounded-xl" [attr.slides-per-view]="1" [attr.space-between]="12" [attr.navigation]="true"
|
||||||
[attr.slides-per-view]="1.2"
|
[attr.pagination]="true" [attr.keyboard]="true" style="width: 100%;">
|
||||||
[attr.space-between]="12"
|
|
||||||
[attr.navigation]="true"
|
|
||||||
[attr.pagination]="true"
|
|
||||||
[attr.keyboard]="true"
|
|
||||||
style="width: 100%;"
|
|
||||||
>
|
|
||||||
@for (img of project.images; track img) {
|
@for (img of project.images; track img) {
|
||||||
<swiper-slide>
|
<swiper-slide class="rounded-xl overflow-hidden flex flex-col bg-[#222]">
|
||||||
<img
|
<img class="w-full h-auto max-h-[clamp(300px,60vh,512px)]! object-contain block shrink-0" [src]="img.url" [alt]="project.title | translate" />
|
||||||
class="slide-img"
|
|
||||||
[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>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-4 pt-4 border-t border-black/10 dark:border-white/10">
|
||||||
|
<div class="flex flex-wrap">
|
||||||
<mat-chip-set aria-label="Technologies">
|
<mat-chip-set aria-label="Technologies">
|
||||||
@for(tech of project.technologies; track tech) {
|
@for(tech of project.technologies; track tech) {
|
||||||
<mat-chip>{{tech}}</mat-chip>
|
<mat-chip>{{tech}}</mat-chip>
|
||||||
}
|
}
|
||||||
</mat-chip-set>
|
</mat-chip-set>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="link-section">
|
<div class="flex flex-wrap gap-fluid-sm mt-6">
|
||||||
@for(link of project.links; track link)
|
@for(link of project.links; track link)
|
||||||
{
|
{
|
||||||
<a mat-button href="{{link.url}}" target="_blank" rel="noopener noreferrer">
|
<a mat-button href="{{link.url}}" target="_blank" rel="noopener noreferrer">
|
||||||
@@ -59,6 +84,8 @@
|
|||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</mat-dialog-content>
|
</mat-dialog-content>
|
||||||
<mat-dialog-actions>
|
<mat-dialog-actions>
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user