diff --git a/src/app/pages/algorithms/cloth/strategies/cloth-cpu-physics.spec.ts b/src/app/pages/algorithms/cloth/strategies/cloth-cpu-physics.spec.ts
new file mode 100644
index 0000000..b95e7a5
--- /dev/null
+++ b/src/app/pages/algorithms/cloth/strategies/cloth-cpu-physics.spec.ts
@@ -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);
+ });
+ });
+});
diff --git a/src/app/pages/algorithms/conway-gol/conway-gol.component.html b/src/app/pages/algorithms/conway-gol/conway-gol.component.html
index dae6f6d..6614973 100644
--- a/src/app/pages/algorithms/conway-gol/conway-gol.component.html
+++ b/src/app/pages/algorithms/conway-gol/conway-gol.component.html
@@ -45,6 +45,7 @@
[min]="MIN_GRID_SIZE"
[max]="MAX_GRID_SIZE"
(ngModelChange)="applyGridRows($event)"
+ (blur)="showClampedGridSize()"
/>
@@ -56,6 +57,7 @@
[min]="MIN_GRID_SIZE"
[max]="MAX_GRID_SIZE"
(ngModelChange)="applyGridCols($event)"
+ (blur)="showClampedGridSize()"
/>
diff --git a/src/app/pages/algorithms/conway-gol/conway-gol.component.spec.ts b/src/app/pages/algorithms/conway-gol/conway-gol.component.spec.ts
new file mode 100644
index 0000000..697bb61
--- /dev/null
+++ b/src/app/pages/algorithms/conway-gol/conway-gol.component.spec.ts
@@ -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;
+ 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);
+ });
+});
diff --git a/src/app/pages/algorithms/conway-gol/conway-gol.component.ts b/src/app/pages/algorithms/conway-gol/conway-gol.component.ts
index 4579437..3078425 100644
--- a/src/app/pages/algorithms/conway-gol/conway-gol.component.ts
+++ b/src/app/pages/algorithms/conway-gol/conway-gol.component.ts
@@ -1,4 +1,4 @@
-import {AfterViewInit, Component, signal, ViewChild} 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',
@@ -32,6 +33,7 @@ import {GenericGridComponent, GridPos} from '../../../shared/components/generic-
templateUrl: './conway-gol.component.html',
})
export class ConwayGolComponent implements AfterViewInit {
+ private readonly golService = inject(ConwayGolService);
algoInformation: AlgorithmInformation = {
title: 'GOL.EXPLANATION.TITLE',
@@ -91,13 +93,30 @@ export class ConwayGolComponent implements AfterViewInit {
protected applyGridRows(rows: number): void {
this.pauseGame();
this.genericGridComponent.gridRows = rows;
- this.genericGridComponent.applyGridSize();
+ 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 ---
@@ -122,68 +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 {
- const rows = this.gridRows();
- const cols = this.gridCols();
- for (let row = 0; row < rows; row++) {
- for (let col = 0; col < cols; 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 {
@@ -194,20 +155,13 @@ export class ConwayGolComponent implements AfterViewInit {
this.gameStarted.set(true);
while (this.gameStarted()){
const startTime = performance.now();
- let lifeIsDead = true;
- const rows = this.gridRows();
- const cols = this.gridCols();
- for (let row = 0; row < rows; row++) {
- for (let col = 0; col < cols; col++) {
- lifeIsDead = this.checkLifeRules(row, col, this.writeGrid) && lifeIsDead;
- }
- }
+ const changed = this.golService.nextGeneration(this.readGrid, this.writeGrid);
this.swapGrids();
const endTime = performance.now();
const elapsed = Number.parseFloat((endTime - startTime).toFixed(4));
this.executionTime.set(elapsed);
- if (lifeIsDead){
+ if (!changed){
this.gameStarted.set(false);
}
const delta = Math.max(this.lifeSpeed() - elapsed, 0);
@@ -216,18 +170,6 @@ export class ConwayGolComponent implements AfterViewInit {
this.executionTime.set(0);
}
- private checkLifeRules(row: number, col: number, writeGrid: Node[][]): boolean {
- const currentCell = this.readGrid[row][col];
- const aliveNeighbors = this.howManyNeighborsAreLiving(row, col);
- const oldLifeState = currentCell.alive;
-
- const nextStateAlive = (currentCell.alive && (aliveNeighbors === 2 || aliveNeighbors === 3)) || (!currentCell.alive && aliveNeighbors === 3);
- writeGrid[row][col].alive = nextStateAlive;
-
- //only if at least one cell changes the game is still alive
- return (nextStateAlive == oldLifeState);
- }
-
private swapGrids() {
const tmp = this.readGrid;
this.readGrid = this.writeGrid;
@@ -238,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;
@@ -267,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;
- }
- }
}
diff --git a/src/app/pages/algorithms/conway-gol/service/conway-gol.service.spec.ts b/src/app/pages/algorithms/conway-gol/service/conway-gol.service.spec.ts
new file mode 100644
index 0000000..04a9649
--- /dev/null
+++ b/src/app/pages/algorithms/conway-gol/service/conway-gol.service.spec.ts
@@ -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();
+ });
+ });
+});
diff --git a/src/app/pages/algorithms/conway-gol/service/conway-gol.service.ts b/src/app/pages/algorithms/conway-gol/service/conway-gol.service.ts
new file mode 100644
index 0000000..4db3931
--- /dev/null
+++ b/src/app/pages/algorithms/conway-gol/service/conway-gol.service.ts
@@ -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;
+ }
+ }
+}
diff --git a/src/app/pages/algorithms/four-color/four-color.component.ts b/src/app/pages/algorithms/four-color/four-color.component.ts
index a0676e1..a459964 100644
--- a/src/app/pages/algorithms/four-color/four-color.component.ts
+++ b/src/app/pages/algorithms/four-color/four-color.component.ts
@@ -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({
@@ -32,6 +32,7 @@ import {UrlConstants} from '../../../constants/UrlConstants';
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);
@@ -60,10 +61,9 @@ export class FourColorComponent implements AfterViewInit {
readonly gridRows = signal(DEFAULT_GRID_ROWS);
readonly gridCols = signal(DEFAULT_GRID_COLS);
- grid: FourColorNode[][] = [];
- regions: Region[] = [];
+ map: FourColorMap = {grid: [], regions: []};
executionTime = 0;
- readonly solutionStatus = signal<'INCOMPLETE' | 'SOLVED' | 'CONFLICTS' | 'INVALID'>('INCOMPLETE');
+ readonly solutionStatus = signal('INCOMPLETE');
@ViewChild('fourColorCanvas') canvasRef!: ElementRef;
private ctx!: CanvasRenderingContext2D;
@@ -82,22 +82,8 @@ export class FourColorComponent implements AfterViewInit {
}
initializeGrid(): void {
- this.grid = [];
+ this.map = this.fourColorService.createMap(this.gridRows(), this.gridCols());
this.solutionStatus.set('INCOMPLETE');
- for (let r = 0; r < this.gridRows(); r++) {
- const row: FourColorNode[] = [];
- for (let c = 0; c < this.gridCols(); c++) {
- row.push({
- row: r,
- col: c,
- regionId: -1,
- color: 0,
- });
- }
- this.grid.push(row);
- }
-
- this.generateRegions();
this.resizeCanvas();
this.drawGrid();
}
@@ -111,71 +97,6 @@ export class FourColorComponent implements AfterViewInit {
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()});
- }
- return seeds;
- }
-
- private getNeighbors(row: number, col: number): GridPos[] {
- const res: GridPos[] = [];
- if (row > 0) res.push({row: row - 1, col});
- if (row < this.gridRows() - 1) res.push({row: row + 1, col});
- if (col > 0) res.push({row, col: col - 1});
- if (col < this.gridCols() - 1) res.push({row, col: col + 1});
- return res;
- }
-
drawGrid(): void {
if (!this.ctx) return;
@@ -184,7 +105,7 @@ export class FourColorComponent implements AfterViewInit {
// 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];
+ 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);
}
@@ -196,16 +117,16 @@ export class FourColorComponent implements AfterViewInit {
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;
+ 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);
}
@@ -267,33 +188,18 @@ export class FourColorComponent implements AfterViewInit {
}
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.fourColorService.resetColors(this.map);
this.solutionStatus.set('INCOMPLETE');
this.drawGrid();
}
@@ -302,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');
@@ -315,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.set(allColored ? 'INVALID' : 'CONFLICTS');
- } else {
- this.solutionStatus.set(allColored ? 'SOLVED' : 'INCOMPLETE');
- }
- }
-
generateNewMap(): void {
this.initializeGrid();
}
diff --git a/src/app/pages/algorithms/four-color/four-color.models.ts b/src/app/pages/algorithms/four-color/four-color.models.ts
index e0c2c2e..68b95c3 100644
--- a/src/app/pages/algorithms/four-color/four-color.models.ts
+++ b/src/app/pages/algorithms/four-color/four-color.models.ts
@@ -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;
diff --git a/src/app/pages/algorithms/four-color/service/four-color.service.spec.ts b/src/app/pages/algorithms/four-color/service/four-color.service.spec.ts
new file mode 100644
index 0000000..d47d219
--- /dev/null
+++ b/src/app/pages/algorithms/four-color/service/four-color.service.spec.ts
@@ -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');
+ });
+ });
+});
diff --git a/src/app/pages/algorithms/four-color/service/four-color.service.ts b/src/app/pages/algorithms/four-color/service/four-color.service.ts
new file mode 100644
index 0000000..3798b48
--- /dev/null
+++ b/src/app/pages/algorithms/four-color/service/four-color.service.ts
@@ -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()});
+ }
+ 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;
+ }
+ }
+ }
+ }
+}
diff --git a/src/app/pages/algorithms/fractal/fractal-view.spec.ts b/src/app/pages/algorithms/fractal/fractal-view.spec.ts
new file mode 100644
index 0000000..29e28a9
--- /dev/null
+++ b/src/app/pages/algorithms/fractal/fractal-view.spec.ts
@@ -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});
+ }
+ });
+ });
+});
diff --git a/src/app/pages/algorithms/fractal/fractal-view.ts b/src/app/pages/algorithms/fractal/fractal-view.ts
new file mode 100644
index 0000000..5f20216
--- /dev/null
+++ b/src/app/pages/algorithms/fractal/fractal-view.ts
@@ -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};
+ }
+}
diff --git a/src/app/pages/algorithms/fractal/fractal.component.ts b/src/app/pages/algorithms/fractal/fractal.component.ts
index c1c6707..8116a13 100644
--- a/src/app/pages/algorithms/fractal/fractal.component.ts
+++ b/src/app/pages/algorithms/fractal/fractal.component.ts
@@ -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',
@@ -141,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 {
@@ -226,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);
}
}
diff --git a/src/app/pages/algorithms/pathfinding/labyrinth/labyrinth.component.ts b/src/app/pages/algorithms/pathfinding/labyrinth/labyrinth.component.ts
index e551f2f..23277f9 100644
--- a/src/app/pages/algorithms/pathfinding/labyrinth/labyrinth.component.ts
+++ b/src/app/pages/algorithms/pathfinding/labyrinth/labyrinth.component.ts
@@ -5,11 +5,12 @@ 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',
@@ -35,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',
@@ -66,7 +67,10 @@ export class LabyrinthComponent implements AfterViewInit {
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);
@@ -93,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.set("∞");
- }
- else
- {
- this.pathLength.set(result.nodesInShortestPathOrder.length + "");
- }
+
+ this.pathLength.set(PathfindingAnimator.pathLengthLabel(result));
this.executionTime.set(endTime - startTime);
- this.animateAlgorithm(result.visitedNodesInOrder, result.nodesInShortestPathOrder);
+ 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';
@@ -349,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;
- };
}
diff --git a/src/app/pages/algorithms/pathfinding/labyrinth/service/maze-generator.service.spec.ts b/src/app/pages/algorithms/pathfinding/labyrinth/service/maze-generator.service.spec.ts
new file mode 100644
index 0000000..88a7c87
--- /dev/null
+++ b/src/app/pages/algorithms/pathfinding/labyrinth/service/maze-generator.service.spec.ts
@@ -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));
+ });
+});
diff --git a/src/app/pages/algorithms/pathfinding/labyrinth/service/maze-generator.service.ts b/src/app/pages/algorithms/pathfinding/labyrinth/service/maze-generator.service.ts
new file mode 100644
index 0000000..e4066bf
--- /dev/null
+++ b/src/app/pages/algorithms/pathfinding/labyrinth/service/maze-generator.service.ts
@@ -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;
+ }
+}
diff --git a/src/app/pages/algorithms/pathfinding/pathfinding-animator.ts b/src/app/pages/algorithms/pathfinding/pathfinding-animator.ts
new file mode 100644
index 0000000..92fd04e
--- /dev/null
+++ b/src/app/pages/algorithms/pathfinding/pathfinding-animator.ts
@@ -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);
+ }
+ });
+ });
+ }
+}
diff --git a/src/app/pages/algorithms/pathfinding/pathfinding.component.ts b/src/app/pages/algorithms/pathfinding/pathfinding.component.ts
index 446ae08..1a2e71d 100644
--- a/src/app/pages/algorithms/pathfinding/pathfinding.component.ts
+++ b/src/app/pages/algorithms/pathfinding/pathfinding.component.ts
@@ -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',
@@ -47,6 +48,7 @@ enum NodeType {
})
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);
@@ -88,7 +90,10 @@ export class PathfindingComponent implements AfterViewInit {
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;
@@ -121,21 +126,7 @@ export class PathfindingComponent implements AfterViewInit {
}
// --- 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';
@@ -157,337 +148,80 @@ export class PathfindingComponent implements AfterViewInit {
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.set("∞");
- }
- else
- {
- this.pathLength.set(result.nodesInShortestPathOrder.length + "");
- }
+
+ this.pathLength.set(PathfindingAnimator.pathLengthLabel(result));
this.executionTime.set(endTime - startTime);
- this.animateAlgorithm(result.visitedNodesInOrder, result.nodesInShortestPathOrder);
+ 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) {
diff --git a/src/app/pages/algorithms/pathfinding/pathfinding.models.ts b/src/app/pages/algorithms/pathfinding/pathfinding.models.ts
index bd1662d..5cbe13a 100644
--- a/src/app/pages/algorithms/pathfinding/pathfinding.models.ts
+++ b/src/app/pages/algorithms/pathfinding/pathfinding.models.ts
@@ -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;
diff --git a/src/app/pages/algorithms/pathfinding/service/pathfinding-grid.service.spec.ts b/src/app/pages/algorithms/pathfinding/service/pathfinding-grid.service.spec.ts
new file mode 100644
index 0000000..405057e
--- /dev/null
+++ b/src/app/pages/algorithms/pathfinding/service/pathfinding-grid.service.spec.ts
@@ -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);
+ }
+ });
+});
diff --git a/src/app/pages/algorithms/pathfinding/service/pathfinding-grid.service.ts b/src/app/pages/algorithms/pathfinding/service/pathfinding-grid.service.ts
new file mode 100644
index 0000000..5c1ff76
--- /dev/null
+++ b/src/app/pages/algorithms/pathfinding/service/pathfinding-grid.service.ts
@@ -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;
+ }
+ }
+}
diff --git a/src/app/pages/algorithms/pathfinding/service/pathfinding.service.spec.ts b/src/app/pages/algorithms/pathfinding/service/pathfinding.service.spec.ts
index b66b441..6cc13af 100644
--- a/src/app/pages/algorithms/pathfinding/service/pathfinding.service.spec.ts
+++ b/src/app/pages/algorithms/pathfinding/service/pathfinding.service.spec.ts
@@ -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);
+ });
+ });
});
diff --git a/src/app/pages/algorithms/pathfinding/service/pathfinding.service.ts b/src/app/pages/algorithms/pathfinding/service/pathfinding.service.ts
index 4f7a183..5b72205 100644
--- a/src/app/pages/algorithms/pathfinding/service/pathfinding.service.ts
+++ b/src/app/pages/algorithms/pathfinding/service/pathfinding.service.ts
@@ -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);
diff --git a/src/app/pages/algorithms/pendulum/strategies/pendulum-cpu-physics.spec.ts b/src/app/pages/algorithms/pendulum/strategies/pendulum-cpu-physics.spec.ts
new file mode 100644
index 0000000..e328be4
--- /dev/null
+++ b/src/app/pages/algorithms/pendulum/strategies/pendulum-cpu-physics.spec.ts
@@ -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 = {}): 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));
+ });
+});