Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
034e397864 | ||
|
|
dc9f09bede | ||
|
|
2abc7c31e2 | ||
|
|
4e59770e53 | ||
|
|
dd7c9f6e3d |
No files matched your search
@@ -46,6 +46,7 @@ Dockerfile text eol=lf
|
|||||||
*.jpeg binary
|
*.jpeg binary
|
||||||
*.gif binary
|
*.gif binary
|
||||||
*.ico binary
|
*.ico binary
|
||||||
|
*.webp binary
|
||||||
*.pdf binary
|
*.pdf binary
|
||||||
*.zip binary
|
*.zip binary
|
||||||
*.tar binary
|
*.tar binary
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ jobs:
|
|||||||
check-latest: true
|
check-latest: true
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
||||||
|
# Shared libraries for the Chrome that Lighthouse CI runs
|
||||||
- name: Install Linux Libs
|
- name: Install Linux Libs
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
@@ -57,21 +58,19 @@ jobs:
|
|||||||
- name: i18n Key Check
|
- name: i18n Key Check
|
||||||
run: npm run i18n:check
|
run: npm run i18n:check
|
||||||
|
|
||||||
# 3. Unit Tests (Logic) — browser/launcher configured in karma.conf.js
|
# 3. Unit Tests (Vitest + jsdom, no browser)
|
||||||
- name: Unit Tests
|
- name: Unit Tests
|
||||||
run: npx ng test --watch=false
|
run: npm test
|
||||||
|
|
||||||
# 4. Build Production (necessary for lighthouse)
|
# 4. Build Production (necessary for lighthouse)
|
||||||
- name: Build Production
|
- name: Build Production
|
||||||
run: npx ng build --configuration production
|
run: npx ng build --configuration production
|
||||||
|
|
||||||
# 5. Lighthouse Audit (Performance & SEO)
|
# 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
|
- name: Lighthouse CI
|
||||||
run: |
|
run: |
|
||||||
CHROME_PATH=$(node -e 'require("puppeteer").executablePath().then(p => console.log(p))')
|
CHROME_PATH=$(npx @puppeteer/browsers install chrome@stable --path "$RUNNER_TEMP/chrome" --format '{{path}}')
|
||||||
export CHROME_PATH=$CHROME_PATH
|
export CHROME_PATH
|
||||||
npx lhci autorun
|
npx lhci autorun
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
# 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 . .
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ To run the unit tests:
|
|||||||
```bash
|
```bash
|
||||||
ng test
|
ng test
|
||||||
```
|
```
|
||||||
|
`npm test` runs once (Vitest + jsdom). `ng test` watches in a terminal; use `--watch=false` to run once.
|
||||||
|
|
||||||
### Linting
|
### Linting
|
||||||
|
|
||||||
|
|||||||
@@ -87,9 +87,10 @@ Then open http://localhost:8080
|
|||||||
| `npm start` | Dev server |
|
| `npm start` | Dev server |
|
||||||
| `npm run build` | Production build |
|
| `npm run build` | Production build |
|
||||||
| `npm run watch` | Rebuild on change (development configuration) |
|
| `npm run watch` | Rebuild on change (development configuration) |
|
||||||
| `npm test` | Unit tests (Karma + Jasmine, headless Chrome) |
|
| `npm test` | Unit tests (Vitest + jsdom, no browser needed) |
|
||||||
| `npm run lint` | ESLint over `src/**/*.ts` and `src/**/*.html` |
|
| `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 |
|
| `npm run i18n:check` | Verifies `en.json` and `de.json` share the exact same keys |
|
||||||
|
| `npm run assets:check` | Verifies every asset path referenced in `src/` exists (also runs in `npm run lint`) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -102,9 +103,9 @@ Triggered on every push to `main` and on pull requests targeting `main`.
|
|||||||
|
|
||||||
**Job 1 — `quality-check`** (runs on both pushes and PRs):
|
**Job 1 — `quality-check`** (runs on both pushes and PRs):
|
||||||
|
|
||||||
1. Lint & type check — `npm run lint`
|
1. Lint & type check — `npm run lint` (includes `npm run assets:check`)
|
||||||
2. i18n key sync — `npm run i18n:check`
|
2. i18n key sync — `npm run i18n:check`
|
||||||
3. Unit tests — `npx ng test --watch=false`
|
3. Unit tests — `npm test`
|
||||||
4. Production build — `npx ng build --configuration production`
|
4. Production build — `npx ng build --configuration production`
|
||||||
5. Lighthouse audit — `npx lhci autorun` (performance & SEO)
|
5. Lighthouse audit — `npx lhci autorun` (performance & SEO)
|
||||||
|
|
||||||
|
|||||||
@@ -87,27 +87,7 @@
|
|||||||
"builder": "@angular/build:extract-i18n"
|
"builder": "@angular/build:extract-i18n"
|
||||||
},
|
},
|
||||||
"test": {
|
"test": {
|
||||||
"builder": "@angular/build:karma",
|
"builder": "@angular/build:unit-test"
|
||||||
"options": {
|
|
||||||
"karmaConfig": "karma.conf.js",
|
|
||||||
"tsConfig": "tsconfig.spec.json",
|
|
||||||
"inlineStyleLanguage": "scss",
|
|
||||||
"assets": [
|
|
||||||
{
|
|
||||||
"glob": "**/*",
|
|
||||||
"input": "public"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"glob": "**/*",
|
|
||||||
"input": "src/assets",
|
|
||||||
"output": "/assets"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"styles": [
|
|
||||||
"src/tailwind.css",
|
|
||||||
"src/styles.scss"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"lint": {
|
"lint": {
|
||||||
"builder": "@angular-eslint/builder:lint",
|
"builder": "@angular-eslint/builder:lint",
|
||||||
|
|||||||
@@ -18,8 +18,6 @@ module.exports = defineConfig([
|
|||||||
"@typescript-eslint/no-inferrable-types": "off",
|
"@typescript-eslint/no-inferrable-types": "off",
|
||||||
"@typescript-eslint/no-explicit-any": "off",
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
"@typescript-eslint/prefer-for-of": "off",
|
"@typescript-eslint/prefer-for-of": "off",
|
||||||
// ng update set ChangeDetectionStrategy.Eager everywhere; OnPush is a separate refactor.
|
|
||||||
"@angular-eslint/prefer-on-push-component-change-detection": "off",
|
|
||||||
"@angular-eslint/directive-selector": [
|
"@angular-eslint/directive-selector": [
|
||||||
"error",
|
"error",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
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,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -6,9 +6,10 @@
|
|||||||
"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 --watch=false",
|
||||||
"lint": "ng lint",
|
"lint": "ng lint && npm run assets:check",
|
||||||
"i18n:check": "node scripts/check-i18n.mjs"
|
"i18n:check": "node scripts/check-i18n.mjs",
|
||||||
|
"assets:check": "node scripts/check-assets.mjs"
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -38,20 +39,14 @@
|
|||||||
"@lhci/cli": "^0.15.1",
|
"@lhci/cli": "^0.15.1",
|
||||||
"@puppeteer/browsers": "3.2.4",
|
"@puppeteer/browsers": "3.2.4",
|
||||||
"@tailwindcss/postcss": "^4.3.3",
|
"@tailwindcss/postcss": "^4.3.3",
|
||||||
"@types/jasmine": "~7.0.0",
|
|
||||||
"@webgpu/types": "^0.1.74",
|
"@webgpu/types": "^0.1.74",
|
||||||
"angular-eslint": "22.6.0",
|
"angular-eslint": "22.6.0",
|
||||||
"eslint": "^10.12.0",
|
"eslint": "^10.12.0",
|
||||||
"jasmine-core": "^7.0.2",
|
"jsdom": "^30.1.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.13.0",
|
|
||||||
"tailwindcss": "^4.3.3",
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript": "~6.0.3",
|
"typescript": "~6.0.3",
|
||||||
"typescript-eslint": "^8.71.1"
|
"typescript-eslint": "^8.71.1",
|
||||||
|
"vitest": "^5.0.4"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"tmp": "^0.2.3",
|
"tmp": "^0.2.3",
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Verifies that every asset path in AssetsConstants.ts and index.html exists.
|
||||||
|
// A wrong path builds fine and only 404s at runtime.
|
||||||
|
|
||||||
|
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
||||||
|
import { join, dirname, extname } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const srcDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'src');
|
||||||
|
const EXTENSIONS = new Set(['.ts', '.html', '.scss', '.json']);
|
||||||
|
|
||||||
|
function* walk(dir) {
|
||||||
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
const path = join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
yield* walk(path);
|
||||||
|
} else if (EXTENSIONS.has(extname(path)) && !path.endsWith('.spec.ts')) {
|
||||||
|
yield path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const missing = [];
|
||||||
|
for (const file of walk(srcDir)) {
|
||||||
|
const content = readFileSync(file, 'utf8');
|
||||||
|
for (const [, path] of content.matchAll(/\b(assets\/[\w./-]+)/g)) {
|
||||||
|
if (!existsSync(join(srcDir, path))) {
|
||||||
|
missing.push(`${file}: ${path}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missing.length > 0) {
|
||||||
|
console.error(`Missing assets:\n${missing.join('\n')}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('All referenced assets exist.');
|
||||||
@@ -1,44 +1,44 @@
|
|||||||
export class AssetsConstants {
|
export class AssetsConstants {
|
||||||
|
|
||||||
static readonly ME = '/assets/me.webp';
|
static readonly ME = '/assets/me.webp';
|
||||||
static readonly LOGO = '/assets/logos/logo-wolf.png';
|
static readonly LOGO = '/assets/logos/logo-wolf.webp';
|
||||||
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';
|
||||||
|
|
||||||
//logos
|
//logos
|
||||||
static readonly CHAMAELEON_LOGO = '/assets/logos/logo_chamaeleon.svg';
|
static readonly CHAMAELEON_LOGO = '/assets/logos/logo_chamaeleon.svg';
|
||||||
static readonly TH_BINGEN_LOGO = '/assets/logos/bingen-logo-white.svg';
|
static readonly TH_BINGEN_LOGO = '/assets/logos/bingen-logo-white.svg';
|
||||||
static readonly ASSYST_LOGO = '/assets/logos/assyst_gmbh_logo.jpg';
|
static readonly ASSYST_LOGO = '/assets/logos/assyst_gmbh_logo.webp';
|
||||||
static readonly COLORDIGITAL_LOGO = '/assets/logos/dmixcloud_logo.jpg';
|
static readonly COLORDIGITAL_LOGO = '/assets/logos/dmixcloud_logo.webp';
|
||||||
static readonly TERAPORT_LOGO = '/assets/logos/teraport_gmbh_logo.jpg';
|
static readonly TERAPORT_LOGO = '/assets/logos/teraport_gmbh_logo.webp';
|
||||||
|
|
||||||
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 = [
|
static readonly PLAYGROUND_IMAGES = [
|
||||||
'/assets/projects/playground/1.png'
|
'/assets/projects/playground/1.webp'
|
||||||
];
|
];
|
||||||
|
|
||||||
static readonly EL_MUCHO_IMAGES = [
|
static readonly EL_MUCHO_IMAGES = [
|
||||||
'/assets/projects/el-mucho/1.jpg',
|
'/assets/projects/el-mucho/1.webp',
|
||||||
'/assets/projects/el-mucho/2.jpg',
|
'/assets/projects/el-mucho/2.webp',
|
||||||
'/assets/projects/el-mucho/3.jpg',
|
'/assets/projects/el-mucho/3.webp',
|
||||||
'/assets/projects/el-mucho/4.jpg'
|
'/assets/projects/el-mucho/4.webp'
|
||||||
];
|
];
|
||||||
|
|
||||||
static readonly GAME_JAMS_IMAGES = [
|
static readonly GAME_JAMS_IMAGES = [
|
||||||
'/assets/projects/game-jams/1.png',
|
'/assets/projects/game-jams/1.webp',
|
||||||
'/assets/projects/game-jams/2.png',
|
'/assets/projects/game-jams/2.webp',
|
||||||
'/assets/projects/game-jams/3.png'
|
'/assets/projects/game-jams/3.webp'
|
||||||
];
|
];
|
||||||
|
|
||||||
static readonly DIPLOMA_IMAGES = [
|
static readonly DIPLOMA_IMAGES = [
|
||||||
'/assets/projects/diploma/1.jpg',
|
'/assets/projects/diploma/1.webp',
|
||||||
'/assets/projects/diploma/2.jpg',
|
'/assets/projects/diploma/2.webp',
|
||||||
'/assets/projects/diploma/3.jpg',
|
'/assets/projects/diploma/3.webp',
|
||||||
'/assets/projects/diploma/4.jpg',
|
'/assets/projects/diploma/4.webp',
|
||||||
'/assets/projects/diploma/5.jpg',
|
'/assets/projects/diploma/5.webp',
|
||||||
'/assets/projects/diploma/6.jpg'
|
'/assets/projects/diploma/6.webp'
|
||||||
];
|
];
|
||||||
|
|
||||||
static readonly CLAUDE_BOX_IMAGES = [
|
static readonly CLAUDE_BOX_IMAGES = [
|
||||||
@@ -46,16 +46,16 @@ export class AssetsConstants {
|
|||||||
];
|
];
|
||||||
|
|
||||||
static readonly TRIBBLE_IMAGES = [
|
static readonly TRIBBLE_IMAGES = [
|
||||||
'/assets/projects/tribble-the-homeserver/1.png',
|
'/assets/projects/tribble-the-homeserver/1.webp',
|
||||||
'/assets/projects/tribble-the-homeserver/2.png',
|
'/assets/projects/tribble-the-homeserver/2.webp',
|
||||||
'/assets/projects/tribble-the-homeserver/3.png',
|
'/assets/projects/tribble-the-homeserver/3.webp',
|
||||||
'/assets/projects/tribble-the-homeserver/4.png'
|
'/assets/projects/tribble-the-homeserver/4.webp'
|
||||||
];
|
];
|
||||||
|
|
||||||
static readonly TBP_IMAGES = [
|
static readonly TBP_IMAGES = [
|
||||||
'/assets/projects/to-be-played/1.png',
|
'/assets/projects/to-be-played/1.webp',
|
||||||
'/assets/projects/to-be-played/2.png',
|
'/assets/projects/to-be-played/2.webp',
|
||||||
'/assets/projects/to-be-played/3.png',
|
'/assets/projects/to-be-played/3.webp',
|
||||||
'/assets/projects/to-be-played/4.png'
|
'/assets/projects/to-be-played/4.webp'
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, inject, ChangeDetectionStrategy } from '@angular/core';
|
import { Component, inject } from '@angular/core';
|
||||||
import { RouterLink, 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';
|
||||||
@@ -10,7 +10,6 @@ import {SeoService} from '../../service/seo.service';
|
|||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
imports: [RouterLink, RouterOutlet, TopbarComponent, TranslatePipe, ParticleBackgroundComponent],
|
imports: [RouterLink, RouterOutlet, TopbarComponent, TranslatePipe, ParticleBackgroundComponent],
|
||||||
templateUrl: './app.component.html',
|
templateUrl: './app.component.html',
|
||||||
changeDetection: ChangeDetectionStrategy.Eager,
|
|
||||||
styleUrl: './app.component.scss'
|
styleUrl: './app.component.scss'
|
||||||
})
|
})
|
||||||
export class AppComponent {
|
export class AppComponent {
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
import { Component, inject, ChangeDetectionStrategy } from '@angular/core';
|
|
||||||
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
|
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
imports: [MatDialogModule, MatButtonModule, MatIconModule],
|
|
||||||
template: `
|
|
||||||
<div class="dialog">
|
|
||||||
<div class="topbar">
|
|
||||||
<div class="title">{{ data.title }}</div>
|
|
||||||
<button mat-icon-button mat-dialog-close aria-label="Schließen">
|
|
||||||
<mat-icon>close</mat-icon>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="stage">
|
|
||||||
<img class="full" [src]="data.src" [alt]="data.title" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
changeDetection: ChangeDetectionStrategy.Eager,
|
|
||||||
styles: [`
|
|
||||||
.dialog {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.topbar {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1rem;
|
|
||||||
|
|
||||||
padding: 14px 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
font-weight: 600;
|
|
||||||
line-height: 1.2;
|
|
||||||
max-width: calc(100% - 56px);
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stage {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
padding: 0 16px 16px;
|
|
||||||
min-height: 0;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.full {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: contain;
|
|
||||||
border-radius: 12px;
|
|
||||||
}
|
|
||||||
`],
|
|
||||||
})
|
|
||||||
export class ImageDialogComponent {
|
|
||||||
data = inject<{
|
|
||||||
title: string;
|
|
||||||
src: string;
|
|
||||||
}>(MAT_DIALOG_DATA);
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<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)">
|
<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="flex items-center gap-[clamp(0.4rem,1vw,0.6rem)] text-inherit no-underline" routerLink="/">
|
<a class="flex items-center gap-[clamp(0.4rem,1vw,0.6rem)] text-inherit no-underline" routerLink="/">
|
||||||
<img class="w-[clamp(36px,10vw,48px)] h-auto rounded-full" src="{{AssetsConstants.LOGO}}" width="117" height="128" alt="" aria-hidden="true" draggable="false"
|
<img class="w-[clamp(36px,10vw,48px)] h-auto rounded-full" [ngSrc]="AssetsConstants.LOGO" width="117" height="128" loading="eager" alt="" aria-hidden="true" draggable="false"
|
||||||
oncontextmenu="return false;">
|
oncontextmenu="return false;">
|
||||||
<span class="font-semibold tracking-[0.2px] text-[clamp(1rem,3vw,1.2rem)]">{{ 'APP.TITLE' | translate }}</span>
|
<span class="font-semibold tracking-[0.2px] text-[clamp(1rem,3vw,1.2rem)]">{{ 'APP.TITLE' | translate }}</span>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Component, computed, inject, ChangeDetectionStrategy } from '@angular/core';
|
import { Component, computed, inject } from '@angular/core';
|
||||||
import { RouterLink, RouterLinkActive } 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 { NgOptimizedImage } from '@angular/common';
|
||||||
import { TranslatePipe } 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';
|
||||||
@@ -17,10 +18,9 @@ import {RouterConstants} from '../../constants/RouterConstants';
|
|||||||
imports: [
|
imports: [
|
||||||
RouterLink, RouterLinkActive,
|
RouterLink, RouterLinkActive,
|
||||||
MatToolbarModule, MatIconModule, MatButtonModule, MatMenuModule, MatTooltipModule,
|
MatToolbarModule, MatIconModule, MatButtonModule, MatMenuModule, MatTooltipModule,
|
||||||
TranslatePipe, MatDivider
|
TranslatePipe, MatDivider, NgOptimizedImage
|
||||||
],
|
],
|
||||||
templateUrl: './topbar.component.html',
|
templateUrl: './topbar.component.html',
|
||||||
changeDetection: ChangeDetectionStrategy.Eager,
|
|
||||||
styleUrl: './topbar.component.scss'
|
styleUrl: './topbar.component.scss'
|
||||||
})
|
})
|
||||||
export class TopbarComponent {
|
export class TopbarComponent {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
import { Component, provideZonelessChangeDetection } from '@angular/core';
|
import { provideZonelessChangeDetection } from '@angular/core';
|
||||||
import { MatCardModule } from '@angular/material/card';
|
|
||||||
import { By, DomSanitizer } from '@angular/platform-browser';
|
import { By, DomSanitizer } from '@angular/platform-browser';
|
||||||
import { MatIconRegistry } from '@angular/material/icon';
|
import { MatIconRegistry } from '@angular/material/icon';
|
||||||
import { provideRouter } from '@angular/router';
|
import { provideRouter } from '@angular/router';
|
||||||
@@ -188,57 +187,5 @@ describe('AboutComponent', () => {
|
|||||||
expect(emptyLinks.length).toBe(0);
|
expect(emptyLinks.length).toBe(0);
|
||||||
expect(fixture.debugElement.query(By.css('[data-testid="contact-mail"]'))).toBeTruthy();
|
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() === 'Gitea')!.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,4 +1,4 @@
|
|||||||
import { Component, computed, signal, ChangeDetectionStrategy } 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';
|
||||||
@@ -37,7 +37,6 @@ interface SkillGroup {
|
|||||||
RouterLink
|
RouterLink
|
||||||
],
|
],
|
||||||
templateUrl: './about.component.html',
|
templateUrl: './about.component.html',
|
||||||
changeDetection: ChangeDetectionStrategy.Eager,
|
|
||||||
styleUrl: './about.component.scss'
|
styleUrl: './about.component.scss'
|
||||||
})
|
})
|
||||||
export class AboutComponent {
|
export class AboutComponent {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, inject, ChangeDetectionStrategy } from '@angular/core';
|
import { Component, inject } from '@angular/core';
|
||||||
import { AlgorithmsService } from './algorithms.service';
|
import { AlgorithmsService } from './algorithms.service';
|
||||||
import { AlgorithmCategory } from './algorithm-category';
|
import { AlgorithmCategory } from './algorithm-category';
|
||||||
import { RouterLink } from '@angular/router';
|
import { RouterLink } from '@angular/router';
|
||||||
@@ -10,7 +10,6 @@ import {TranslatePipe} from '@ngx-translate/core';
|
|||||||
selector: 'app-algorithms',
|
selector: 'app-algorithms',
|
||||||
templateUrl: './algorithms.component.html',
|
templateUrl: './algorithms.component.html',
|
||||||
styleUrl: './algorithms.component.scss',
|
styleUrl: './algorithms.component.scss',
|
||||||
changeDetection: ChangeDetectionStrategy.Eager,
|
|
||||||
imports: [RouterLink, MatCardModule, MatIconModule, TranslatePipe],
|
imports: [RouterLink, MatCardModule, MatIconModule, TranslatePipe],
|
||||||
})
|
})
|
||||||
export class AlgorithmsComponent {
|
export class AlgorithmsComponent {
|
||||||
|
|||||||
@@ -7,17 +7,17 @@
|
|||||||
<div class="flex flex-col mb-4">
|
<div class="flex flex-col mb-4">
|
||||||
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
|
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
|
||||||
<button mat-raised-button color="primary" (click)="toggleWind()">
|
<button mat-raised-button color="primary" (click)="toggleWind()">
|
||||||
{{ isWindActive ? ('CLOTH.WIND_OFF' | translate) : ('CLOTH.WIND_ON' | translate) }}
|
{{ isWindActive() ? ('CLOTH.WIND_OFF' | translate) : ('CLOTH.WIND_ON' | translate) }}
|
||||||
</button>
|
</button>
|
||||||
<button mat-raised-button color="primary" (click)="toggleMesh()">
|
<button mat-raised-button color="primary" (click)="toggleMesh()">
|
||||||
{{ isOutlineActive ? ('CLOTH.OUTLINE_OFF' | translate) : ('CLOTH.OUTLINE_ON' | translate) }}
|
{{ isOutlineActive() ? ('CLOTH.OUTLINE_OFF' | translate) : ('CLOTH.OUTLINE_ON' | translate) }}
|
||||||
</button>
|
</button>
|
||||||
<button mat-raised-button color="accent" (click)="restartSimulation()">
|
<button mat-raised-button color="accent" (click)="restartSimulation()">
|
||||||
{{ 'CLOTH.RESTART_SIMULATION' | translate }}
|
{{ 'CLOTH.RESTART_SIMULATION' | translate }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2.5">
|
<div class="flex items-center gap-2.5">
|
||||||
<span>{{ 'CLOTH.ELONGATION' | translate }}: {{ elongation }}</span>
|
<span>{{ 'CLOTH.ELONGATION' | translate }}: {{ elongation() }}</span>
|
||||||
<mat-slider min="0.5" max="2.0" step="0.1">
|
<mat-slider min="0.5" max="2.0" step="0.1">
|
||||||
<input matSliderThumb [(ngModel)]="elongation">
|
<input matSliderThumb [(ngModel)]="elongation">
|
||||||
</mat-slider>
|
</mat-slider>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {Component, ChangeDetectionStrategy} from '@angular/core';
|
import {Component, signal} from '@angular/core';
|
||||||
import {FormsModule} from '@angular/forms';
|
import {FormsModule} from '@angular/forms';
|
||||||
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
|
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
|
||||||
import {MatSliderModule} from '@angular/material/slider';
|
import {MatSliderModule} from '@angular/material/slider';
|
||||||
@@ -28,7 +28,6 @@ import {ClothCpuStrategy} from './strategies/cloth-cpu.strategy';
|
|||||||
Information
|
Information
|
||||||
],
|
],
|
||||||
templateUrl: './cloth.component.html',
|
templateUrl: './cloth.component.html',
|
||||||
changeDetection: ChangeDetectionStrategy.Eager,
|
|
||||||
styleUrl: './cloth.component.scss',
|
styleUrl: './cloth.component.scss',
|
||||||
})
|
})
|
||||||
export class ClothComponent {
|
export class ClothComponent {
|
||||||
@@ -36,10 +35,10 @@ export class ClothComponent {
|
|||||||
private simulationTime: number = 0;
|
private simulationTime: number = 0;
|
||||||
private strategy: ClothSimulationStrategy | null = null;
|
private strategy: ClothSimulationStrategy | null = null;
|
||||||
|
|
||||||
public isWindActive: boolean = false;
|
public readonly isWindActive = signal(false);
|
||||||
public isOutlineActive: boolean = false;
|
public readonly isOutlineActive = signal(false);
|
||||||
public stiffness: number = 80;
|
public stiffness: number = 80;
|
||||||
public elongation: number = 1.0;
|
public readonly elongation = signal(1.0);
|
||||||
|
|
||||||
public renderConfig: RenderConfig = {
|
public renderConfig: RenderConfig = {
|
||||||
mode: '3D',
|
mode: '3D',
|
||||||
@@ -85,16 +84,16 @@ export class ClothComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public toggleWind(): void {
|
public toggleWind(): void {
|
||||||
this.isWindActive = !this.isWindActive;
|
this.isWindActive.update(v => !v);
|
||||||
}
|
}
|
||||||
|
|
||||||
public toggleMesh(): void {
|
public toggleMesh(): void {
|
||||||
this.isOutlineActive = !this.isOutlineActive;
|
this.isOutlineActive.update(v => !v);
|
||||||
const mesh = this.strategy?.getMesh();
|
const mesh = this.strategy?.getMesh();
|
||||||
if (!mesh?.material) {
|
if (!mesh?.material) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
mesh.material.wireframe = this.isOutlineActive;
|
mesh.material.wireframe = this.isOutlineActive();
|
||||||
}
|
}
|
||||||
|
|
||||||
public restartSimulation(): void {
|
public restartSimulation(): void {
|
||||||
@@ -130,8 +129,8 @@ export class ClothComponent {
|
|||||||
if (this.strategy) {
|
if (this.strategy) {
|
||||||
this.strategy.updateParams({
|
this.strategy.updateParams({
|
||||||
stiffness: this.stiffness,
|
stiffness: this.stiffness,
|
||||||
elongation: this.elongation,
|
elongation: this.elongation(),
|
||||||
isWindActive: this.isWindActive,
|
isWindActive: this.isWindActive(),
|
||||||
simulationTime: this.simulationTime,
|
simulationTime: this.simulationTime,
|
||||||
deltaTime: engine.getDeltaTime() / 1000.0
|
deltaTime: engine.getDeltaTime() / 1000.0
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import {ClothPhysicsParams, integratePositions, solveConstraints, updateVelocities} from './cloth-cpu-physics';
|
||||||
|
|
||||||
|
const params: ClothPhysicsParams = {
|
||||||
|
dt: 0.1, gravityY: -10, compliance: 0, numVertices: 2,
|
||||||
|
windX: 0, windY: 0, windZ: 0, time: 0, elongation: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Vertex layout: [x, y, z, invMass]. */
|
||||||
|
function vertices(...rows: number[][]): Float32Array {
|
||||||
|
return new Float32Array(rows.flat());
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('cloth cpu physics', () => {
|
||||||
|
describe('integratePositions', () => {
|
||||||
|
it('never moves a pinned vertex (invMass 0)', () => {
|
||||||
|
const positions = vertices([1, 2, 3, 0], [0, 0, 0, 1]);
|
||||||
|
const prev = new Float32Array(8);
|
||||||
|
const velocities = new Float32Array(8);
|
||||||
|
|
||||||
|
integratePositions(positions, prev, velocities, params);
|
||||||
|
|
||||||
|
expect(Array.from(positions.slice(0, 4))).toEqual([1, 2, 3, 0]);
|
||||||
|
expect(Array.from(velocities.slice(0, 4))).toEqual([0, 0, 0, 0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets a free vertex fall by g*dt*dt and stores the old position', () => {
|
||||||
|
const positions = vertices([0, 5, 0, 1], [0, 0, 0, 0]);
|
||||||
|
const prev = new Float32Array(8);
|
||||||
|
const velocities = new Float32Array(8);
|
||||||
|
|
||||||
|
integratePositions(positions, prev, velocities, params);
|
||||||
|
|
||||||
|
expect(positions[1]).toBeCloseTo(5 + params.gravityY * params.dt * params.dt);
|
||||||
|
expect(prev[1]).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('solveConstraints', () => {
|
||||||
|
it('pulls two free vertices to restLength * elongation', () => {
|
||||||
|
const positions = vertices([0, 0, 0, 1], [4, 0, 0, 1]);
|
||||||
|
const constraints = new Float32Array([0, 1, 1, 1]);
|
||||||
|
|
||||||
|
solveConstraints(positions, constraints, {...params, elongation: 1.5});
|
||||||
|
|
||||||
|
expect(positions[4] - positions[0]).toBeCloseTo(1.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves only the free side when the other is pinned', () => {
|
||||||
|
const positions = vertices([0, 0, 0, 0], [4, 0, 0, 1]);
|
||||||
|
const constraints = new Float32Array([0, 1, 1, 1]);
|
||||||
|
|
||||||
|
solveConstraints(positions, constraints, params);
|
||||||
|
|
||||||
|
expect(positions[0]).toBe(0);
|
||||||
|
expect(positions[4]).toBeCloseTo(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips constraints of type 0', () => {
|
||||||
|
const positions = vertices([0, 0, 0, 1], [4, 0, 0, 1]);
|
||||||
|
|
||||||
|
solveConstraints(positions, new Float32Array([0, 1, 1, 0]), params);
|
||||||
|
|
||||||
|
expect(positions[4]).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips coincident vertices', () => {
|
||||||
|
const positions = vertices([1, 1, 1, 1], [1, 1, 1, 1]);
|
||||||
|
|
||||||
|
solveConstraints(positions, new Float32Array([0, 1, 1, 1]), params);
|
||||||
|
|
||||||
|
expect(Array.from(positions)).toEqual([1, 1, 1, 1, 1, 1, 1, 1]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateVelocities', () => {
|
||||||
|
it('derives velocity from the position change and leaves pinned vertices alone', () => {
|
||||||
|
const positions = vertices([1, 0, 0, 0], [3, 2, 1, 1]);
|
||||||
|
const prev = vertices([0, 0, 0, 0], [1, 2, 0, 1]);
|
||||||
|
const velocities = vertices([9, 9, 9, 9], [0, 0, 0, 0]);
|
||||||
|
|
||||||
|
updateVelocities(positions, prev, velocities, params);
|
||||||
|
|
||||||
|
expect(Array.from(velocities.slice(0, 4))).toEqual([9, 9, 9, 9]);
|
||||||
|
expect(velocities[4]).toBeCloseTo(20);
|
||||||
|
expect(velocities[5]).toBeCloseTo(0);
|
||||||
|
expect(velocities[6]).toBeCloseTo(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
<mat-icon>play_arrow</mat-icon> {{ 'GOL.START' | translate }}
|
<mat-icon>play_arrow</mat-icon> {{ 'GOL.START' | translate }}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
<p>{{ 'SORTING.EXECUTION_TIME' | translate }}: {{ executionTime }} ms</p>
|
<p>{{ 'SORTING.EXECUTION_TIME' | translate }}: {{ executionTime() }} ms</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-3 items-center flex-wrap">
|
<div class="flex gap-3 items-center flex-wrap">
|
||||||
<mat-form-field appearance="outline" class="w-[150px]">
|
<mat-form-field appearance="outline" class="w-[150px]">
|
||||||
@@ -44,7 +44,8 @@
|
|||||||
[(ngModel)]="gridRows"
|
[(ngModel)]="gridRows"
|
||||||
[min]="MIN_GRID_SIZE"
|
[min]="MIN_GRID_SIZE"
|
||||||
[max]="MAX_GRID_SIZE"
|
[max]="MAX_GRID_SIZE"
|
||||||
(ngModelChange)="pauseGame(); genericGridComponent.gridRows = gridRows; genericGridComponent.applyGridSize()"
|
(ngModelChange)="applyGridRows($event)"
|
||||||
|
(blur)="showClampedGridSize()"
|
||||||
/>
|
/>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
<mat-form-field appearance="outline" class="w-[150px]">
|
<mat-form-field appearance="outline" class="w-[150px]">
|
||||||
@@ -55,7 +56,8 @@
|
|||||||
[(ngModel)]="gridCols"
|
[(ngModel)]="gridCols"
|
||||||
[min]="MIN_GRID_SIZE"
|
[min]="MIN_GRID_SIZE"
|
||||||
[max]="MAX_GRID_SIZE"
|
[max]="MAX_GRID_SIZE"
|
||||||
(ngModelChange)="pauseGame(); genericGridComponent.gridCols = gridCols; genericGridComponent.applyGridSize()"
|
(ngModelChange)="applyGridCols($event)"
|
||||||
|
(blur)="showClampedGridSize()"
|
||||||
/>
|
/>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
<mat-form-field appearance="outline" class="w-[150px]">
|
<mat-form-field appearance="outline" class="w-[150px]">
|
||||||
@@ -78,8 +80,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<app-generic-grid
|
<app-generic-grid
|
||||||
[gridRows]="gridRows"
|
[gridRows]="gridRows()"
|
||||||
[gridCols]="gridCols"
|
[gridCols]="gridCols()"
|
||||||
[minGridSize]="MIN_GRID_SIZE"
|
[minGridSize]="MIN_GRID_SIZE"
|
||||||
[maxGridSize]="MAX_GRID_SIZE"
|
[maxGridSize]="MAX_GRID_SIZE"
|
||||||
[maxGridPx]="MAX_GRID_PX"
|
[maxGridPx]="MAX_GRID_PX"
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import {ComponentFixture, TestBed} from '@angular/core/testing';
|
||||||
|
import {provideZonelessChangeDetection} from '@angular/core';
|
||||||
|
import {provideNoopAnimations} from '@angular/platform-browser/animations';
|
||||||
|
import {provideTranslateService} from '@ngx-translate/core';
|
||||||
|
import {ConwayGolComponent} from './conway-gol.component';
|
||||||
|
|
||||||
|
describe('ConwayGolComponent', () => {
|
||||||
|
let fixture: ComponentFixture<ConwayGolComponent>;
|
||||||
|
let component: ConwayGolComponent;
|
||||||
|
const originalGetContext = HTMLCanvasElement.prototype.getContext;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// jsdom has no 2D context.
|
||||||
|
const fakeContext = {
|
||||||
|
fillRect: vi.fn(), strokeRect: vi.fn(), beginPath: vi.fn(), moveTo: vi.fn(), lineTo: vi.fn(), stroke: vi.fn(),
|
||||||
|
fillStyle: '', strokeStyle: '', lineWidth: 1,
|
||||||
|
};
|
||||||
|
Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', {
|
||||||
|
configurable: true,
|
||||||
|
value: () => fakeContext,
|
||||||
|
});
|
||||||
|
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [ConwayGolComponent],
|
||||||
|
providers: [
|
||||||
|
provideZonelessChangeDetection(),
|
||||||
|
provideNoopAnimations(),
|
||||||
|
provideTranslateService({lang: 'en', fallbackLang: 'en'}),
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(ConwayGolComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
component.pauseGame();
|
||||||
|
Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', {configurable: true, value: originalGetContext});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs a generation on a grid that the generic grid clamped below the typed size', async () => {
|
||||||
|
component['gridCols'].set(500); // what ngModel does before ngModelChange
|
||||||
|
component['applyGridCols'](500);
|
||||||
|
|
||||||
|
const run = component.startGame();
|
||||||
|
component.pauseGame();
|
||||||
|
|
||||||
|
await expect(run).resolves.toBeUndefined();
|
||||||
|
expect(component.readGrid[0].length).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the clamped size in the input signals', () => {
|
||||||
|
component['gridRows'].set(500);
|
||||||
|
component['applyGridRows'](500);
|
||||||
|
|
||||||
|
expect(component['gridRows']()).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a cleared input empty', () => {
|
||||||
|
const cleared = null as unknown as number; // what ngModel emits for an empty number input
|
||||||
|
component['gridCols'].set(cleared);
|
||||||
|
component['applyGridCols'](cleared);
|
||||||
|
|
||||||
|
expect(component['gridCols']()).toBeNull();
|
||||||
|
expect(component.readGrid[0].length).toBe(20);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import {AfterViewInit, Component, signal, ViewChild, ChangeDetectionStrategy} from '@angular/core';
|
import {AfterViewInit, Component, inject, signal, ViewChild} from '@angular/core';
|
||||||
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from "@angular/material/card";
|
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from "@angular/material/card";
|
||||||
import {TranslatePipe} from "@ngx-translate/core";
|
import {TranslatePipe} from "@ngx-translate/core";
|
||||||
import {UrlConstants} from '../../../constants/UrlConstants';
|
import {UrlConstants} from '../../../constants/UrlConstants';
|
||||||
@@ -8,8 +8,9 @@ import {MatButton} from '@angular/material/button';
|
|||||||
import {MatIcon} from '@angular/material/icon';
|
import {MatIcon} from '@angular/material/icon';
|
||||||
import {MatFormField, MatInput, MatLabel} from '@angular/material/input';
|
import {MatFormField, MatInput, MatLabel} from '@angular/material/input';
|
||||||
import {FormsModule, ReactiveFormsModule} from '@angular/forms';
|
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 {DEFAULT_GRID_COLS, DEFAULT_GRID_ROWS, DEFAULT_TIME_PER_GENERATION, 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';
|
import {GenericGridComponent, GridPos} from '../../../shared/components/generic-grid/generic-grid';
|
||||||
|
import {ConwayGolService} from './service/conway-gol.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-conway-gol',
|
selector: 'app-conway-gol',
|
||||||
@@ -29,10 +30,10 @@ import {GenericGridComponent, GridPos} from '../../../shared/components/generic-
|
|||||||
FormsModule,
|
FormsModule,
|
||||||
GenericGridComponent
|
GenericGridComponent
|
||||||
],
|
],
|
||||||
changeDetection: ChangeDetectionStrategy.Eager,
|
|
||||||
templateUrl: './conway-gol.component.html',
|
templateUrl: './conway-gol.component.html',
|
||||||
})
|
})
|
||||||
export class ConwayGolComponent implements AfterViewInit {
|
export class ConwayGolComponent implements AfterViewInit {
|
||||||
|
private readonly golService = inject(ConwayGolService);
|
||||||
|
|
||||||
algoInformation: AlgorithmInformation = {
|
algoInformation: AlgorithmInformation = {
|
||||||
title: 'GOL.EXPLANATION.TITLE',
|
title: 'GOL.EXPLANATION.TITLE',
|
||||||
@@ -48,16 +49,16 @@ export class ConwayGolComponent implements AfterViewInit {
|
|||||||
disclaimerListEntry: ['GOL.EXPLANATION.DISCLAIMER_1', 'GOL.EXPLANATION.DISCLAIMER_2', 'GOL.EXPLANATION.DISCLAIMER_3', 'GOL.EXPLANATION.DISCLAIMER_4']
|
disclaimerListEntry: ['GOL.EXPLANATION.DISCLAIMER_1', 'GOL.EXPLANATION.DISCLAIMER_2', 'GOL.EXPLANATION.DISCLAIMER_3', 'GOL.EXPLANATION.DISCLAIMER_4']
|
||||||
};
|
};
|
||||||
|
|
||||||
protected gridCols = DEFAULT_GRID_COLS;
|
protected readonly gridCols = signal(DEFAULT_GRID_COLS);
|
||||||
protected gridRows = DEFAULT_GRID_ROWS;
|
protected readonly gridRows = signal(DEFAULT_GRID_ROWS);
|
||||||
protected lifeSpeed = DEFAULT_TIME_PER_GENERATION;
|
protected readonly lifeSpeed = signal(DEFAULT_TIME_PER_GENERATION);
|
||||||
protected readonly MIN_GRID_SIZE = MIN_GRID_SIZE;
|
protected readonly MIN_GRID_SIZE = MIN_GRID_SIZE;
|
||||||
protected readonly MAX_GRID_SIZE = MAX_GRID_SIZE;
|
protected readonly MAX_GRID_SIZE = MAX_GRID_SIZE;
|
||||||
protected readonly MAX_GRID_PX = MAX_GRID_PX;
|
protected readonly MAX_GRID_PX = MAX_GRID_PX;
|
||||||
|
|
||||||
readGrid: Node[][] = [];
|
readGrid: Node[][] = [];
|
||||||
writeGrid: Node[][] = [];
|
writeGrid: Node[][] = [];
|
||||||
executionTime = 0;
|
readonly executionTime = signal(0);
|
||||||
currentScenario: Scenario = Scenario.SIMPLE;
|
currentScenario: Scenario = Scenario.SIMPLE;
|
||||||
readonly gameStarted = signal(false);
|
readonly gameStarted = signal(false);
|
||||||
|
|
||||||
@@ -69,8 +70,8 @@ export class ConwayGolComponent implements AfterViewInit {
|
|||||||
this.genericGridComponent.createNodeFn = this.createConwayNode;
|
this.genericGridComponent.createNodeFn = this.createConwayNode;
|
||||||
this.genericGridComponent.getNodeColorFn = this.getConwayNodeColor;
|
this.genericGridComponent.getNodeColorFn = this.getConwayNodeColor;
|
||||||
this.genericGridComponent.applySelectionFn = this.applyConwaySelection;
|
this.genericGridComponent.applySelectionFn = this.applyConwaySelection;
|
||||||
this.genericGridComponent.gridRows = this.gridRows;
|
this.genericGridComponent.gridRows = this.gridRows();
|
||||||
this.genericGridComponent.gridCols = this.gridCols;
|
this.genericGridComponent.gridCols = this.gridCols();
|
||||||
this.genericGridComponent.minGridSize = this.MIN_GRID_SIZE;
|
this.genericGridComponent.minGridSize = this.MIN_GRID_SIZE;
|
||||||
this.genericGridComponent.maxGridSize = this.MAX_GRID_SIZE;
|
this.genericGridComponent.maxGridSize = this.MAX_GRID_SIZE;
|
||||||
this.genericGridComponent.maxGridPx = this.MAX_GRID_PX;
|
this.genericGridComponent.maxGridPx = this.MAX_GRID_PX;
|
||||||
@@ -86,7 +87,36 @@ export class ConwayGolComponent implements AfterViewInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
applySpeed(): void {
|
applySpeed(): void {
|
||||||
this.lifeSpeed = Math.min(Math.max(this.lifeSpeed, MIN_TIME_PER_GENERATION), MAX_TIME_PER_GENERATION);
|
this.lifeSpeed.set(Math.min(Math.max(this.lifeSpeed(), MIN_TIME_PER_GENERATION), MAX_TIME_PER_GENERATION));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected applyGridRows(rows: number): void {
|
||||||
|
this.pauseGame();
|
||||||
|
this.genericGridComponent.gridRows = rows;
|
||||||
|
this.applyClampedGridSize();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected applyGridCols(cols: number): void {
|
||||||
|
this.pauseGame();
|
||||||
|
this.genericGridComponent.gridCols = cols;
|
||||||
|
this.applyClampedGridSize();
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyClampedGridSize(): void {
|
||||||
|
this.genericGridComponent.applyGridSize();
|
||||||
|
// Only clamp down while typing; raising to the minimum would fight the user typing "100".
|
||||||
|
// A cleared input emits null: keep it as it is.
|
||||||
|
this.gridRows.update(rows => this.clampDown(rows, this.genericGridComponent.gridRows));
|
||||||
|
this.gridCols.update(cols => this.clampDown(cols, this.genericGridComponent.gridCols));
|
||||||
|
}
|
||||||
|
|
||||||
|
private clampDown(value: number, max: number): number {
|
||||||
|
return Number.isFinite(value) && value > max ? max : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected showClampedGridSize(): void {
|
||||||
|
this.gridRows.set(this.genericGridComponent.gridRows);
|
||||||
|
this.gridCols.set(this.genericGridComponent.gridCols);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Callbacks for GenericGridComponent ---
|
// --- Callbacks for GenericGridComponent ---
|
||||||
@@ -111,66 +141,10 @@ export class ConwayGolComponent implements AfterViewInit {
|
|||||||
initializeConwayGrid = (grid: Node[][]): void => {
|
initializeConwayGrid = (grid: Node[][]): void => {
|
||||||
this.gameStarted.set(false);
|
this.gameStarted.set(false);
|
||||||
this.readGrid = grid;
|
this.readGrid = grid;
|
||||||
switch(this.currentScenario) {
|
this.golService.applyScenario(grid, 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);
|
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
|
// --- The rules of the game
|
||||||
|
|
||||||
pauseGame(): void {
|
pauseGame(): void {
|
||||||
@@ -181,35 +155,19 @@ export class ConwayGolComponent implements AfterViewInit {
|
|||||||
this.gameStarted.set(true);
|
this.gameStarted.set(true);
|
||||||
while (this.gameStarted()){
|
while (this.gameStarted()){
|
||||||
const startTime = performance.now();
|
const startTime = performance.now();
|
||||||
let lifeIsDead = true;
|
const changed = this.golService.nextGeneration(this.readGrid, this.writeGrid);
|
||||||
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();
|
this.swapGrids();
|
||||||
const endTime = performance.now();
|
const endTime = performance.now();
|
||||||
this.executionTime = Number.parseFloat((endTime - startTime).toFixed(4));
|
const elapsed = Number.parseFloat((endTime - startTime).toFixed(4));
|
||||||
if (lifeIsDead){
|
this.executionTime.set(elapsed);
|
||||||
|
if (!changed){
|
||||||
this.gameStarted.set(false);
|
this.gameStarted.set(false);
|
||||||
}
|
}
|
||||||
const delta = Math.max(this.lifeSpeed - this.executionTime, 0);
|
const delta = Math.max(this.lifeSpeed() - elapsed, 0);
|
||||||
await this.delay(delta);
|
await this.delay(delta);
|
||||||
}
|
}
|
||||||
this.executionTime = 0;
|
this.executionTime.set(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() {
|
private swapGrids() {
|
||||||
@@ -222,27 +180,6 @@ export class ConwayGolComponent implements AfterViewInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 ---
|
// --- Other methods ---
|
||||||
protected readonly Scenario = Scenario;
|
protected readonly Scenario = Scenario;
|
||||||
protected readonly MIN_TIME_PER_GENERATION = MIN_TIME_PER_GENERATION;
|
protected readonly MIN_TIME_PER_GENERATION = MIN_TIME_PER_GENERATION;
|
||||||
@@ -251,10 +188,4 @@ export class ConwayGolComponent implements AfterViewInit {
|
|||||||
delay(ms: number) {
|
delay(ms: number) {
|
||||||
return new Promise( resolve => setTimeout(resolve, ms) );
|
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import {ConwayGolService} from './conway-gol.service';
|
||||||
|
import {Node, Scenario} from '../conway-gol.models';
|
||||||
|
|
||||||
|
function createGrid(rows: number, cols: number, aliveCells: number[][] = []): Node[][] {
|
||||||
|
const grid = Array.from({length: rows}, (_, row) =>
|
||||||
|
Array.from({length: cols}, (_, col): Node => ({row, col, alive: false})));
|
||||||
|
for (const [row, col] of aliveCells) {
|
||||||
|
grid[row][col].alive = true;
|
||||||
|
}
|
||||||
|
return grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
function aliveCells(grid: Node[][]): string[] {
|
||||||
|
return grid.flat().filter(node => node.alive).map(node => `${node.row},${node.col}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ConwayGolService', () => {
|
||||||
|
const service = new ConwayGolService();
|
||||||
|
|
||||||
|
describe('countLivingNeighbors', () => {
|
||||||
|
it('counts all neighbors of a center cell but not the cell itself', () => {
|
||||||
|
const grid = createGrid(3, 3);
|
||||||
|
grid.flat().forEach(node => node.alive = true);
|
||||||
|
|
||||||
|
expect(service.countLivingNeighbors(grid, 1, 1)).toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts only in-bounds neighbors on corners and edges', () => {
|
||||||
|
const grid = createGrid(3, 3);
|
||||||
|
grid.flat().forEach(node => node.alive = true);
|
||||||
|
|
||||||
|
expect(service.countLivingNeighbors(grid, 0, 0)).toBe(3);
|
||||||
|
expect(service.countLivingNeighbors(grid, 0, 1)).toBe(5);
|
||||||
|
expect(service.countLivingNeighbors(grid, 2, 2)).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('nextGeneration', () => {
|
||||||
|
function step(read: Node[][]): { next: Node[][]; changed: boolean } {
|
||||||
|
const next = structuredClone(read);
|
||||||
|
const changed = service.nextGeneration(read, next);
|
||||||
|
return {next, changed};
|
||||||
|
}
|
||||||
|
|
||||||
|
it('flips a blinker and reports a change', () => {
|
||||||
|
const {next, changed} = step(createGrid(5, 5, [[2, 1], [2, 2], [2, 3]]));
|
||||||
|
|
||||||
|
expect(changed).toBe(true);
|
||||||
|
expect(aliveCells(next)).toEqual(['1,2', '2,2', '3,2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a block and reports no change', () => {
|
||||||
|
const block = [[1, 1], [1, 2], [2, 1], [2, 2]];
|
||||||
|
const {next, changed} = step(createGrid(4, 4, block));
|
||||||
|
|
||||||
|
expect(changed).toBe(false);
|
||||||
|
expect(aliveCells(next)).toEqual(['1,1', '1,2', '2,1', '2,2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('kills a lonely cell (underpopulation)', () => {
|
||||||
|
expect(aliveCells(step(createGrid(3, 3, [[1, 1]])).next)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('kills a cell with four neighbors (overpopulation)', () => {
|
||||||
|
const {next} = step(createGrid(3, 3, [[1, 1], [0, 1], [2, 1], [1, 0], [1, 2]]));
|
||||||
|
|
||||||
|
expect(next[1][1].alive).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('births a dead cell with exactly three neighbors', () => {
|
||||||
|
const {next} = step(createGrid(3, 3, [[0, 0], [0, 1], [0, 2]]));
|
||||||
|
|
||||||
|
expect(next[1][1].alive).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('applyScenario', () => {
|
||||||
|
it('SIMPLE sets the five glider cells', () => {
|
||||||
|
const grid = createGrid(50, 50);
|
||||||
|
|
||||||
|
service.applyScenario(grid, Scenario.SIMPLE);
|
||||||
|
|
||||||
|
expect(aliveCells(grid).sort()).toEqual(['3,4', '4,5', '5,3', '5,4', '5,5']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PULSAR is symmetric around the center', () => {
|
||||||
|
const grid = createGrid(50, 50);
|
||||||
|
|
||||||
|
service.applyScenario(grid, Scenario.PULSAR);
|
||||||
|
|
||||||
|
const alive = grid.flat().filter(node => node.alive);
|
||||||
|
expect(alive.length).toBe(48);
|
||||||
|
for (const node of alive) {
|
||||||
|
expect(grid[50 - node.row][50 - node.col].alive).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GUN sets 36 cells on 50x50', () => {
|
||||||
|
const grid = createGrid(50, 50);
|
||||||
|
|
||||||
|
service.applyScenario(grid, Scenario.GUN);
|
||||||
|
|
||||||
|
expect(aliveCells(grid).length).toBe(36);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('EMPTY changes nothing', () => {
|
||||||
|
const grid = createGrid(50, 50);
|
||||||
|
|
||||||
|
service.applyScenario(grid, Scenario.EMPTY);
|
||||||
|
|
||||||
|
expect(aliveCells(grid)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('RANDOM sets at least one cell on 50x50', () => {
|
||||||
|
const grid = createGrid(50, 50);
|
||||||
|
|
||||||
|
service.applyScenario(grid, Scenario.RANDOM);
|
||||||
|
|
||||||
|
expect(aliveCells(grid).length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[Scenario.SIMPLE, 5],
|
||||||
|
[Scenario.GUN, 2],
|
||||||
|
])('sets only the in-bounds cells on a 10x10 grid for scenario %s', (scenario, expected) => {
|
||||||
|
const grid = createGrid(10, 10);
|
||||||
|
|
||||||
|
service.applyScenario(grid, scenario);
|
||||||
|
|
||||||
|
expect(aliveCells(grid).length).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not throw for PULSAR on a 10x10 grid', () => {
|
||||||
|
expect(() => service.applyScenario(createGrid(10, 10), Scenario.PULSAR)).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import {Injectable} from '@angular/core';
|
||||||
|
import {LIVE_SPAWN_PROBABILITY, Node, Scenario} from '../conway-gol.models';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class ConwayGolService {
|
||||||
|
|
||||||
|
applyScenario(grid: Node[][], scenario: Scenario): void {
|
||||||
|
switch (scenario) {
|
||||||
|
case Scenario.RANDOM: this.setupRandomLives(grid); break;
|
||||||
|
case Scenario.SIMPLE: this.setupGlider(grid); break;
|
||||||
|
case Scenario.PULSAR: this.setupPulsar(grid); break;
|
||||||
|
case Scenario.GUN: this.setupGliderGun(grid); break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
countLivingNeighbors(grid: Node[][], 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, grid.length - 1);
|
||||||
|
const maxCol = Math.min(col + 1, grid[0].length - 1);
|
||||||
|
|
||||||
|
for (let nRow = minRow; nRow <= maxRow; nRow++) {
|
||||||
|
for (let nCol = minCol; nCol <= maxCol; nCol++) {
|
||||||
|
if (nRow === row && nCol === col) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (grid[nRow][nCol].alive) {
|
||||||
|
aliveNeighborCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return aliveNeighborCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes the next generation of readGrid into writeGrid. Returns true when at least one cell changed. */
|
||||||
|
nextGeneration(readGrid: Node[][], writeGrid: Node[][]): boolean {
|
||||||
|
let changed = false;
|
||||||
|
for (let row = 0; row < readGrid.length; row++) {
|
||||||
|
for (let col = 0; col < readGrid[row].length; col++) {
|
||||||
|
const wasAlive = readGrid[row][col].alive;
|
||||||
|
const aliveNeighbors = this.countLivingNeighbors(readGrid, row, col);
|
||||||
|
const nextAlive = wasAlive ? (aliveNeighbors === 2 || aliveNeighbors === 3) : aliveNeighbors === 3;
|
||||||
|
writeGrid[row][col].alive = nextAlive;
|
||||||
|
changed ||= nextAlive !== wasAlive;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupRandomLives(grid: Node[][]): void {
|
||||||
|
for (const row of grid) {
|
||||||
|
for (const node of row) {
|
||||||
|
node.alive = Math.random() <= LIVE_SPAWN_PROBABILITY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupGlider(grid: Node[][]): void {
|
||||||
|
this.setAlive(grid, 3, 4);
|
||||||
|
this.setAlive(grid, 4, 5);
|
||||||
|
this.setAlive(grid, 5, 3);
|
||||||
|
this.setAlive(grid, 5, 4);
|
||||||
|
this.setAlive(grid, 5, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupPulsar(grid: Node[][]): void {
|
||||||
|
const centerRow = Math.floor(grid.length / 2);
|
||||||
|
const centerCol = Math.floor(grid[0].length / 2);
|
||||||
|
|
||||||
|
const rows = [-6, -1, 1, 6];
|
||||||
|
const offsets = [2, 3, 4];
|
||||||
|
|
||||||
|
for (const r of rows) {
|
||||||
|
for (const c of offsets) {
|
||||||
|
this.setAlive(grid, centerRow + r, centerCol + c);
|
||||||
|
this.setAlive(grid, centerRow + r, centerCol - c);
|
||||||
|
this.setAlive(grid, centerRow + c, centerCol + r);
|
||||||
|
this.setAlive(grid, centerRow - c, centerCol + r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupGliderGun(grid: Node[][]): 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]
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [row, col] of dots) {
|
||||||
|
this.setAlive(grid, row, col);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setAlive(grid: Node[][], row: number, col: number): void {
|
||||||
|
if (row >= 0 && row < grid.length && col >= 0 && col < grid[0].length) {
|
||||||
|
grid[row][col].alive = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,13 +49,13 @@
|
|||||||
|
|
||||||
<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"
|
<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]="{
|
[ngClass]="{
|
||||||
'border-l-gray-500 bg-app-bg': solutionStatus === 'INCOMPLETE',
|
'border-l-gray-500 bg-app-bg': solutionStatus() === 'INCOMPLETE',
|
||||||
'border-l-green-500 bg-green-50 text-green-800': solutionStatus === 'SOLVED',
|
'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-orange-500 bg-orange-50 text-orange-700': solutionStatus() === 'CONFLICTS',
|
||||||
'border-l-red-500 bg-red-50 text-red-800': solutionStatus === 'INVALID'
|
'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 class="uppercase text-[0.8em] opacity-70">{{ 'FOUR_COLOR.STATUS.LABEL' | translate }}:</span>
|
||||||
<span>{{ 'FOUR_COLOR.STATUS.' + solutionStatus | translate }}</span>
|
<span>{{ 'FOUR_COLOR.STATUS.' + solutionStatus() | translate }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {AfterViewInit, Component, ElementRef, inject, ViewChild, ChangeDetectionStrategy} from '@angular/core';
|
import {AfterViewInit, Component, ElementRef, inject, signal, ViewChild} from '@angular/core';
|
||||||
import {CommonModule} from '@angular/common';
|
import {CommonModule} from '@angular/common';
|
||||||
import {FormsModule} from '@angular/forms';
|
import {FormsModule} from '@angular/forms';
|
||||||
import {MatButtonModule} from '@angular/material/button';
|
import {MatButtonModule} from '@angular/material/button';
|
||||||
@@ -8,11 +8,11 @@ import {MatInputModule} from '@angular/material/input';
|
|||||||
import {TranslatePipe, TranslateService} from '@ngx-translate/core';
|
import {TranslatePipe, TranslateService} from '@ngx-translate/core';
|
||||||
import {MatSnackBar} from '@angular/material/snack-bar';
|
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 {DEFAULT_GRID_COLS, DEFAULT_GRID_ROWS, MAX_GRID_PX, MAX_GRID_SIZE, MIN_GRID_SIZE, FourColorMap, FourColorNode, SolutionStatus} from './four-color.models';
|
||||||
import {AlgorithmInformation} from '../information/information.models';
|
import {AlgorithmInformation} from '../information/information.models';
|
||||||
import {Information} from '../information/information';
|
import {Information} from '../information/information';
|
||||||
import {GridPos} from '../../../shared/components/generic-grid/generic-grid';
|
import {GridPos} from '../../../shared/components/generic-grid/generic-grid';
|
||||||
import {SharedFunctions} from '../../../shared/SharedFunctions';
|
import {FourColorService} from './service/four-color.service';
|
||||||
import {UrlConstants} from '../../../constants/UrlConstants';
|
import {UrlConstants} from '../../../constants/UrlConstants';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -29,10 +29,10 @@ import {UrlConstants} from '../../../constants/UrlConstants';
|
|||||||
Information
|
Information
|
||||||
],
|
],
|
||||||
templateUrl: './four-color.component.html',
|
templateUrl: './four-color.component.html',
|
||||||
changeDetection: ChangeDetectionStrategy.Eager,
|
|
||||||
styleUrl: './four-color.component.scss'
|
styleUrl: './four-color.component.scss'
|
||||||
})
|
})
|
||||||
export class FourColorComponent implements AfterViewInit {
|
export class FourColorComponent implements AfterViewInit {
|
||||||
|
private readonly fourColorService = inject(FourColorService);
|
||||||
private readonly translate = inject(TranslateService);
|
private readonly translate = inject(TranslateService);
|
||||||
private readonly snackBar = inject(MatSnackBar);
|
private readonly snackBar = inject(MatSnackBar);
|
||||||
|
|
||||||
@@ -59,12 +59,11 @@ export class FourColorComponent implements AfterViewInit {
|
|||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
gridRows = DEFAULT_GRID_ROWS;
|
readonly gridRows = signal(DEFAULT_GRID_ROWS);
|
||||||
gridCols = DEFAULT_GRID_COLS;
|
readonly gridCols = signal(DEFAULT_GRID_COLS);
|
||||||
grid: FourColorNode[][] = [];
|
map: FourColorMap = {grid: [], regions: []};
|
||||||
regions: Region[] = [];
|
|
||||||
executionTime = 0;
|
executionTime = 0;
|
||||||
solutionStatus: 'INCOMPLETE' | 'SOLVED' | 'CONFLICTS' | 'INVALID' = 'INCOMPLETE';
|
readonly solutionStatus = signal<SolutionStatus>('INCOMPLETE');
|
||||||
|
|
||||||
@ViewChild('fourColorCanvas') canvasRef!: ElementRef<HTMLCanvasElement>;
|
@ViewChild('fourColorCanvas') canvasRef!: ElementRef<HTMLCanvasElement>;
|
||||||
private ctx!: CanvasRenderingContext2D;
|
private ctx!: CanvasRenderingContext2D;
|
||||||
@@ -76,107 +75,26 @@ export class FourColorComponent implements AfterViewInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
applyGridSize(): void {
|
applyGridSize(): void {
|
||||||
if (this.gridRows < MIN_GRID_SIZE) this.gridRows = MIN_GRID_SIZE;
|
this.gridRows.set(Math.min(Math.max(this.gridRows(), MIN_GRID_SIZE), MAX_GRID_SIZE));
|
||||||
if (this.gridRows > MAX_GRID_SIZE) this.gridRows = MAX_GRID_SIZE;
|
this.gridCols.set(Math.min(Math.max(this.gridCols(), MIN_GRID_SIZE), 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();
|
this.initializeGrid();
|
||||||
}
|
}
|
||||||
|
|
||||||
initializeGrid(): void {
|
initializeGrid(): void {
|
||||||
this.grid = [];
|
this.map = this.fourColorService.createMap(this.gridRows(), this.gridCols());
|
||||||
this.solutionStatus = 'INCOMPLETE';
|
this.solutionStatus.set('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.resizeCanvas();
|
||||||
this.drawGrid();
|
this.drawGrid();
|
||||||
}
|
}
|
||||||
|
|
||||||
private resizeCanvas(): void {
|
private resizeCanvas(): void {
|
||||||
const canvas = this.canvasRef.nativeElement;
|
const canvas = this.canvasRef.nativeElement;
|
||||||
const maxDim = Math.max(this.gridRows, this.gridCols);
|
const maxDim = Math.max(this.gridRows(), this.gridCols());
|
||||||
this.nodeSize = Math.floor(MAX_GRID_PX / maxDim);
|
this.nodeSize = Math.floor(MAX_GRID_PX / maxDim);
|
||||||
|
|
||||||
canvas.width = this.gridCols * this.nodeSize;
|
canvas.width = this.gridCols() * this.nodeSize;
|
||||||
canvas.height = this.gridRows * 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 {
|
drawGrid(): void {
|
||||||
@@ -185,9 +103,9 @@ export class FourColorComponent implements AfterViewInit {
|
|||||||
this.ctx.clearRect(0, 0, this.canvasRef.nativeElement.width, this.canvasRef.nativeElement.height);
|
this.ctx.clearRect(0, 0, this.canvasRef.nativeElement.width, this.canvasRef.nativeElement.height);
|
||||||
|
|
||||||
// 1. Draw Cell Backgrounds
|
// 1. Draw Cell Backgrounds
|
||||||
for (let r = 0; r < this.gridRows; r++) {
|
for (let r = 0; r < this.gridRows(); r++) {
|
||||||
for (let c = 0; c < this.gridCols; c++) {
|
for (let c = 0; c < this.gridCols(); c++) {
|
||||||
const node = this.grid[r][c];
|
const node = this.map.grid[r][c];
|
||||||
this.ctx.fillStyle = this.getNodeColor(node);
|
this.ctx.fillStyle = this.getNodeColor(node);
|
||||||
this.ctx.fillRect(c * this.nodeSize, r * this.nodeSize, this.nodeSize, this.nodeSize);
|
this.ctx.fillRect(c * this.nodeSize, r * this.nodeSize, this.nodeSize, this.nodeSize);
|
||||||
}
|
}
|
||||||
@@ -197,18 +115,18 @@ export class FourColorComponent implements AfterViewInit {
|
|||||||
this.ctx.strokeStyle = '#000';
|
this.ctx.strokeStyle = '#000';
|
||||||
this.ctx.lineWidth = 2;
|
this.ctx.lineWidth = 2;
|
||||||
this.ctx.beginPath();
|
this.ctx.beginPath();
|
||||||
for (let r = 0; r < this.gridRows; r++) {
|
for (let r = 0; r < this.gridRows(); r++) {
|
||||||
for (let c = 0; c < this.gridCols; c++) {
|
for (let c = 0; c < this.gridCols(); c++) {
|
||||||
const currentRegion = this.grid[r][c].regionId;
|
const currentRegion = this.map.grid[r][c].regionId;
|
||||||
|
|
||||||
// Right border
|
// Right border
|
||||||
if (c < this.gridCols - 1 && this.grid[r][c+1].regionId !== currentRegion) {
|
if (c < this.gridCols() - 1 && this.map.grid[r][c+1].regionId !== currentRegion) {
|
||||||
this.ctx.moveTo((c + 1) * this.nodeSize, r * this.nodeSize);
|
this.ctx.moveTo((c + 1) * this.nodeSize, r * this.nodeSize);
|
||||||
this.ctx.lineTo((c + 1) * this.nodeSize, (r + 1) * this.nodeSize);
|
this.ctx.lineTo((c + 1) * this.nodeSize, (r + 1) * this.nodeSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bottom border
|
// Bottom border
|
||||||
if (r < this.gridRows - 1 && this.grid[r+1][c].regionId !== currentRegion) {
|
if (r < this.gridRows() - 1 && this.map.grid[r+1][c].regionId !== currentRegion) {
|
||||||
this.ctx.moveTo(c * this.nodeSize, (r + 1) * this.nodeSize);
|
this.ctx.moveTo(c * this.nodeSize, (r + 1) * this.nodeSize);
|
||||||
this.ctx.lineTo((c + 1) * this.nodeSize, (r + 1) * this.nodeSize);
|
this.ctx.lineTo((c + 1) * this.nodeSize, (r + 1) * this.nodeSize);
|
||||||
}
|
}
|
||||||
@@ -219,7 +137,7 @@ export class FourColorComponent implements AfterViewInit {
|
|||||||
// 3. Draw Outer Border
|
// 3. Draw Outer Border
|
||||||
this.ctx.strokeStyle = '#000';
|
this.ctx.strokeStyle = '#000';
|
||||||
this.ctx.lineWidth = 2;
|
this.ctx.lineWidth = 2;
|
||||||
this.ctx.strokeRect(0, 0, this.gridCols * this.nodeSize, this.gridRows * this.nodeSize);
|
this.ctx.strokeRect(0, 0, this.gridCols() * this.nodeSize, this.gridRows() * this.nodeSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getNodeColor(node: FourColorNode): string {
|
private getNodeColor(node: FourColorNode): string {
|
||||||
@@ -260,44 +178,29 @@ export class FourColorComponent implements AfterViewInit {
|
|||||||
const x = event.clientX - rect.left;
|
const x = event.clientX - rect.left;
|
||||||
const y = event.clientY - rect.top;
|
const y = event.clientY - rect.top;
|
||||||
|
|
||||||
const col = Math.floor(x / (rect.width / this.gridCols));
|
const col = Math.floor(x / (rect.width / this.gridCols()));
|
||||||
const row = Math.floor(y / (rect.height / this.gridRows));
|
const row = Math.floor(y / (rect.height / this.gridRows()));
|
||||||
|
|
||||||
if (row >= 0 && row < this.gridRows && col >= 0 && col < this.gridCols) {
|
if (row >= 0 && row < this.gridRows() && col >= 0 && col < this.gridCols()) {
|
||||||
return {row, col};
|
return {row, col};
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleInteraction(pos: GridPos): void {
|
private handleInteraction(pos: GridPos): void {
|
||||||
const node = this.grid[pos.row][pos.col];
|
const node = this.map.grid[pos.row][pos.col];
|
||||||
if (node.regionId === -1){
|
if (node.regionId === -1){
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const region = this.regions[node.regionId];
|
this.fourColorService.cycleRegionColor(this.map, node.regionId);
|
||||||
region.color = (region.color % 4) + 1;
|
this.solutionStatus.set(this.fourColorService.evaluate(this.map.regions));
|
||||||
this.updateRegionColors(region);
|
|
||||||
this.checkSolution();
|
|
||||||
this.drawGrid();
|
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 {
|
resetColors(): void {
|
||||||
for (const region of this.regions) {
|
this.fourColorService.resetColors(this.map);
|
||||||
region.color = 0;
|
this.solutionStatus.set('INCOMPLETE');
|
||||||
this.updateRegionColors(region);
|
|
||||||
}
|
|
||||||
this.solutionStatus = 'INCOMPLETE';
|
|
||||||
this.drawGrid();
|
this.drawGrid();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,12 +208,12 @@ export class FourColorComponent implements AfterViewInit {
|
|||||||
const startTime = performance.now();
|
const startTime = performance.now();
|
||||||
this.resetColors();
|
this.resetColors();
|
||||||
|
|
||||||
const success = this.backtrackSolve(0);
|
const success = this.fourColorService.solve(this.map);
|
||||||
const endTime = performance.now();
|
const endTime = performance.now();
|
||||||
this.executionTime = endTime - startTime;
|
this.executionTime = endTime - startTime;
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
this.checkSolution();
|
this.solutionStatus.set(this.fourColorService.evaluate(this.map.regions));
|
||||||
this.drawGrid();
|
this.drawGrid();
|
||||||
} else {
|
} else {
|
||||||
const message = this.translate.instant('FOUR_COLOR.ALERT.NO_SOLUTION');
|
const message = this.translate.instant('FOUR_COLOR.ALERT.NO_SOLUTION');
|
||||||
@@ -318,57 +221,6 @@ export class FourColorComponent implements AfterViewInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
generateNewMap(): void {
|
||||||
this.initializeGrid();
|
this.initializeGrid();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,3 +16,13 @@ export const DEFAULT_GRID_COLS = 25;
|
|||||||
export const MIN_GRID_SIZE = 20;
|
export const MIN_GRID_SIZE = 20;
|
||||||
export const MAX_GRID_SIZE = 38;
|
export const MAX_GRID_SIZE = 38;
|
||||||
export const MAX_GRID_PX = 600;
|
export const MAX_GRID_PX = 600;
|
||||||
|
|
||||||
|
export type SolutionStatus = 'INCOMPLETE' | 'SOLVED' | 'CONFLICTS' | 'INVALID';
|
||||||
|
|
||||||
|
export interface FourColorMap {
|
||||||
|
grid: FourColorNode[][];
|
||||||
|
regions: Region[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CELLS_PER_REGION = 30;
|
||||||
|
export const COLOR_COUNT = 4;
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import {FourColorService} from './four-color.service';
|
||||||
|
import {FourColorMap, Region} from '../four-color.models';
|
||||||
|
|
||||||
|
function region(id: number, color: number, neighbors: number[]): Region {
|
||||||
|
return {id, color, neighbors: new Set(neighbors)};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One grid row, one cell per region; all regions touch each other as given by `neighbors`. */
|
||||||
|
function handBuiltMap(regions: Region[]): FourColorMap {
|
||||||
|
return {
|
||||||
|
grid: [regions.map((r, col) => ({row: 0, col, regionId: r.id, color: 0}))],
|
||||||
|
regions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function complete(count: number): Region[] {
|
||||||
|
return Array.from({length: count}, (_, id) =>
|
||||||
|
region(id, 0, Array.from({length: count}, (_, other) => other).filter(other => other !== id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('FourColorService', () => {
|
||||||
|
const service = new FourColorService();
|
||||||
|
|
||||||
|
describe('createMap', () => {
|
||||||
|
it('creates floor(cells / 30) regions that cover every cell', () => {
|
||||||
|
const map = service.createMap(25, 25);
|
||||||
|
|
||||||
|
expect(map.regions.length).toBe(20);
|
||||||
|
const ids = new Set(map.grid.flat().map(node => node.regionId));
|
||||||
|
expect(ids.size).toBe(20);
|
||||||
|
for (const id of ids) {
|
||||||
|
expect(id).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(id).toBeLessThan(20);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds symmetric adjacency without self links', () => {
|
||||||
|
const map = service.createMap(25, 25);
|
||||||
|
|
||||||
|
for (const r of map.regions) {
|
||||||
|
expect(r.neighbors.has(r.id)).toBe(false);
|
||||||
|
for (const other of r.neighbors) {
|
||||||
|
expect(map.regions[other].neighbors.has(r.id)).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates no regions below 30 cells and evaluates the empty map as SOLVED', () => {
|
||||||
|
const map = service.createMap(5, 5);
|
||||||
|
|
||||||
|
expect(map.regions).toEqual([]);
|
||||||
|
expect(service.evaluate(map.regions)).toBe('SOLVED');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cycleRegionColor goes 0, 1, 2, 3, 4, 1 and colors all nodes of the region', () => {
|
||||||
|
const map = handBuiltMap([region(0, 0, [])]);
|
||||||
|
const seen: number[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
service.cycleRegionColor(map, 0);
|
||||||
|
seen.push(map.regions[0].color);
|
||||||
|
expect(map.grid[0][0].color).toBe(map.regions[0].color);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(seen).toEqual([1, 2, 3, 4, 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resetColors sets every region and node to 0', () => {
|
||||||
|
const map = handBuiltMap(complete(3));
|
||||||
|
service.solve(map);
|
||||||
|
|
||||||
|
service.resetColors(map);
|
||||||
|
|
||||||
|
expect(map.regions.every(r => r.color === 0)).toBe(true);
|
||||||
|
expect(map.grid.flat().every(node => node.color === 0)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('solve', () => {
|
||||||
|
it('colors K4 with four distinct colors and mirrors them to the grid', () => {
|
||||||
|
const map = handBuiltMap(complete(4));
|
||||||
|
|
||||||
|
expect(service.solve(map)).toBe(true);
|
||||||
|
|
||||||
|
expect(new Set(map.regions.map(r => r.color)).size).toBe(4);
|
||||||
|
map.regions.forEach((r, i) => expect(map.grid[0][i].color).toBe(r.color));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails on K5 and leaves every color at 0', () => {
|
||||||
|
const map = handBuiltMap(complete(5));
|
||||||
|
|
||||||
|
expect(service.solve(map)).toBe(false);
|
||||||
|
|
||||||
|
expect(map.regions.every(r => r.color === 0)).toBe(true);
|
||||||
|
expect(map.grid.flat().every(node => node.color === 0)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('solves a generated map', () => {
|
||||||
|
const map = service.createMap(25, 25);
|
||||||
|
|
||||||
|
expect(service.solve(map)).toBe(true);
|
||||||
|
expect(service.evaluate(map.regions)).toBe('SOLVED');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('evaluate', () => {
|
||||||
|
it('is INCOMPLETE when nothing is colored', () => {
|
||||||
|
expect(service.evaluate(complete(3))).toBe('INCOMPLETE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is SOLVED for a full valid coloring', () => {
|
||||||
|
const regions = complete(3);
|
||||||
|
regions.forEach((r, i) => r.color = i + 1);
|
||||||
|
|
||||||
|
expect(service.evaluate(regions)).toBe('SOLVED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is CONFLICTS for a partial coloring with a clash', () => {
|
||||||
|
const regions = complete(3);
|
||||||
|
regions[0].color = 1;
|
||||||
|
regions[1].color = 1;
|
||||||
|
|
||||||
|
expect(service.evaluate(regions)).toBe('CONFLICTS');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is INVALID for a full coloring with a clash', () => {
|
||||||
|
const regions = complete(3);
|
||||||
|
regions[0].color = 1;
|
||||||
|
regions[1].color = 1;
|
||||||
|
regions[2].color = 2;
|
||||||
|
|
||||||
|
expect(service.evaluate(regions)).toBe('INVALID');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import {Injectable} from '@angular/core';
|
||||||
|
import {CELLS_PER_REGION, COLOR_COUNT, FourColorMap, FourColorNode, Region, SolutionStatus} from '../four-color.models';
|
||||||
|
import {GridPos} from '../../../../shared/components/generic-grid/generic-grid';
|
||||||
|
import {SharedFunctions} from '../../../../shared/SharedFunctions';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class FourColorService {
|
||||||
|
|
||||||
|
createMap(rows: number, cols: number): FourColorMap {
|
||||||
|
const grid: FourColorNode[][] = [];
|
||||||
|
for (let r = 0; r < rows; r++) {
|
||||||
|
const row: FourColorNode[] = [];
|
||||||
|
for (let c = 0; c < cols; c++) {
|
||||||
|
row.push({row: r, col: c, regionId: -1, color: 0});
|
||||||
|
}
|
||||||
|
grid.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
const map: FourColorMap = {grid, regions: []};
|
||||||
|
const numRegions = Math.floor((rows * cols) / CELLS_PER_REGION);
|
||||||
|
const seeds = this.placeSeeds(map, numRegions);
|
||||||
|
this.growRegions(map, seeds);
|
||||||
|
this.linkNeighbors(map);
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
cycleRegionColor(map: FourColorMap, regionId: number): void {
|
||||||
|
const region = map.regions[regionId];
|
||||||
|
region.color = (region.color % COLOR_COUNT) + 1;
|
||||||
|
this.applyRegionColors(map);
|
||||||
|
}
|
||||||
|
|
||||||
|
resetColors(map: FourColorMap): void {
|
||||||
|
for (const region of map.regions) {
|
||||||
|
region.color = 0;
|
||||||
|
}
|
||||||
|
this.applyRegionColors(map);
|
||||||
|
}
|
||||||
|
|
||||||
|
solve(map: FourColorMap): boolean {
|
||||||
|
const solved = this.assignColors(map.regions, 0);
|
||||||
|
this.applyRegionColors(map);
|
||||||
|
return solved;
|
||||||
|
}
|
||||||
|
|
||||||
|
evaluate(regions: Region[]): SolutionStatus {
|
||||||
|
let allColored = true;
|
||||||
|
let hasConflicts = false;
|
||||||
|
|
||||||
|
for (const region of regions) {
|
||||||
|
if (region.color === 0) {
|
||||||
|
allColored = false;
|
||||||
|
}
|
||||||
|
if (region.color > 0 && !this.isColorValid(regions, region, region.color)) {
|
||||||
|
hasConflicts = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasConflicts) {
|
||||||
|
return allColored ? 'INVALID' : 'CONFLICTS';
|
||||||
|
}
|
||||||
|
return allColored ? 'SOLVED' : 'INCOMPLETE';
|
||||||
|
}
|
||||||
|
|
||||||
|
private placeSeeds(map: FourColorMap, numRegions: number): GridPos[] {
|
||||||
|
const rows = map.grid.length;
|
||||||
|
const cols = map.grid[0].length;
|
||||||
|
const seeds: GridPos[] = [];
|
||||||
|
for (let i = 0; i < numRegions; i++) {
|
||||||
|
let r = SharedFunctions.randomIntFromInterval(0, rows - 1);
|
||||||
|
let c = SharedFunctions.randomIntFromInterval(0, cols - 1);
|
||||||
|
while (map.grid[r][c].regionId !== -1) {
|
||||||
|
r = SharedFunctions.randomIntFromInterval(0, rows - 1);
|
||||||
|
c = SharedFunctions.randomIntFromInterval(0, cols - 1);
|
||||||
|
}
|
||||||
|
map.grid[r][c].regionId = i;
|
||||||
|
seeds.push({row: r, col: c});
|
||||||
|
map.regions.push({id: i, color: 0, neighbors: new Set<number>()});
|
||||||
|
}
|
||||||
|
return seeds;
|
||||||
|
}
|
||||||
|
|
||||||
|
private growRegions(map: FourColorMap, seeds: GridPos[]): void {
|
||||||
|
const queue: GridPos[] = [...seeds];
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const {row, col} = queue.shift()!;
|
||||||
|
const regionId = map.grid[row][col].regionId;
|
||||||
|
|
||||||
|
for (const neighbor of this.neighborsOf(map.grid, row, col)) {
|
||||||
|
if (map.grid[neighbor.row][neighbor.col].regionId === -1) {
|
||||||
|
map.grid[neighbor.row][neighbor.col].regionId = regionId;
|
||||||
|
queue.push(neighbor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private linkNeighbors(map: FourColorMap): void {
|
||||||
|
for (let row = 0; row < map.grid.length; row++) {
|
||||||
|
for (let col = 0; col < map.grid[0].length; col++) {
|
||||||
|
const currentRegionId = map.grid[row][col].regionId;
|
||||||
|
for (const neighbor of this.neighborsOf(map.grid, row, col)) {
|
||||||
|
const neighborRegionId = map.grid[neighbor.row][neighbor.col].regionId;
|
||||||
|
if (neighborRegionId !== -1 && neighborRegionId !== currentRegionId) {
|
||||||
|
map.regions[currentRegionId].neighbors.add(neighborRegionId);
|
||||||
|
map.regions[neighborRegionId].neighbors.add(currentRegionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private neighborsOf(grid: FourColorNode[][], row: number, col: number): GridPos[] {
|
||||||
|
const neighbors: GridPos[] = [];
|
||||||
|
if (row > 0) {
|
||||||
|
neighbors.push({row: row - 1, col});
|
||||||
|
}
|
||||||
|
if (row < grid.length - 1) {
|
||||||
|
neighbors.push({row: row + 1, col});
|
||||||
|
}
|
||||||
|
if (col > 0) {
|
||||||
|
neighbors.push({row, col: col - 1});
|
||||||
|
}
|
||||||
|
if (col < grid[0].length - 1) {
|
||||||
|
neighbors.push({row, col: col + 1});
|
||||||
|
}
|
||||||
|
return neighbors;
|
||||||
|
}
|
||||||
|
|
||||||
|
private assignColors(regions: Region[], regionIndex: number): boolean {
|
||||||
|
if (regionIndex === regions.length) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const region = regions[regionIndex];
|
||||||
|
for (let color = 1; color <= COLOR_COUNT; color++) {
|
||||||
|
if (this.isColorValid(regions, region, color)) {
|
||||||
|
region.color = color;
|
||||||
|
if (this.assignColors(regions, regionIndex + 1)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
region.color = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isColorValid(regions: Region[], region: Region, color: number): boolean {
|
||||||
|
for (const neighborId of region.neighbors) {
|
||||||
|
if (regions[neighborId].color === color) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyRegionColors(map: FourColorMap): void {
|
||||||
|
for (const row of map.grid) {
|
||||||
|
for (const node of row) {
|
||||||
|
if (node.regionId !== -1) {
|
||||||
|
node.color = map.regions[node.regionId].color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import {clampZoom, initialView, iterationsForZoom, zoomAroundPoint} from './fractal-view';
|
||||||
|
|
||||||
|
describe('fractal-view', () => {
|
||||||
|
describe('iterationsForZoom', () => {
|
||||||
|
it('is 100 up to zoom 1', () => {
|
||||||
|
expect(iterationsForZoom(0.2)).toBe(100);
|
||||||
|
expect(iterationsForZoom(1)).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds 200 iterations per decade of zoom, floored', () => {
|
||||||
|
expect(iterationsForZoom(10)).toBe(300);
|
||||||
|
expect(iterationsForZoom(100)).toBe(500);
|
||||||
|
expect(iterationsForZoom(2)).toBe(160);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clampZoom keeps the zoom inside the bounds', () => {
|
||||||
|
expect(clampZoom(0.01, 0.2, 64000)).toBe(0.2);
|
||||||
|
expect(clampZoom(1e6, 0.2, 64000)).toBe(64000);
|
||||||
|
expect(clampZoom(5, 0.2, 64000)).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('zoomAroundPoint', () => {
|
||||||
|
it('keeps the world point under the cursor fixed', () => {
|
||||||
|
const view = {zoom: 2, offsetX: 0.3, offsetY: -0.4};
|
||||||
|
const point = {x: 0.25, y: -0.1};
|
||||||
|
|
||||||
|
const next = zoomAroundPoint(view, point, 5);
|
||||||
|
|
||||||
|
expect(next.zoom).toBe(5);
|
||||||
|
expect(point.x / next.zoom + next.offsetX).toBeCloseTo(point.x / view.zoom + view.offsetX);
|
||||||
|
expect(point.y / next.zoom + next.offsetY).toBeCloseTo(point.y / view.zoom + view.offsetY);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the offsets when zooming at the view center', () => {
|
||||||
|
const next = zoomAroundPoint({zoom: 1, offsetX: 0.3, offsetY: -0.4}, {x: 0, y: 0}, 3);
|
||||||
|
|
||||||
|
expect(next).toEqual({zoom: 3, offsetX: 0.3, offsetY: -0.4});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('initialView', () => {
|
||||||
|
it('returns the Burning Ship preset for algorithm 2', () => {
|
||||||
|
expect(initialView(2)).toEqual({zoom: 8, offsetX: -1.75, offsetY: -0.03});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the Mandelbrot preset for algorithm 0', () => {
|
||||||
|
expect(initialView(0)).toEqual({zoom: 0.2, offsetX: -0.5, offsetY: 0});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('centers Julia, Newton and unknown algorithms', () => {
|
||||||
|
for (const algorithm of [1, 3, 99]) {
|
||||||
|
expect(initialView(algorithm)).toEqual({zoom: 0.2, offsetX: 0, offsetY: 0});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
export interface FractalView {
|
||||||
|
zoom: number;
|
||||||
|
offsetX: number;
|
||||||
|
offsetY: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function iterationsForZoom(zoom: number): number {
|
||||||
|
const baseIterations = 100;
|
||||||
|
const factor = 200;
|
||||||
|
|
||||||
|
if (zoom <= 1) {
|
||||||
|
return baseIterations;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.floor(baseIterations + Math.log10(zoom) * factor);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampZoom(zoom: number, min: number, max: number): number {
|
||||||
|
return Math.max(Math.min(zoom, max), min);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zooms to newZoom and moves the offset so the world point under `point` (view units) stays fixed. */
|
||||||
|
export function zoomAroundPoint(view: FractalView, point: { x: number; y: number }, newZoom: number): FractalView {
|
||||||
|
const worldX = point.x / view.zoom + view.offsetX;
|
||||||
|
const worldY = point.y / view.zoom + view.offsetY;
|
||||||
|
return {
|
||||||
|
zoom: newZoom,
|
||||||
|
offsetX: worldX - point.x / newZoom,
|
||||||
|
offsetY: worldY - point.y / newZoom
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initialView(algorithm: number): FractalView {
|
||||||
|
switch (algorithm) {
|
||||||
|
case 0: return {zoom: 0.2, offsetX: -0.5, offsetY: 0};
|
||||||
|
case 2: return {zoom: 8, offsetX: -1.75, offsetY: -0.03};
|
||||||
|
default: return {zoom: 0.2, offsetX: 0, offsetY: 0};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import {Component, OnInit, signal, ChangeDetectionStrategy} from '@angular/core';
|
import {Component, OnInit, signal} from '@angular/core';
|
||||||
import {Information} from '../information/information';
|
import {Information} from '../information/information';
|
||||||
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
|
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
|
||||||
import {TranslatePipe} from '@ngx-translate/core';
|
import {TranslatePipe} from '@ngx-translate/core';
|
||||||
@@ -14,6 +14,7 @@ import {PointerEventTypes, PointerInfo, ShaderMaterial, Vector2} from '@babylonj
|
|||||||
import {MatButton} from '@angular/material/button';
|
import {MatButton} from '@angular/material/button';
|
||||||
import {MatIcon} from '@angular/material/icon';
|
import {MatIcon} from '@angular/material/icon';
|
||||||
import {NgxSliderModule, Options} from '@angular-slider/ngx-slider';
|
import {NgxSliderModule, Options} from '@angular-slider/ngx-slider';
|
||||||
|
import {clampZoom, initialView, iterationsForZoom, zoomAroundPoint} from './fractal-view';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-fractal',
|
selector: 'app-fractal',
|
||||||
@@ -35,7 +36,6 @@ import {NgxSliderModule, Options} from '@angular-slider/ngx-slider';
|
|||||||
NgxSliderModule
|
NgxSliderModule
|
||||||
],
|
],
|
||||||
templateUrl: './fractal.component.html',
|
templateUrl: './fractal.component.html',
|
||||||
changeDetection: ChangeDetectionStrategy.Eager,
|
|
||||||
styleUrl: './fractal.component.scss',
|
styleUrl: './fractal.component.scss',
|
||||||
})
|
})
|
||||||
export class FractalComponent implements OnInit {
|
export class FractalComponent implements OnInit {
|
||||||
@@ -142,18 +142,8 @@ export class FractalComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onReset(): void {
|
onReset(): void {
|
||||||
|
({zoom: this.zoom, offsetX: this.offsetX, offsetY: this.offsetY} = initialView(this.selectedAlgorithm));
|
||||||
this.offsetY = 0.0;
|
|
||||||
this.zoom = 0.2
|
|
||||||
this.maxIterations = 100;
|
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 {
|
onSceneReady(event: SceneEventData): void {
|
||||||
@@ -227,40 +217,24 @@ export class FractalComponent implements OnInit {
|
|||||||
const mouseXView = mouseXPixels / rect.height;
|
const mouseXView = mouseXPixels / rect.height;
|
||||||
const mouseYView = mouseYPixels / rect.height;
|
const mouseYView = mouseYPixels / rect.height;
|
||||||
|
|
||||||
const mouseXWorld = mouseXView / this.zoom + this.offsetX;
|
|
||||||
const mouseYWorld = mouseYView / this.zoom + this.offsetY;
|
|
||||||
|
|
||||||
const zoomFactor = 1.1;
|
const zoomFactor = 1.1;
|
||||||
if (event.deltaY < 0) {
|
const newZoom = clampZoom(
|
||||||
this.zoom *= zoomFactor;
|
event.deltaY < 0 ? this.zoom * zoomFactor : this.zoom / zoomFactor,
|
||||||
} else {
|
this.minZoomValue,
|
||||||
this.zoom /= zoomFactor;
|
this.maxZoomValue
|
||||||
}
|
);
|
||||||
|
|
||||||
this.zoom = Math.max(Math.min(this.zoom, this.maxZoomValue), this.minZoomValue);
|
({zoom: this.zoom, offsetX: this.offsetX, offsetY: this.offsetY} = zoomAroundPoint(
|
||||||
|
{zoom: this.zoom, offsetX: this.offsetX, offsetY: this.offsetY},
|
||||||
|
{x: mouseXView, y: mouseYView},
|
||||||
|
newZoom
|
||||||
|
));
|
||||||
this.sliderValue.set(this.zoom);
|
this.sliderValue.set(this.zoom);
|
||||||
const optimalIterations = this.getIterationsForZoom(this.zoom);
|
this.maxIterations = Math.min(iterationsForZoom(this.zoom), 3000);
|
||||||
this.maxIterations = Math.min(optimalIterations, 3000);
|
|
||||||
|
|
||||||
this.offsetX = mouseXWorld - mouseXView / this.zoom;
|
|
||||||
this.offsetY = mouseYWorld - mouseYView / this.zoom;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onSliderChange(newValue: number): void {
|
onSliderChange(newValue: number): void {
|
||||||
|
this.zoom = clampZoom(newValue, this.minZoomValue, this.maxZoomValue);
|
||||||
this.zoom = newValue;
|
this.maxIterations = iterationsForZoom(this.zoom);
|
||||||
this.zoom = Math.max(Math.min(this.zoom, this.maxZoomValue), this.minZoomValue);
|
|
||||||
this.maxIterations = this.getIterationsForZoom(this.zoom);
|
|
||||||
}
|
|
||||||
|
|
||||||
private getIterationsForZoom(zoom: number): number {
|
|
||||||
const baseIterations = 100;
|
|
||||||
const factor = 200;
|
|
||||||
|
|
||||||
if (zoom <= 1) {
|
|
||||||
return baseIterations;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Math.floor(baseIterations + Math.log10(zoom) * factor);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import {Component, ChangeDetectionStrategy} from '@angular/core';
|
import {Component} from '@angular/core';
|
||||||
import {ArcRotateCamera, Camera, ShaderMaterial} from '@babylonjs/core';
|
import {ArcRotateCamera, Camera, ShaderMaterial} from '@babylonjs/core';
|
||||||
import {MANDELBULB_FRAGMENT, MANDELBULB_VERTEX} from './fractal3d.shader';
|
import {MANDELBULB_FRAGMENT, MANDELBULB_VERTEX} from './fractal3d.shader';
|
||||||
import {Information} from '../information/information';
|
import {Information} from '../information/information';
|
||||||
@@ -22,7 +22,6 @@ import {BabylonCanvas, RenderCallback, RenderConfig} from '../../../shared/compo
|
|||||||
BabylonCanvas
|
BabylonCanvas
|
||||||
],
|
],
|
||||||
templateUrl: './fractal3d.component.html',
|
templateUrl: './fractal3d.component.html',
|
||||||
changeDetection: ChangeDetectionStrategy.Eager,
|
|
||||||
styleUrl: './fractal3d.component.scss',
|
styleUrl: './fractal3d.component.scss',
|
||||||
})
|
})
|
||||||
export class Fractal3dComponent {
|
export class Fractal3dComponent {
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
|
@let info = algorithmInformation();
|
||||||
<div class="mb-4 py-3 px-4 border border-[#ddd] rounded-lg">
|
<div class="mb-4 py-3 px-4 border border-[#ddd] rounded-lg">
|
||||||
<h3 class="m-0 mb-2">{{ algorithmInformation.title | translate }}</h3>
|
<h3 class="m-0 mb-2">{{ info.title | translate }}</h3>
|
||||||
|
|
||||||
@if(algorithmInformation.entries && algorithmInformation.entries.length > 0){
|
@if(info.entries && info.entries.length > 0){
|
||||||
@for (algo of algorithmInformation.entries; track algo)
|
@for (algo of info.entries; track algo)
|
||||||
{
|
{
|
||||||
<p class="my-2">
|
<p class="my-2">
|
||||||
<strong>
|
<strong>
|
||||||
@@ -18,24 +19,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (algorithmInformation.disclaimer !== '')
|
@if (info.disclaimer !== '')
|
||||||
{
|
{
|
||||||
<p class="my-2">
|
<p class="my-2">
|
||||||
<strong>{{ 'ALGORITHM.NOTE' | translate}}</strong> {{ algorithmInformation.disclaimer | translate}}
|
<strong>{{ 'ALGORITHM.NOTE' | translate}}</strong> {{ info.disclaimer | translate}}
|
||||||
</p>
|
</p>
|
||||||
@if (algorithmInformation.disclaimerListEntry && algorithmInformation.disclaimerListEntry.length > 0)
|
@if (info.disclaimerListEntry && info.disclaimerListEntry.length > 0)
|
||||||
{
|
{
|
||||||
<ul>
|
<ul>
|
||||||
@for (entry of algorithmInformation.disclaimerListEntry; track entry)
|
@for (entry of info.disclaimerListEntry; track entry)
|
||||||
{
|
{
|
||||||
<li>{{ entry | translate}}</li>
|
<li>{{ entry | translate}}</li>
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
}
|
}
|
||||||
@if (algorithmInformation.disclaimerBottom !== '')
|
@if (info.disclaimerBottom !== '')
|
||||||
{
|
{
|
||||||
<p>
|
<p>
|
||||||
{{ algorithmInformation.disclaimerBottom | translate}}
|
{{ info.disclaimerBottom | translate}}
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {Component, Input, ChangeDetectionStrategy} from '@angular/core';
|
import {Component, input} from '@angular/core';
|
||||||
import {TranslatePipe} from "@ngx-translate/core";
|
import {TranslatePipe} from "@ngx-translate/core";
|
||||||
import {AlgorithmInformation} from './information.models';
|
import {AlgorithmInformation} from './information.models';
|
||||||
|
|
||||||
@@ -8,10 +8,9 @@ import {AlgorithmInformation} from './information.models';
|
|||||||
TranslatePipe
|
TranslatePipe
|
||||||
],
|
],
|
||||||
templateUrl: './information.html',
|
templateUrl: './information.html',
|
||||||
changeDetection: ChangeDetectionStrategy.Eager,
|
|
||||||
styleUrl: './information.scss',
|
styleUrl: './information.scss',
|
||||||
})
|
})
|
||||||
export class Information {
|
export class Information {
|
||||||
|
|
||||||
@Input({ required: true }) algorithmInformation!: AlgorithmInformation;
|
readonly algorithmInformation = input.required<AlgorithmInformation>();
|
||||||
}
|
}
|
||||||
@@ -22,8 +22,8 @@
|
|||||||
<span><span class="legend-swatch bg-[gold]"></span> {{ 'PATHFINDING.PATH' | translate }}</span>
|
<span><span class="legend-swatch bg-[gold]"></span> {{ 'PATHFINDING.PATH' | translate }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
|
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
|
||||||
<p>{{ 'PATHFINDING.PATH_LENGTH' | translate }}: {{ pathLength }}</p>
|
<p>{{ 'PATHFINDING.PATH_LENGTH' | translate }}: {{ pathLength() }}</p>
|
||||||
<p>{{ 'PATHFINDING.EXECUTION_TIME' | translate }}: {{ executionTime | number:'1.2-2' }} ms</p>
|
<p>{{ 'PATHFINDING.EXECUTION_TIME' | translate }}: {{ executionTime() | number:'1.2-2' }} ms</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<app-generic-grid
|
<app-generic-grid
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import {AfterViewInit, Component, inject, signal, ViewChild, ChangeDetectionStrategy} from '@angular/core';
|
import {AfterViewInit, Component, inject, signal, ViewChild} from '@angular/core';
|
||||||
import {Information} from '../../information/information';
|
import {Information} from '../../information/information';
|
||||||
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
|
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
|
||||||
import {TranslatePipe} from '@ngx-translate/core';
|
import {TranslatePipe} from '@ngx-translate/core';
|
||||||
import {GenericGridComponent, GridPos} from '../../../../shared/components/generic-grid/generic-grid';
|
import {GenericGridComponent, GridPos} from '../../../../shared/components/generic-grid/generic-grid';
|
||||||
import {AlgorithmInformation} from '../../information/information.models';
|
import {AlgorithmInformation} from '../../information/information.models';
|
||||||
import {UrlConstants} from '../../../../constants/UrlConstants';
|
import {UrlConstants} from '../../../../constants/UrlConstants';
|
||||||
import {Node} from '../pathfinding.models';
|
import {createPathfindingNode, Node, PathfindingAlgorithm} from '../pathfinding.models';
|
||||||
import {SharedFunctions} from '../../../../shared/SharedFunctions';
|
|
||||||
import {MatButton} from '@angular/material/button';
|
import {MatButton} from '@angular/material/button';
|
||||||
import {DecimalPipe} from '@angular/common';
|
import {DecimalPipe} from '@angular/common';
|
||||||
import {PathfindingService} from '../service/pathfinding.service';
|
import {PathfindingService} from '../service/pathfinding.service';
|
||||||
|
import {PathfindingAnimator} from '../pathfinding-animator';
|
||||||
|
import {MazeGeneratorService} from './service/maze-generator.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-labyrinth',
|
selector: 'app-labyrinth',
|
||||||
@@ -25,7 +26,6 @@ import {PathfindingService} from '../service/pathfinding.service';
|
|||||||
DecimalPipe
|
DecimalPipe
|
||||||
],
|
],
|
||||||
templateUrl: './labyrinth.component.html',
|
templateUrl: './labyrinth.component.html',
|
||||||
changeDetection: ChangeDetectionStrategy.Eager,
|
|
||||||
styleUrl: './labyrinth.component.scss',
|
styleUrl: './labyrinth.component.scss',
|
||||||
})
|
})
|
||||||
export class LabyrinthComponent implements AfterViewInit {
|
export class LabyrinthComponent implements AfterViewInit {
|
||||||
@@ -36,7 +36,7 @@ export class LabyrinthComponent implements AfterViewInit {
|
|||||||
protected readonly MAX_GRID_PX = 1000;
|
protected readonly MAX_GRID_PX = 1000;
|
||||||
protected readonly MIN_GRID_SIZE = 101;
|
protected readonly MIN_GRID_SIZE = 101;
|
||||||
private readonly pathfindingService = inject(PathfindingService);
|
private readonly pathfindingService = inject(PathfindingService);
|
||||||
|
private readonly mazeGenerator = inject(MazeGeneratorService);
|
||||||
|
|
||||||
algoInformation: AlgorithmInformation = {
|
algoInformation: AlgorithmInformation = {
|
||||||
title: 'LABYRINTH.EXPLANATION.TITLE',
|
title: 'LABYRINTH.EXPLANATION.TITLE',
|
||||||
@@ -64,10 +64,13 @@ export class LabyrinthComponent implements AfterViewInit {
|
|||||||
endNode: Node | null = null;
|
endNode: Node | null = null;
|
||||||
animationSpeed = 3;
|
animationSpeed = 3;
|
||||||
mazeAnimationSpeed = 1;
|
mazeAnimationSpeed = 1;
|
||||||
pathLength = "0";
|
readonly pathLength = signal("0");
|
||||||
executionTime = 0;
|
readonly executionTime = signal(0);
|
||||||
|
|
||||||
private timeoutIds: number[] = [];
|
private readonly animator = new PathfindingAnimator(
|
||||||
|
node => this.genericGridComponent?.drawNode(node),
|
||||||
|
() => this.animationSpeed
|
||||||
|
);
|
||||||
protected mazeNodesInOrder: Node[] = [];
|
protected mazeNodesInOrder: Node[] = [];
|
||||||
readonly isAnimationRunning = signal(false);
|
readonly isAnimationRunning = signal(false);
|
||||||
|
|
||||||
@@ -94,247 +97,31 @@ export class LabyrinthComponent implements AfterViewInit {
|
|||||||
|
|
||||||
createRandom(prim: boolean): void {
|
createRandom(prim: boolean): void {
|
||||||
this.isAnimationRunning.set(true);
|
this.isAnimationRunning.set(true);
|
||||||
this.stopAnimations();
|
this.animator.stop();
|
||||||
this.clearPath();
|
this.clearPath();
|
||||||
this.startNode = null;
|
const result = prim ? this.mazeGenerator.generatePrim(this.grid) : this.mazeGenerator.generateKruskal(this.grid);
|
||||||
this.endNode = null;
|
this.startNode = result.startNode;
|
||||||
if (prim)
|
this.endNode = result.endNode;
|
||||||
{
|
this.mazeNodesInOrder = result.nodesToCarveInOrder;
|
||||||
this.createPrimMaze();
|
|
||||||
}
|
|
||||||
else{
|
|
||||||
this.createKruskalMaze();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.cleanupGrid();
|
|
||||||
this.genericGridComponent.drawGrid();
|
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();
|
this.animateMazeGeneration();
|
||||||
}
|
}
|
||||||
|
|
||||||
private initKuskal() {
|
visualize(algorithm: PathfindingAlgorithm): void {
|
||||||
let roomId = 0;
|
this.animator.stop();
|
||||||
|
|
||||||
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();
|
this.clearPath();
|
||||||
|
|
||||||
const startTime = performance.now();
|
const startTime = performance.now();
|
||||||
let result;
|
const result = this.pathfindingService.findPath(algorithm, this.grid, this.startNode!, this.endNode!);
|
||||||
|
|
||||||
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 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);
|
this.pathLength.set(PathfindingAnimator.pathLengthLabel(result));
|
||||||
|
this.executionTime.set(endTime - startTime);
|
||||||
|
|
||||||
|
this.animator.animate(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
createMazeNode = (row: number, col: number): Node => {
|
readonly createMazeNode = createPathfindingNode;
|
||||||
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 => {
|
getMazeColor = (node: Node): string => {
|
||||||
if (node.isStart) return 'green';
|
if (node.isStart) return 'green';
|
||||||
@@ -350,110 +137,27 @@ export class LabyrinthComponent implements AfterViewInit {
|
|||||||
//dont need a selection for the maze case
|
//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 {
|
private clearPath(): void {
|
||||||
for (let row = 0; row < this.gridRows; row++) {
|
this.pathfindingService.resetSearchState(this.grid);
|
||||||
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
|
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 {
|
private animateMazeGeneration(): void {
|
||||||
for (let i = 0; i < this.mazeNodesInOrder.length; i++) {
|
const last = this.mazeNodesInOrder.length - 1;
|
||||||
const id = globalThis.setTimeout(() => {
|
this.mazeNodesInOrder.forEach((node, i) => {
|
||||||
const node = this.mazeNodesInOrder[i];
|
this.animator.schedule(this.mazeAnimationSpeed * i, () => {
|
||||||
node.isWall = false;
|
node.isWall = false;
|
||||||
this.genericGridComponent?.drawNode(node);
|
this.genericGridComponent?.drawNode(node);
|
||||||
if (i === this.mazeNodesInOrder.length - 1) {
|
if (i === last) {
|
||||||
this.cleanupGrid();
|
|
||||||
if (this.startNode) {
|
if (this.startNode) {
|
||||||
this.genericGridComponent?.drawNode(this.startNode);
|
this.genericGridComponent?.drawNode(this.startNode);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.endNode) {
|
if (this.endNode) {
|
||||||
this.genericGridComponent?.drawNode(this.endNode);
|
this.genericGridComponent?.drawNode(this.endNode);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if (i == this.mazeNodesInOrder.length - 1) {
|
|
||||||
this.isAnimationRunning.set(false);
|
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;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import {MazeGeneratorService, MazeResult} from './maze-generator.service';
|
||||||
|
import {PathfindingService} from '../../service/pathfinding.service';
|
||||||
|
import {createPathfindingNode, Node} from '../../pathfinding.models';
|
||||||
|
|
||||||
|
const SIZE = 11;
|
||||||
|
const ROOMS = 36; // 6 x 6 rooms on even/even cells
|
||||||
|
|
||||||
|
function createGrid(): Node[][] {
|
||||||
|
return Array.from({length: SIZE}, (_, row) =>
|
||||||
|
Array.from({length: SIZE}, (_, col) => createPathfindingNode(row, col)));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MazeGeneratorService', () => {
|
||||||
|
const service = new MazeGeneratorService();
|
||||||
|
const generators = [
|
||||||
|
['prim', (grid: Node[][]) => service.generatePrim(grid)],
|
||||||
|
['kruskal', (grid: Node[][]) => service.generateKruskal(grid)],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
describe.each(generators)('%s', (_name, generate) => {
|
||||||
|
let grid: Node[][];
|
||||||
|
let result: MazeResult;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
grid = createGrid();
|
||||||
|
result = generate(grid);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('picks two different corner rooms as open start and end', () => {
|
||||||
|
expect(result.startNode).not.toBe(result.endNode);
|
||||||
|
expect(result.startNode.isStart).toBe(true);
|
||||||
|
expect(result.endNode.isEnd).toBe(true);
|
||||||
|
expect(result.startNode.isWall).toBe(false);
|
||||||
|
expect(result.endNode.isWall).toBe(false);
|
||||||
|
for (const node of [result.startNode, result.endNode]) {
|
||||||
|
expect([0, SIZE - 1]).toContain(node.row);
|
||||||
|
expect([0, SIZE - 1]).toContain(node.col);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves no search marks behind', () => {
|
||||||
|
for (const node of grid.flat()) {
|
||||||
|
expect(node.isVisited).toBe(false);
|
||||||
|
expect(node.linkedNode).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('connects start and end once the carve list is applied', () => {
|
||||||
|
for (const node of result.nodesToCarveInOrder) {
|
||||||
|
node.isWall = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let row = 0; row < SIZE; row += 2) {
|
||||||
|
for (let col = 0; col < SIZE; col += 2) {
|
||||||
|
expect(grid[row][col].isWall).toBe(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const path = new PathfindingService().dijkstra(grid, result.startNode, result.endNode);
|
||||||
|
expect(path.nodesInShortestPathOrder.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prim starts with every cell walled except the start room and the two corners', () => {
|
||||||
|
const grid = createGrid();
|
||||||
|
const result = service.generatePrim(grid);
|
||||||
|
|
||||||
|
const startRoom = result.nodesToCarveInOrder[0];
|
||||||
|
const open = new Set([startRoom, result.startNode, result.endNode]);
|
||||||
|
for (const node of grid.flat()) {
|
||||||
|
expect(node.isWall).toBe(!open.has(node));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('kruskal opens every room and carves exactly rooms - 1 walls', () => {
|
||||||
|
const grid = createGrid();
|
||||||
|
const result = service.generateKruskal(grid);
|
||||||
|
|
||||||
|
expect(result.nodesToCarveInOrder.length).toBe(ROOMS - 1);
|
||||||
|
for (const node of grid.flat()) {
|
||||||
|
const isRoom = node.row % 2 === 0 && node.col % 2 === 0;
|
||||||
|
expect(node.isWall).toBe(!isRoom);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prim carves the start room plus a wall and a room per other room', () => {
|
||||||
|
const result = service.generatePrim(createGrid());
|
||||||
|
|
||||||
|
expect(result.nodesToCarveInOrder.length).toBe(1 + 2 * (ROOMS - 1));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import {Injectable} from '@angular/core';
|
||||||
|
import {Node} from '../../pathfinding.models';
|
||||||
|
import {SharedFunctions} from '../../../../../shared/SharedFunctions';
|
||||||
|
|
||||||
|
export interface MazeResult {
|
||||||
|
/** Walls (and rooms for Prim) to open one after the other to animate the carving. */
|
||||||
|
nodesToCarveInOrder: Node[];
|
||||||
|
startNode: Node;
|
||||||
|
endNode: Node;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WallBetweenRooms {
|
||||||
|
row: number;
|
||||||
|
col: number;
|
||||||
|
roomA: Node;
|
||||||
|
roomB: Node;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class MazeGeneratorService {
|
||||||
|
|
||||||
|
generatePrim(grid: Node[][]): MazeResult {
|
||||||
|
this.fillWithWalls(grid);
|
||||||
|
const frontier: Node[] = [];
|
||||||
|
|
||||||
|
const startRoom = this.pickRandomStartRoom(grid);
|
||||||
|
const nodesToCarveInOrder: Node[] = [startRoom];
|
||||||
|
this.collectNeighborWalls(grid, startRoom.row, startRoom.col, 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;
|
||||||
|
|
||||||
|
nodesToCarveInOrder.push(wallNode, target);
|
||||||
|
|
||||||
|
this.collectNeighborWalls(grid, target.row, target.col, frontier);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.finish(grid, nodesToCarveInOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
generateKruskal(grid: Node[][]): MazeResult {
|
||||||
|
this.initRooms(grid);
|
||||||
|
const nodesToCarveInOrder: Node[] = [];
|
||||||
|
const walls = this.findWallsBetweenRooms(grid);
|
||||||
|
SharedFunctions.shuffleArray(walls);
|
||||||
|
for (const {row, col, roomA, roomB} of walls) {
|
||||||
|
if (roomA.nodeData !== roomB.nodeData) {
|
||||||
|
nodesToCarveInOrder.push(grid[row][col]);
|
||||||
|
|
||||||
|
this.mergeSets(grid, roomB.nodeData, roomA.nodeData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.finish(grid, nodesToCarveInOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
private finish(grid: Node[][], nodesToCarveInOrder: Node[]): MazeResult {
|
||||||
|
const {startNode, endNode} = this.pickRandomCorners(grid);
|
||||||
|
this.clearSearchMarks(grid);
|
||||||
|
return {nodesToCarveInOrder, startNode, endNode};
|
||||||
|
}
|
||||||
|
|
||||||
|
private fillWithWalls(grid: Node[][]): void {
|
||||||
|
for (const row of grid) {
|
||||||
|
for (const node of row) {
|
||||||
|
node.isWall = true;
|
||||||
|
node.isStart = false;
|
||||||
|
node.isEnd = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private initRooms(grid: Node[][]): void {
|
||||||
|
let roomId = 0;
|
||||||
|
|
||||||
|
for (const [row, nodes] of grid.entries()) {
|
||||||
|
for (const [col, node] of nodes.entries()) {
|
||||||
|
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(grid: Node[][], oldId: number, newId: number): void {
|
||||||
|
for (let r = 0; r < grid.length; r += 2) {
|
||||||
|
for (let c = 0; c < grid[0].length; c += 2) {
|
||||||
|
if (grid[r][c].nodeData === oldId) {
|
||||||
|
grid[r][c].nodeData = newId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private findWallsBetweenRooms(grid: Node[][]): WallBetweenRooms[] {
|
||||||
|
const rows = grid.length;
|
||||||
|
const cols = grid[0].length;
|
||||||
|
const walls: WallBetweenRooms[] = [];
|
||||||
|
for (let row = 0; row < rows; row++) {
|
||||||
|
for (let col = 0; col < cols; col++) {
|
||||||
|
if (row % 2 === 0 && col % 2 !== 0 && col > 0 && col < cols - 1) {
|
||||||
|
walls.push({row, col, roomA: grid[row][col - 1], roomB: grid[row][col + 1]});
|
||||||
|
}
|
||||||
|
if (row % 2 !== 0 && col % 2 === 0 && row > 0 && row < rows - 1) {
|
||||||
|
walls.push({row, col, roomA: grid[row - 1][col], roomB: grid[row + 1][col]});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return walls;
|
||||||
|
}
|
||||||
|
|
||||||
|
private pickRandomCorners(grid: Node[][]): { startNode: Node; endNode: Node } {
|
||||||
|
const lastRow = Math.floor((grid.length - 1) / 2) * 2;
|
||||||
|
const lastCol = Math.floor((grid[0].length - 1) / 2) * 2;
|
||||||
|
|
||||||
|
const corners = [
|
||||||
|
{r: 0, c: 0},
|
||||||
|
{r: 0, c: lastCol},
|
||||||
|
{r: lastRow, c: 0},
|
||||||
|
{r: lastRow, c: lastCol}
|
||||||
|
];
|
||||||
|
|
||||||
|
const lastIndex = corners.length - 1;
|
||||||
|
const startIndex = SharedFunctions.randomIntFromInterval(0, lastIndex);
|
||||||
|
let endIndex = SharedFunctions.randomIntFromInterval(0, lastIndex);
|
||||||
|
while (endIndex === startIndex) {
|
||||||
|
endIndex = SharedFunctions.randomIntFromInterval(0, lastIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
const startNode = grid[corners[startIndex].r][corners[startIndex].c];
|
||||||
|
startNode.isStart = true;
|
||||||
|
startNode.isWall = false;
|
||||||
|
|
||||||
|
const endNode = grid[corners[endIndex].r][corners[endIndex].c];
|
||||||
|
endNode.isEnd = true;
|
||||||
|
endNode.isWall = false;
|
||||||
|
return {startNode, endNode};
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearSearchMarks(grid: Node[][]): void {
|
||||||
|
for (const row of grid) {
|
||||||
|
for (const node of row) {
|
||||||
|
node.isVisited = false;
|
||||||
|
node.linkedNode = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private pickRandomStartRoom(grid: Node[][]): Node {
|
||||||
|
const startRow = SharedFunctions.randomEventIntFromInterval(grid.length - 1);
|
||||||
|
const startCol = SharedFunctions.randomEventIntFromInterval(grid[0].length - 1);
|
||||||
|
|
||||||
|
const startNode = grid[startRow][startCol];
|
||||||
|
startNode.isWall = false;
|
||||||
|
startNode.isVisited = true;
|
||||||
|
return startNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
private collectNeighborWalls(grid: Node[][], 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.isInside(grid, nextRow, nextCol) && grid[nextRow][nextCol].isWall && !grid[nextRow][nextCol].isVisited) {
|
||||||
|
const node = grid[row + dr / 2][col + dc / 2];
|
||||||
|
node.linkedNode = grid[nextRow][nextCol];
|
||||||
|
frontier.push(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private isInside(grid: Node[][], row: number, col: number): boolean {
|
||||||
|
return row >= 0 && row < grid.length && col >= 0 && col < grid[0].length;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import {Node, PathfindingResult} from './pathfinding.models';
|
||||||
|
|
||||||
|
/** Plays a search result node by node with timeouts. Shared by the pathfinding and labyrinth pages. */
|
||||||
|
export class PathfindingAnimator {
|
||||||
|
private timeoutIds: number[] = [];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly drawNode: (node: Node) => void,
|
||||||
|
private readonly getSpeed: () => number
|
||||||
|
) {}
|
||||||
|
|
||||||
|
static pathLengthLabel(result: PathfindingResult): string {
|
||||||
|
const length = result.nodesInShortestPathOrder.length;
|
||||||
|
return length === 0 ? '∞' : String(length);
|
||||||
|
}
|
||||||
|
|
||||||
|
schedule(delayMs: number, action: () => void): void {
|
||||||
|
this.timeoutIds.push(globalThis.setTimeout(action, delayMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
for (const id of this.timeoutIds) {
|
||||||
|
clearTimeout(id);
|
||||||
|
}
|
||||||
|
this.timeoutIds = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
animate(result: PathfindingResult): void {
|
||||||
|
const speed = this.getSpeed();
|
||||||
|
const visited = result.visitedNodesInOrder;
|
||||||
|
visited.forEach((node, i) => {
|
||||||
|
this.schedule(speed * i, () => {
|
||||||
|
if (!node.isStart && !node.isEnd) {
|
||||||
|
node.isVisited = true;
|
||||||
|
this.drawNode(node);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
this.schedule(speed * visited.length, () => this.animateShortestPath(result.nodesInShortestPathOrder));
|
||||||
|
}
|
||||||
|
|
||||||
|
private animateShortestPath(path: Node[]): void {
|
||||||
|
const speed = this.getSpeed();
|
||||||
|
path.forEach((node, i) => {
|
||||||
|
this.schedule(speed * i, () => {
|
||||||
|
if (!node.isStart && !node.isEnd) {
|
||||||
|
node.isPath = true;
|
||||||
|
this.drawNode(node);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
[min]="MIN_GRID_SIZE"
|
[min]="MIN_GRID_SIZE"
|
||||||
[max]="MAX_GRID_SIZE"
|
[max]="MAX_GRID_SIZE"
|
||||||
[(ngModel)]="gridRows"
|
[(ngModel)]="gridRows"
|
||||||
(ngModelChange)="genericGridComponent.gridRows = gridRows; genericGridComponent.applyGridSize()"
|
(ngModelChange)="applyGridRows($event)"
|
||||||
/> </mat-form-field>
|
/> </mat-form-field>
|
||||||
|
|
||||||
<mat-form-field appearance="outline" class="w-[150px]">
|
<mat-form-field appearance="outline" class="w-[150px]">
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
[min]="MIN_GRID_SIZE"
|
[min]="MIN_GRID_SIZE"
|
||||||
[max]="MAX_GRID_SIZE"
|
[max]="MAX_GRID_SIZE"
|
||||||
[(ngModel)]="gridCols"
|
[(ngModel)]="gridCols"
|
||||||
(ngModelChange)="genericGridComponent.gridCols = gridCols; genericGridComponent.applyGridSize()"
|
(ngModelChange)="applyGridCols($event)"
|
||||||
/> </mat-form-field>
|
/> </mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -59,14 +59,14 @@
|
|||||||
<span><span class="legend-swatch bg-[gold]"></span> {{ 'PATHFINDING.PATH' | translate }}</span>
|
<span><span class="legend-swatch bg-[gold]"></span> {{ 'PATHFINDING.PATH' | translate }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
|
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
|
||||||
<p>{{ 'PATHFINDING.PATH_LENGTH' | translate }}: {{ pathLength }}</p>
|
<p>{{ 'PATHFINDING.PATH_LENGTH' | translate }}: {{ pathLength() }}</p>
|
||||||
<p>{{ 'PATHFINDING.EXECUTION_TIME' | translate }}: {{ executionTime | number:'1.2-2' }} ms</p>
|
<p>{{ 'PATHFINDING.EXECUTION_TIME' | translate }}: {{ executionTime() | number:'1.2-2' }} ms</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<app-generic-grid
|
<app-generic-grid
|
||||||
[gridRows]="gridRows"
|
[gridRows]="gridRows()"
|
||||||
[gridCols]="gridCols"
|
[gridCols]="gridCols()"
|
||||||
[minGridSize]="MIN_GRID_SIZE"
|
[minGridSize]="MIN_GRID_SIZE"
|
||||||
[maxGridSize]="MAX_GRID_SIZE"
|
[maxGridSize]="MAX_GRID_SIZE"
|
||||||
[maxGridPx]="MAX_GRID_PX"
|
[maxGridPx]="MAX_GRID_PX"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {AfterViewInit, Component, inject, ViewChild, ChangeDetectionStrategy} from '@angular/core';
|
import {AfterViewInit, Component, inject, signal, ViewChild} from '@angular/core';
|
||||||
import {CommonModule} from '@angular/common';
|
import {CommonModule} from '@angular/common';
|
||||||
import {FormsModule} from '@angular/forms';
|
import {FormsModule} from '@angular/forms';
|
||||||
|
|
||||||
@@ -10,14 +10,15 @@ import {MatSnackBar} from '@angular/material/snack-bar';
|
|||||||
|
|
||||||
import {TranslatePipe, TranslateService} from '@ngx-translate/core';
|
import {TranslatePipe, TranslateService} from '@ngx-translate/core';
|
||||||
|
|
||||||
import {DEFAULT_GRID_COLS, DEFAULT_GRID_ROWS, MAX_GRID_PX, MAX_GRID_SIZE, MAX_RANDOM_WALLS_FACTORS, MIN_GRID_SIZE, Node} from './pathfinding.models';
|
import {createPathfindingNode, DEFAULT_GRID_COLS, DEFAULT_GRID_ROWS, MAX_GRID_PX, MAX_GRID_SIZE, MIN_GRID_SIZE, Node, PathfindingAlgorithm, PathfindingScenario} from './pathfinding.models';
|
||||||
import {PathfindingService} from './service/pathfinding.service';
|
import {PathfindingService} from './service/pathfinding.service';
|
||||||
|
import {PathfindingGridService} from './service/pathfinding-grid.service';
|
||||||
|
import {PathfindingAnimator} from './pathfinding-animator';
|
||||||
import {UrlConstants} from '../../../constants/UrlConstants';
|
import {UrlConstants} from '../../../constants/UrlConstants';
|
||||||
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
|
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
|
||||||
import {Information} from '../information/information';
|
import {Information} from '../information/information';
|
||||||
import {AlgorithmInformation} from '../information/information.models';
|
import {AlgorithmInformation} from '../information/information.models';
|
||||||
import {GenericGridComponent, GridPos} from '../../../shared/components/generic-grid/generic-grid';
|
import {GenericGridComponent, GridPos} from '../../../shared/components/generic-grid/generic-grid';
|
||||||
import {SharedFunctions} from '../../../shared/SharedFunctions';
|
|
||||||
|
|
||||||
enum NodeType {
|
enum NodeType {
|
||||||
Start = 'start',
|
Start = 'start',
|
||||||
@@ -43,11 +44,11 @@ enum NodeType {
|
|||||||
Information,
|
Information,
|
||||||
GenericGridComponent
|
GenericGridComponent
|
||||||
],
|
],
|
||||||
changeDetection: ChangeDetectionStrategy.Eager,
|
|
||||||
templateUrl: './pathfinding.component.html',
|
templateUrl: './pathfinding.component.html',
|
||||||
})
|
})
|
||||||
export class PathfindingComponent implements AfterViewInit {
|
export class PathfindingComponent implements AfterViewInit {
|
||||||
private readonly pathfindingService = inject(PathfindingService);
|
private readonly pathfindingService = inject(PathfindingService);
|
||||||
|
private readonly gridService = inject(PathfindingGridService);
|
||||||
private readonly translate = inject(TranslateService);
|
private readonly translate = inject(TranslateService);
|
||||||
private readonly snackBar = inject(MatSnackBar);
|
private readonly snackBar = inject(MatSnackBar);
|
||||||
|
|
||||||
@@ -75,21 +76,24 @@ export class PathfindingComponent implements AfterViewInit {
|
|||||||
disclaimerListEntry: []
|
disclaimerListEntry: []
|
||||||
};
|
};
|
||||||
|
|
||||||
gridRows = DEFAULT_GRID_ROWS;
|
readonly gridRows = signal(DEFAULT_GRID_ROWS);
|
||||||
gridCols = DEFAULT_GRID_COLS;
|
readonly gridCols = signal(DEFAULT_GRID_COLS);
|
||||||
|
|
||||||
grid: Node[][] = [];
|
grid: Node[][] = [];
|
||||||
startNode: Node | null = null;
|
startNode: Node | null = null;
|
||||||
endNode: Node | null = null;
|
endNode: Node | null = null;
|
||||||
|
|
||||||
selectedNodeType: NodeType = NodeType.None;
|
readonly selectedNodeType = signal<NodeType>(NodeType.None);
|
||||||
private shouldAddWall = true; // Moved here
|
private shouldAddWall = true; // Moved here
|
||||||
|
|
||||||
animationSpeed = 3;
|
animationSpeed = 3;
|
||||||
pathLength = "0";
|
readonly pathLength = signal("0");
|
||||||
executionTime = 0;
|
readonly executionTime = signal(0);
|
||||||
|
|
||||||
private timeoutIds: number[] = [];
|
private readonly animator = new PathfindingAnimator(
|
||||||
|
node => this.genericGridComponent?.drawNode(node),
|
||||||
|
() => this.animationSpeed
|
||||||
|
);
|
||||||
|
|
||||||
@ViewChild(GenericGridComponent) genericGridComponent!: GenericGridComponent;
|
@ViewChild(GenericGridComponent) genericGridComponent!: GenericGridComponent;
|
||||||
|
|
||||||
@@ -101,8 +105,8 @@ export class PathfindingComponent implements AfterViewInit {
|
|||||||
this.genericGridComponent.createNodeFn = this.createPathfindingNode;
|
this.genericGridComponent.createNodeFn = this.createPathfindingNode;
|
||||||
this.genericGridComponent.getNodeColorFn = this.getPathfindingNodeColor;
|
this.genericGridComponent.getNodeColorFn = this.getPathfindingNodeColor;
|
||||||
this.genericGridComponent.applySelectionFn = this.applyPathfindingSelection;
|
this.genericGridComponent.applySelectionFn = this.applyPathfindingSelection;
|
||||||
this.genericGridComponent.gridRows = this.gridRows;
|
this.genericGridComponent.gridRows = this.gridRows();
|
||||||
this.genericGridComponent.gridCols = this.gridCols;
|
this.genericGridComponent.gridCols = this.gridCols();
|
||||||
this.genericGridComponent.minGridSize = this.MIN_GRID_SIZE;
|
this.genericGridComponent.minGridSize = this.MIN_GRID_SIZE;
|
||||||
this.genericGridComponent.maxGridSize = this.MAX_GRID_SIZE;
|
this.genericGridComponent.maxGridSize = this.MAX_GRID_SIZE;
|
||||||
this.genericGridComponent.maxGridPx = MAX_GRID_PX;
|
this.genericGridComponent.maxGridPx = MAX_GRID_PX;
|
||||||
@@ -111,22 +115,18 @@ export class PathfindingComponent implements AfterViewInit {
|
|||||||
this.createCase({withWalls: true, scenario: "normal"});
|
this.createCase({withWalls: true, scenario: "normal"});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected applyGridRows(rows: number): void {
|
||||||
|
this.genericGridComponent.gridRows = rows;
|
||||||
|
this.genericGridComponent.applyGridSize();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected applyGridCols(cols: number): void {
|
||||||
|
this.genericGridComponent.gridCols = cols;
|
||||||
|
this.genericGridComponent.applyGridSize();
|
||||||
|
}
|
||||||
|
|
||||||
// --- Callbacks for GenericGridComponent ---
|
// --- Callbacks for GenericGridComponent ---
|
||||||
createPathfindingNode = (row: number, col: number): Node => {
|
readonly createPathfindingNode = createPathfindingNode;
|
||||||
return {
|
|
||||||
row,
|
|
||||||
col,
|
|
||||||
isStart: false,
|
|
||||||
isEnd: false,
|
|
||||||
isWall: false,
|
|
||||||
isVisited: false,
|
|
||||||
isPath: false,
|
|
||||||
nodeData: Infinity,
|
|
||||||
linkedNode: null,
|
|
||||||
hScore: 0,
|
|
||||||
fScore: Infinity,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
getPathfindingNodeColor = (node: Node): string => {
|
getPathfindingNodeColor = (node: Node): string => {
|
||||||
if (node.isStart) return 'green';
|
if (node.isStart) return 'green';
|
||||||
@@ -142,343 +142,86 @@ export class PathfindingComponent implements AfterViewInit {
|
|||||||
const node = grid[pos.row][pos.col];
|
const node = grid[pos.row][pos.col];
|
||||||
|
|
||||||
// Determine if we should add or remove a wall
|
// Determine if we should add or remove a wall
|
||||||
if (this.selectedNodeType === NodeType.Wall && this.genericGridComponent.isDrawing && this.genericGridComponent['lastCell'] === null) {
|
if (this.selectedNodeType() === NodeType.Wall && this.genericGridComponent.isDrawing && this.genericGridComponent['lastCell'] === null) {
|
||||||
this.shouldAddWall = !node.isWall;
|
this.shouldAddWall = !node.isWall;
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (this.selectedNodeType) {
|
switch (this.selectedNodeType()) {
|
||||||
case NodeType.Start:
|
case NodeType.Start:
|
||||||
this.trySetStart(node);
|
if (this.gridService.placeStart(node, this.startNode)) {
|
||||||
|
if (this.startNode) {
|
||||||
|
this.genericGridComponent.drawNode(this.startNode); // Redraw old start node
|
||||||
|
}
|
||||||
|
this.startNode = node;
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case NodeType.End:
|
case NodeType.End:
|
||||||
this.trySetEnd(node);
|
if (this.gridService.placeEnd(node, this.endNode)) {
|
||||||
|
if (this.endNode) {
|
||||||
|
this.genericGridComponent.drawNode(this.endNode); // Redraw old end node
|
||||||
|
}
|
||||||
|
this.endNode = node;
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case NodeType.Wall:
|
case NodeType.Wall:
|
||||||
this.tryToggleWall(node, this.shouldAddWall);
|
this.gridService.setWall(node, this.shouldAddWall);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case NodeType.None:
|
case NodeType.None:
|
||||||
this.tryClearNode(node);
|
this.gridService.clearNode(node);
|
||||||
|
if (node === this.startNode) {
|
||||||
|
this.startNode = null;
|
||||||
|
}
|
||||||
|
if (node === this.endNode) {
|
||||||
|
this.endNode = null;
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
visualize(algorithm: string): void {
|
visualize(algorithm: PathfindingAlgorithm): void {
|
||||||
if (!this.ensureStartAndEnd()) {
|
if (!this.ensureStartAndEnd()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.stopAnimations();
|
this.animator.stop();
|
||||||
this.clearPath();
|
this.clearPath();
|
||||||
|
|
||||||
const startTime = performance.now();
|
const startTime = performance.now();
|
||||||
let result;
|
const result = this.pathfindingService.findPath(algorithm, this.grid, this.startNode!, this.endNode!);
|
||||||
|
|
||||||
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 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);
|
this.pathLength.set(PathfindingAnimator.pathLengthLabel(result));
|
||||||
|
this.executionTime.set(endTime - startTime);
|
||||||
|
|
||||||
|
this.animator.animate(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
initializePathfindingGrid = (grid: Node[][]): void => {
|
initializePathfindingGrid = (grid: Node[][]): void => {
|
||||||
this.grid = grid; // Update the component's grid reference
|
this.setupGrid(grid, 'normal', true);
|
||||||
const {start, end} = this.getScenarioStartEnd('normal'); // Default 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;
|
|
||||||
|
|
||||||
this.placeDefaultDiagonalWall('normal');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Helper methods for node manipulation (kept local) ---
|
createCase({withWalls, scenario}: { withWalls: boolean, scenario: PathfindingScenario }): void {
|
||||||
private trySetStart(node: Node): void {
|
this.animator.stop();
|
||||||
if (!this.canBeStart(node)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.startNode) {
|
|
||||||
this.startNode.isStart = false;
|
|
||||||
this.genericGridComponent.drawNode(this.startNode); // Redraw old start node
|
|
||||||
}
|
|
||||||
|
|
||||||
node.isStart = true;
|
|
||||||
this.startNode = node;
|
|
||||||
}
|
|
||||||
|
|
||||||
private trySetEnd(node: Node): void {
|
|
||||||
if (!this.canBeEnd(node)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.endNode) {
|
|
||||||
this.endNode.isEnd = false;
|
|
||||||
this.genericGridComponent.drawNode(this.endNode); // Redraw old end node
|
|
||||||
}
|
|
||||||
|
|
||||||
node.isEnd = true;
|
|
||||||
this.endNode = node;
|
|
||||||
}
|
|
||||||
|
|
||||||
private tryToggleWall(node: Node, shouldBeWall: boolean): void {
|
|
||||||
if (!this.canBeWall(node)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
node.isWall = shouldBeWall;
|
|
||||||
}
|
|
||||||
|
|
||||||
private tryClearNode(node: Node): void {
|
|
||||||
if (node.isStart) {
|
|
||||||
node.isStart = false;
|
|
||||||
this.startNode = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node.isEnd) {
|
|
||||||
node.isEnd = false;
|
|
||||||
this.endNode = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node.isWall) {
|
|
||||||
node.isWall = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private canBeStart(node: Node): boolean {
|
|
||||||
return !node.isEnd && !node.isWall;
|
|
||||||
}
|
|
||||||
|
|
||||||
private canBeEnd(node: Node): boolean {
|
|
||||||
return !node.isStart && !node.isWall;
|
|
||||||
}
|
|
||||||
|
|
||||||
private canBeWall(node: Node): boolean {
|
|
||||||
return !node.isStart && !node.isEnd;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Grid manipulation for scenarios (kept local) ---
|
|
||||||
createCase({withWalls, scenario}: { withWalls: boolean, scenario: "normal" | "edge" | "random" }): void {
|
|
||||||
this.stopAnimations();
|
|
||||||
// Reinitialize grid through the generic component
|
// Reinitialize grid through the generic component
|
||||||
this.genericGridComponent.initializationFn = (grid) => {
|
this.genericGridComponent.initializationFn = (grid) => this.setupGrid(grid, scenario, withWalls);
|
||||||
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
|
this.genericGridComponent.initializeGrid(); // Trigger re-initialization and redraw
|
||||||
}
|
}
|
||||||
|
|
||||||
private getScenarioStartEnd(scenario: 'normal' | 'edge' | 'random'): { start: GridPos; end: GridPos } {
|
private setupGrid(grid: Node[][], scenario: PathfindingScenario, withWalls: boolean): void {
|
||||||
if (scenario === 'edge') {
|
this.grid = grid;
|
||||||
return {
|
const {startNode, endNode} = this.gridService.setupScenario(grid, scenario, withWalls);
|
||||||
start: {row: 0, col: 0},
|
this.startNode = startNode;
|
||||||
end: {row: this.gridRows - 1, col: this.gridCols - 1}
|
this.endNode = endNode;
|
||||||
};
|
|
||||||
} 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}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
private clearPath(): void {
|
||||||
for (let row = 0; row < this.gridRows; row++) {
|
this.pathfindingService.resetSearchState(this.grid);
|
||||||
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
|
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 ---
|
// --- Validation ---
|
||||||
private ensureStartAndEnd(): boolean {
|
private ensureStartAndEnd(): boolean {
|
||||||
if (this.startNode && this.endNode) {
|
if (this.startNode && this.endNode) {
|
||||||
|
|||||||
@@ -12,6 +12,30 @@ export interface Node {
|
|||||||
hScore: number;
|
hScore: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PathfindingScenario = 'normal' | 'edge' | 'random';
|
||||||
|
export type PathfindingAlgorithm = 'dijkstra' | 'astar';
|
||||||
|
|
||||||
|
export interface PathfindingResult {
|
||||||
|
visitedNodesInOrder: Node[];
|
||||||
|
nodesInShortestPathOrder: Node[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPathfindingNode(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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const DEFAULT_GRID_ROWS = 50;
|
export const DEFAULT_GRID_ROWS = 50;
|
||||||
export const DEFAULT_GRID_COLS = 50;
|
export const DEFAULT_GRID_COLS = 50;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import {PathfindingGridService} from './pathfinding-grid.service';
|
||||||
|
import {createPathfindingNode, Node} from '../pathfinding.models';
|
||||||
|
|
||||||
|
function createGrid(rows: number, cols: number): Node[][] {
|
||||||
|
return Array.from({length: rows}, (_, row) =>
|
||||||
|
Array.from({length: cols}, (_, col) => createPathfindingNode(row, col)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function countWalls(grid: Node[][]): number {
|
||||||
|
return grid.flat().filter(node => node.isWall).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PathfindingGridService', () => {
|
||||||
|
let service: PathfindingGridService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
service = new PathfindingGridService();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setupScenario', () => {
|
||||||
|
it('places start and end mid-left and mid-right with a vertical wall for "normal"', () => {
|
||||||
|
const grid = createGrid(20, 20);
|
||||||
|
|
||||||
|
const {startNode, endNode} = service.setupScenario(grid, 'normal', true);
|
||||||
|
|
||||||
|
expect(startNode).toBe(grid[10][0]);
|
||||||
|
expect(endNode).toBe(grid[10][19]);
|
||||||
|
expect(startNode.isStart).toBe(true);
|
||||||
|
expect(endNode.isEnd).toBe(true);
|
||||||
|
for (let row = 0; row < 20; row++) {
|
||||||
|
expect(grid[row][10].isWall).toBe(row >= 5 && row < 15);
|
||||||
|
}
|
||||||
|
expect(startNode.isWall).toBe(false);
|
||||||
|
expect(endNode.isWall).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('places start and end in opposite corners with a diagonal wall for "edge"', () => {
|
||||||
|
const grid = createGrid(20, 20);
|
||||||
|
|
||||||
|
const {startNode, endNode} = service.setupScenario(grid, 'edge', true);
|
||||||
|
|
||||||
|
expect(startNode).toBe(grid[0][0]);
|
||||||
|
expect(endNode).toBe(grid[19][19]);
|
||||||
|
expect(countWalls(grid)).toBe(10);
|
||||||
|
expect(startNode.isWall).toBe(false);
|
||||||
|
expect(endNode.isWall).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('puts start and end on different sides and places up to 30% walls for "random"', () => {
|
||||||
|
const grid = createGrid(20, 20);
|
||||||
|
|
||||||
|
const {startNode, endNode} = service.setupScenario(grid, 'random', true);
|
||||||
|
|
||||||
|
const mid = 10;
|
||||||
|
expect(startNode.col <= mid).not.toBe(endNode.col <= mid);
|
||||||
|
// 120 random picks; repeated picks of one cell make the wall count lower.
|
||||||
|
expect(countWalls(grid)).toBeGreaterThan(60);
|
||||||
|
expect(countWalls(grid)).toBeLessThanOrEqual(120);
|
||||||
|
expect(startNode.isWall).toBe(false);
|
||||||
|
expect(endNode.isWall).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['normal', 'edge', 'random'] as const)('adds no walls without walls flag for "%s"', (scenario) => {
|
||||||
|
const grid = createGrid(20, 20);
|
||||||
|
|
||||||
|
service.setupScenario(grid, scenario, false);
|
||||||
|
|
||||||
|
expect(countWalls(grid)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['normal', 'edge', 'random'] as const)('stays inside a small grid for "%s"', (scenario) => {
|
||||||
|
const grid = createGrid(10, 10);
|
||||||
|
|
||||||
|
const {startNode, endNode} = service.setupScenario(grid, scenario, true);
|
||||||
|
|
||||||
|
expect(grid.flat()).toContain(startNode);
|
||||||
|
expect(grid.flat()).toContain(endNode);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('node placement', () => {
|
||||||
|
it('placeStart refuses end and wall nodes', () => {
|
||||||
|
const grid = createGrid(1, 3);
|
||||||
|
grid[0][1].isEnd = true;
|
||||||
|
grid[0][2].isWall = true;
|
||||||
|
|
||||||
|
expect(service.placeStart(grid[0][1], null)).toBe(false);
|
||||||
|
expect(service.placeStart(grid[0][2], null)).toBe(false);
|
||||||
|
expect(grid[0][1].isStart).toBe(false);
|
||||||
|
expect(grid[0][2].isStart).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('placeStart moves the start flag from the previous node', () => {
|
||||||
|
const grid = createGrid(1, 2);
|
||||||
|
grid[0][0].isStart = true;
|
||||||
|
|
||||||
|
expect(service.placeStart(grid[0][1], grid[0][0])).toBe(true);
|
||||||
|
|
||||||
|
expect(grid[0][0].isStart).toBe(false);
|
||||||
|
expect(grid[0][1].isStart).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('placeEnd refuses start and wall nodes and moves the end flag', () => {
|
||||||
|
const grid = createGrid(1, 4);
|
||||||
|
grid[0][0].isStart = true;
|
||||||
|
grid[0][1].isWall = true;
|
||||||
|
grid[0][2].isEnd = true;
|
||||||
|
|
||||||
|
expect(service.placeEnd(grid[0][0], grid[0][2])).toBe(false);
|
||||||
|
expect(service.placeEnd(grid[0][1], grid[0][2])).toBe(false);
|
||||||
|
expect(grid[0][2].isEnd).toBe(true);
|
||||||
|
|
||||||
|
expect(service.placeEnd(grid[0][3], grid[0][2])).toBe(true);
|
||||||
|
expect(grid[0][2].isEnd).toBe(false);
|
||||||
|
expect(grid[0][3].isEnd).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setWall refuses start and end nodes', () => {
|
||||||
|
const grid = createGrid(1, 3);
|
||||||
|
grid[0][0].isStart = true;
|
||||||
|
grid[0][1].isEnd = true;
|
||||||
|
|
||||||
|
expect(service.setWall(grid[0][0], true)).toBe(false);
|
||||||
|
expect(service.setWall(grid[0][1], true)).toBe(false);
|
||||||
|
expect(service.setWall(grid[0][2], true)).toBe(true);
|
||||||
|
expect(grid[0][2].isWall).toBe(true);
|
||||||
|
expect(service.setWall(grid[0][2], false)).toBe(true);
|
||||||
|
expect(grid[0][2].isWall).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clearNode clears exactly the flag the node has', () => {
|
||||||
|
const grid = createGrid(1, 3);
|
||||||
|
grid[0][0].isStart = true;
|
||||||
|
grid[0][1].isEnd = true;
|
||||||
|
grid[0][2].isWall = true;
|
||||||
|
|
||||||
|
service.clearNode(grid[0][0]);
|
||||||
|
expect(grid[0][0].isStart).toBe(false);
|
||||||
|
expect(grid[0][1].isEnd).toBe(true);
|
||||||
|
expect(grid[0][2].isWall).toBe(true);
|
||||||
|
|
||||||
|
service.clearNode(grid[0][1]);
|
||||||
|
service.clearNode(grid[0][2]);
|
||||||
|
expect(grid[0][1].isEnd).toBe(false);
|
||||||
|
expect(grid[0][2].isWall).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the random scenario inside a 2-column grid', () => {
|
||||||
|
const grid = createGrid(5, 2);
|
||||||
|
|
||||||
|
const {endNode} = service.setupScenario(grid, 'random', false);
|
||||||
|
|
||||||
|
expect(grid.flat()).toContain(endNode);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('picks different start and end nodes for the random scenario', () => {
|
||||||
|
for (let run = 0; run < 300; run++) {
|
||||||
|
const grid = createGrid(2, 2);
|
||||||
|
|
||||||
|
const {startNode, endNode} = service.setupScenario(grid, 'random', false);
|
||||||
|
|
||||||
|
expect(endNode).not.toBe(startNode);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import {Injectable} from '@angular/core';
|
||||||
|
import {MAX_RANDOM_WALLS_FACTORS, Node, PathfindingScenario} from '../pathfinding.models';
|
||||||
|
import {GridPos} from '../../../../shared/components/generic-grid/generic-grid';
|
||||||
|
import {SharedFunctions} from '../../../../shared/SharedFunctions';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class PathfindingGridService {
|
||||||
|
|
||||||
|
setupScenario(grid: Node[][], scenario: PathfindingScenario, withWalls: boolean): { startNode: Node; endNode: Node } {
|
||||||
|
const rows = grid.length;
|
||||||
|
const cols = grid[0].length;
|
||||||
|
const {start, end} = this.getScenarioStartEnd(rows, cols, scenario);
|
||||||
|
const startNode = grid[start.row][start.col];
|
||||||
|
const endNode = grid[end.row][end.col];
|
||||||
|
startNode.isStart = true;
|
||||||
|
endNode.isEnd = true;
|
||||||
|
|
||||||
|
if (withWalls) {
|
||||||
|
this.placeScenarioWalls(grid, scenario);
|
||||||
|
}
|
||||||
|
return {startNode, endNode};
|
||||||
|
}
|
||||||
|
|
||||||
|
placeStart(node: Node, previousStart: Node | null): boolean {
|
||||||
|
if (node.isEnd || node.isWall) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (previousStart) {
|
||||||
|
previousStart.isStart = false;
|
||||||
|
}
|
||||||
|
node.isStart = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
placeEnd(node: Node, previousEnd: Node | null): boolean {
|
||||||
|
if (node.isStart || node.isWall) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (previousEnd) {
|
||||||
|
previousEnd.isEnd = false;
|
||||||
|
}
|
||||||
|
node.isEnd = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
setWall(node: Node, isWall: boolean): boolean {
|
||||||
|
if (node.isStart || node.isEnd) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
node.isWall = isWall;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearNode(node: Node): void {
|
||||||
|
if (node.isStart) {
|
||||||
|
node.isStart = false;
|
||||||
|
} else if (node.isEnd) {
|
||||||
|
node.isEnd = false;
|
||||||
|
} else if (node.isWall) {
|
||||||
|
node.isWall = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getScenarioStartEnd(rows: number, cols: number, scenario: PathfindingScenario): { start: GridPos; end: GridPos } {
|
||||||
|
if (scenario === 'edge') {
|
||||||
|
return {
|
||||||
|
start: {row: 0, col: 0},
|
||||||
|
end: {row: rows - 1, col: cols - 1}
|
||||||
|
};
|
||||||
|
} else if (scenario === 'random') {
|
||||||
|
return this.createRandomStartEndPosition(rows, cols);
|
||||||
|
} else {
|
||||||
|
const midRow = Math.floor(rows / 2);
|
||||||
|
return {
|
||||||
|
start: {row: midRow, col: 0},
|
||||||
|
end: {row: midRow, col: cols - 1}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private createRandomStartEndPosition(rows: number, cols: number): { start: GridPos; end: GridPos } {
|
||||||
|
const midCol = Math.floor(cols / 2);
|
||||||
|
|
||||||
|
const startRow = SharedFunctions.randomIntFromInterval(0, rows - 1);
|
||||||
|
const startCol = SharedFunctions.randomIntFromInterval(0, cols - 1);
|
||||||
|
|
||||||
|
let endRow = SharedFunctions.randomIntFromInterval(0, rows - 1);
|
||||||
|
const endCol = startCol <= midCol
|
||||||
|
? SharedFunctions.randomIntFromInterval(Math.min(midCol + 1, cols - 1), cols - 1)
|
||||||
|
: SharedFunctions.randomIntFromInterval(0, midCol);
|
||||||
|
while (endRow === startRow && endCol === startCol) {
|
||||||
|
endRow = SharedFunctions.randomIntFromInterval(0, rows - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
start: {row: startRow, col: startCol},
|
||||||
|
end: {row: endRow, col: endCol}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private placeScenarioWalls(grid: Node[][], scenario: PathfindingScenario): void {
|
||||||
|
if (scenario === 'edge') {
|
||||||
|
this.createDiagonalWall(grid);
|
||||||
|
} else if (scenario === 'normal') {
|
||||||
|
this.createVerticalWall(grid);
|
||||||
|
} else {
|
||||||
|
this.createRandomWalls(grid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private createRandomWalls(grid: Node[][]): void {
|
||||||
|
const rows = grid.length;
|
||||||
|
const cols = grid[0].length;
|
||||||
|
const maxNumberOfWalls = Math.floor(MAX_RANDOM_WALLS_FACTORS * cols * rows);
|
||||||
|
|
||||||
|
for (let wall = 0; wall < maxNumberOfWalls; wall++) {
|
||||||
|
const row = SharedFunctions.randomIntFromInterval(0, rows - 1);
|
||||||
|
const col = SharedFunctions.randomIntFromInterval(0, cols - 1);
|
||||||
|
|
||||||
|
const node = grid[row][col];
|
||||||
|
if (node.isStart || node.isEnd) {
|
||||||
|
wall--;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
node.isWall = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private createVerticalWall(grid: Node[][]): void {
|
||||||
|
const height = grid.length;
|
||||||
|
const startCol = Math.floor(grid[0].length / 2);
|
||||||
|
|
||||||
|
for (let row = 5; row < (height - 5); row++) {
|
||||||
|
const node = grid[row][startCol];
|
||||||
|
if (node.isStart || node.isEnd) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
node.isWall = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private createDiagonalWall(grid: Node[][]): void {
|
||||||
|
const rows = grid.length;
|
||||||
|
const cols = grid[0].length;
|
||||||
|
const len = Math.min(rows, cols);
|
||||||
|
const startCol = Math.floor((cols - len) / 2);
|
||||||
|
|
||||||
|
for (let i = 0; i < Math.max(0, len - 10); i++) {
|
||||||
|
const node = grid[len - i - 1][startCol + i];
|
||||||
|
if (node.isStart || node.isEnd) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
node.isWall = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -118,4 +118,34 @@ describe('PathfindingService', () => {
|
|||||||
expect(nodesInShortestPathOrder[nodesInShortestPathOrder.length - 1]).toBe(end);
|
expect(nodesInShortestPathOrder[nodesInShortestPathOrder.length - 1]).toBe(end);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('findPath', () => {
|
||||||
|
it.each(['dijkstra', 'astar'] as const)('%s finds the shortest path on an open grid', (algorithm) => {
|
||||||
|
const grid = createGrid(5, 5);
|
||||||
|
|
||||||
|
const { nodesInShortestPathOrder } = service.findPath(algorithm, grid, grid[0][0], grid[4][4]);
|
||||||
|
|
||||||
|
expect(nodesInShortestPathOrder.length).toBe(9);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resetSearchState', () => {
|
||||||
|
it('clears the search marks and keeps the node roles', () => {
|
||||||
|
const grid = createGrid(2, 2);
|
||||||
|
const node = grid[0][1];
|
||||||
|
Object.assign(node, { isVisited: true, isPath: true, nodeData: 3, linkedNode: grid[0][0], isWall: true });
|
||||||
|
grid[0][0].isStart = true;
|
||||||
|
grid[1][1].isEnd = true;
|
||||||
|
|
||||||
|
service.resetSearchState(grid);
|
||||||
|
|
||||||
|
expect(node.isVisited).toBe(false);
|
||||||
|
expect(node.isPath).toBe(false);
|
||||||
|
expect(node.nodeData).toBe(Infinity);
|
||||||
|
expect(node.linkedNode).toBeNull();
|
||||||
|
expect(node.isWall).toBe(true);
|
||||||
|
expect(grid[0][0].isStart).toBe(true);
|
||||||
|
expect(grid[1][1].isEnd).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { Node} from '../pathfinding.models';
|
import { Node, PathfindingAlgorithm, PathfindingResult } from '../pathfinding.models';
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
@@ -30,8 +30,25 @@ export class PathfindingService {
|
|||||||
return shortestPathNodes;
|
return shortestPathNodes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
findPath(algorithm: PathfindingAlgorithm, grid: Node[][], startNode: Node, endNode: Node): PathfindingResult {
|
||||||
|
return algorithm === 'astar'
|
||||||
|
? this.aStar(grid, startNode, endNode)
|
||||||
|
: this.dijkstra(grid, startNode, endNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
resetSearchState(grid: Node[][]): void {
|
||||||
|
for (const row of grid) {
|
||||||
|
for (const node of row) {
|
||||||
|
node.isVisited = false;
|
||||||
|
node.isPath = false;
|
||||||
|
node.nodeData = Infinity;
|
||||||
|
node.linkedNode = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Dijkstra's Algorithm
|
// Dijkstra's Algorithm
|
||||||
dijkstra(grid: Node[][], startNode: Node, endNode: Node): { visitedNodesInOrder: Node[], nodesInShortestPathOrder: Node[] } {
|
dijkstra(grid: Node[][], startNode: Node, endNode: Node): PathfindingResult {
|
||||||
const visitedNodesInOrder: Node[] = [];
|
const visitedNodesInOrder: Node[] = [];
|
||||||
startNode.nodeData = 0;
|
startNode.nodeData = 0;
|
||||||
const unvisitedNodes: Node[] = this.getAllNodes(grid);
|
const unvisitedNodes: Node[] = this.getAllNodes(grid);
|
||||||
@@ -77,7 +94,7 @@ export class PathfindingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A* Search Algorithm
|
// A* Search Algorithm
|
||||||
aStar(grid: Node[][], startNode: Node, endNode: Node): { visitedNodesInOrder: Node[], nodesInShortestPathOrder: Node[] } {
|
aStar(grid: Node[][], startNode: Node, endNode: Node): PathfindingResult {
|
||||||
const visitedNodesInOrder: Node[] = [];
|
const visitedNodesInOrder: Node[] = [];
|
||||||
startNode.nodeData = 0;
|
startNode.nodeData = 0;
|
||||||
startNode['hScore'] = this.calculateHeuristic(startNode, endNode);
|
startNode['hScore'] = this.calculateHeuristic(startNode, endNode);
|
||||||
|
|||||||
@@ -8,34 +8,34 @@
|
|||||||
<div class="grid grid-cols-2 tablet:grid-cols-1 tablet:gap-4 mb-6">
|
<div class="grid grid-cols-2 tablet:grid-cols-1 tablet:gap-4 mb-6">
|
||||||
<div class="flex items-center gap-4 mr-4">
|
<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>
|
<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>
|
<ngx-slider class="grow" [(value)]="trailDecay" [options]="trailDecayOptions" ></ngx-slider>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-4 mr-4">
|
<div class="flex items-center gap-4 mr-4">
|
||||||
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.ATTRACTION' | translate }}</p>
|
<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>
|
<ngx-slider class="grow" [(value)]="g" [options]="gravityOptions" ></ngx-slider>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-4 mr-4">
|
<div class="flex items-center gap-4 mr-4">
|
||||||
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.L1_LENGTH' | translate }}</p>
|
<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>
|
<ngx-slider class="grow" [(value)]="l1" [options]="lengthOptions" ></ngx-slider>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-4 mr-4">
|
<div class="flex items-center gap-4 mr-4">
|
||||||
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.L2_LENGTH' | translate }}</p>
|
<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>
|
<ngx-slider class="grow" [(value)]="l2" [options]="lengthOptions" ></ngx-slider>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-4 mr-4">
|
<div class="flex items-center gap-4 mr-4">
|
||||||
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.M1_MASS' | translate }}</p>
|
<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>
|
<ngx-slider class="grow" [(value)]="m1" [options]="massOptions" ></ngx-slider>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-4 mr-4">
|
<div class="flex items-center gap-4 mr-4">
|
||||||
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.M2_MASS' | translate }}</p>
|
<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>
|
<ngx-slider class="grow" [(value)]="m2" [options]="massOptions" ></ngx-slider>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-4 mr-4 col-span-full">
|
<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>
|
<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>
|
<ngx-slider class="grow" [(value)]="damping" [options]="dampingOptions" ></ngx-slider>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {Component, ChangeDetectionStrategy} from '@angular/core';
|
import {Component, signal} from '@angular/core';
|
||||||
import {BabylonCanvas, RenderConfig, SceneEventData} from '../../../shared/components/render-canvas/babylon-canvas.component';
|
import {BabylonCanvas, RenderConfig, SceneEventData} from '../../../shared/components/render-canvas/babylon-canvas.component';
|
||||||
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
|
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
|
||||||
|
|
||||||
@@ -29,7 +29,6 @@ import {PendulumCpuStrategy} from './strategies/pendulum-cpu.strategy';
|
|||||||
Information,
|
Information,
|
||||||
],
|
],
|
||||||
templateUrl: './pendulum.component.html',
|
templateUrl: './pendulum.component.html',
|
||||||
changeDetection: ChangeDetectionStrategy.Eager,
|
|
||||||
styleUrl: './pendulum.component.scss',
|
styleUrl: './pendulum.component.scss',
|
||||||
})
|
})
|
||||||
class PendulumComponent {
|
class PendulumComponent {
|
||||||
@@ -103,16 +102,17 @@ class PendulumComponent {
|
|||||||
hidePointerLabels: false
|
hidePointerLabels: false
|
||||||
};
|
};
|
||||||
|
|
||||||
readonly simParams = {
|
readonly trailDecay = signal(DEFAULT_TRAIL_DECAY);
|
||||||
|
readonly g = signal(DEFAULT_G);
|
||||||
|
readonly l1 = signal(DEFAULT_L1_LENGTH);
|
||||||
|
readonly l2 = signal(DEFAULT_L2_LENGTH);
|
||||||
|
readonly m1 = signal(DEFAULT_M1_MASS);
|
||||||
|
readonly m2 = signal(DEFAULT_M2_MASS);
|
||||||
|
readonly damping = signal(DEFAULT_DAMPING);
|
||||||
|
|
||||||
|
private readonly loop = {
|
||||||
time: 0,
|
time: 0,
|
||||||
dt: 0.015,
|
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,
|
impulseM1: 0.0,
|
||||||
impulseM2: 0.0,
|
impulseM2: 0.0,
|
||||||
};
|
};
|
||||||
@@ -147,10 +147,19 @@ class PendulumComponent {
|
|||||||
private startParamUpdateLoop(scene: any): void {
|
private startParamUpdateLoop(scene: any): void {
|
||||||
scene.onAfterRenderObservable.clear();
|
scene.onAfterRenderObservable.clear();
|
||||||
scene.onAfterRenderObservable.add(() => {
|
scene.onAfterRenderObservable.add(() => {
|
||||||
this.simParams.time += this.simParams.dt;
|
this.loop.time += this.loop.dt;
|
||||||
|
|
||||||
if (this.strategy) {
|
if (this.strategy) {
|
||||||
this.strategy.updateParams({...this.simParams});
|
this.strategy.updateParams({
|
||||||
|
...this.loop,
|
||||||
|
g: this.g(),
|
||||||
|
m1: this.m1(),
|
||||||
|
m2: this.m2(),
|
||||||
|
l1: this.l1(),
|
||||||
|
l2: this.l2(),
|
||||||
|
damping: this.damping(),
|
||||||
|
trailDecay: this.trailDecay()
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
this.resetImpulses();
|
this.resetImpulses();
|
||||||
@@ -158,22 +167,22 @@ class PendulumComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private resetImpulses() {
|
private resetImpulses() {
|
||||||
if (this.simParams.impulseM1 !== 0.0) {
|
if (this.loop.impulseM1 !== 0.0) {
|
||||||
this.simParams.impulseM1 = 0;
|
this.loop.impulseM1 = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.simParams.impulseM2 !== 0.0) {
|
if (this.loop.impulseM2 !== 0.0) {
|
||||||
this.simParams.impulseM2 = 0;
|
this.loop.impulseM2 = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pushPendulum(m1: boolean) {
|
pushPendulum(m1: boolean) {
|
||||||
if (m1) {
|
if (m1) {
|
||||||
this.simParams.impulseM1 = IMPULSE_M1;
|
this.loop.impulseM1 = IMPULSE_M1;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.simParams.impulseM2 = IMPULSE_M2;
|
this.loop.impulseM2 = IMPULSE_M2;
|
||||||
}
|
}
|
||||||
|
|
||||||
resetPendulum() {
|
resetPendulum() {
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import {PendulumPhysicsParams, PendulumState, stepPendulumPhysics} from './pendulum-cpu-physics';
|
||||||
|
|
||||||
|
const params: PendulumPhysicsParams = {
|
||||||
|
dt: 0.01, g: 9.81, m1: 1, m2: 1, l1: 1, l2: 1, damping: 1, impulseM1: 0, impulseM2: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
function step(state: PendulumState, overrides: Partial<PendulumPhysicsParams> = {}): PendulumState {
|
||||||
|
const next = {...state};
|
||||||
|
stepPendulumPhysics(next, {...params, ...overrides});
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('stepPendulumPhysics', () => {
|
||||||
|
it('keeps a pendulum hanging at rest', () => {
|
||||||
|
expect(step({theta1: 0, theta2: 0, v1: 0, v2: 0})).toEqual({theta1: 0, theta2: 0, v1: 0, v2: 0});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pulls a displaced first arm back (negative angular velocity)', () => {
|
||||||
|
const next = step({theta1: 0.3, theta2: 0, v1: 0, v2: 0});
|
||||||
|
|
||||||
|
expect(next.v1).toBeLessThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds the impulse to the velocities at rest with damping 1', () => {
|
||||||
|
const next = step({theta1: 0, theta2: 0, v1: 0, v2: 0}, {impulseM1: 0.5, impulseM2: -0.25});
|
||||||
|
|
||||||
|
expect(next.v1).toBeCloseTo(0.5);
|
||||||
|
expect(next.v2).toBeCloseTo(-0.25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is mirror symmetric', () => {
|
||||||
|
const state = {theta1: 0.7, theta2: -0.2, v1: 0.3, v2: 0.1};
|
||||||
|
const mirrored = {theta1: -0.7, theta2: 0.2, v1: -0.3, v2: -0.1};
|
||||||
|
|
||||||
|
const a = step(state);
|
||||||
|
const b = step(mirrored);
|
||||||
|
|
||||||
|
expect(b.theta1).toBeCloseTo(-a.theta1);
|
||||||
|
expect(b.theta2).toBeCloseTo(-a.theta2);
|
||||||
|
expect(b.v1).toBeCloseTo(-a.v1);
|
||||||
|
expect(b.v2).toBeCloseTo(-a.v2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('damping below 1 reduces the speed', () => {
|
||||||
|
const state = {theta1: 0, theta2: 0, v1: 2, v2: -2};
|
||||||
|
|
||||||
|
const free = step(state);
|
||||||
|
const damped = step(state, {damping: 0.9});
|
||||||
|
|
||||||
|
expect(Math.abs(damped.v1)).toBeLessThan(Math.abs(free.v1));
|
||||||
|
expect(Math.abs(damped.v2)).toBeLessThan(Math.abs(free.v2));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -44,7 +44,7 @@ describe('SortingService', () => {
|
|||||||
const result = finalValues(snapshots);
|
const result = finalValues(snapshots);
|
||||||
|
|
||||||
expect(result).toEqual(expectedSorted);
|
expect(result).toEqual(expectedSorted);
|
||||||
expect(isAscending(result)).toBeTrue();
|
expect(isAscending(result)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it(`${algorithm} should not mutate the input array`, () => {
|
it(`${algorithm} should not mutate the input array`, () => {
|
||||||
|
|||||||
@@ -38,19 +38,20 @@
|
|||||||
<mat-icon>add_box</mat-icon> {{ 'SORTING.GENERATE_NEW_ARRAY' | translate }}
|
<mat-icon>add_box</mat-icon> {{ 'SORTING.GENERATE_NEW_ARRAY' | translate }}
|
||||||
</button>
|
</button>
|
||||||
<button mat-raised-button (click)="toggleSound()">
|
<button mat-raised-button (click)="toggleSound()">
|
||||||
<mat-icon>{{ isSoundEnabled ? 'volume_up' : 'volume_off' }}</mat-icon>
|
<mat-icon>{{ isSoundEnabled() ? 'volume_up' : 'volume_off' }}</mat-icon>
|
||||||
{{ isSoundEnabled ? ('SORTING.SOUND_OFF' | translate) : ('SORTING.SOUND_ON' | translate) }}
|
{{ isSoundEnabled() ? ('SORTING.SOUND_OFF' | translate) : ('SORTING.SOUND_ON' | translate) }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
|
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
|
||||||
<p>{{ 'SORTING.EXECUTION_TIME' | translate }}: {{ executionTime }} ms</p>
|
<p>{{ 'SORTING.EXECUTION_TIME' | translate }}: {{ executionTime() }} ms</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-end h-[clamp(200px,40vh,400px)] border-b border-app-fg mb-[clamp(10px,3vw,20px)] gap-px bg-card-bg">
|
<div class="flex items-end h-[clamp(200px,40vh,400px)] border-b border-app-fg mb-[clamp(10px,3vw,20px)] gap-px bg-card-bg">
|
||||||
@for (item of sortArray; track $index) {
|
@for (item of sortArray(); track $index) {
|
||||||
<div
|
<div
|
||||||
class="grow w-2.5 min-w-px transition-all duration-50"
|
class="grow w-2.5 min-w-px transition-all duration-50"
|
||||||
[style.height.px]="item.value * 3"
|
[style.height.px]="item.value * 3"
|
||||||
|
data-testid="sort-bar"
|
||||||
[ngClass]="{
|
[ngClass]="{
|
||||||
'bg-[#424242]': item.state === 'unsorted',
|
'bg-[#424242]': item.state === 'unsorted',
|
||||||
'bg-[#ffeb3b]': item.state === 'comparing',
|
'bg-[#ffeb3b]': item.state === 'comparing',
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { provideZonelessChangeDetection } from '@angular/core';
|
||||||
|
import { By } from '@angular/platform-browser';
|
||||||
|
import { provideNoopAnimations } from '@angular/platform-browser/animations';
|
||||||
|
import { provideTranslateService } from '@ngx-translate/core';
|
||||||
|
import { SortingComponent } from './sorting.component';
|
||||||
|
import { SortingAudioService } from './service/sorting-audio.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The animation writes the bars from setTimeout callbacks. Under OnPush a bar
|
||||||
|
* only repaints when the sortArray signal is replaced, so these tests fail if
|
||||||
|
* the component goes back to mutating the array in place.
|
||||||
|
*/
|
||||||
|
describe('SortingComponent', () => {
|
||||||
|
let fixture: ComponentFixture<SortingComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
|
||||||
|
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [SortingComponent],
|
||||||
|
providers: [
|
||||||
|
provideZonelessChangeDetection(),
|
||||||
|
provideNoopAnimations(),
|
||||||
|
provideTranslateService({ lang: 'en', fallbackLang: 'en' }),
|
||||||
|
{
|
||||||
|
provide: SortingAudioService,
|
||||||
|
useValue: { initOnUserGesture: vi.fn(), playTone: vi.fn(), playSortedSweep: vi.fn() },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(SortingComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
function bars() {
|
||||||
|
return fixture.debugElement.queryAll(By.css('[data-testid="sort-bar"]'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function heights(): number[] {
|
||||||
|
return bars().map(bar => Number.parseFloat((bar.nativeElement as HTMLElement).style.height));
|
||||||
|
}
|
||||||
|
|
||||||
|
it('shows every bar as sorted once the animation has run', () => {
|
||||||
|
fixture.componentInstance.startSorting();
|
||||||
|
vi.runAllTimers();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(bars()).toHaveLength(50);
|
||||||
|
expect(bars().every(bar => bar.nativeElement.classList.contains('bg-[#4caf50]'))).toBe(true);
|
||||||
|
const sorted = heights();
|
||||||
|
expect(sorted).toEqual([...sorted].sort((a, b) => a - b));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores the unsorted array when reset during the animation', () => {
|
||||||
|
const initial = heights();
|
||||||
|
|
||||||
|
fixture.componentInstance.startSorting();
|
||||||
|
vi.advanceTimersByTime(300);
|
||||||
|
fixture.componentInstance.resetSorting();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(heights()).toEqual(initial);
|
||||||
|
expect(bars().some(bar => bar.nativeElement.classList.contains('bg-[#ffeb3b]'))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import {ChangeDetectorRef, Component, inject, OnInit, ChangeDetectionStrategy} from '@angular/core';
|
import {Component, inject, OnInit, signal} from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import {MatCardModule} from "@angular/material/card";
|
import {MatCardModule} from "@angular/material/card";
|
||||||
import {MatFormFieldModule} from "@angular/material/form-field";
|
import {MatFormFieldModule} from "@angular/material/form-field";
|
||||||
@@ -18,14 +18,12 @@ import {Information} from '../information/information';
|
|||||||
selector: 'app-sorting',
|
selector: 'app-sorting',
|
||||||
imports: [CommonModule, MatCardModule, MatFormFieldModule, MatSelectModule, MatButtonModule, MatIconModule, TranslatePipe, FormsModule, MatInput, Information],
|
imports: [CommonModule, MatCardModule, MatFormFieldModule, MatSelectModule, MatButtonModule, MatIconModule, TranslatePipe, FormsModule, MatInput, Information],
|
||||||
templateUrl: './sorting.component.html',
|
templateUrl: './sorting.component.html',
|
||||||
changeDetection: ChangeDetectionStrategy.Eager,
|
|
||||||
styleUrl: './sorting.component.scss'
|
styleUrl: './sorting.component.scss'
|
||||||
})
|
})
|
||||||
export class SortingComponent implements OnInit {
|
export class SortingComponent implements OnInit {
|
||||||
|
|
||||||
private readonly sortingService: SortingService = inject(SortingService);
|
private readonly sortingService: SortingService = inject(SortingService);
|
||||||
private readonly audioService: SortingAudioService = inject(SortingAudioService);
|
private readonly audioService: SortingAudioService = inject(SortingAudioService);
|
||||||
private readonly cdr: ChangeDetectorRef = inject(ChangeDetectorRef);
|
|
||||||
|
|
||||||
readonly MAX_ARRAY_SIZE: number = 200;
|
readonly MAX_ARRAY_SIZE: number = 200;
|
||||||
readonly MIN_ARRAY_SIZE: number = 20;
|
readonly MIN_ARRAY_SIZE: number = 20;
|
||||||
@@ -69,14 +67,14 @@ export class SortingComponent implements OnInit {
|
|||||||
};
|
};
|
||||||
|
|
||||||
private timeoutIds: number[] = [];
|
private timeoutIds: number[] = [];
|
||||||
sortArray: SortData[] = [];
|
readonly sortArray = signal<SortData[]>([]);
|
||||||
unsortedArrayCopy: SortData[] = [];
|
unsortedArrayCopy: SortData[] = [];
|
||||||
arraySize = 50;
|
readonly arraySize = signal(50);
|
||||||
maxArrayValue = 100;
|
maxArrayValue = 100;
|
||||||
animationSpeed = 100; // Milliseconds per step
|
animationSpeed = 100; // Milliseconds per step
|
||||||
selectedAlgorithm: string = this.algoInformation.entries[0].name;
|
readonly selectedAlgorithm = signal(this.algoInformation.entries[0].name);
|
||||||
executionTime = 0;
|
readonly executionTime = signal(0);
|
||||||
isSoundEnabled = false;
|
readonly isSoundEnabled = signal(false);
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.generateNewArray();
|
this.generateNewArray();
|
||||||
@@ -84,8 +82,9 @@ export class SortingComponent implements OnInit {
|
|||||||
|
|
||||||
newArraySizeSet()
|
newArraySizeSet()
|
||||||
{
|
{
|
||||||
this.arraySize = Math.min(Math.max(this.arraySize, this.MIN_ARRAY_SIZE), this.MAX_ARRAY_SIZE);
|
const clamped = Math.min(Math.max(this.arraySize(), this.MIN_ARRAY_SIZE), this.MAX_ARRAY_SIZE);
|
||||||
if (this.arraySize == this.sortArray.length)
|
this.arraySize.set(clamped);
|
||||||
|
if (clamped == this.sortArray().length)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -94,35 +93,27 @@ export class SortingComponent implements OnInit {
|
|||||||
|
|
||||||
generateNewArray(): void {
|
generateNewArray(): void {
|
||||||
this.resetSorting();
|
this.resetSorting();
|
||||||
this.executionTime = 0;
|
this.executionTime.set(0);
|
||||||
this.unsortedArrayCopy = [];
|
const unsorted: SortData[] = [];
|
||||||
this.sortArray = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < this.arraySize; i++) {
|
for (let i = 0; i < this.arraySize(); i++) {
|
||||||
const randomValue = Math.floor(Math.random() * this.maxArrayValue) + 1;
|
const randomValue = Math.floor(Math.random() * this.maxArrayValue) + 1;
|
||||||
this.sortArray.push({
|
unsorted.push({
|
||||||
value: randomValue,
|
|
||||||
state: 'unsorted'
|
|
||||||
});
|
|
||||||
|
|
||||||
this.unsortedArrayCopy.push({
|
|
||||||
value: randomValue,
|
value: randomValue,
|
||||||
state: 'unsorted'
|
state: 'unsorted'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.unsortedArrayCopy = unsorted;
|
||||||
|
this.resetSortState();
|
||||||
}
|
}
|
||||||
|
|
||||||
private resetSortState() {
|
private resetSortState() {
|
||||||
for (let i = 0; i < this.sortArray.length; i++) {
|
this.sortArray.set(this.unsortedArrayCopy.map(item => ({...item})));
|
||||||
const element = this.sortArray[i];
|
|
||||||
const unsortedElement = this.unsortedArrayCopy[i];
|
|
||||||
element.value = unsortedElement.value;
|
|
||||||
element.state = 'unsorted';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleSound(): void {
|
toggleSound(): void {
|
||||||
this.isSoundEnabled = !this.isSoundEnabled;
|
this.isSoundEnabled.update(v => !v);
|
||||||
}
|
}
|
||||||
|
|
||||||
async startSorting(): Promise<void> {
|
async startSorting(): Promise<void> {
|
||||||
@@ -132,26 +123,26 @@ export class SortingComponent implements OnInit {
|
|||||||
const startTime = performance.now();
|
const startTime = performance.now();
|
||||||
let snapshots: SortSnapshot[] = [];
|
let snapshots: SortSnapshot[] = [];
|
||||||
|
|
||||||
switch (this.selectedAlgorithm) {
|
switch (this.selectedAlgorithm()) {
|
||||||
case 'Bubble Sort':
|
case 'Bubble Sort':
|
||||||
snapshots = this.sortingService.bubbleSort(this.sortArray);
|
snapshots = this.sortingService.bubbleSort(this.sortArray());
|
||||||
break;
|
break;
|
||||||
case 'Quick Sort':
|
case 'Quick Sort':
|
||||||
snapshots = this.sortingService.quickSort(this.sortArray);
|
snapshots = this.sortingService.quickSort(this.sortArray());
|
||||||
break;
|
break;
|
||||||
case 'Heap Sort':
|
case 'Heap Sort':
|
||||||
snapshots = this.sortingService.heapSort(this.sortArray);
|
snapshots = this.sortingService.heapSort(this.sortArray());
|
||||||
break;
|
break;
|
||||||
case 'Cocktail Sort':
|
case 'Cocktail Sort':
|
||||||
snapshots = this.sortingService.cocktailSort(this.sortArray);
|
snapshots = this.sortingService.cocktailSort(this.sortArray());
|
||||||
break;
|
break;
|
||||||
case 'Tim Sort':
|
case 'Tim Sort':
|
||||||
snapshots = this.sortingService.timSort(this.sortArray);
|
snapshots = this.sortingService.timSort(this.sortArray());
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const endTime = performance.now();
|
const endTime = performance.now();
|
||||||
this.executionTime = Number.parseFloat((endTime - startTime).toFixed(4));
|
this.executionTime.set(Number.parseFloat((endTime - startTime).toFixed(4)));
|
||||||
|
|
||||||
this.animateSorting(snapshots);
|
this.animateSorting(snapshots);
|
||||||
}
|
}
|
||||||
@@ -159,14 +150,9 @@ export class SortingComponent implements OnInit {
|
|||||||
private animateSorting(snapshots: SortSnapshot[]): void {
|
private animateSorting(snapshots: SortSnapshot[]): void {
|
||||||
snapshots.forEach((snapshot, index) => {
|
snapshots.forEach((snapshot, index) => {
|
||||||
const id = setTimeout(() => {
|
const id = setTimeout(() => {
|
||||||
for (let i = 0; i < this.sortArray.length; i++) {
|
this.sortArray.set(snapshot.array);
|
||||||
if (snapshot.array[i]) {
|
|
||||||
this.sortArray[i].value = snapshot.array[i].value;
|
|
||||||
this.sortArray[i].state = snapshot.array[i].state;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.isSoundEnabled) {
|
if (this.isSoundEnabled()) {
|
||||||
// Play a tone for each comparing bar (max 2 at once to avoid noise)
|
// Play a tone for each comparing bar (max 2 at once to avoid noise)
|
||||||
snapshot.array
|
snapshot.array
|
||||||
.filter(item => item.state === 'comparing')
|
.filter(item => item.state === 'comparing')
|
||||||
@@ -174,13 +160,10 @@ export class SortingComponent implements OnInit {
|
|||||||
.forEach(item => this.audioService.playTone(item.value, this.maxArrayValue, this.animationSpeed));
|
.forEach(item => this.audioService.playTone(item.value, this.maxArrayValue, this.animationSpeed));
|
||||||
}
|
}
|
||||||
|
|
||||||
this.cdr.detectChanges();
|
|
||||||
|
|
||||||
if (index === snapshots.length - 1) {
|
if (index === snapshots.length - 1) {
|
||||||
this.sortArray.forEach(item => item.state = 'sorted');
|
this.sortArray.set(snapshot.array.map(item => ({...item, state: 'sorted'})));
|
||||||
this.cdr.detectChanges();
|
if (this.isSoundEnabled()) {
|
||||||
if (this.isSoundEnabled) {
|
this.audioService.playSortedSweep(this.sortArray().map(item => item.value), this.maxArrayValue);
|
||||||
this.audioService.playSortedSweep(this.sortArray.map(item => item.value), this.maxArrayValue);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, index * this.animationSpeed);
|
}, index * this.animationSpeed);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
import { Component } from '@angular/core';
|
||||||
import {MatCard} from '@angular/material/card';
|
import {MatCard} from '@angular/material/card';
|
||||||
import {TranslatePipe} from '@ngx-translate/core';
|
import {TranslatePipe} from '@ngx-translate/core';
|
||||||
import {SharedFunctions} from '../../shared/SharedFunctions';
|
import {SharedFunctions} from '../../shared/SharedFunctions';
|
||||||
@@ -10,7 +10,6 @@ import {SharedFunctions} from '../../shared/SharedFunctions';
|
|||||||
TranslatePipe
|
TranslatePipe
|
||||||
],
|
],
|
||||||
templateUrl: './imprint.component.html',
|
templateUrl: './imprint.component.html',
|
||||||
changeDetection: ChangeDetectionStrategy.Eager,
|
|
||||||
styleUrl: './imprint.component.scss',
|
styleUrl: './imprint.component.scss',
|
||||||
})
|
})
|
||||||
export class ImprintComponent {
|
export class ImprintComponent {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
import { Component } from '@angular/core';
|
||||||
import { RouterLink } from '@angular/router';
|
import { RouterLink } from '@angular/router';
|
||||||
import { MatCard } from '@angular/material/card';
|
import { MatCard } from '@angular/material/card';
|
||||||
import { MatButton } from '@angular/material/button';
|
import { MatButton } from '@angular/material/button';
|
||||||
@@ -8,7 +8,6 @@ import { RouterConstants } from '../../constants/RouterConstants';
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-not-found',
|
selector: 'app-not-found',
|
||||||
imports: [RouterLink, MatCard, MatButton, TranslatePipe],
|
imports: [RouterLink, MatCard, MatButton, TranslatePipe],
|
||||||
changeDetection: ChangeDetectionStrategy.Eager,
|
|
||||||
templateUrl: './not-found.component.html',
|
templateUrl: './not-found.component.html',
|
||||||
})
|
})
|
||||||
export class NotFoundComponent {
|
export class NotFoundComponent {
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { provideZonelessChangeDetection } from '@angular/core';
|
||||||
|
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
|
||||||
|
import { provideTranslateService } from '@ngx-translate/core';
|
||||||
|
import { register } from 'swiper/element/bundle';
|
||||||
|
import { ProjectDialogComponent } from './project-dialog.component';
|
||||||
|
|
||||||
|
describe('ProjectDialogComponent', () => {
|
||||||
|
// jsdom has no Element.part, which swiper-container uses while it renders.
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(Element.prototype, 'part', {
|
||||||
|
value: { add: () => undefined },
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
delete (Element.prototype as { part?: unknown }).part;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers the swiper custom elements', () => {
|
||||||
|
expect(customElements.get('swiper-container')).toBeDefined();
|
||||||
|
expect(customElements.get('swiper-slide')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registering again does not throw', () => {
|
||||||
|
expect(() => register()).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders an upgraded swiper-container for project images', async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [ProjectDialogComponent],
|
||||||
|
providers: [
|
||||||
|
provideZonelessChangeDetection(),
|
||||||
|
provideTranslateService({ lang: 'en', fallbackLang: 'en' }),
|
||||||
|
{
|
||||||
|
provide: MAT_DIALOG_DATA,
|
||||||
|
useValue: {
|
||||||
|
title: 'T',
|
||||||
|
technologies: [],
|
||||||
|
images: [{ url: 'a.png' }, { url: 'b.png' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
const fixture = TestBed.createComponent(ProjectDialogComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const el: HTMLElement = fixture.nativeElement.querySelector('swiper-container');
|
||||||
|
expect(el).not.toBeNull();
|
||||||
|
expect(el instanceof customElements.get('swiper-container')!).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import {AfterViewInit, Component, inject, CUSTOM_ELEMENTS_SCHEMA, ViewChild, ElementRef, ChangeDetectionStrategy} from '@angular/core';
|
import {AfterViewInit, Component, inject, CUSTOM_ELEMENTS_SCHEMA, ViewChild, ElementRef} from '@angular/core';
|
||||||
import {
|
import {
|
||||||
MAT_DIALOG_DATA,
|
MAT_DIALOG_DATA,
|
||||||
MatDialogActions,
|
MatDialogActions,
|
||||||
@@ -11,6 +11,9 @@ import {TranslatePipe} from '@ngx-translate/core';
|
|||||||
import {MatIcon} from '@angular/material/icon';
|
import {MatIcon} from '@angular/material/icon';
|
||||||
import {MatChip, MatChipSet} from '@angular/material/chips';
|
import {MatChip, MatChipSet} from '@angular/material/chips';
|
||||||
import {MatButton} from '@angular/material/button';
|
import {MatButton} from '@angular/material/button';
|
||||||
|
import {register} from 'swiper/element/bundle';
|
||||||
|
|
||||||
|
register();
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-project-dialog',
|
selector: 'app-project-dialog',
|
||||||
@@ -27,7 +30,6 @@ import {MatButton} from '@angular/material/button';
|
|||||||
MatButton,
|
MatButton,
|
||||||
MatDialogClose
|
MatDialogClose
|
||||||
],
|
],
|
||||||
changeDetection: ChangeDetectionStrategy.Eager,
|
|
||||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
})
|
})
|
||||||
export class ProjectDialogComponent implements AfterViewInit {
|
export class ProjectDialogComponent implements AfterViewInit {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, computed, inject, CUSTOM_ELEMENTS_SCHEMA, OnDestroy, OnInit, ChangeDetectionStrategy } from '@angular/core';
|
import { Component, computed, inject, CUSTOM_ELEMENTS_SCHEMA, OnDestroy, OnInit } from '@angular/core';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { Subscription } from "rxjs";
|
import { Subscription } from "rxjs";
|
||||||
import { MatCardModule } from "@angular/material/card";
|
import { MatCardModule } from "@angular/material/card";
|
||||||
@@ -43,7 +43,6 @@ export interface Projects {
|
|||||||
],
|
],
|
||||||
templateUrl: './projects.component.html',
|
templateUrl: './projects.component.html',
|
||||||
styleUrl: './projects.component.scss',
|
styleUrl: './projects.component.scss',
|
||||||
changeDetection: ChangeDetectionStrategy.Eager,
|
|
||||||
schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
||||||
})
|
})
|
||||||
export class ProjectsComponent implements OnInit, OnDestroy {
|
export class ProjectsComponent implements OnInit, OnDestroy {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {Component, computed, effect, inject, OnDestroy, signal, WritableSignal, ChangeDetectionStrategy} from '@angular/core';
|
import {Component, computed, effect, inject, OnDestroy, signal, WritableSignal} from '@angular/core';
|
||||||
import {FormsModule} from '@angular/forms';
|
import {FormsModule} from '@angular/forms';
|
||||||
import {MatButtonModule} from '@angular/material/button';
|
import {MatButtonModule} from '@angular/material/button';
|
||||||
import {MatCardModule} from '@angular/material/card';
|
import {MatCardModule} from '@angular/material/card';
|
||||||
@@ -30,7 +30,6 @@ const SECOND_INTERVAL_FREQUENCY_HZ = 587;
|
|||||||
MatCheckboxModule,
|
MatCheckboxModule,
|
||||||
TranslatePipe
|
TranslatePipe
|
||||||
],
|
],
|
||||||
changeDetection: ChangeDetectionStrategy.Eager,
|
|
||||||
templateUrl: './stopwatch.component.html'
|
templateUrl: './stopwatch.component.html'
|
||||||
})
|
})
|
||||||
export class StopwatchComponent implements OnDestroy {
|
export class StopwatchComponent implements OnDestroy {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ describe('GpuCapabilityService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
setGpu(originalGpu);
|
setGpu(originalGpu);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -28,7 +29,7 @@ describe('GpuCapabilityService', () => {
|
|||||||
it('falls back to "webgl" when WebGPU is missing but a WebGL2 context exists', async () => {
|
it('falls back to "webgl" when WebGPU is missing but a WebGL2 context exists', async () => {
|
||||||
setGpu(undefined);
|
setGpu(undefined);
|
||||||
const fakeCanvas = { getContext: () => ({}) } as unknown as HTMLCanvasElement;
|
const fakeCanvas = { getContext: () => ({}) } as unknown as HTMLCanvasElement;
|
||||||
spyOn(document, 'createElement').and.returnValue(fakeCanvas);
|
vi.spyOn(document, 'createElement').mockReturnValue(fakeCanvas);
|
||||||
|
|
||||||
const tier = await service.detect();
|
const tier = await service.detect();
|
||||||
|
|
||||||
@@ -38,7 +39,7 @@ describe('GpuCapabilityService', () => {
|
|||||||
it('reports "none" when neither WebGPU nor WebGL is available', async () => {
|
it('reports "none" when neither WebGPU nor WebGL is available', async () => {
|
||||||
setGpu(undefined);
|
setGpu(undefined);
|
||||||
const fakeCanvas = { getContext: () => null } as unknown as HTMLCanvasElement;
|
const fakeCanvas = { getContext: () => null } as unknown as HTMLCanvasElement;
|
||||||
spyOn(document, 'createElement').and.returnValue(fakeCanvas);
|
vi.spyOn(document, 'createElement').mockReturnValue(fakeCanvas);
|
||||||
|
|
||||||
const tier = await service.detect();
|
const tier = await service.detect();
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { TranslateService } from '@ngx-translate/core';
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
|
import type { Mock } from 'vitest';
|
||||||
import { LanguageService } from './language.service';
|
import { LanguageService } from './language.service';
|
||||||
import { LocalStoreConstants } from '../constants/LocalStoreConstants';
|
import { LocalStoreConstants } from '../constants/LocalStoreConstants';
|
||||||
|
|
||||||
describe('LanguageService', () => {
|
describe('LanguageService', () => {
|
||||||
let translateSpy: jasmine.SpyObj<TranslateService>;
|
let translateSpy: { use: Mock };
|
||||||
|
|
||||||
function createService(): LanguageService {
|
function createService(): LanguageService {
|
||||||
translateSpy = jasmine.createSpyObj<TranslateService>('TranslateService', ['use']);
|
translateSpy = { use: vi.fn() };
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
providers: [{ provide: TranslateService, useValue: translateSpy }],
|
providers: [{ provide: TranslateService, useValue: translateSpy }],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {AfterViewInit, Component, ElementRef, EventEmitter, Input, Output, ViewChild, ChangeDetectionStrategy} from '@angular/core';
|
import {AfterViewInit, Component, ElementRef, EventEmitter, Input, Output, ViewChild} from '@angular/core';
|
||||||
import {CommonModule} from '@angular/common';
|
import {CommonModule} from '@angular/common';
|
||||||
|
|
||||||
export interface GridPos { row: number; col: number }
|
export interface GridPos { row: number; col: number }
|
||||||
@@ -7,7 +7,6 @@ export interface GridPos { row: number; col: number }
|
|||||||
selector: 'app-generic-grid',
|
selector: 'app-generic-grid',
|
||||||
imports: [CommonModule],
|
imports: [CommonModule],
|
||||||
templateUrl: './generic-grid.html',
|
templateUrl: './generic-grid.html',
|
||||||
changeDetection: ChangeDetectionStrategy.Eager,
|
|
||||||
styleUrl: './generic-grid.scss',
|
styleUrl: './generic-grid.scss',
|
||||||
})
|
})
|
||||||
export class GenericGridComponent implements AfterViewInit {
|
export class GenericGridComponent implements AfterViewInit {
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import {AfterViewInit, Component, ElementRef, HostListener, inject, NgZone, OnDestroy, PLATFORM_ID, ViewChild, ChangeDetectionStrategy} from '@angular/core';
|
import {AfterViewInit, Component, ElementRef, HostListener, inject, NgZone, OnDestroy, PLATFORM_ID, ViewChild} from '@angular/core';
|
||||||
import {isPlatformBrowser} from '@angular/common';
|
import {isPlatformBrowser} from '@angular/common';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-particles-background',
|
selector: 'app-particles-background',
|
||||||
imports: [],
|
imports: [],
|
||||||
templateUrl: './particles-background.component.html',
|
templateUrl: './particles-background.component.html',
|
||||||
changeDetection: ChangeDetectionStrategy.Eager,
|
|
||||||
styleUrl: './particles-background.component.scss',
|
styleUrl: './particles-background.component.scss',
|
||||||
})
|
})
|
||||||
export class ParticleBackgroundComponent implements AfterViewInit, OnDestroy {
|
export class ParticleBackgroundComponent implements AfterViewInit, OnDestroy {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {AfterViewInit, Component, ElementRef, EventEmitter, inject, Input, NgZone, OnDestroy, Output, ViewChild, ChangeDetectionStrategy} from '@angular/core';
|
import {AfterViewInit, Component, ElementRef, EventEmitter, inject, input, NgZone, OnDestroy, Output, ViewChild} from '@angular/core';
|
||||||
import {MatSnackBar} from '@angular/material/snack-bar';
|
import {MatSnackBar} from '@angular/material/snack-bar';
|
||||||
import {TranslateService} from '@ngx-translate/core';
|
import {TranslateService} from '@ngx-translate/core';
|
||||||
import {ArcRotateCamera, Camera, Engine, MeshBuilder, Scene, ShaderLanguage, ShaderMaterial, Vector2, Vector3, WebGPUEngine} from '@babylonjs/core';
|
import {ArcRotateCamera, Camera, Engine, MeshBuilder, Scene, ShaderLanguage, ShaderMaterial, Vector2, Vector3, WebGPUEngine} from '@babylonjs/core';
|
||||||
@@ -26,7 +26,6 @@ export interface SceneEventData {
|
|||||||
selector: 'app-babylon-canvas',
|
selector: 'app-babylon-canvas',
|
||||||
imports: [],
|
imports: [],
|
||||||
templateUrl: './babylon-canvas.component.html',
|
templateUrl: './babylon-canvas.component.html',
|
||||||
changeDetection: ChangeDetectionStrategy.Eager,
|
|
||||||
styleUrl: './babylon-canvas.component.scss',
|
styleUrl: './babylon-canvas.component.scss',
|
||||||
})
|
})
|
||||||
export class BabylonCanvas implements AfterViewInit, OnDestroy {
|
export class BabylonCanvas implements AfterViewInit, OnDestroy {
|
||||||
@@ -37,8 +36,8 @@ export class BabylonCanvas implements AfterViewInit, OnDestroy {
|
|||||||
|
|
||||||
@ViewChild('renderCanvas', { static: true }) canvasRef!: ElementRef<HTMLCanvasElement>;
|
@ViewChild('renderCanvas', { static: true }) canvasRef!: ElementRef<HTMLCanvasElement>;
|
||||||
|
|
||||||
@Input({ required: true }) config!: RenderConfig;
|
readonly config = input.required<RenderConfig>();
|
||||||
@Input() renderCallback?: RenderCallback;
|
readonly renderCallback = input<RenderCallback>();
|
||||||
|
|
||||||
@Output() sceneReady = new EventEmitter<SceneEventData>();
|
@Output() sceneReady = new EventEmitter<SceneEventData>();
|
||||||
@Output() sceneResized = new EventEmitter<SceneEventData>();
|
@Output() sceneResized = new EventEmitter<SceneEventData>();
|
||||||
@@ -131,7 +130,7 @@ export class BabylonCanvas implements AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private setupCamera(canvas: HTMLCanvasElement) {
|
private setupCamera(canvas: HTMLCanvasElement) {
|
||||||
if (this.config.mode === '3D') {
|
if (this.config().mode === '3D') {
|
||||||
this.setup3dCamera(canvas);
|
this.setup3dCamera(canvas);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -144,7 +143,7 @@ export class BabylonCanvas implements AfterViewInit, OnDestroy {
|
|||||||
cam.mode = Camera.ORTHOGRAPHIC_CAMERA;
|
cam.mode = Camera.ORTHOGRAPHIC_CAMERA;
|
||||||
|
|
||||||
const aspect = canvas.width / canvas.height;
|
const aspect = canvas.width / canvas.height;
|
||||||
const viewSize = this.config?.initialViewSize ?? 10;
|
const viewSize = this.config().initialViewSize ?? 10;
|
||||||
cam.orthoLeft = -viewSize * aspect / 2;
|
cam.orthoLeft = -viewSize * aspect / 2;
|
||||||
cam.orthoRight = viewSize * aspect / 2;
|
cam.orthoRight = viewSize * aspect / 2;
|
||||||
cam.orthoTop = viewSize / 2;
|
cam.orthoTop = viewSize / 2;
|
||||||
@@ -161,17 +160,18 @@ export class BabylonCanvas implements AfterViewInit, OnDestroy {
|
|||||||
cam.maxZ = 100;
|
cam.maxZ = 100;
|
||||||
cam.lowerRadiusLimit = 1.5;
|
cam.lowerRadiusLimit = 1.5;
|
||||||
cam.upperRadiusLimit = 20;
|
cam.upperRadiusLimit = 20;
|
||||||
cam.radius = this.config?.initialViewSize ?? 1;
|
cam.radius = this.config().initialViewSize ?? 1;
|
||||||
cam.attachControl(canvas, true);
|
cam.attachControl(canvas, true);
|
||||||
this.camera = cam;
|
this.camera = cam;
|
||||||
}
|
}
|
||||||
|
|
||||||
private createFullScreenRect() {
|
private createFullScreenRect() {
|
||||||
if (!this.config.vertexShader || !this.config.fragmentShader) {
|
const config = this.config();
|
||||||
|
if (!config.vertexShader || !config.fragmentShader) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const plane = MeshBuilder.CreatePlane("plane", {size: 100}, this.scene);
|
const plane = MeshBuilder.CreatePlane("plane", {size: 100}, this.scene);
|
||||||
if (this.config.mode === '3D') {
|
if (config.mode === '3D') {
|
||||||
plane.parent = this.camera;
|
plane.parent = this.camera;
|
||||||
plane.position.z = 1;
|
plane.position.z = 1;
|
||||||
} else {
|
} else {
|
||||||
@@ -183,7 +183,8 @@ export class BabylonCanvas implements AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private createShaderMaterial() {
|
private createShaderMaterial() {
|
||||||
if (!this.config.vertexShader || !this.config.fragmentShader || !this.config.uniformNames) {
|
const config = this.config();
|
||||||
|
if (!config.vertexShader || !config.fragmentShader || !config.uniformNames) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,14 +192,14 @@ export class BabylonCanvas implements AfterViewInit, OnDestroy {
|
|||||||
"shaderMaterial",
|
"shaderMaterial",
|
||||||
this.scene,
|
this.scene,
|
||||||
{
|
{
|
||||||
vertexSource: this.config.vertexShader,
|
vertexSource: config.vertexShader,
|
||||||
fragmentSource: this.config.fragmentShader
|
fragmentSource: config.fragmentShader
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
attributes: ["position", "uv"],
|
attributes: ["position", "uv"],
|
||||||
uniforms: ["resolution", "cameraPosition", ...this.config.uniformNames],
|
uniforms: ["resolution", "cameraPosition", ...config.uniformNames],
|
||||||
uniformBuffers: this.config.uniformBufferNames ?? [],
|
uniformBuffers: config.uniformBufferNames ?? [],
|
||||||
shaderLanguage: this.config.shaderLanguage ?? ShaderLanguage.GLSL
|
shaderLanguage: config.shaderLanguage ?? ShaderLanguage.GLSL
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
this.shaderMaterial.disableDepthWrite = true;
|
this.shaderMaterial.disableDepthWrite = true;
|
||||||
@@ -209,8 +210,9 @@ export class BabylonCanvas implements AfterViewInit, OnDestroy {
|
|||||||
this.engine.runRenderLoop(() => {
|
this.engine.runRenderLoop(() => {
|
||||||
|
|
||||||
// callback call to call specific uniforms
|
// callback call to call specific uniforms
|
||||||
if (this.renderCallback) {
|
const callback = this.renderCallback();
|
||||||
this.renderCallback(this.shaderMaterial, this.camera, canvas, this.scene);
|
if (callback) {
|
||||||
|
callback(this.shaderMaterial, this.camera, canvas, this.scene);
|
||||||
}
|
}
|
||||||
|
|
||||||
// default uniforms which maybe each scene has
|
// default uniforms which maybe each scene has
|
||||||
@@ -228,8 +230,9 @@ export class BabylonCanvas implements AfterViewInit, OnDestroy {
|
|||||||
this.engine.resize();
|
this.engine.resize();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.config.mode === '2D' && this.camera instanceof ArcRotateCamera) {
|
const config = this.config();
|
||||||
const viewSize = this.config?.initialViewSize ?? 10;
|
if (config.mode === '2D' && this.camera instanceof ArcRotateCamera) {
|
||||||
|
const viewSize = config.initialViewSize ?? 10;
|
||||||
this.camera.orthoLeft = -viewSize / 2;
|
this.camera.orthoLeft = -viewSize / 2;
|
||||||
this.camera.orthoRight = viewSize / 2;
|
this.camera.orthoRight = viewSize / 2;
|
||||||
this.camera.orthoTop = viewSize / 2;
|
this.camera.orthoTop = viewSize / 2;
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 932 B |
|
After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 268 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 426 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 202 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 739 KiB |
|
After Width: | Height: | Size: 198 KiB |
|
Before Width: | Height: | Size: 742 KiB |
|
After Width: | Height: | Size: 207 KiB |
|
Before Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 13 KiB |