Compare commits

..
5 Commits
Author SHA1 Message Date
lobo 034e397864 refactor(algorithms): move algorithm logic out of components
Build, Test & Push Frontend / docker (push) Successful in 51s
Build, Test & Push Frontend / quality-check (push) Successful in 1m25s
Move logic from 5 components into services and pure modules, with a
shared animator for pathfinding and labyrinth. Add specs for them and
for pendulum and cloth physics.
Fix Game of Life crash: clamp rows and cols to the max.
Fix random pathfinding on 2 columns: in bounds, start not equal end.
2026-10-13 11:49:17 +02:00
lobo dc9f09bede refactor(components): use OnPush and signals in components
Angular 22 defaults to OnPush, so Eager is removed.
Template state is now signals, so sorting needs no detectChanges.
Game of Life execution time now updates live.
Remove the unused ImageDialogComponent and turn the OnPush lint rule
back on.
2026-10-13 11:10:04 +02:00
lobo 2abc7c31e2 perf(assets): convert images to webp and check asset paths
Convert 26 PNG/JPG assets to WebP: max 1600 px, q80, diagrams lossless.
src/assets shrinks from 8.3 MB to 4.2 MB.
Use me.jpg for og:image and twitter:image; some crawlers skip WebP.
Add scripts/check-assets.mjs to lint, so a wrong asset path fails lint,
not only at runtime.
2026-10-13 09:13:58 +02:00
lobo 4e59770e53 build(test): run unit tests with vitest and jsdom
Karma needed headless Chrome. Vitest with jsdom runs with no browser.
Lighthouse CI now gets Chrome from @puppeteer/browsers.
Two CSS font tests are dropped: jsdom does not apply real CSS.
2026-10-13 09:02:45 +02:00
lobo dd7c9f6e3d fix(projects): register swiper custom elements in project dialog
The slider never ran because Swiper custom elements were not registered.
Use the bundle build so the navigation and pagination modules are included.
Add a spec that checks the registration.
2026-10-13 08:18:22 +02:00
124 changed files with 2890 additions and 3127 deletions

No files matched your search

+1
View File
@@ -46,6 +46,7 @@ Dockerfile text eol=lf
*.jpeg binary
*.gif binary
*.ico binary
*.webp binary
*.pdf binary
*.zip binary
*.tar binary
+5 -6
View File
@@ -26,6 +26,7 @@ jobs:
check-latest: true
cache: 'npm'
# Shared libraries for the Chrome that Lighthouse CI runs
- name: Install Linux Libs
run: |
sudo apt-get update
@@ -57,21 +58,19 @@ jobs:
- name: i18n Key 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
run: npx ng test --watch=false
run: npm test
# 4. Build Production (necessary for lighthouse)
- name: Build Production
run: npx ng build --configuration production
# 5. Lighthouse Audit (Performance & SEO)
# Puppeteer is a devDependency, so `npm ci` already installed it and
# downloaded Chrome. executablePath() is async in Puppeteer v25, so await it.
- name: Lighthouse CI
run: |
CHROME_PATH=$(node -e 'require("puppeteer").executablePath().then(p => console.log(p))')
export CHROME_PATH=$CHROME_PATH
CHROME_PATH=$(npx @puppeteer/browsers install chrome@stable --path "$RUNNER_TEMP/chrome" --format '{{path}}')
export CHROME_PATH
npx lhci autorun
# ------------------------------------------------------------------
-2
View File
@@ -1,8 +1,6 @@
# Build
FROM node:22-alpine AS build
WORKDIR /app
# The production build never runs tests, so skip Puppeteer's Chrome download.
ENV PUPPETEER_SKIP_DOWNLOAD=1
COPY package*.json ./
RUN npm ci
COPY . .
+1
View File
@@ -39,6 +39,7 @@ To run the unit tests:
```bash
ng test
```
`npm test` runs once (Vitest + jsdom). `ng test` watches in a terminal; use `--watch=false` to run once.
### Linting
+4 -3
View File
@@ -87,9 +87,10 @@ Then open http://localhost:8080
| `npm start` | Dev server |
| `npm run build` | Production build |
| `npm run watch` | Rebuild on change (development configuration) |
| `npm test` | Unit tests (Karma + Jasmine, headless Chrome) |
| `npm test` | Unit tests (Vitest + jsdom, no browser needed) |
| `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 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):
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`
3. Unit tests — `npx ng test --watch=false`
3. Unit tests — `npm test`
4. Production build — `npx ng build --configuration production`
5. Lighthouse audit — `npx lhci autorun` (performance & SEO)
+1 -21
View File
@@ -87,27 +87,7 @@
"builder": "@angular/build:extract-i18n"
},
"test": {
"builder": "@angular/build:karma",
"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"
]
}
"builder": "@angular/build:unit-test"
},
"lint": {
"builder": "@angular-eslint/builder:lint",
-2
View File
@@ -18,8 +18,6 @@ module.exports = defineConfig([
"@typescript-eslint/no-inferrable-types": "off",
"@typescript-eslint/no-explicit-any": "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": [
"error",
{
-43
View File
@@ -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,
});
};
+717 -1721
View File
File diff suppressed because it is too large. Load diff
+7 -12
View File
@@ -6,9 +6,10 @@
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test",
"lint": "ng lint",
"i18n:check": "node scripts/check-i18n.mjs"
"test": "ng test --watch=false",
"lint": "ng lint && npm run assets:check",
"i18n:check": "node scripts/check-i18n.mjs",
"assets:check": "node scripts/check-assets.mjs"
},
"private": true,
"dependencies": {
@@ -38,20 +39,14 @@
"@lhci/cli": "^0.15.1",
"@puppeteer/browsers": "3.2.4",
"@tailwindcss/postcss": "^4.3.3",
"@types/jasmine": "~7.0.0",
"@webgpu/types": "^0.1.74",
"angular-eslint": "22.6.0",
"eslint": "^10.12.0",
"jasmine-core": "^7.0.2",
"karma": "^6.4.4",
"karma-chrome-launcher": "^3.2.0",
"karma-coverage": "^2.2.1",
"karma-jasmine": "^5.1.0",
"karma-jasmine-html-reporter": "^2.2.0",
"puppeteer": "^25.13.0",
"jsdom": "^30.1.2",
"tailwindcss": "^4.3.3",
"typescript": "~6.0.3",
"typescript-eslint": "^8.71.1"
"typescript-eslint": "^8.71.1",
"vitest": "^5.0.4"
},
"overrides": {
"tmp": "^0.2.3",
+37
View File
@@ -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.');
+26 -26
View File
@@ -1,44 +1,44 @@
export class AssetsConstants {
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_EN = '/assets/flags/gb.svg';
//logos
static readonly CHAMAELEON_LOGO = '/assets/logos/logo_chamaeleon.svg';
static readonly TH_BINGEN_LOGO = '/assets/logos/bingen-logo-white.svg';
static readonly ASSYST_LOGO = '/assets/logos/assyst_gmbh_logo.jpg';
static readonly COLORDIGITAL_LOGO = '/assets/logos/dmixcloud_logo.jpg';
static readonly TERAPORT_LOGO = '/assets/logos/teraport_gmbh_logo.jpg';
static readonly ASSYST_LOGO = '/assets/logos/assyst_gmbh_logo.webp';
static readonly COLORDIGITAL_LOGO = '/assets/logos/dmixcloud_logo.webp';
static readonly TERAPORT_LOGO = '/assets/logos/teraport_gmbh_logo.webp';
static readonly DIPLOMA = '/assets/projects/diploma/Dahm2010-Diplomarbeit.pdf';
//project images
static readonly PLAYGROUND_IMAGES = [
'/assets/projects/playground/1.png'
'/assets/projects/playground/1.webp'
];
static readonly EL_MUCHO_IMAGES = [
'/assets/projects/el-mucho/1.jpg',
'/assets/projects/el-mucho/2.jpg',
'/assets/projects/el-mucho/3.jpg',
'/assets/projects/el-mucho/4.jpg'
'/assets/projects/el-mucho/1.webp',
'/assets/projects/el-mucho/2.webp',
'/assets/projects/el-mucho/3.webp',
'/assets/projects/el-mucho/4.webp'
];
static readonly GAME_JAMS_IMAGES = [
'/assets/projects/game-jams/1.png',
'/assets/projects/game-jams/2.png',
'/assets/projects/game-jams/3.png'
'/assets/projects/game-jams/1.webp',
'/assets/projects/game-jams/2.webp',
'/assets/projects/game-jams/3.webp'
];
static readonly DIPLOMA_IMAGES = [
'/assets/projects/diploma/1.jpg',
'/assets/projects/diploma/2.jpg',
'/assets/projects/diploma/3.jpg',
'/assets/projects/diploma/4.jpg',
'/assets/projects/diploma/5.jpg',
'/assets/projects/diploma/6.jpg'
'/assets/projects/diploma/1.webp',
'/assets/projects/diploma/2.webp',
'/assets/projects/diploma/3.webp',
'/assets/projects/diploma/4.webp',
'/assets/projects/diploma/5.webp',
'/assets/projects/diploma/6.webp'
];
static readonly CLAUDE_BOX_IMAGES = [
@@ -46,16 +46,16 @@ export class AssetsConstants {
];
static readonly TRIBBLE_IMAGES = [
'/assets/projects/tribble-the-homeserver/1.png',
'/assets/projects/tribble-the-homeserver/2.png',
'/assets/projects/tribble-the-homeserver/3.png',
'/assets/projects/tribble-the-homeserver/4.png'
'/assets/projects/tribble-the-homeserver/1.webp',
'/assets/projects/tribble-the-homeserver/2.webp',
'/assets/projects/tribble-the-homeserver/3.webp',
'/assets/projects/tribble-the-homeserver/4.webp'
];
static readonly TBP_IMAGES = [
'/assets/projects/to-be-played/1.png',
'/assets/projects/to-be-played/2.png',
'/assets/projects/to-be-played/3.png',
'/assets/projects/to-be-played/4.png'
'/assets/projects/to-be-played/1.webp',
'/assets/projects/to-be-played/2.webp',
'/assets/projects/to-be-played/3.webp',
'/assets/projects/to-be-played/4.webp'
];
}
+1 -2
View File
@@ -1,4 +1,4 @@
import { Component, inject, ChangeDetectionStrategy } from '@angular/core';
import { Component, inject } from '@angular/core';
import { RouterLink, RouterOutlet } from '@angular/router';
import {TopbarComponent} from '../topbar/topbar.component';
import {TranslatePipe} from '@ngx-translate/core';
@@ -10,7 +10,6 @@ import {SeoService} from '../../service/seo.service';
selector: 'app-root',
imports: [RouterLink, RouterOutlet, TopbarComponent, TranslatePipe, ParticleBackgroundComponent],
templateUrl: './app.component.html',
changeDetection: ChangeDetectionStrategy.Eager,
styleUrl: './app.component.scss'
})
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 -1
View File
@@ -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)">
<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;">
<span class="font-semibold tracking-[0.2px] text-[clamp(1rem,3vw,1.2rem)]">{{ 'APP.TITLE' | translate }}</span>
</a>
+3 -3
View File
@@ -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 { MatToolbarModule } from '@angular/material/toolbar';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu';
import { MatTooltipModule } from '@angular/material/tooltip';
import { NgOptimizedImage } from '@angular/common';
import { TranslatePipe } from '@ngx-translate/core';
import { ThemeService } from '../../service/theme.service';
import { LanguageService } from '../../service/language.service';
@@ -17,10 +18,9 @@ import {RouterConstants} from '../../constants/RouterConstants';
imports: [
RouterLink, RouterLinkActive,
MatToolbarModule, MatIconModule, MatButtonModule, MatMenuModule, MatTooltipModule,
TranslatePipe, MatDivider
TranslatePipe, MatDivider, NgOptimizedImage
],
templateUrl: './topbar.component.html',
changeDetection: ChangeDetectionStrategy.Eager,
styleUrl: './topbar.component.scss'
})
export class TopbarComponent {
+1 -54
View File
@@ -1,6 +1,5 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Component, provideZonelessChangeDetection } from '@angular/core';
import { MatCardModule } from '@angular/material/card';
import { provideZonelessChangeDetection } from '@angular/core';
import { By, DomSanitizer } from '@angular/platform-browser';
import { MatIconRegistry } from '@angular/material/icon';
import { provideRouter } from '@angular/router';
@@ -188,57 +187,5 @@ describe('AboutComponent', () => {
expect(emptyLinks.length).toBe(0);
expect(fixture.debugElement.query(By.css('[data-testid="contact-mail"]'))).toBeTruthy();
});
it('keeps the mail button in the same font as the links it sits next to', () => {
const mailButton = fixture.debugElement.query(By.css('[data-testid="contact-mail"]')).nativeElement;
const siblingLink = fixture.debugElement
.queryAll(By.css('a'))
.find(anchor => anchor.nativeElement.textContent.trim() === '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 -2
View File
@@ -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 { MatCardModule } from '@angular/material/card';
import { MatChipsModule } from '@angular/material/chips';
@@ -37,7 +37,6 @@ interface SkillGroup {
RouterLink
],
templateUrl: './about.component.html',
changeDetection: ChangeDetectionStrategy.Eager,
styleUrl: './about.component.scss'
})
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 { AlgorithmCategory } from './algorithm-category';
import { RouterLink } from '@angular/router';
@@ -10,7 +10,6 @@ import {TranslatePipe} from '@ngx-translate/core';
selector: 'app-algorithms',
templateUrl: './algorithms.component.html',
styleUrl: './algorithms.component.scss',
changeDetection: ChangeDetectionStrategy.Eager,
imports: [RouterLink, MatCardModule, MatIconModule, TranslatePipe],
})
export class AlgorithmsComponent {
@@ -7,17 +7,17 @@
<div class="flex flex-col mb-4">
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
<button mat-raised-button color="primary" (click)="toggleWind()">
{{ isWindActive ? ('CLOTH.WIND_OFF' | translate) : ('CLOTH.WIND_ON' | translate) }}
{{ isWindActive() ? ('CLOTH.WIND_OFF' | translate) : ('CLOTH.WIND_ON' | translate) }}
</button>
<button mat-raised-button color="primary" (click)="toggleMesh()">
{{ isOutlineActive ? ('CLOTH.OUTLINE_OFF' | translate) : ('CLOTH.OUTLINE_ON' | translate) }}
{{ isOutlineActive() ? ('CLOTH.OUTLINE_OFF' | translate) : ('CLOTH.OUTLINE_ON' | translate) }}
</button>
<button mat-raised-button color="accent" (click)="restartSimulation()">
{{ 'CLOTH.RESTART_SIMULATION' | translate }}
</button>
</div>
<div class="flex items-center gap-2.5">
<span>{{ 'CLOTH.ELONGATION' | translate }}: {{ elongation }}</span>
<span>{{ 'CLOTH.ELONGATION' | translate }}: {{ elongation() }}</span>
<mat-slider min="0.5" max="2.0" step="0.1">
<input matSliderThumb [(ngModel)]="elongation">
</mat-slider>
@@ -1,4 +1,4 @@
import {Component, ChangeDetectionStrategy} from '@angular/core';
import {Component, signal} from '@angular/core';
import {FormsModule} from '@angular/forms';
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
import {MatSliderModule} from '@angular/material/slider';
@@ -28,7 +28,6 @@ import {ClothCpuStrategy} from './strategies/cloth-cpu.strategy';
Information
],
templateUrl: './cloth.component.html',
changeDetection: ChangeDetectionStrategy.Eager,
styleUrl: './cloth.component.scss',
})
export class ClothComponent {
@@ -36,10 +35,10 @@ export class ClothComponent {
private simulationTime: number = 0;
private strategy: ClothSimulationStrategy | null = null;
public isWindActive: boolean = false;
public isOutlineActive: boolean = false;
public readonly isWindActive = signal(false);
public readonly isOutlineActive = signal(false);
public stiffness: number = 80;
public elongation: number = 1.0;
public readonly elongation = signal(1.0);
public renderConfig: RenderConfig = {
mode: '3D',
@@ -85,16 +84,16 @@ export class ClothComponent {
}
public toggleWind(): void {
this.isWindActive = !this.isWindActive;
this.isWindActive.update(v => !v);
}
public toggleMesh(): void {
this.isOutlineActive = !this.isOutlineActive;
this.isOutlineActive.update(v => !v);
const mesh = this.strategy?.getMesh();
if (!mesh?.material) {
return;
}
mesh.material.wireframe = this.isOutlineActive;
mesh.material.wireframe = this.isOutlineActive();
}
public restartSimulation(): void {
@@ -130,8 +129,8 @@ export class ClothComponent {
if (this.strategy) {
this.strategy.updateParams({
stiffness: this.stiffness,
elongation: this.elongation,
isWindActive: this.isWindActive,
elongation: this.elongation(),
isWindActive: this.isWindActive(),
simulationTime: this.simulationTime,
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 }}
</button>
}
<p>{{ 'SORTING.EXECUTION_TIME' | translate }}: {{ executionTime }} ms</p>
<p>{{ 'SORTING.EXECUTION_TIME' | translate }}: {{ executionTime() }} ms</p>
</div>
<div class="flex gap-3 items-center flex-wrap">
<mat-form-field appearance="outline" class="w-[150px]">
@@ -44,7 +44,8 @@
[(ngModel)]="gridRows"
[min]="MIN_GRID_SIZE"
[max]="MAX_GRID_SIZE"
(ngModelChange)="pauseGame(); genericGridComponent.gridRows = gridRows; genericGridComponent.applyGridSize()"
(ngModelChange)="applyGridRows($event)"
(blur)="showClampedGridSize()"
/>
</mat-form-field>
<mat-form-field appearance="outline" class="w-[150px]">
@@ -55,7 +56,8 @@
[(ngModel)]="gridCols"
[min]="MIN_GRID_SIZE"
[max]="MAX_GRID_SIZE"
(ngModelChange)="pauseGame(); genericGridComponent.gridCols = gridCols; genericGridComponent.applyGridSize()"
(ngModelChange)="applyGridCols($event)"
(blur)="showClampedGridSize()"
/>
</mat-form-field>
<mat-form-field appearance="outline" class="w-[150px]">
@@ -78,8 +80,8 @@
</div>
</div>
<app-generic-grid
[gridRows]="gridRows"
[gridCols]="gridCols"
[gridRows]="gridRows()"
[gridCols]="gridCols()"
[minGridSize]="MIN_GRID_SIZE"
[maxGridSize]="MAX_GRID_SIZE"
[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 {TranslatePipe} from "@ngx-translate/core";
import {UrlConstants} from '../../../constants/UrlConstants';
@@ -8,8 +8,9 @@ import {MatButton} from '@angular/material/button';
import {MatIcon} from '@angular/material/icon';
import {MatFormField, MatInput, MatLabel} from '@angular/material/input';
import {FormsModule, ReactiveFormsModule} from '@angular/forms';
import {DEFAULT_GRID_COLS, DEFAULT_GRID_ROWS, DEFAULT_TIME_PER_GENERATION, LIVE_SPAWN_PROBABILITY, MAX_GRID_PX, MAX_GRID_SIZE, MAX_TIME_PER_GENERATION, MIN_GRID_SIZE, MIN_TIME_PER_GENERATION, Node, Scenario} from './conway-gol.models';
import {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 {ConwayGolService} from './service/conway-gol.service';
@Component({
selector: 'app-conway-gol',
@@ -29,10 +30,10 @@ import {GenericGridComponent, GridPos} from '../../../shared/components/generic-
FormsModule,
GenericGridComponent
],
changeDetection: ChangeDetectionStrategy.Eager,
templateUrl: './conway-gol.component.html',
})
export class ConwayGolComponent implements AfterViewInit {
private readonly golService = inject(ConwayGolService);
algoInformation: AlgorithmInformation = {
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']
};
protected gridCols = DEFAULT_GRID_COLS;
protected gridRows = DEFAULT_GRID_ROWS;
protected lifeSpeed = DEFAULT_TIME_PER_GENERATION;
protected readonly gridCols = signal(DEFAULT_GRID_COLS);
protected readonly gridRows = signal(DEFAULT_GRID_ROWS);
protected readonly lifeSpeed = signal(DEFAULT_TIME_PER_GENERATION);
protected readonly MIN_GRID_SIZE = MIN_GRID_SIZE;
protected readonly MAX_GRID_SIZE = MAX_GRID_SIZE;
protected readonly MAX_GRID_PX = MAX_GRID_PX;
readGrid: Node[][] = [];
writeGrid: Node[][] = [];
executionTime = 0;
readonly executionTime = signal(0);
currentScenario: Scenario = Scenario.SIMPLE;
readonly gameStarted = signal(false);
@@ -69,8 +70,8 @@ export class ConwayGolComponent implements AfterViewInit {
this.genericGridComponent.createNodeFn = this.createConwayNode;
this.genericGridComponent.getNodeColorFn = this.getConwayNodeColor;
this.genericGridComponent.applySelectionFn = this.applyConwaySelection;
this.genericGridComponent.gridRows = this.gridRows;
this.genericGridComponent.gridCols = this.gridCols;
this.genericGridComponent.gridRows = this.gridRows();
this.genericGridComponent.gridCols = this.gridCols();
this.genericGridComponent.minGridSize = this.MIN_GRID_SIZE;
this.genericGridComponent.maxGridSize = this.MAX_GRID_SIZE;
this.genericGridComponent.maxGridPx = this.MAX_GRID_PX;
@@ -86,7 +87,36 @@ export class ConwayGolComponent implements AfterViewInit {
}
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 ---
@@ -111,66 +141,10 @@ export class ConwayGolComponent implements AfterViewInit {
initializeConwayGrid = (grid: Node[][]): void => {
this.gameStarted.set(false);
this.readGrid = grid;
switch(this.currentScenario) {
case Scenario.RANDOM: this.setupRandomLives(); break;
case Scenario.SIMPLE: this.setupSimpleLive(); break;
case Scenario.PULSAR: this.setupPulsar(); break;
case Scenario.GUN: this.setupGliderGun(); break;
}
this.golService.applyScenario(grid, this.currentScenario);
this.writeGrid = structuredClone(this.readGrid);
};
// --- Conway-specific logic (kept local) ---
setupRandomLives(): void {
for (let row = 0; row < this.gridRows; row++) {
for (let col = 0; col < this.gridCols; col++) {
this.readGrid[row][col].alive = Math.random() <= LIVE_SPAWN_PROBABILITY;
}
}
}
setupSimpleLive(): void {
this.readGrid[3][4].alive = true;
this.readGrid[4][5].alive = true;
this.readGrid[5][3].alive = true;
this.readGrid[5][4].alive = true;
this.readGrid[5][5].alive = true;
}
setupPulsar(): void {
const centerRow = Math.floor(this.gridRows / 2);
const centerCol = Math.floor(this.gridCols / 2);
const rows = [-6, -1, 1, 6];
const offsets = [2, 3, 4];
rows.forEach(r => {
offsets.forEach(c => {
this.setAlive(centerRow + r, centerCol + c);
this.setAlive(centerRow + r, centerCol - c);
this.setAlive(centerRow + c, centerCol + r);
this.setAlive(centerRow - c, centerCol + r);
});
});
}
setupGliderGun(): void {
const r = 5;
const c = 5;
const dots = [
[r+4, c], [r+4, c+1], [r+5, c], [r+5, c+1], // Block links
[r+4, c+10], [r+5, c+10], [r+6, c+10], [r+3, c+11], [r+7, c+11],
[r+2, c+12], [r+8, c+12], [r+2, c+13], [r+8, c+13], [r+5, c+14],
[r+3, c+15], [r+7, c+15], [r+4, c+16], [r+5, c+16], [r+6, c+16], [r+5, c+17],
[r+2, c+20], [r+3, c+20], [r+4, c+20], [r+2, c+21], [r+3, c+21], [r+4, c+21],
[r+1, c+22], [r+5, c+22], [r+0, c+24], [r+1, c+24], [r+5, c+24], [r+6, c+24],
[r+2, c+34], [r+3, c+34], [r+2, c+35], [r+3, c+35]
];
dots.forEach(([row, col]) => this.setAlive(row, col));
}
// --- The rules of the game
pauseGame(): void {
@@ -181,35 +155,19 @@ export class ConwayGolComponent implements AfterViewInit {
this.gameStarted.set(true);
while (this.gameStarted()){
const startTime = performance.now();
let lifeIsDead = true;
for (let row = 0; row < this.gridRows; row++) {
for (let col = 0; col < this.gridCols; col++) {
lifeIsDead = this.checkLifeRules(row, col, this.writeGrid) && lifeIsDead;
}
}
const changed = this.golService.nextGeneration(this.readGrid, this.writeGrid);
this.swapGrids();
const endTime = performance.now();
this.executionTime = Number.parseFloat((endTime - startTime).toFixed(4));
if (lifeIsDead){
const elapsed = Number.parseFloat((endTime - startTime).toFixed(4));
this.executionTime.set(elapsed);
if (!changed){
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);
}
this.executionTime = 0;
}
private checkLifeRules(row: number, col: number, writeGrid: Node[][]): boolean {
const currentCell = this.readGrid[row][col];
const aliveNeighbors = this.howManyNeighborsAreLiving(row, col);
const oldLifeState = currentCell.alive;
const nextStateAlive = (currentCell.alive && (aliveNeighbors === 2 || aliveNeighbors === 3)) || (!currentCell.alive && aliveNeighbors === 3);
writeGrid[row][col].alive = nextStateAlive;
//only if at least one cell changes the game is still alive
return (nextStateAlive == oldLifeState);
this.executionTime.set(0);
}
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 ---
protected readonly Scenario = Scenario;
protected readonly MIN_TIME_PER_GENERATION = MIN_TIME_PER_GENERATION;
@@ -251,10 +188,4 @@ export class ConwayGolComponent implements AfterViewInit {
delay(ms: number) {
return new Promise( resolve => setTimeout(resolve, ms) );
}
private setAlive(r: number, c: number): void {
if (r >= 0 && r < this.gridRows && c >= 0 && c < this.gridCols) {
this.readGrid[r][c].alive = true;
}
}
}
@@ -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"
[ngClass]="{
'border-l-gray-500 bg-app-bg': solutionStatus === 'INCOMPLETE',
'border-l-green-500 bg-green-50 text-green-800': solutionStatus === 'SOLVED',
'border-l-orange-500 bg-orange-50 text-orange-700': solutionStatus === 'CONFLICTS',
'border-l-red-500 bg-red-50 text-red-800': solutionStatus === 'INVALID'
'border-l-gray-500 bg-app-bg': solutionStatus() === 'INCOMPLETE',
'border-l-green-500 bg-green-50 text-green-800': solutionStatus() === 'SOLVED',
'border-l-orange-500 bg-orange-50 text-orange-700': solutionStatus() === 'CONFLICTS',
'border-l-red-500 bg-red-50 text-red-800': solutionStatus() === 'INVALID'
}">
<span class="uppercase text-[0.8em] opacity-70">{{ 'FOUR_COLOR.STATUS.LABEL' | translate }}:</span>
<span>{{ 'FOUR_COLOR.STATUS.' + solutionStatus | translate }}</span>
<span>{{ 'FOUR_COLOR.STATUS.' + solutionStatus() | translate }}</span>
</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 {FormsModule} from '@angular/forms';
import {MatButtonModule} from '@angular/material/button';
@@ -8,11 +8,11 @@ import {MatInputModule} from '@angular/material/input';
import {TranslatePipe, TranslateService} from '@ngx-translate/core';
import {MatSnackBar} from '@angular/material/snack-bar';
import {DEFAULT_GRID_COLS, DEFAULT_GRID_ROWS, MAX_GRID_PX, MAX_GRID_SIZE, MIN_GRID_SIZE, FourColorNode, Region} from './four-color.models';
import {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 {Information} from '../information/information';
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';
@Component({
@@ -29,10 +29,10 @@ import {UrlConstants} from '../../../constants/UrlConstants';
Information
],
templateUrl: './four-color.component.html',
changeDetection: ChangeDetectionStrategy.Eager,
styleUrl: './four-color.component.scss'
})
export class FourColorComponent implements AfterViewInit {
private readonly fourColorService = inject(FourColorService);
private readonly translate = inject(TranslateService);
private readonly snackBar = inject(MatSnackBar);
@@ -59,12 +59,11 @@ export class FourColorComponent implements AfterViewInit {
]
};
gridRows = DEFAULT_GRID_ROWS;
gridCols = DEFAULT_GRID_COLS;
grid: FourColorNode[][] = [];
regions: Region[] = [];
readonly gridRows = signal(DEFAULT_GRID_ROWS);
readonly gridCols = signal(DEFAULT_GRID_COLS);
map: FourColorMap = {grid: [], regions: []};
executionTime = 0;
solutionStatus: 'INCOMPLETE' | 'SOLVED' | 'CONFLICTS' | 'INVALID' = 'INCOMPLETE';
readonly solutionStatus = signal<SolutionStatus>('INCOMPLETE');
@ViewChild('fourColorCanvas') canvasRef!: ElementRef<HTMLCanvasElement>;
private ctx!: CanvasRenderingContext2D;
@@ -76,107 +75,26 @@ export class FourColorComponent implements AfterViewInit {
}
applyGridSize(): void {
if (this.gridRows < MIN_GRID_SIZE) this.gridRows = MIN_GRID_SIZE;
if (this.gridRows > MAX_GRID_SIZE) this.gridRows = MAX_GRID_SIZE;
if (this.gridCols < MIN_GRID_SIZE) this.gridCols = MIN_GRID_SIZE;
if (this.gridCols > MAX_GRID_SIZE) this.gridCols = MAX_GRID_SIZE;
this.gridRows.set(Math.min(Math.max(this.gridRows(), MIN_GRID_SIZE), MAX_GRID_SIZE));
this.gridCols.set(Math.min(Math.max(this.gridCols(), MIN_GRID_SIZE), MAX_GRID_SIZE));
this.initializeGrid();
}
initializeGrid(): void {
this.grid = [];
this.solutionStatus = 'INCOMPLETE';
for (let r = 0; r < this.gridRows; r++) {
const row: FourColorNode[] = [];
for (let c = 0; c < this.gridCols; c++) {
row.push({
row: r,
col: c,
regionId: -1,
color: 0,
});
}
this.grid.push(row);
}
this.generateRegions();
this.map = this.fourColorService.createMap(this.gridRows(), this.gridCols());
this.solutionStatus.set('INCOMPLETE');
this.resizeCanvas();
this.drawGrid();
}
private resizeCanvas(): void {
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);
canvas.width = this.gridCols * this.nodeSize;
canvas.height = this.gridRows * this.nodeSize;
}
generateRegions(): void {
const numRegions = Math.floor((this.gridRows * this.gridCols) / 30);
this.regions = [];
const seeds = this.determineSeeds(numRegions);
this.regionGrowth(seeds);
this.determineAdjacency();
}
private determineAdjacency() {
for (let row = 0; row < this.gridRows; row++) {
for (let col = 0; col < this.gridCols; col++) {
const currentRegionId = this.grid[row][col].regionId;
const neighbors = this.getNeighbors(row, col);
for (const neighbor of neighbors) {
const neighborRegionId = this.grid[neighbor.row][neighbor.col].regionId;
if (neighborRegionId !== -1 && neighborRegionId !== currentRegionId) {
this.regions[currentRegionId].neighbors.add(neighborRegionId);
this.regions[neighborRegionId].neighbors.add(currentRegionId);
}
}
}
}
}
private regionGrowth(seeds: GridPos[]) {
const queue: GridPos[] = [...seeds];
while (queue.length > 0) {
const {row, col} = queue.shift()!;
const regionId = this.grid[row][col].regionId;
const neighbors = this.getNeighbors(row, col);
for (const neighbor of neighbors) {
if (this.grid[neighbor.row][neighbor.col].regionId === -1) {
this.grid[neighbor.row][neighbor.col].regionId = regionId;
queue.push(neighbor);
}
}
}
}
private determineSeeds(numRegions: number) {
const seeds: GridPos[] = [];
for (let i = 0; i < numRegions; i++) {
let r = SharedFunctions.randomIntFromInterval(0, this.gridRows - 1);
let c = SharedFunctions.randomIntFromInterval(0, this.gridCols - 1);
while (this.grid[r][c].regionId !== -1) {
r = SharedFunctions.randomIntFromInterval(0, this.gridRows - 1);
c = SharedFunctions.randomIntFromInterval(0, this.gridCols - 1);
}
this.grid[r][c].regionId = i;
seeds.push({row: r, col: c});
this.regions.push({id: i, color: 0, neighbors: new Set<number>()});
}
return seeds;
}
private getNeighbors(row: number, col: number): GridPos[] {
const res: GridPos[] = [];
if (row > 0) res.push({row: row - 1, col});
if (row < this.gridRows - 1) res.push({row: row + 1, col});
if (col > 0) res.push({row, col: col - 1});
if (col < this.gridCols - 1) res.push({row, col: col + 1});
return res;
canvas.width = this.gridCols() * this.nodeSize;
canvas.height = this.gridRows() * this.nodeSize;
}
drawGrid(): void {
@@ -185,9 +103,9 @@ export class FourColorComponent implements AfterViewInit {
this.ctx.clearRect(0, 0, this.canvasRef.nativeElement.width, this.canvasRef.nativeElement.height);
// 1. Draw Cell Backgrounds
for (let r = 0; r < this.gridRows; r++) {
for (let c = 0; c < this.gridCols; c++) {
const node = this.grid[r][c];
for (let r = 0; r < this.gridRows(); r++) {
for (let c = 0; c < this.gridCols(); c++) {
const node = this.map.grid[r][c];
this.ctx.fillStyle = this.getNodeColor(node);
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.lineWidth = 2;
this.ctx.beginPath();
for (let r = 0; r < this.gridRows; r++) {
for (let c = 0; c < this.gridCols; c++) {
const currentRegion = this.grid[r][c].regionId;
for (let r = 0; r < this.gridRows(); r++) {
for (let c = 0; c < this.gridCols(); c++) {
const currentRegion = this.map.grid[r][c].regionId;
// 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.lineTo((c + 1) * this.nodeSize, (r + 1) * this.nodeSize);
}
// 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.lineTo((c + 1) * this.nodeSize, (r + 1) * this.nodeSize);
}
@@ -219,7 +137,7 @@ export class FourColorComponent implements AfterViewInit {
// 3. Draw Outer Border
this.ctx.strokeStyle = '#000';
this.ctx.lineWidth = 2;
this.ctx.strokeRect(0, 0, this.gridCols * this.nodeSize, this.gridRows * this.nodeSize);
this.ctx.strokeRect(0, 0, this.gridCols() * this.nodeSize, this.gridRows() * this.nodeSize);
}
private getNodeColor(node: FourColorNode): string {
@@ -260,44 +178,29 @@ export class FourColorComponent implements AfterViewInit {
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
const col = Math.floor(x / (rect.width / this.gridCols));
const row = Math.floor(y / (rect.height / this.gridRows));
const col = Math.floor(x / (rect.width / this.gridCols()));
const row = Math.floor(y / (rect.height / this.gridRows()));
if (row >= 0 && row < this.gridRows && col >= 0 && col < this.gridCols) {
if (row >= 0 && row < this.gridRows() && col >= 0 && col < this.gridCols()) {
return {row, col};
}
return null;
}
private handleInteraction(pos: GridPos): void {
const node = this.grid[pos.row][pos.col];
const node = this.map.grid[pos.row][pos.col];
if (node.regionId === -1){
return;
}
const region = this.regions[node.regionId];
region.color = (region.color % 4) + 1;
this.updateRegionColors(region);
this.checkSolution();
this.fourColorService.cycleRegionColor(this.map, node.regionId);
this.solutionStatus.set(this.fourColorService.evaluate(this.map.regions));
this.drawGrid();
}
private updateRegionColors(region: Region): void {
for (let row = 0; row < this.gridRows; row++) {
for (let col = 0; col < this.gridCols; col++) {
if (this.grid[row][col].regionId === region.id) {
this.grid[row][col].color = region.color;
}
}
}
}
resetColors(): void {
for (const region of this.regions) {
region.color = 0;
this.updateRegionColors(region);
}
this.solutionStatus = 'INCOMPLETE';
this.fourColorService.resetColors(this.map);
this.solutionStatus.set('INCOMPLETE');
this.drawGrid();
}
@@ -305,12 +208,12 @@ export class FourColorComponent implements AfterViewInit {
const startTime = performance.now();
this.resetColors();
const success = this.backtrackSolve(0);
const success = this.fourColorService.solve(this.map);
const endTime = performance.now();
this.executionTime = endTime - startTime;
if (success) {
this.checkSolution();
this.solutionStatus.set(this.fourColorService.evaluate(this.map.regions));
this.drawGrid();
} else {
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 {
this.initializeGrid();
}
@@ -16,3 +16,13 @@ export const DEFAULT_GRID_COLS = 25;
export const MIN_GRID_SIZE = 20;
export const MAX_GRID_SIZE = 38;
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 {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
import {TranslatePipe} from '@ngx-translate/core';
@@ -14,6 +14,7 @@ import {PointerEventTypes, PointerInfo, ShaderMaterial, Vector2} from '@babylonj
import {MatButton} from '@angular/material/button';
import {MatIcon} from '@angular/material/icon';
import {NgxSliderModule, Options} from '@angular-slider/ngx-slider';
import {clampZoom, initialView, iterationsForZoom, zoomAroundPoint} from './fractal-view';
@Component({
selector: 'app-fractal',
@@ -35,7 +36,6 @@ import {NgxSliderModule, Options} from '@angular-slider/ngx-slider';
NgxSliderModule
],
templateUrl: './fractal.component.html',
changeDetection: ChangeDetectionStrategy.Eager,
styleUrl: './fractal.component.scss',
})
export class FractalComponent implements OnInit {
@@ -142,18 +142,8 @@ export class FractalComponent implements OnInit {
}
onReset(): void {
this.offsetY = 0.0;
this.zoom = 0.2
({zoom: this.zoom, offsetX: this.offsetX, offsetY: this.offsetY} = initialView(this.selectedAlgorithm));
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 {
@@ -227,40 +217,24 @@ export class FractalComponent implements OnInit {
const mouseXView = mouseXPixels / rect.height;
const mouseYView = mouseYPixels / rect.height;
const mouseXWorld = mouseXView / this.zoom + this.offsetX;
const mouseYWorld = mouseYView / this.zoom + this.offsetY;
const zoomFactor = 1.1;
if (event.deltaY < 0) {
this.zoom *= zoomFactor;
} else {
this.zoom /= zoomFactor;
}
const newZoom = clampZoom(
event.deltaY < 0 ? this.zoom * zoomFactor : this.zoom / zoomFactor,
this.minZoomValue,
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);
const optimalIterations = this.getIterationsForZoom(this.zoom);
this.maxIterations = Math.min(optimalIterations, 3000);
this.offsetX = mouseXWorld - mouseXView / this.zoom;
this.offsetY = mouseYWorld - mouseYView / this.zoom;
this.maxIterations = Math.min(iterationsForZoom(this.zoom), 3000);
}
onSliderChange(newValue: number): void {
this.zoom = newValue;
this.zoom = Math.max(Math.min(this.zoom, this.maxZoomValue), this.minZoomValue);
this.maxIterations = this.getIterationsForZoom(this.zoom);
}
private getIterationsForZoom(zoom: number): number {
const baseIterations = 100;
const factor = 200;
if (zoom <= 1) {
return baseIterations;
}
return Math.floor(baseIterations + Math.log10(zoom) * factor);
this.zoom = clampZoom(newValue, this.minZoomValue, this.maxZoomValue);
this.maxIterations = iterationsForZoom(this.zoom);
}
}
@@ -1,4 +1,4 @@
import {Component, ChangeDetectionStrategy} from '@angular/core';
import {Component} from '@angular/core';
import {ArcRotateCamera, Camera, ShaderMaterial} from '@babylonjs/core';
import {MANDELBULB_FRAGMENT, MANDELBULB_VERTEX} from './fractal3d.shader';
import {Information} from '../information/information';
@@ -22,7 +22,6 @@ import {BabylonCanvas, RenderCallback, RenderConfig} from '../../../shared/compo
BabylonCanvas
],
templateUrl: './fractal3d.component.html',
changeDetection: ChangeDetectionStrategy.Eager,
styleUrl: './fractal3d.component.scss',
})
export class Fractal3dComponent {
@@ -1,8 +1,9 @@
@let info = algorithmInformation();
<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){
@for (algo of algorithmInformation.entries; track algo)
@if(info.entries && info.entries.length > 0){
@for (algo of info.entries; track algo)
{
<p class="my-2">
<strong>
@@ -18,24 +19,24 @@
}
}
@if (algorithmInformation.disclaimer !== '')
@if (info.disclaimer !== '')
{
<p class="my-2">
<strong>{{ 'ALGORITHM.NOTE' | translate}}</strong> {{ algorithmInformation.disclaimer | translate}}
<strong>{{ 'ALGORITHM.NOTE' | translate}}</strong> {{ info.disclaimer | translate}}
</p>
@if (algorithmInformation.disclaimerListEntry && algorithmInformation.disclaimerListEntry.length > 0)
@if (info.disclaimerListEntry && info.disclaimerListEntry.length > 0)
{
<ul>
@for (entry of algorithmInformation.disclaimerListEntry; track entry)
@for (entry of info.disclaimerListEntry; track entry)
{
<li>{{ entry | translate}}</li>
}
</ul>
}
@if (algorithmInformation.disclaimerBottom !== '')
@if (info.disclaimerBottom !== '')
{
<p>
{{ algorithmInformation.disclaimerBottom | translate}}
{{ info.disclaimerBottom | translate}}
</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 {AlgorithmInformation} from './information.models';
@@ -8,10 +8,9 @@ import {AlgorithmInformation} from './information.models';
TranslatePipe
],
templateUrl: './information.html',
changeDetection: ChangeDetectionStrategy.Eager,
styleUrl: './information.scss',
})
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>
</div>
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
<p>{{ 'PATHFINDING.PATH_LENGTH' | translate }}: {{ pathLength }}</p>
<p>{{ 'PATHFINDING.EXECUTION_TIME' | translate }}: {{ executionTime | number:'1.2-2' }} ms</p>
<p>{{ 'PATHFINDING.PATH_LENGTH' | translate }}: {{ pathLength() }}</p>
<p>{{ 'PATHFINDING.EXECUTION_TIME' | translate }}: {{ executionTime() | number:'1.2-2' }} ms</p>
</div>
</div>
<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 {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
import {TranslatePipe} from '@ngx-translate/core';
import {GenericGridComponent, GridPos} from '../../../../shared/components/generic-grid/generic-grid';
import {AlgorithmInformation} from '../../information/information.models';
import {UrlConstants} from '../../../../constants/UrlConstants';
import {Node} from '../pathfinding.models';
import {SharedFunctions} from '../../../../shared/SharedFunctions';
import {createPathfindingNode, Node, PathfindingAlgorithm} from '../pathfinding.models';
import {MatButton} from '@angular/material/button';
import {DecimalPipe} from '@angular/common';
import {PathfindingService} from '../service/pathfinding.service';
import {PathfindingAnimator} from '../pathfinding-animator';
import {MazeGeneratorService} from './service/maze-generator.service';
@Component({
selector: 'app-labyrinth',
@@ -25,7 +26,6 @@ import {PathfindingService} from '../service/pathfinding.service';
DecimalPipe
],
templateUrl: './labyrinth.component.html',
changeDetection: ChangeDetectionStrategy.Eager,
styleUrl: './labyrinth.component.scss',
})
export class LabyrinthComponent implements AfterViewInit {
@@ -36,7 +36,7 @@ export class LabyrinthComponent implements AfterViewInit {
protected readonly MAX_GRID_PX = 1000;
protected readonly MIN_GRID_SIZE = 101;
private readonly pathfindingService = inject(PathfindingService);
private readonly mazeGenerator = inject(MazeGeneratorService);
algoInformation: AlgorithmInformation = {
title: 'LABYRINTH.EXPLANATION.TITLE',
@@ -64,10 +64,13 @@ export class LabyrinthComponent implements AfterViewInit {
endNode: Node | null = null;
animationSpeed = 3;
mazeAnimationSpeed = 1;
pathLength = "0";
executionTime = 0;
readonly pathLength = signal("0");
readonly executionTime = signal(0);
private timeoutIds: number[] = [];
private readonly animator = new PathfindingAnimator(
node => this.genericGridComponent?.drawNode(node),
() => this.animationSpeed
);
protected mazeNodesInOrder: Node[] = [];
readonly isAnimationRunning = signal(false);
@@ -94,247 +97,31 @@ export class LabyrinthComponent implements AfterViewInit {
createRandom(prim: boolean): void {
this.isAnimationRunning.set(true);
this.stopAnimations();
this.animator.stop();
this.clearPath();
this.startNode = null;
this.endNode = null;
if (prim)
{
this.createPrimMaze();
}
else{
this.createKruskalMaze();
}
this.cleanupGrid();
const result = prim ? this.mazeGenerator.generatePrim(this.grid) : this.mazeGenerator.generateKruskal(this.grid);
this.startNode = result.startNode;
this.endNode = result.endNode;
this.mazeNodesInOrder = result.nodesToCarveInOrder;
this.genericGridComponent.drawGrid();
}
// ------- Kuskal -------
private createKruskalMaze(): void {
this.initKuskal();
this.mazeNodesInOrder = [];
const walls = this.findWallsWithADistanceOfTwoRooms();
SharedFunctions.shuffleArray(walls);
for (const wallInfo of walls) {
const { row, col, roomA, roomB } = wallInfo;
if (roomA.nodeData !== roomB.nodeData) {
const wallNode = this.grid[row][col];
wallNode.isWall = true;
this.mazeNodesInOrder.push(wallNode);
const oldId = roomB.nodeData;
const newId = roomA.nodeData;
this.mergeSets(oldId, newId);
}
}
this.setRandomStartAndEnd();
this.animateMazeGeneration();
}
private initKuskal() {
let roomId = 0;
for (let row = 0; row < this.gridRows; row++) {
for (let col = 0; col < this.gridCols; col++) {
const node = this.grid[row][col];
node.isStart = false;
node.isEnd = false;
if (row % 2 === 0 && col % 2 === 0) {
node.isWall = false;
node.nodeData = roomId++;
} else {
node.isWall = true;
}
}
}
}
private mergeSets(oldId: number, newId: number): void {
for (let r = 0; r < this.gridRows; r += 2) {
for (let c = 0; c < this.gridCols; c += 2) {
if (this.grid[r][c].nodeData === oldId) {
this.grid[r][c].nodeData = newId;
}
}
}
}
private findWallsWithADistanceOfTwoRooms() {
const walls: { row: number, col: number, roomA: Node, roomB: Node }[] = [];
for (let row = 0; row < this.gridRows; row++) {
for (let col = 0; col < this.gridCols; col++) {
if (row % 2 === 0 && col % 2 !== 0 && col > 0 && col < this.gridCols - 1) {
walls.push({
row, col,
roomA: this.grid[row][col - 1],
roomB: this.grid[row][col + 1]
});
}
if (row % 2 !== 0 && col % 2 === 0 && row > 0 && row < this.gridRows - 1) {
walls.push({
row, col,
roomA: this.grid[row - 1][col],
roomB: this.grid[row + 1][col]
});
}
}
}
return walls;
}
private setRandomStartAndEnd(): void {
const lastRow = Math.floor((this.gridRows - 1) / 2) * 2;
const lastCol = Math.floor((this.gridCols - 1) / 2) * 2;
const corners = [
{ r: 0, c: 0 },
{ r: 0, c: lastCol },
{ r: lastRow, c: 0 },
{ r: lastRow, c: lastCol }
];
const startIndex = Math.floor(Math.random() * corners.length);
let endIndex = Math.floor(Math.random() * corners.length);
while (endIndex === startIndex) {
endIndex = Math.floor(Math.random() * corners.length);
}
const start = corners[startIndex];
const end = corners[endIndex];
this.startNode = this.grid[start.r][start.c];
this.startNode.isStart = true;
this.startNode.isWall = false;
this.endNode =this.grid[end.r][end.c];
this.endNode.isEnd = true;
this.endNode.isWall = false;
}
// ------- PRIM -------
private createPrimMaze(): void {
this.initPrim();
this.mazeNodesInOrder = [];
const frontier: Node[] = [];
const {startRow, startCol, startNode} = this.findStartNode();
this.mazeNodesInOrder.push(startNode);
this.getNeighborWalls(startRow, startCol, frontier);
while (frontier.length > 0) {
const randomIndex = SharedFunctions.randomIntFromInterval(0, frontier.length - 1);
const lastIndex = frontier.length - 1;
[frontier[randomIndex], frontier[lastIndex]] = [frontier[lastIndex], frontier[randomIndex]];
const wallNode = frontier.pop()!;
const target = wallNode.linkedNode;
if (!target || target.isVisited) {
continue;
}
wallNode.isVisited = true;
target.isVisited = true;
this.mazeNodesInOrder.push(wallNode, target);
this.getNeighborWalls(target.row, target.col, frontier);
}
this.setRandomStartAndEnd();
this.animateMazeGeneration();
}
private initPrim() {
for (let row = 0; row < this.grid.length; row++) {
for (let col = 0; col < this.grid[row].length; col++) {
this.grid[row][col].isWall = true;
this.grid[row][col].isStart = false;
this.grid[row][col].isEnd = false;
}
}
}
private cleanupGrid() {
for (let row = 0; row < this.grid.length; row++) {
for (let col = 0; col < this.grid[row].length; col++) {
this.grid[row][col].isVisited = false;
this.grid[row][col].linkedNode = null;
}
}
}
private findStartNode() {
const startRow: number = SharedFunctions.randomEventIntFromInterval(this.gridRows - 1);
const startCol: number = SharedFunctions.randomEventIntFromInterval(this.gridCols - 1);
const startNode = this.grid[startRow][startCol];
startNode.isWall = false;
startNode.isVisited = true;
return {startRow, startCol, startNode};
}
visualize(algorithm: string): void {
this.stopAnimations();
visualize(algorithm: PathfindingAlgorithm): void {
this.animator.stop();
this.clearPath();
const startTime = performance.now();
let result;
switch (algorithm) {
case 'dijkstra': result = this.pathfindingService.dijkstra(
this.grid,
this.grid[this.startNode!.row][this.startNode!.col],
this.grid[this.endNode!.row][this.endNode!.col]
);
break;
case 'astar': result = this.pathfindingService.aStar(
this.grid,
this.grid[this.startNode!.row][this.startNode!.col],
this.grid[this.endNode!.row][this.endNode!.col]
);
break;
}
if (!result)
{
return;
}
const result = this.pathfindingService.findPath(algorithm, this.grid, this.startNode!, this.endNode!);
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 => {
return {
row,
col,
isStart: false,
isEnd: false,
isWall: false,
isVisited: false,
isPath: false,
nodeData: Infinity,
linkedNode: null,
hScore: 0,
fScore: Infinity,
};
};
readonly createMazeNode = createPathfindingNode;
getMazeColor = (node: Node): string => {
if (node.isStart) return 'green';
@@ -350,110 +137,27 @@ export class LabyrinthComponent implements AfterViewInit {
//dont need a selection for the maze case
}
// --- Animation (adapted to use genericGridComponent for redraw) ---
private stopAnimations(): void {
for (const id of this.timeoutIds) {
clearTimeout(id);
}
this.timeoutIds = [];
}
private clearPath(): void {
for (let row = 0; row < this.gridRows; row++) {
for (let col = 0; col < this.gridCols; col++) {
const node = this.grid[row][col];
node.isVisited = false;
node.isPath = false;
node.nodeData = Infinity;
node.linkedNode = null;
}
}
this.pathfindingService.resetSearchState(this.grid);
this.genericGridComponent?.drawGrid(); // Redraw the grid via generic grid component
}
private animateAlgorithm(visited: Node[], path: Node[]): void {
for (let i = 0; i <= visited.length; i++) {
if (i === visited.length) {
const id = globalThis.setTimeout(() => this.animateShortestPath(path), this.animationSpeed * i);
this.timeoutIds.push(id);
return;
}
const node = visited[i];
const id = globalThis.setTimeout(() => {
if (!node.isStart && !node.isEnd) {
node.isVisited = true;
this.genericGridComponent?.drawNode(node);
}
}, this.animationSpeed * i);
this.timeoutIds.push(id);
}
}
private animateShortestPath(path: Node[]): void {
for (let i = 0; i < path.length; i++) {
const node = path[i];
const id = globalThis.setTimeout(() => {
if (!node.isStart && !node.isEnd) {
node.isPath = true;
this.genericGridComponent?.drawNode(node); // Redraw single node
}
}, this.animationSpeed * i);
this.timeoutIds.push(id);
}
}
private animateMazeGeneration(): void {
for (let i = 0; i < this.mazeNodesInOrder.length; i++) {
const id = globalThis.setTimeout(() => {
const node = this.mazeNodesInOrder[i];
const last = this.mazeNodesInOrder.length - 1;
this.mazeNodesInOrder.forEach((node, i) => {
this.animator.schedule(this.mazeAnimationSpeed * i, () => {
node.isWall = false;
this.genericGridComponent?.drawNode(node);
if (i === this.mazeNodesInOrder.length - 1) {
this.cleanupGrid();
if (i === last) {
if (this.startNode) {
this.genericGridComponent?.drawNode(this.startNode);
}
if (this.endNode) {
this.genericGridComponent?.drawNode(this.endNode);
}
}
if (i == this.mazeNodesInOrder.length - 1) {
this.isAnimationRunning.set(false);
}
}, this.mazeAnimationSpeed * i);
this.timeoutIds.push(id);
}
});
});
}
//utility
private getNeighborWalls(row: number, col: number, frontier: Node[]): void{
const directions = [
[0, 2], [0, -2], [2, 0], [-2, 0]
];
for (const [dr, dc] of directions) {
const nextRow = row + dr;
const nextCol = col + dc;
if (this.isValid(nextRow, nextCol) && this.grid[nextRow][nextCol].isWall && !this.grid[nextRow][nextCol].isVisited) {
const wallRow = row + dr / 2;
const wallCol = col + dc / 2;
const node = this.grid[wallRow][wallCol];
node.linkedNode = this.grid[nextRow][nextCol];
frontier.push(node);
}
}
}
isValid = (row: number, col: number): boolean => {
return row >= 0 && row < this.gridRows && col >= 0 && col < this.gridCols;
};
}
@@ -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"
[max]="MAX_GRID_SIZE"
[(ngModel)]="gridRows"
(ngModelChange)="genericGridComponent.gridRows = gridRows; genericGridComponent.applyGridSize()"
(ngModelChange)="applyGridRows($event)"
/> </mat-form-field>
<mat-form-field appearance="outline" class="w-[150px]">
@@ -46,7 +46,7 @@
[min]="MIN_GRID_SIZE"
[max]="MAX_GRID_SIZE"
[(ngModel)]="gridCols"
(ngModelChange)="genericGridComponent.gridCols = gridCols; genericGridComponent.applyGridSize()"
(ngModelChange)="applyGridCols($event)"
/> </mat-form-field>
</div>
</div>
@@ -59,14 +59,14 @@
<span><span class="legend-swatch bg-[gold]"></span> {{ 'PATHFINDING.PATH' | translate }}</span>
</div>
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
<p>{{ 'PATHFINDING.PATH_LENGTH' | translate }}: {{ pathLength }}</p>
<p>{{ 'PATHFINDING.EXECUTION_TIME' | translate }}: {{ executionTime | number:'1.2-2' }} ms</p>
<p>{{ 'PATHFINDING.PATH_LENGTH' | translate }}: {{ pathLength() }}</p>
<p>{{ 'PATHFINDING.EXECUTION_TIME' | translate }}: {{ executionTime() | number:'1.2-2' }} ms</p>
</div>
</div>
<app-generic-grid
[gridRows]="gridRows"
[gridCols]="gridCols"
[gridRows]="gridRows()"
[gridCols]="gridCols()"
[minGridSize]="MIN_GRID_SIZE"
[maxGridSize]="MAX_GRID_SIZE"
[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 {FormsModule} from '@angular/forms';
@@ -10,14 +10,15 @@ import {MatSnackBar} from '@angular/material/snack-bar';
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 {PathfindingGridService} from './service/pathfinding-grid.service';
import {PathfindingAnimator} from './pathfinding-animator';
import {UrlConstants} from '../../../constants/UrlConstants';
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
import {Information} from '../information/information';
import {AlgorithmInformation} from '../information/information.models';
import {GenericGridComponent, GridPos} from '../../../shared/components/generic-grid/generic-grid';
import {SharedFunctions} from '../../../shared/SharedFunctions';
enum NodeType {
Start = 'start',
@@ -43,11 +44,11 @@ enum NodeType {
Information,
GenericGridComponent
],
changeDetection: ChangeDetectionStrategy.Eager,
templateUrl: './pathfinding.component.html',
})
export class PathfindingComponent implements AfterViewInit {
private readonly pathfindingService = inject(PathfindingService);
private readonly gridService = inject(PathfindingGridService);
private readonly translate = inject(TranslateService);
private readonly snackBar = inject(MatSnackBar);
@@ -75,21 +76,24 @@ export class PathfindingComponent implements AfterViewInit {
disclaimerListEntry: []
};
gridRows = DEFAULT_GRID_ROWS;
gridCols = DEFAULT_GRID_COLS;
readonly gridRows = signal(DEFAULT_GRID_ROWS);
readonly gridCols = signal(DEFAULT_GRID_COLS);
grid: Node[][] = [];
startNode: Node | null = null;
endNode: Node | null = null;
selectedNodeType: NodeType = NodeType.None;
readonly selectedNodeType = signal<NodeType>(NodeType.None);
private shouldAddWall = true; // Moved here
animationSpeed = 3;
pathLength = "0";
executionTime = 0;
readonly pathLength = signal("0");
readonly executionTime = signal(0);
private timeoutIds: number[] = [];
private readonly animator = new PathfindingAnimator(
node => this.genericGridComponent?.drawNode(node),
() => this.animationSpeed
);
@ViewChild(GenericGridComponent) genericGridComponent!: GenericGridComponent;
@@ -101,8 +105,8 @@ export class PathfindingComponent implements AfterViewInit {
this.genericGridComponent.createNodeFn = this.createPathfindingNode;
this.genericGridComponent.getNodeColorFn = this.getPathfindingNodeColor;
this.genericGridComponent.applySelectionFn = this.applyPathfindingSelection;
this.genericGridComponent.gridRows = this.gridRows;
this.genericGridComponent.gridCols = this.gridCols;
this.genericGridComponent.gridRows = this.gridRows();
this.genericGridComponent.gridCols = this.gridCols();
this.genericGridComponent.minGridSize = this.MIN_GRID_SIZE;
this.genericGridComponent.maxGridSize = this.MAX_GRID_SIZE;
this.genericGridComponent.maxGridPx = MAX_GRID_PX;
@@ -111,22 +115,18 @@ export class PathfindingComponent implements AfterViewInit {
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 ---
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,
};
};
readonly createPathfindingNode = createPathfindingNode;
getPathfindingNodeColor = (node: Node): string => {
if (node.isStart) return 'green';
@@ -142,343 +142,86 @@ export class PathfindingComponent implements AfterViewInit {
const node = grid[pos.row][pos.col];
// 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;
}
switch (this.selectedNodeType) {
switch (this.selectedNodeType()) {
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;
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;
case NodeType.Wall:
this.tryToggleWall(node, this.shouldAddWall);
this.gridService.setWall(node, this.shouldAddWall);
break;
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;
}
};
visualize(algorithm: string): void {
visualize(algorithm: PathfindingAlgorithm): void {
if (!this.ensureStartAndEnd()) {
return;
}
this.stopAnimations();
this.animator.stop();
this.clearPath();
const startTime = performance.now();
let result;
switch (algorithm) {
case 'dijkstra': result = this.pathfindingService.dijkstra(
this.grid,
this.grid[this.startNode!.row][this.startNode!.col],
this.grid[this.endNode!.row][this.endNode!.col]
);
break;
case 'astar': result = this.pathfindingService.aStar(
this.grid,
this.grid[this.startNode!.row][this.startNode!.col],
this.grid[this.endNode!.row][this.endNode!.col]
);
break;
}
if (!result)
{
return;
}
const result = this.pathfindingService.findPath(algorithm, this.grid, this.startNode!, this.endNode!);
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 => {
this.grid = grid; // Update the component's grid reference
const {start, end} = this.getScenarioStartEnd('normal'); // Default scenario
this.startNode = this.grid[start.row][start.col];
this.endNode = this.grid[end.row][end.col];
this.startNode.isStart = true;
this.endNode.isEnd = true;
this.placeDefaultDiagonalWall('normal');
this.setupGrid(grid, 'normal', true);
};
// --- Helper methods for node manipulation (kept local) ---
private trySetStart(node: Node): void {
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();
createCase({withWalls, scenario}: { withWalls: boolean, scenario: PathfindingScenario }): void {
this.animator.stop();
// Reinitialize grid through the generic component
this.genericGridComponent.initializationFn = (grid) => {
this.grid = grid;
const {start, end} = this.getScenarioStartEnd(scenario);
this.startNode = this.grid[start.row][start.col];
this.endNode = this.grid[end.row][end.col];
this.startNode.isStart = true;
this.endNode.isEnd = true;
if (withWalls) {
this.placeDefaultDiagonalWall(scenario);
}
};
this.genericGridComponent.initializationFn = (grid) => this.setupGrid(grid, scenario, withWalls);
this.genericGridComponent.initializeGrid(); // Trigger re-initialization and redraw
}
private getScenarioStartEnd(scenario: 'normal' | 'edge' | 'random'): { start: GridPos; end: GridPos } {
if (scenario === 'edge') {
return {
start: {row: 0, col: 0},
end: {row: this.gridRows - 1, col: this.gridCols - 1}
};
} else if (scenario === 'random') {
return this.createRandomStartEndPosition();
} else {
// normal: mid-left -> mid-right
const midRow = Math.floor(this.gridRows / 2);
return {
start: {row: midRow, col: 0},
end: {row: midRow, col: this.gridCols - 1}
};
}
}
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 setupGrid(grid: Node[][], scenario: PathfindingScenario, withWalls: boolean): void {
this.grid = grid;
const {startNode, endNode} = this.gridService.setupScenario(grid, scenario, withWalls);
this.startNode = startNode;
this.endNode = endNode;
}
private clearPath(): void {
for (let row = 0; row < this.gridRows; row++) {
for (let col = 0; col < this.gridCols; col++) {
const node = this.grid[row][col];
node.isVisited = false;
node.isPath = false;
node.nodeData = Infinity;
node.linkedNode = null;
}
}
this.pathfindingService.resetSearchState(this.grid);
this.genericGridComponent?.drawGrid(); // Redraw the grid via generic grid component
}
private animateAlgorithm(visited: Node[], path: Node[]): void {
for (let i = 0; i <= visited.length; i++) {
if (i === visited.length) {
const id = globalThis.setTimeout(() => this.animateShortestPath(path), this.animationSpeed * i);
this.timeoutIds.push(id);
return;
}
const node = visited[i];
const id = globalThis.setTimeout(() => {
if (!node.isStart && !node.isEnd) {
node.isVisited = true;
this.genericGridComponent?.drawNode(node); // Redraw single node
}
}, this.animationSpeed * i);
this.timeoutIds.push(id);
}
}
private animateShortestPath(path: Node[]): void {
for (let i = 0; i < path.length; i++) {
const node = path[i];
const id = globalThis.setTimeout(() => {
if (!node.isStart && !node.isEnd) {
node.isPath = true;
this.genericGridComponent?.drawNode(node); // Redraw single node
}
}, this.animationSpeed * i);
this.timeoutIds.push(id);
}
}
// --- Validation ---
private ensureStartAndEnd(): boolean {
if (this.startNode && this.endNode) {
@@ -12,6 +12,30 @@ export interface Node {
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_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);
});
});
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 { Node} from '../pathfinding.models';
import { Node, PathfindingAlgorithm, PathfindingResult } from '../pathfinding.models';
@Injectable({
providedIn: 'root'
@@ -30,8 +30,25 @@ export class PathfindingService {
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(grid: Node[][], startNode: Node, endNode: Node): { visitedNodesInOrder: Node[], nodesInShortestPathOrder: Node[] } {
dijkstra(grid: Node[][], startNode: Node, endNode: Node): PathfindingResult {
const visitedNodesInOrder: Node[] = [];
startNode.nodeData = 0;
const unvisitedNodes: Node[] = this.getAllNodes(grid);
@@ -77,7 +94,7 @@ export class PathfindingService {
}
// 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[] = [];
startNode.nodeData = 0;
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="flex items-center gap-4 mr-4">
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.TRAIL_DECAY_TIME' | translate }}</p>
<ngx-slider class="grow" [(value)]="simParams.trailDecay" [options]="trailDecayOptions" ></ngx-slider>
<ngx-slider class="grow" [(value)]="trailDecay" [options]="trailDecayOptions" ></ngx-slider>
</div>
<div class="flex items-center gap-4 mr-4">
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.ATTRACTION' | translate }}</p>
<ngx-slider class="grow" [(value)]="simParams.g" [options]="gravityOptions" ></ngx-slider>
<ngx-slider class="grow" [(value)]="g" [options]="gravityOptions" ></ngx-slider>
</div>
<div class="flex items-center gap-4 mr-4">
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.L1_LENGTH' | translate }}</p>
<ngx-slider class="grow" [(value)]="simParams.l1" [options]="lengthOptions" ></ngx-slider>
<ngx-slider class="grow" [(value)]="l1" [options]="lengthOptions" ></ngx-slider>
</div>
<div class="flex items-center gap-4 mr-4">
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.L2_LENGTH' | translate }}</p>
<ngx-slider class="grow" [(value)]="simParams.l2" [options]="lengthOptions" ></ngx-slider>
<ngx-slider class="grow" [(value)]="l2" [options]="lengthOptions" ></ngx-slider>
</div>
<div class="flex items-center gap-4 mr-4">
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.M1_MASS' | translate }}</p>
<ngx-slider class="grow" [(value)]="simParams.m1" [options]="massOptions" ></ngx-slider>
<ngx-slider class="grow" [(value)]="m1" [options]="massOptions" ></ngx-slider>
</div>
<div class="flex items-center gap-4 mr-4">
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.M2_MASS' | translate }}</p>
<ngx-slider class="grow" [(value)]="simParams.m2" [options]="massOptions" ></ngx-slider>
<ngx-slider class="grow" [(value)]="m2" [options]="massOptions" ></ngx-slider>
</div>
<div class="flex items-center gap-4 mr-4 col-span-full">
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.DAMPING' | translate }}</p>
<ngx-slider class="grow" [(value)]="simParams.damping" [options]="dampingOptions" ></ngx-slider>
<ngx-slider class="grow" [(value)]="damping" [options]="dampingOptions" ></ngx-slider>
</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 {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
@@ -29,7 +29,6 @@ import {PendulumCpuStrategy} from './strategies/pendulum-cpu.strategy';
Information,
],
templateUrl: './pendulum.component.html',
changeDetection: ChangeDetectionStrategy.Eager,
styleUrl: './pendulum.component.scss',
})
class PendulumComponent {
@@ -103,16 +102,17 @@ class PendulumComponent {
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,
dt: 0.015,
g: DEFAULT_G,
m1: DEFAULT_M1_MASS,
m2: DEFAULT_M2_MASS,
l1: DEFAULT_L1_LENGTH,
l2: DEFAULT_L2_LENGTH,
damping: DEFAULT_DAMPING,
trailDecay: DEFAULT_TRAIL_DECAY,
impulseM1: 0.0,
impulseM2: 0.0,
};
@@ -147,10 +147,19 @@ class PendulumComponent {
private startParamUpdateLoop(scene: any): void {
scene.onAfterRenderObservable.clear();
scene.onAfterRenderObservable.add(() => {
this.simParams.time += this.simParams.dt;
this.loop.time += this.loop.dt;
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();
@@ -158,22 +167,22 @@ class PendulumComponent {
}
private resetImpulses() {
if (this.simParams.impulseM1 !== 0.0) {
this.simParams.impulseM1 = 0;
if (this.loop.impulseM1 !== 0.0) {
this.loop.impulseM1 = 0;
}
if (this.simParams.impulseM2 !== 0.0) {
this.simParams.impulseM2 = 0;
if (this.loop.impulseM2 !== 0.0) {
this.loop.impulseM2 = 0;
}
}
pushPendulum(m1: boolean) {
if (m1) {
this.simParams.impulseM1 = IMPULSE_M1;
this.loop.impulseM1 = IMPULSE_M1;
return;
}
this.simParams.impulseM2 = IMPULSE_M2;
this.loop.impulseM2 = IMPULSE_M2;
}
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);
expect(result).toEqual(expectedSorted);
expect(isAscending(result)).toBeTrue();
expect(isAscending(result)).toBe(true);
});
it(`${algorithm} should not mutate the input array`, () => {
@@ -38,19 +38,20 @@
<mat-icon>add_box</mat-icon> {{ 'SORTING.GENERATE_NEW_ARRAY' | translate }}
</button>
<button mat-raised-button (click)="toggleSound()">
<mat-icon>{{ isSoundEnabled ? 'volume_up' : 'volume_off' }}</mat-icon>
{{ isSoundEnabled ? ('SORTING.SOUND_OFF' | translate) : ('SORTING.SOUND_ON' | translate) }}
<mat-icon>{{ isSoundEnabled() ? 'volume_up' : 'volume_off' }}</mat-icon>
{{ isSoundEnabled() ? ('SORTING.SOUND_OFF' | translate) : ('SORTING.SOUND_ON' | translate) }}
</button>
</div>
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
<p>{{ 'SORTING.EXECUTION_TIME' | translate }}: {{ executionTime }} ms</p>
<p>{{ 'SORTING.EXECUTION_TIME' | translate }}: {{ executionTime() }} ms</p>
</div>
<div class="flex items-end h-[clamp(200px,40vh,400px)] border-b border-app-fg mb-[clamp(10px,3vw,20px)] gap-px bg-card-bg">
@for (item of sortArray; track $index) {
@for (item of sortArray(); track $index) {
<div
class="grow w-2.5 min-w-px transition-all duration-50"
[style.height.px]="item.value * 3"
data-testid="sort-bar"
[ngClass]="{
'bg-[#424242]': item.state === 'unsorted',
'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 {MatCardModule} from "@angular/material/card";
import {MatFormFieldModule} from "@angular/material/form-field";
@@ -18,14 +18,12 @@ import {Information} from '../information/information';
selector: 'app-sorting',
imports: [CommonModule, MatCardModule, MatFormFieldModule, MatSelectModule, MatButtonModule, MatIconModule, TranslatePipe, FormsModule, MatInput, Information],
templateUrl: './sorting.component.html',
changeDetection: ChangeDetectionStrategy.Eager,
styleUrl: './sorting.component.scss'
})
export class SortingComponent implements OnInit {
private readonly sortingService: SortingService = inject(SortingService);
private readonly audioService: SortingAudioService = inject(SortingAudioService);
private readonly cdr: ChangeDetectorRef = inject(ChangeDetectorRef);
readonly MAX_ARRAY_SIZE: number = 200;
readonly MIN_ARRAY_SIZE: number = 20;
@@ -69,14 +67,14 @@ export class SortingComponent implements OnInit {
};
private timeoutIds: number[] = [];
sortArray: SortData[] = [];
readonly sortArray = signal<SortData[]>([]);
unsortedArrayCopy: SortData[] = [];
arraySize = 50;
readonly arraySize = signal(50);
maxArrayValue = 100;
animationSpeed = 100; // Milliseconds per step
selectedAlgorithm: string = this.algoInformation.entries[0].name;
executionTime = 0;
isSoundEnabled = false;
readonly selectedAlgorithm = signal(this.algoInformation.entries[0].name);
readonly executionTime = signal(0);
readonly isSoundEnabled = signal(false);
ngOnInit(): void {
this.generateNewArray();
@@ -84,8 +82,9 @@ export class SortingComponent implements OnInit {
newArraySizeSet()
{
this.arraySize = Math.min(Math.max(this.arraySize, this.MIN_ARRAY_SIZE), this.MAX_ARRAY_SIZE);
if (this.arraySize == this.sortArray.length)
const clamped = Math.min(Math.max(this.arraySize(), this.MIN_ARRAY_SIZE), this.MAX_ARRAY_SIZE);
this.arraySize.set(clamped);
if (clamped == this.sortArray().length)
{
return;
}
@@ -94,35 +93,27 @@ export class SortingComponent implements OnInit {
generateNewArray(): void {
this.resetSorting();
this.executionTime = 0;
this.unsortedArrayCopy = [];
this.sortArray = [];
this.executionTime.set(0);
const unsorted: SortData[] = [];
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;
this.sortArray.push({
value: randomValue,
state: 'unsorted'
});
this.unsortedArrayCopy.push({
unsorted.push({
value: randomValue,
state: 'unsorted'
});
}
this.unsortedArrayCopy = unsorted;
this.resetSortState();
}
private resetSortState() {
for (let i = 0; i < this.sortArray.length; i++) {
const element = this.sortArray[i];
const unsortedElement = this.unsortedArrayCopy[i];
element.value = unsortedElement.value;
element.state = 'unsorted';
}
this.sortArray.set(this.unsortedArrayCopy.map(item => ({...item})));
}
toggleSound(): void {
this.isSoundEnabled = !this.isSoundEnabled;
this.isSoundEnabled.update(v => !v);
}
async startSorting(): Promise<void> {
@@ -132,26 +123,26 @@ export class SortingComponent implements OnInit {
const startTime = performance.now();
let snapshots: SortSnapshot[] = [];
switch (this.selectedAlgorithm) {
switch (this.selectedAlgorithm()) {
case 'Bubble Sort':
snapshots = this.sortingService.bubbleSort(this.sortArray);
snapshots = this.sortingService.bubbleSort(this.sortArray());
break;
case 'Quick Sort':
snapshots = this.sortingService.quickSort(this.sortArray);
snapshots = this.sortingService.quickSort(this.sortArray());
break;
case 'Heap Sort':
snapshots = this.sortingService.heapSort(this.sortArray);
snapshots = this.sortingService.heapSort(this.sortArray());
break;
case 'Cocktail Sort':
snapshots = this.sortingService.cocktailSort(this.sortArray);
snapshots = this.sortingService.cocktailSort(this.sortArray());
break;
case 'Tim Sort':
snapshots = this.sortingService.timSort(this.sortArray);
snapshots = this.sortingService.timSort(this.sortArray());
break;
}
const endTime = performance.now();
this.executionTime = Number.parseFloat((endTime - startTime).toFixed(4));
this.executionTime.set(Number.parseFloat((endTime - startTime).toFixed(4)));
this.animateSorting(snapshots);
}
@@ -159,14 +150,9 @@ export class SortingComponent implements OnInit {
private animateSorting(snapshots: SortSnapshot[]): void {
snapshots.forEach((snapshot, index) => {
const id = setTimeout(() => {
for (let i = 0; i < this.sortArray.length; i++) {
if (snapshot.array[i]) {
this.sortArray[i].value = snapshot.array[i].value;
this.sortArray[i].state = snapshot.array[i].state;
}
}
this.sortArray.set(snapshot.array);
if (this.isSoundEnabled) {
if (this.isSoundEnabled()) {
// Play a tone for each comparing bar (max 2 at once to avoid noise)
snapshot.array
.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));
}
this.cdr.detectChanges();
if (index === snapshots.length - 1) {
this.sortArray.forEach(item => item.state = 'sorted');
this.cdr.detectChanges();
if (this.isSoundEnabled) {
this.audioService.playSortedSweep(this.sortArray.map(item => item.value), this.maxArrayValue);
this.sortArray.set(snapshot.array.map(item => ({...item, state: 'sorted'})));
if (this.isSoundEnabled()) {
this.audioService.playSortedSweep(this.sortArray().map(item => item.value), this.maxArrayValue);
}
}
}, index * this.animationSpeed);
+1 -2
View File
@@ -1,4 +1,4 @@
import { Component, ChangeDetectionStrategy } from '@angular/core';
import { Component } from '@angular/core';
import {MatCard} from '@angular/material/card';
import {TranslatePipe} from '@ngx-translate/core';
import {SharedFunctions} from '../../shared/SharedFunctions';
@@ -10,7 +10,6 @@ import {SharedFunctions} from '../../shared/SharedFunctions';
TranslatePipe
],
templateUrl: './imprint.component.html',
changeDetection: ChangeDetectionStrategy.Eager,
styleUrl: './imprint.component.scss',
})
export class ImprintComponent {
@@ -1,4 +1,4 @@
import { Component, ChangeDetectionStrategy } from '@angular/core';
import { Component } from '@angular/core';
import { RouterLink } from '@angular/router';
import { MatCard } from '@angular/material/card';
import { MatButton } from '@angular/material/button';
@@ -8,7 +8,6 @@ import { RouterConstants } from '../../constants/RouterConstants';
@Component({
selector: 'app-not-found',
imports: [RouterLink, MatCard, MatButton, TranslatePipe],
changeDetection: ChangeDetectionStrategy.Eager,
templateUrl: './not-found.component.html',
})
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 {
MAT_DIALOG_DATA,
MatDialogActions,
@@ -11,6 +11,9 @@ import {TranslatePipe} from '@ngx-translate/core';
import {MatIcon} from '@angular/material/icon';
import {MatChip, MatChipSet} from '@angular/material/chips';
import {MatButton} from '@angular/material/button';
import {register} from 'swiper/element/bundle';
register();
@Component({
selector: 'app-project-dialog',
@@ -27,7 +30,6 @@ import {MatButton} from '@angular/material/button';
MatButton,
MatDialogClose
],
changeDetection: ChangeDetectionStrategy.Eager,
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class ProjectDialogComponent implements AfterViewInit {
+1 -2
View File
@@ -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 { Subscription } from "rxjs";
import { MatCardModule } from "@angular/material/card";
@@ -43,7 +43,6 @@ export interface Projects {
],
templateUrl: './projects.component.html',
styleUrl: './projects.component.scss',
changeDetection: ChangeDetectionStrategy.Eager,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
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 {MatButtonModule} from '@angular/material/button';
import {MatCardModule} from '@angular/material/card';
@@ -30,7 +30,6 @@ const SECOND_INTERVAL_FREQUENCY_HZ = 587;
MatCheckboxModule,
TranslatePipe
],
changeDetection: ChangeDetectionStrategy.Eager,
templateUrl: './stopwatch.component.html'
})
export class StopwatchComponent implements OnDestroy {
@@ -13,6 +13,7 @@ describe('GpuCapabilityService', () => {
});
afterEach(() => {
vi.restoreAllMocks();
setGpu(originalGpu);
});
@@ -28,7 +29,7 @@ describe('GpuCapabilityService', () => {
it('falls back to "webgl" when WebGPU is missing but a WebGL2 context exists', async () => {
setGpu(undefined);
const fakeCanvas = { getContext: () => ({}) } as unknown as HTMLCanvasElement;
spyOn(document, 'createElement').and.returnValue(fakeCanvas);
vi.spyOn(document, 'createElement').mockReturnValue(fakeCanvas);
const tier = await service.detect();
@@ -38,7 +39,7 @@ describe('GpuCapabilityService', () => {
it('reports "none" when neither WebGPU nor WebGL is available', async () => {
setGpu(undefined);
const fakeCanvas = { getContext: () => null } as unknown as HTMLCanvasElement;
spyOn(document, 'createElement').and.returnValue(fakeCanvas);
vi.spyOn(document, 'createElement').mockReturnValue(fakeCanvas);
const tier = await service.detect();
+3 -2
View File
@@ -1,13 +1,14 @@
import { TestBed } from '@angular/core/testing';
import { TranslateService } from '@ngx-translate/core';
import type { Mock } from 'vitest';
import { LanguageService } from './language.service';
import { LocalStoreConstants } from '../constants/LocalStoreConstants';
describe('LanguageService', () => {
let translateSpy: jasmine.SpyObj<TranslateService>;
let translateSpy: { use: Mock };
function createService(): LanguageService {
translateSpy = jasmine.createSpyObj<TranslateService>('TranslateService', ['use']);
translateSpy = { use: vi.fn() };
TestBed.configureTestingModule({
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';
export interface GridPos { row: number; col: number }
@@ -7,7 +7,6 @@ export interface GridPos { row: number; col: number }
selector: 'app-generic-grid',
imports: [CommonModule],
templateUrl: './generic-grid.html',
changeDetection: ChangeDetectionStrategy.Eager,
styleUrl: './generic-grid.scss',
})
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';
@Component({
selector: 'app-particles-background',
imports: [],
templateUrl: './particles-background.component.html',
changeDetection: ChangeDetectionStrategy.Eager,
styleUrl: './particles-background.component.scss',
})
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 {TranslateService} from '@ngx-translate/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',
imports: [],
templateUrl: './babylon-canvas.component.html',
changeDetection: ChangeDetectionStrategy.Eager,
styleUrl: './babylon-canvas.component.scss',
})
export class BabylonCanvas implements AfterViewInit, OnDestroy {
@@ -37,8 +36,8 @@ export class BabylonCanvas implements AfterViewInit, OnDestroy {
@ViewChild('renderCanvas', { static: true }) canvasRef!: ElementRef<HTMLCanvasElement>;
@Input({ required: true }) config!: RenderConfig;
@Input() renderCallback?: RenderCallback;
readonly config = input.required<RenderConfig>();
readonly renderCallback = input<RenderCallback>();
@Output() sceneReady = new EventEmitter<SceneEventData>();
@Output() sceneResized = new EventEmitter<SceneEventData>();
@@ -131,7 +130,7 @@ export class BabylonCanvas implements AfterViewInit, OnDestroy {
}
private setupCamera(canvas: HTMLCanvasElement) {
if (this.config.mode === '3D') {
if (this.config().mode === '3D') {
this.setup3dCamera(canvas);
return;
}
@@ -144,7 +143,7 @@ export class BabylonCanvas implements AfterViewInit, OnDestroy {
cam.mode = Camera.ORTHOGRAPHIC_CAMERA;
const aspect = canvas.width / canvas.height;
const viewSize = this.config?.initialViewSize ?? 10;
const viewSize = this.config().initialViewSize ?? 10;
cam.orthoLeft = -viewSize * aspect / 2;
cam.orthoRight = viewSize * aspect / 2;
cam.orthoTop = viewSize / 2;
@@ -161,17 +160,18 @@ export class BabylonCanvas implements AfterViewInit, OnDestroy {
cam.maxZ = 100;
cam.lowerRadiusLimit = 1.5;
cam.upperRadiusLimit = 20;
cam.radius = this.config?.initialViewSize ?? 1;
cam.radius = this.config().initialViewSize ?? 1;
cam.attachControl(canvas, true);
this.camera = cam;
}
private createFullScreenRect() {
if (!this.config.vertexShader || !this.config.fragmentShader) {
const config = this.config();
if (!config.vertexShader || !config.fragmentShader) {
return;
}
const plane = MeshBuilder.CreatePlane("plane", {size: 100}, this.scene);
if (this.config.mode === '3D') {
if (config.mode === '3D') {
plane.parent = this.camera;
plane.position.z = 1;
} else {
@@ -183,7 +183,8 @@ export class BabylonCanvas implements AfterViewInit, OnDestroy {
}
private createShaderMaterial() {
if (!this.config.vertexShader || !this.config.fragmentShader || !this.config.uniformNames) {
const config = this.config();
if (!config.vertexShader || !config.fragmentShader || !config.uniformNames) {
return;
}
@@ -191,14 +192,14 @@ export class BabylonCanvas implements AfterViewInit, OnDestroy {
"shaderMaterial",
this.scene,
{
vertexSource: this.config.vertexShader,
fragmentSource: this.config.fragmentShader
vertexSource: config.vertexShader,
fragmentSource: config.fragmentShader
},
{
attributes: ["position", "uv"],
uniforms: ["resolution", "cameraPosition", ...this.config.uniformNames],
uniformBuffers: this.config.uniformBufferNames ?? [],
shaderLanguage: this.config.shaderLanguage ?? ShaderLanguage.GLSL
uniforms: ["resolution", "cameraPosition", ...config.uniformNames],
uniformBuffers: config.uniformBufferNames ?? [],
shaderLanguage: config.shaderLanguage ?? ShaderLanguage.GLSL
}
);
this.shaderMaterial.disableDepthWrite = true;
@@ -209,8 +210,9 @@ export class BabylonCanvas implements AfterViewInit, OnDestroy {
this.engine.runRenderLoop(() => {
// callback call to call specific uniforms
if (this.renderCallback) {
this.renderCallback(this.shaderMaterial, this.camera, canvas, this.scene);
const callback = this.renderCallback();
if (callback) {
callback(this.shaderMaterial, this.camera, canvas, this.scene);
}
// default uniforms which maybe each scene has
@@ -228,8 +230,9 @@ export class BabylonCanvas implements AfterViewInit, OnDestroy {
this.engine.resize();
}
if (this.config.mode === '2D' && this.camera instanceof ArcRotateCamera) {
const viewSize = this.config?.initialViewSize ?? 10;
const config = this.config();
if (config.mode === '2D' && this.camera instanceof ArcRotateCamera) {
const viewSize = config.initialViewSize ?? 10;
this.camera.orthoLeft = -viewSize / 2;
this.camera.orthoRight = viewSize / 2;
this.camera.orthoTop = viewSize / 2;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 932 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 268 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 426 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 739 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 742 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Loaded 100 of 124 files, more files were not shown because too many files have changed in this diff. Show more