refactor(algorithms): move algorithm logic out of components
Move logic from 5 components into services and pure modules, with a shared animator for pathfinding and labyrinth. Add specs for them and for pendulum and cloth physics. Fix Game of Life crash: clamp rows and cols to the max. Fix random pathfinding on 2 columns: in bounds, start not equal end.
This commit is contained in:
1 parent
dc9f09bede
commit
034e397864
24 files changed
+1739
-947
No files matched your search
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -45,6 +45,7 @@
|
|||||||
[min]="MIN_GRID_SIZE"
|
[min]="MIN_GRID_SIZE"
|
||||||
[max]="MAX_GRID_SIZE"
|
[max]="MAX_GRID_SIZE"
|
||||||
(ngModelChange)="applyGridRows($event)"
|
(ngModelChange)="applyGridRows($event)"
|
||||||
|
(blur)="showClampedGridSize()"
|
||||||
/>
|
/>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
<mat-form-field appearance="outline" class="w-[150px]">
|
<mat-form-field appearance="outline" class="w-[150px]">
|
||||||
@@ -56,6 +57,7 @@
|
|||||||
[min]="MIN_GRID_SIZE"
|
[min]="MIN_GRID_SIZE"
|
||||||
[max]="MAX_GRID_SIZE"
|
[max]="MAX_GRID_SIZE"
|
||||||
(ngModelChange)="applyGridCols($event)"
|
(ngModelChange)="applyGridCols($event)"
|
||||||
|
(blur)="showClampedGridSize()"
|
||||||
/>
|
/>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
<mat-form-field appearance="outline" class="w-[150px]">
|
<mat-form-field appearance="outline" class="w-[150px]">
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import {ComponentFixture, TestBed} from '@angular/core/testing';
|
||||||
|
import {provideZonelessChangeDetection} from '@angular/core';
|
||||||
|
import {provideNoopAnimations} from '@angular/platform-browser/animations';
|
||||||
|
import {provideTranslateService} from '@ngx-translate/core';
|
||||||
|
import {ConwayGolComponent} from './conway-gol.component';
|
||||||
|
|
||||||
|
describe('ConwayGolComponent', () => {
|
||||||
|
let fixture: ComponentFixture<ConwayGolComponent>;
|
||||||
|
let component: ConwayGolComponent;
|
||||||
|
const originalGetContext = HTMLCanvasElement.prototype.getContext;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// jsdom has no 2D context.
|
||||||
|
const fakeContext = {
|
||||||
|
fillRect: vi.fn(), strokeRect: vi.fn(), beginPath: vi.fn(), moveTo: vi.fn(), lineTo: vi.fn(), stroke: vi.fn(),
|
||||||
|
fillStyle: '', strokeStyle: '', lineWidth: 1,
|
||||||
|
};
|
||||||
|
Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', {
|
||||||
|
configurable: true,
|
||||||
|
value: () => fakeContext,
|
||||||
|
});
|
||||||
|
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [ConwayGolComponent],
|
||||||
|
providers: [
|
||||||
|
provideZonelessChangeDetection(),
|
||||||
|
provideNoopAnimations(),
|
||||||
|
provideTranslateService({lang: 'en', fallbackLang: 'en'}),
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(ConwayGolComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
component.pauseGame();
|
||||||
|
Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', {configurable: true, value: originalGetContext});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs a generation on a grid that the generic grid clamped below the typed size', async () => {
|
||||||
|
component['gridCols'].set(500); // what ngModel does before ngModelChange
|
||||||
|
component['applyGridCols'](500);
|
||||||
|
|
||||||
|
const run = component.startGame();
|
||||||
|
component.pauseGame();
|
||||||
|
|
||||||
|
await expect(run).resolves.toBeUndefined();
|
||||||
|
expect(component.readGrid[0].length).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the clamped size in the input signals', () => {
|
||||||
|
component['gridRows'].set(500);
|
||||||
|
component['applyGridRows'](500);
|
||||||
|
|
||||||
|
expect(component['gridRows']()).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a cleared input empty', () => {
|
||||||
|
const cleared = null as unknown as number; // what ngModel emits for an empty number input
|
||||||
|
component['gridCols'].set(cleared);
|
||||||
|
component['applyGridCols'](cleared);
|
||||||
|
|
||||||
|
expect(component['gridCols']()).toBeNull();
|
||||||
|
expect(component.readGrid[0].length).toBe(20);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import {AfterViewInit, Component, signal, ViewChild} from '@angular/core';
|
import {AfterViewInit, Component, inject, signal, ViewChild} from '@angular/core';
|
||||||
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from "@angular/material/card";
|
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from "@angular/material/card";
|
||||||
import {TranslatePipe} from "@ngx-translate/core";
|
import {TranslatePipe} from "@ngx-translate/core";
|
||||||
import {UrlConstants} from '../../../constants/UrlConstants';
|
import {UrlConstants} from '../../../constants/UrlConstants';
|
||||||
@@ -8,8 +8,9 @@ import {MatButton} from '@angular/material/button';
|
|||||||
import {MatIcon} from '@angular/material/icon';
|
import {MatIcon} from '@angular/material/icon';
|
||||||
import {MatFormField, MatInput, MatLabel} from '@angular/material/input';
|
import {MatFormField, MatInput, MatLabel} from '@angular/material/input';
|
||||||
import {FormsModule, ReactiveFormsModule} from '@angular/forms';
|
import {FormsModule, ReactiveFormsModule} from '@angular/forms';
|
||||||
import {DEFAULT_GRID_COLS, DEFAULT_GRID_ROWS, DEFAULT_TIME_PER_GENERATION, LIVE_SPAWN_PROBABILITY, MAX_GRID_PX, MAX_GRID_SIZE, MAX_TIME_PER_GENERATION, MIN_GRID_SIZE, MIN_TIME_PER_GENERATION, Node, Scenario} from './conway-gol.models';
|
import {DEFAULT_GRID_COLS, DEFAULT_GRID_ROWS, DEFAULT_TIME_PER_GENERATION, MAX_GRID_PX, MAX_GRID_SIZE, MAX_TIME_PER_GENERATION, MIN_GRID_SIZE, MIN_TIME_PER_GENERATION, Node, Scenario} from './conway-gol.models';
|
||||||
import {GenericGridComponent, GridPos} from '../../../shared/components/generic-grid/generic-grid';
|
import {GenericGridComponent, GridPos} from '../../../shared/components/generic-grid/generic-grid';
|
||||||
|
import {ConwayGolService} from './service/conway-gol.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-conway-gol',
|
selector: 'app-conway-gol',
|
||||||
@@ -32,6 +33,7 @@ import {GenericGridComponent, GridPos} from '../../../shared/components/generic-
|
|||||||
templateUrl: './conway-gol.component.html',
|
templateUrl: './conway-gol.component.html',
|
||||||
})
|
})
|
||||||
export class ConwayGolComponent implements AfterViewInit {
|
export class ConwayGolComponent implements AfterViewInit {
|
||||||
|
private readonly golService = inject(ConwayGolService);
|
||||||
|
|
||||||
algoInformation: AlgorithmInformation = {
|
algoInformation: AlgorithmInformation = {
|
||||||
title: 'GOL.EXPLANATION.TITLE',
|
title: 'GOL.EXPLANATION.TITLE',
|
||||||
@@ -91,13 +93,30 @@ export class ConwayGolComponent implements AfterViewInit {
|
|||||||
protected applyGridRows(rows: number): void {
|
protected applyGridRows(rows: number): void {
|
||||||
this.pauseGame();
|
this.pauseGame();
|
||||||
this.genericGridComponent.gridRows = rows;
|
this.genericGridComponent.gridRows = rows;
|
||||||
this.genericGridComponent.applyGridSize();
|
this.applyClampedGridSize();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected applyGridCols(cols: number): void {
|
protected applyGridCols(cols: number): void {
|
||||||
this.pauseGame();
|
this.pauseGame();
|
||||||
this.genericGridComponent.gridCols = cols;
|
this.genericGridComponent.gridCols = cols;
|
||||||
|
this.applyClampedGridSize();
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyClampedGridSize(): void {
|
||||||
this.genericGridComponent.applyGridSize();
|
this.genericGridComponent.applyGridSize();
|
||||||
|
// Only clamp down while typing; raising to the minimum would fight the user typing "100".
|
||||||
|
// A cleared input emits null: keep it as it is.
|
||||||
|
this.gridRows.update(rows => this.clampDown(rows, this.genericGridComponent.gridRows));
|
||||||
|
this.gridCols.update(cols => this.clampDown(cols, this.genericGridComponent.gridCols));
|
||||||
|
}
|
||||||
|
|
||||||
|
private clampDown(value: number, max: number): number {
|
||||||
|
return Number.isFinite(value) && value > max ? max : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected showClampedGridSize(): void {
|
||||||
|
this.gridRows.set(this.genericGridComponent.gridRows);
|
||||||
|
this.gridCols.set(this.genericGridComponent.gridCols);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Callbacks for GenericGridComponent ---
|
// --- Callbacks for GenericGridComponent ---
|
||||||
@@ -122,68 +141,10 @@ export class ConwayGolComponent implements AfterViewInit {
|
|||||||
initializeConwayGrid = (grid: Node[][]): void => {
|
initializeConwayGrid = (grid: Node[][]): void => {
|
||||||
this.gameStarted.set(false);
|
this.gameStarted.set(false);
|
||||||
this.readGrid = grid;
|
this.readGrid = grid;
|
||||||
switch(this.currentScenario) {
|
this.golService.applyScenario(grid, this.currentScenario);
|
||||||
case Scenario.RANDOM: this.setupRandomLives(); break;
|
|
||||||
case Scenario.SIMPLE: this.setupSimpleLive(); break;
|
|
||||||
case Scenario.PULSAR: this.setupPulsar(); break;
|
|
||||||
case Scenario.GUN: this.setupGliderGun(); break;
|
|
||||||
}
|
|
||||||
this.writeGrid = structuredClone(this.readGrid);
|
this.writeGrid = structuredClone(this.readGrid);
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Conway-specific logic (kept local) ---
|
|
||||||
setupRandomLives(): void {
|
|
||||||
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
|
// --- The rules of the game
|
||||||
|
|
||||||
pauseGame(): void {
|
pauseGame(): void {
|
||||||
@@ -194,20 +155,13 @@ export class ConwayGolComponent implements AfterViewInit {
|
|||||||
this.gameStarted.set(true);
|
this.gameStarted.set(true);
|
||||||
while (this.gameStarted()){
|
while (this.gameStarted()){
|
||||||
const startTime = performance.now();
|
const startTime = performance.now();
|
||||||
let lifeIsDead = true;
|
const changed = this.golService.nextGeneration(this.readGrid, this.writeGrid);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.swapGrids();
|
this.swapGrids();
|
||||||
const endTime = performance.now();
|
const endTime = performance.now();
|
||||||
const elapsed = Number.parseFloat((endTime - startTime).toFixed(4));
|
const elapsed = Number.parseFloat((endTime - startTime).toFixed(4));
|
||||||
this.executionTime.set(elapsed);
|
this.executionTime.set(elapsed);
|
||||||
if (lifeIsDead){
|
if (!changed){
|
||||||
this.gameStarted.set(false);
|
this.gameStarted.set(false);
|
||||||
}
|
}
|
||||||
const delta = Math.max(this.lifeSpeed() - elapsed, 0);
|
const delta = Math.max(this.lifeSpeed() - elapsed, 0);
|
||||||
@@ -216,18 +170,6 @@ export class ConwayGolComponent implements AfterViewInit {
|
|||||||
this.executionTime.set(0);
|
this.executionTime.set(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
private checkLifeRules(row: number, col: number, writeGrid: Node[][]): boolean {
|
|
||||||
const currentCell = this.readGrid[row][col];
|
|
||||||
const aliveNeighbors = this.howManyNeighborsAreLiving(row, col);
|
|
||||||
const oldLifeState = currentCell.alive;
|
|
||||||
|
|
||||||
const nextStateAlive = (currentCell.alive && (aliveNeighbors === 2 || aliveNeighbors === 3)) || (!currentCell.alive && aliveNeighbors === 3);
|
|
||||||
writeGrid[row][col].alive = nextStateAlive;
|
|
||||||
|
|
||||||
//only if at least one cell changes the game is still alive
|
|
||||||
return (nextStateAlive == oldLifeState);
|
|
||||||
}
|
|
||||||
|
|
||||||
private swapGrids() {
|
private swapGrids() {
|
||||||
const tmp = this.readGrid;
|
const tmp = this.readGrid;
|
||||||
this.readGrid = this.writeGrid;
|
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 ---
|
// --- Other methods ---
|
||||||
protected readonly Scenario = Scenario;
|
protected readonly Scenario = Scenario;
|
||||||
protected readonly MIN_TIME_PER_GENERATION = MIN_TIME_PER_GENERATION;
|
protected readonly MIN_TIME_PER_GENERATION = MIN_TIME_PER_GENERATION;
|
||||||
@@ -267,10 +188,4 @@ export class ConwayGolComponent implements AfterViewInit {
|
|||||||
delay(ms: number) {
|
delay(ms: number) {
|
||||||
return new Promise( resolve => setTimeout(resolve, ms) );
|
return new Promise( resolve => setTimeout(resolve, ms) );
|
||||||
}
|
}
|
||||||
|
|
||||||
private setAlive(r: number, c: number): void {
|
|
||||||
if (r >= 0 && r < this.gridRows() && c >= 0 && c < this.gridCols()) {
|
|
||||||
this.readGrid[r][c].alive = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import {ConwayGolService} from './conway-gol.service';
|
||||||
|
import {Node, Scenario} from '../conway-gol.models';
|
||||||
|
|
||||||
|
function createGrid(rows: number, cols: number, aliveCells: number[][] = []): Node[][] {
|
||||||
|
const grid = Array.from({length: rows}, (_, row) =>
|
||||||
|
Array.from({length: cols}, (_, col): Node => ({row, col, alive: false})));
|
||||||
|
for (const [row, col] of aliveCells) {
|
||||||
|
grid[row][col].alive = true;
|
||||||
|
}
|
||||||
|
return grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
function aliveCells(grid: Node[][]): string[] {
|
||||||
|
return grid.flat().filter(node => node.alive).map(node => `${node.row},${node.col}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ConwayGolService', () => {
|
||||||
|
const service = new ConwayGolService();
|
||||||
|
|
||||||
|
describe('countLivingNeighbors', () => {
|
||||||
|
it('counts all neighbors of a center cell but not the cell itself', () => {
|
||||||
|
const grid = createGrid(3, 3);
|
||||||
|
grid.flat().forEach(node => node.alive = true);
|
||||||
|
|
||||||
|
expect(service.countLivingNeighbors(grid, 1, 1)).toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts only in-bounds neighbors on corners and edges', () => {
|
||||||
|
const grid = createGrid(3, 3);
|
||||||
|
grid.flat().forEach(node => node.alive = true);
|
||||||
|
|
||||||
|
expect(service.countLivingNeighbors(grid, 0, 0)).toBe(3);
|
||||||
|
expect(service.countLivingNeighbors(grid, 0, 1)).toBe(5);
|
||||||
|
expect(service.countLivingNeighbors(grid, 2, 2)).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('nextGeneration', () => {
|
||||||
|
function step(read: Node[][]): { next: Node[][]; changed: boolean } {
|
||||||
|
const next = structuredClone(read);
|
||||||
|
const changed = service.nextGeneration(read, next);
|
||||||
|
return {next, changed};
|
||||||
|
}
|
||||||
|
|
||||||
|
it('flips a blinker and reports a change', () => {
|
||||||
|
const {next, changed} = step(createGrid(5, 5, [[2, 1], [2, 2], [2, 3]]));
|
||||||
|
|
||||||
|
expect(changed).toBe(true);
|
||||||
|
expect(aliveCells(next)).toEqual(['1,2', '2,2', '3,2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a block and reports no change', () => {
|
||||||
|
const block = [[1, 1], [1, 2], [2, 1], [2, 2]];
|
||||||
|
const {next, changed} = step(createGrid(4, 4, block));
|
||||||
|
|
||||||
|
expect(changed).toBe(false);
|
||||||
|
expect(aliveCells(next)).toEqual(['1,1', '1,2', '2,1', '2,2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('kills a lonely cell (underpopulation)', () => {
|
||||||
|
expect(aliveCells(step(createGrid(3, 3, [[1, 1]])).next)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('kills a cell with four neighbors (overpopulation)', () => {
|
||||||
|
const {next} = step(createGrid(3, 3, [[1, 1], [0, 1], [2, 1], [1, 0], [1, 2]]));
|
||||||
|
|
||||||
|
expect(next[1][1].alive).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('births a dead cell with exactly three neighbors', () => {
|
||||||
|
const {next} = step(createGrid(3, 3, [[0, 0], [0, 1], [0, 2]]));
|
||||||
|
|
||||||
|
expect(next[1][1].alive).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('applyScenario', () => {
|
||||||
|
it('SIMPLE sets the five glider cells', () => {
|
||||||
|
const grid = createGrid(50, 50);
|
||||||
|
|
||||||
|
service.applyScenario(grid, Scenario.SIMPLE);
|
||||||
|
|
||||||
|
expect(aliveCells(grid).sort()).toEqual(['3,4', '4,5', '5,3', '5,4', '5,5']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PULSAR is symmetric around the center', () => {
|
||||||
|
const grid = createGrid(50, 50);
|
||||||
|
|
||||||
|
service.applyScenario(grid, Scenario.PULSAR);
|
||||||
|
|
||||||
|
const alive = grid.flat().filter(node => node.alive);
|
||||||
|
expect(alive.length).toBe(48);
|
||||||
|
for (const node of alive) {
|
||||||
|
expect(grid[50 - node.row][50 - node.col].alive).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GUN sets 36 cells on 50x50', () => {
|
||||||
|
const grid = createGrid(50, 50);
|
||||||
|
|
||||||
|
service.applyScenario(grid, Scenario.GUN);
|
||||||
|
|
||||||
|
expect(aliveCells(grid).length).toBe(36);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('EMPTY changes nothing', () => {
|
||||||
|
const grid = createGrid(50, 50);
|
||||||
|
|
||||||
|
service.applyScenario(grid, Scenario.EMPTY);
|
||||||
|
|
||||||
|
expect(aliveCells(grid)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('RANDOM sets at least one cell on 50x50', () => {
|
||||||
|
const grid = createGrid(50, 50);
|
||||||
|
|
||||||
|
service.applyScenario(grid, Scenario.RANDOM);
|
||||||
|
|
||||||
|
expect(aliveCells(grid).length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[Scenario.SIMPLE, 5],
|
||||||
|
[Scenario.GUN, 2],
|
||||||
|
])('sets only the in-bounds cells on a 10x10 grid for scenario %s', (scenario, expected) => {
|
||||||
|
const grid = createGrid(10, 10);
|
||||||
|
|
||||||
|
service.applyScenario(grid, scenario);
|
||||||
|
|
||||||
|
expect(aliveCells(grid).length).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not throw for PULSAR on a 10x10 grid', () => {
|
||||||
|
expect(() => service.applyScenario(createGrid(10, 10), Scenario.PULSAR)).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import {Injectable} from '@angular/core';
|
||||||
|
import {LIVE_SPAWN_PROBABILITY, Node, Scenario} from '../conway-gol.models';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class ConwayGolService {
|
||||||
|
|
||||||
|
applyScenario(grid: Node[][], scenario: Scenario): void {
|
||||||
|
switch (scenario) {
|
||||||
|
case Scenario.RANDOM: this.setupRandomLives(grid); break;
|
||||||
|
case Scenario.SIMPLE: this.setupGlider(grid); break;
|
||||||
|
case Scenario.PULSAR: this.setupPulsar(grid); break;
|
||||||
|
case Scenario.GUN: this.setupGliderGun(grid); break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
countLivingNeighbors(grid: Node[][], row: number, col: number): number {
|
||||||
|
let aliveNeighborCount = 0;
|
||||||
|
const minRow = Math.max(row - 1, 0);
|
||||||
|
const minCol = Math.max(col - 1, 0);
|
||||||
|
const maxRow = Math.min(row + 1, grid.length - 1);
|
||||||
|
const maxCol = Math.min(col + 1, grid[0].length - 1);
|
||||||
|
|
||||||
|
for (let nRow = minRow; nRow <= maxRow; nRow++) {
|
||||||
|
for (let nCol = minCol; nCol <= maxCol; nCol++) {
|
||||||
|
if (nRow === row && nCol === col) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (grid[nRow][nCol].alive) {
|
||||||
|
aliveNeighborCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return aliveNeighborCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes the next generation of readGrid into writeGrid. Returns true when at least one cell changed. */
|
||||||
|
nextGeneration(readGrid: Node[][], writeGrid: Node[][]): boolean {
|
||||||
|
let changed = false;
|
||||||
|
for (let row = 0; row < readGrid.length; row++) {
|
||||||
|
for (let col = 0; col < readGrid[row].length; col++) {
|
||||||
|
const wasAlive = readGrid[row][col].alive;
|
||||||
|
const aliveNeighbors = this.countLivingNeighbors(readGrid, row, col);
|
||||||
|
const nextAlive = wasAlive ? (aliveNeighbors === 2 || aliveNeighbors === 3) : aliveNeighbors === 3;
|
||||||
|
writeGrid[row][col].alive = nextAlive;
|
||||||
|
changed ||= nextAlive !== wasAlive;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupRandomLives(grid: Node[][]): void {
|
||||||
|
for (const row of grid) {
|
||||||
|
for (const node of row) {
|
||||||
|
node.alive = Math.random() <= LIVE_SPAWN_PROBABILITY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupGlider(grid: Node[][]): void {
|
||||||
|
this.setAlive(grid, 3, 4);
|
||||||
|
this.setAlive(grid, 4, 5);
|
||||||
|
this.setAlive(grid, 5, 3);
|
||||||
|
this.setAlive(grid, 5, 4);
|
||||||
|
this.setAlive(grid, 5, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupPulsar(grid: Node[][]): void {
|
||||||
|
const centerRow = Math.floor(grid.length / 2);
|
||||||
|
const centerCol = Math.floor(grid[0].length / 2);
|
||||||
|
|
||||||
|
const rows = [-6, -1, 1, 6];
|
||||||
|
const offsets = [2, 3, 4];
|
||||||
|
|
||||||
|
for (const r of rows) {
|
||||||
|
for (const c of offsets) {
|
||||||
|
this.setAlive(grid, centerRow + r, centerCol + c);
|
||||||
|
this.setAlive(grid, centerRow + r, centerCol - c);
|
||||||
|
this.setAlive(grid, centerRow + c, centerCol + r);
|
||||||
|
this.setAlive(grid, centerRow - c, centerCol + r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupGliderGun(grid: Node[][]): void {
|
||||||
|
const r = 5;
|
||||||
|
const c = 5;
|
||||||
|
|
||||||
|
const dots = [
|
||||||
|
[r+4, c], [r+4, c+1], [r+5, c], [r+5, c+1], // Block links
|
||||||
|
[r+4, c+10], [r+5, c+10], [r+6, c+10], [r+3, c+11], [r+7, c+11],
|
||||||
|
[r+2, c+12], [r+8, c+12], [r+2, c+13], [r+8, c+13], [r+5, c+14],
|
||||||
|
[r+3, c+15], [r+7, c+15], [r+4, c+16], [r+5, c+16], [r+6, c+16], [r+5, c+17],
|
||||||
|
[r+2, c+20], [r+3, c+20], [r+4, c+20], [r+2, c+21], [r+3, c+21], [r+4, c+21],
|
||||||
|
[r+1, c+22], [r+5, c+22], [r+0, c+24], [r+1, c+24], [r+5, c+24], [r+6, c+24],
|
||||||
|
[r+2, c+34], [r+3, c+34], [r+2, c+35], [r+3, c+35]
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [row, col] of dots) {
|
||||||
|
this.setAlive(grid, row, col);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setAlive(grid: Node[][], row: number, col: number): void {
|
||||||
|
if (row >= 0 && row < grid.length && col >= 0 && col < grid[0].length) {
|
||||||
|
grid[row][col].alive = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,11 +8,11 @@ import {MatInputModule} from '@angular/material/input';
|
|||||||
import {TranslatePipe, TranslateService} from '@ngx-translate/core';
|
import {TranslatePipe, TranslateService} from '@ngx-translate/core';
|
||||||
import {MatSnackBar} from '@angular/material/snack-bar';
|
import {MatSnackBar} from '@angular/material/snack-bar';
|
||||||
|
|
||||||
import {DEFAULT_GRID_COLS, DEFAULT_GRID_ROWS, MAX_GRID_PX, MAX_GRID_SIZE, MIN_GRID_SIZE, FourColorNode, Region} from './four-color.models';
|
import {DEFAULT_GRID_COLS, DEFAULT_GRID_ROWS, MAX_GRID_PX, MAX_GRID_SIZE, MIN_GRID_SIZE, FourColorMap, FourColorNode, SolutionStatus} from './four-color.models';
|
||||||
import {AlgorithmInformation} from '../information/information.models';
|
import {AlgorithmInformation} from '../information/information.models';
|
||||||
import {Information} from '../information/information';
|
import {Information} from '../information/information';
|
||||||
import {GridPos} from '../../../shared/components/generic-grid/generic-grid';
|
import {GridPos} from '../../../shared/components/generic-grid/generic-grid';
|
||||||
import {SharedFunctions} from '../../../shared/SharedFunctions';
|
import {FourColorService} from './service/four-color.service';
|
||||||
import {UrlConstants} from '../../../constants/UrlConstants';
|
import {UrlConstants} from '../../../constants/UrlConstants';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -32,6 +32,7 @@ import {UrlConstants} from '../../../constants/UrlConstants';
|
|||||||
styleUrl: './four-color.component.scss'
|
styleUrl: './four-color.component.scss'
|
||||||
})
|
})
|
||||||
export class FourColorComponent implements AfterViewInit {
|
export class FourColorComponent implements AfterViewInit {
|
||||||
|
private readonly fourColorService = inject(FourColorService);
|
||||||
private readonly translate = inject(TranslateService);
|
private readonly translate = inject(TranslateService);
|
||||||
private readonly snackBar = inject(MatSnackBar);
|
private readonly snackBar = inject(MatSnackBar);
|
||||||
|
|
||||||
@@ -60,10 +61,9 @@ export class FourColorComponent implements AfterViewInit {
|
|||||||
|
|
||||||
readonly gridRows = signal(DEFAULT_GRID_ROWS);
|
readonly gridRows = signal(DEFAULT_GRID_ROWS);
|
||||||
readonly gridCols = signal(DEFAULT_GRID_COLS);
|
readonly gridCols = signal(DEFAULT_GRID_COLS);
|
||||||
grid: FourColorNode[][] = [];
|
map: FourColorMap = {grid: [], regions: []};
|
||||||
regions: Region[] = [];
|
|
||||||
executionTime = 0;
|
executionTime = 0;
|
||||||
readonly solutionStatus = signal<'INCOMPLETE' | 'SOLVED' | 'CONFLICTS' | 'INVALID'>('INCOMPLETE');
|
readonly solutionStatus = signal<SolutionStatus>('INCOMPLETE');
|
||||||
|
|
||||||
@ViewChild('fourColorCanvas') canvasRef!: ElementRef<HTMLCanvasElement>;
|
@ViewChild('fourColorCanvas') canvasRef!: ElementRef<HTMLCanvasElement>;
|
||||||
private ctx!: CanvasRenderingContext2D;
|
private ctx!: CanvasRenderingContext2D;
|
||||||
@@ -82,22 +82,8 @@ export class FourColorComponent implements AfterViewInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
initializeGrid(): void {
|
initializeGrid(): void {
|
||||||
this.grid = [];
|
this.map = this.fourColorService.createMap(this.gridRows(), this.gridCols());
|
||||||
this.solutionStatus.set('INCOMPLETE');
|
this.solutionStatus.set('INCOMPLETE');
|
||||||
for (let r = 0; r < this.gridRows(); r++) {
|
|
||||||
const row: FourColorNode[] = [];
|
|
||||||
for (let c = 0; c < this.gridCols(); c++) {
|
|
||||||
row.push({
|
|
||||||
row: r,
|
|
||||||
col: c,
|
|
||||||
regionId: -1,
|
|
||||||
color: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
this.grid.push(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.generateRegions();
|
|
||||||
this.resizeCanvas();
|
this.resizeCanvas();
|
||||||
this.drawGrid();
|
this.drawGrid();
|
||||||
}
|
}
|
||||||
@@ -111,71 +97,6 @@ export class FourColorComponent implements AfterViewInit {
|
|||||||
canvas.height = this.gridRows() * this.nodeSize;
|
canvas.height = this.gridRows() * this.nodeSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
generateRegions(): void {
|
|
||||||
const numRegions = Math.floor((this.gridRows() * this.gridCols()) / 30);
|
|
||||||
this.regions = [];
|
|
||||||
const seeds = this.determineSeeds(numRegions);
|
|
||||||
this.regionGrowth(seeds);
|
|
||||||
this.determineAdjacency();
|
|
||||||
}
|
|
||||||
|
|
||||||
private determineAdjacency() {
|
|
||||||
for (let row = 0; row < this.gridRows(); row++) {
|
|
||||||
for (let col = 0; col < this.gridCols(); col++) {
|
|
||||||
const currentRegionId = this.grid[row][col].regionId;
|
|
||||||
const neighbors = this.getNeighbors(row, col);
|
|
||||||
for (const neighbor of neighbors) {
|
|
||||||
const neighborRegionId = this.grid[neighbor.row][neighbor.col].regionId;
|
|
||||||
if (neighborRegionId !== -1 && neighborRegionId !== currentRegionId) {
|
|
||||||
this.regions[currentRegionId].neighbors.add(neighborRegionId);
|
|
||||||
this.regions[neighborRegionId].neighbors.add(currentRegionId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private regionGrowth(seeds: GridPos[]) {
|
|
||||||
const queue: GridPos[] = [...seeds];
|
|
||||||
while (queue.length > 0) {
|
|
||||||
const {row, col} = queue.shift()!;
|
|
||||||
const regionId = this.grid[row][col].regionId;
|
|
||||||
|
|
||||||
const neighbors = this.getNeighbors(row, col);
|
|
||||||
for (const neighbor of neighbors) {
|
|
||||||
if (this.grid[neighbor.row][neighbor.col].regionId === -1) {
|
|
||||||
this.grid[neighbor.row][neighbor.col].regionId = regionId;
|
|
||||||
queue.push(neighbor);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private determineSeeds(numRegions: number) {
|
|
||||||
const seeds: GridPos[] = [];
|
|
||||||
for (let i = 0; i < numRegions; i++) {
|
|
||||||
let r = SharedFunctions.randomIntFromInterval(0, this.gridRows() - 1);
|
|
||||||
let c = SharedFunctions.randomIntFromInterval(0, this.gridCols() - 1);
|
|
||||||
while (this.grid[r][c].regionId !== -1) {
|
|
||||||
r = SharedFunctions.randomIntFromInterval(0, this.gridRows() - 1);
|
|
||||||
c = SharedFunctions.randomIntFromInterval(0, this.gridCols() - 1);
|
|
||||||
}
|
|
||||||
this.grid[r][c].regionId = i;
|
|
||||||
seeds.push({row: r, col: c});
|
|
||||||
this.regions.push({id: i, color: 0, neighbors: new Set<number>()});
|
|
||||||
}
|
|
||||||
return seeds;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getNeighbors(row: number, col: number): GridPos[] {
|
|
||||||
const res: GridPos[] = [];
|
|
||||||
if (row > 0) res.push({row: row - 1, col});
|
|
||||||
if (row < this.gridRows() - 1) res.push({row: row + 1, col});
|
|
||||||
if (col > 0) res.push({row, col: col - 1});
|
|
||||||
if (col < this.gridCols() - 1) res.push({row, col: col + 1});
|
|
||||||
return res;
|
|
||||||
}
|
|
||||||
|
|
||||||
drawGrid(): void {
|
drawGrid(): void {
|
||||||
if (!this.ctx) return;
|
if (!this.ctx) return;
|
||||||
|
|
||||||
@@ -184,7 +105,7 @@ export class FourColorComponent implements AfterViewInit {
|
|||||||
// 1. Draw Cell Backgrounds
|
// 1. Draw Cell Backgrounds
|
||||||
for (let r = 0; r < this.gridRows(); r++) {
|
for (let r = 0; r < this.gridRows(); r++) {
|
||||||
for (let c = 0; c < this.gridCols(); c++) {
|
for (let c = 0; c < this.gridCols(); c++) {
|
||||||
const node = this.grid[r][c];
|
const node = this.map.grid[r][c];
|
||||||
this.ctx.fillStyle = this.getNodeColor(node);
|
this.ctx.fillStyle = this.getNodeColor(node);
|
||||||
this.ctx.fillRect(c * this.nodeSize, r * this.nodeSize, this.nodeSize, this.nodeSize);
|
this.ctx.fillRect(c * this.nodeSize, r * this.nodeSize, this.nodeSize, this.nodeSize);
|
||||||
}
|
}
|
||||||
@@ -196,16 +117,16 @@ export class FourColorComponent implements AfterViewInit {
|
|||||||
this.ctx.beginPath();
|
this.ctx.beginPath();
|
||||||
for (let r = 0; r < this.gridRows(); r++) {
|
for (let r = 0; r < this.gridRows(); r++) {
|
||||||
for (let c = 0; c < this.gridCols(); c++) {
|
for (let c = 0; c < this.gridCols(); c++) {
|
||||||
const currentRegion = this.grid[r][c].regionId;
|
const currentRegion = this.map.grid[r][c].regionId;
|
||||||
|
|
||||||
// Right border
|
// Right border
|
||||||
if (c < this.gridCols() - 1 && this.grid[r][c+1].regionId !== currentRegion) {
|
if (c < this.gridCols() - 1 && this.map.grid[r][c+1].regionId !== currentRegion) {
|
||||||
this.ctx.moveTo((c + 1) * this.nodeSize, r * this.nodeSize);
|
this.ctx.moveTo((c + 1) * this.nodeSize, r * this.nodeSize);
|
||||||
this.ctx.lineTo((c + 1) * this.nodeSize, (r + 1) * this.nodeSize);
|
this.ctx.lineTo((c + 1) * this.nodeSize, (r + 1) * this.nodeSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bottom border
|
// Bottom border
|
||||||
if (r < this.gridRows() - 1 && this.grid[r+1][c].regionId !== currentRegion) {
|
if (r < this.gridRows() - 1 && this.map.grid[r+1][c].regionId !== currentRegion) {
|
||||||
this.ctx.moveTo(c * this.nodeSize, (r + 1) * this.nodeSize);
|
this.ctx.moveTo(c * this.nodeSize, (r + 1) * this.nodeSize);
|
||||||
this.ctx.lineTo((c + 1) * this.nodeSize, (r + 1) * this.nodeSize);
|
this.ctx.lineTo((c + 1) * this.nodeSize, (r + 1) * this.nodeSize);
|
||||||
}
|
}
|
||||||
@@ -267,33 +188,18 @@ export class FourColorComponent implements AfterViewInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private handleInteraction(pos: GridPos): void {
|
private handleInteraction(pos: GridPos): void {
|
||||||
const node = this.grid[pos.row][pos.col];
|
const node = this.map.grid[pos.row][pos.col];
|
||||||
if (node.regionId === -1){
|
if (node.regionId === -1){
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const region = this.regions[node.regionId];
|
this.fourColorService.cycleRegionColor(this.map, node.regionId);
|
||||||
region.color = (region.color % 4) + 1;
|
this.solutionStatus.set(this.fourColorService.evaluate(this.map.regions));
|
||||||
this.updateRegionColors(region);
|
|
||||||
this.checkSolution();
|
|
||||||
this.drawGrid();
|
this.drawGrid();
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateRegionColors(region: Region): void {
|
|
||||||
for (let row = 0; row < this.gridRows(); row++) {
|
|
||||||
for (let col = 0; col < this.gridCols(); col++) {
|
|
||||||
if (this.grid[row][col].regionId === region.id) {
|
|
||||||
this.grid[row][col].color = region.color;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
resetColors(): void {
|
resetColors(): void {
|
||||||
for (const region of this.regions) {
|
this.fourColorService.resetColors(this.map);
|
||||||
region.color = 0;
|
|
||||||
this.updateRegionColors(region);
|
|
||||||
}
|
|
||||||
this.solutionStatus.set('INCOMPLETE');
|
this.solutionStatus.set('INCOMPLETE');
|
||||||
this.drawGrid();
|
this.drawGrid();
|
||||||
}
|
}
|
||||||
@@ -302,12 +208,12 @@ export class FourColorComponent implements AfterViewInit {
|
|||||||
const startTime = performance.now();
|
const startTime = performance.now();
|
||||||
this.resetColors();
|
this.resetColors();
|
||||||
|
|
||||||
const success = this.backtrackSolve(0);
|
const success = this.fourColorService.solve(this.map);
|
||||||
const endTime = performance.now();
|
const endTime = performance.now();
|
||||||
this.executionTime = endTime - startTime;
|
this.executionTime = endTime - startTime;
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
this.checkSolution();
|
this.solutionStatus.set(this.fourColorService.evaluate(this.map.regions));
|
||||||
this.drawGrid();
|
this.drawGrid();
|
||||||
} else {
|
} else {
|
||||||
const message = this.translate.instant('FOUR_COLOR.ALERT.NO_SOLUTION');
|
const message = this.translate.instant('FOUR_COLOR.ALERT.NO_SOLUTION');
|
||||||
@@ -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 {
|
generateNewMap(): void {
|
||||||
this.initializeGrid();
|
this.initializeGrid();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,3 +16,13 @@ export const DEFAULT_GRID_COLS = 25;
|
|||||||
export const MIN_GRID_SIZE = 20;
|
export const MIN_GRID_SIZE = 20;
|
||||||
export const MAX_GRID_SIZE = 38;
|
export const MAX_GRID_SIZE = 38;
|
||||||
export const MAX_GRID_PX = 600;
|
export const MAX_GRID_PX = 600;
|
||||||
|
|
||||||
|
export type SolutionStatus = 'INCOMPLETE' | 'SOLVED' | 'CONFLICTS' | 'INVALID';
|
||||||
|
|
||||||
|
export interface FourColorMap {
|
||||||
|
grid: FourColorNode[][];
|
||||||
|
regions: Region[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CELLS_PER_REGION = 30;
|
||||||
|
export const COLOR_COUNT = 4;
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import {FourColorService} from './four-color.service';
|
||||||
|
import {FourColorMap, Region} from '../four-color.models';
|
||||||
|
|
||||||
|
function region(id: number, color: number, neighbors: number[]): Region {
|
||||||
|
return {id, color, neighbors: new Set(neighbors)};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One grid row, one cell per region; all regions touch each other as given by `neighbors`. */
|
||||||
|
function handBuiltMap(regions: Region[]): FourColorMap {
|
||||||
|
return {
|
||||||
|
grid: [regions.map((r, col) => ({row: 0, col, regionId: r.id, color: 0}))],
|
||||||
|
regions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function complete(count: number): Region[] {
|
||||||
|
return Array.from({length: count}, (_, id) =>
|
||||||
|
region(id, 0, Array.from({length: count}, (_, other) => other).filter(other => other !== id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('FourColorService', () => {
|
||||||
|
const service = new FourColorService();
|
||||||
|
|
||||||
|
describe('createMap', () => {
|
||||||
|
it('creates floor(cells / 30) regions that cover every cell', () => {
|
||||||
|
const map = service.createMap(25, 25);
|
||||||
|
|
||||||
|
expect(map.regions.length).toBe(20);
|
||||||
|
const ids = new Set(map.grid.flat().map(node => node.regionId));
|
||||||
|
expect(ids.size).toBe(20);
|
||||||
|
for (const id of ids) {
|
||||||
|
expect(id).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(id).toBeLessThan(20);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds symmetric adjacency without self links', () => {
|
||||||
|
const map = service.createMap(25, 25);
|
||||||
|
|
||||||
|
for (const r of map.regions) {
|
||||||
|
expect(r.neighbors.has(r.id)).toBe(false);
|
||||||
|
for (const other of r.neighbors) {
|
||||||
|
expect(map.regions[other].neighbors.has(r.id)).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates no regions below 30 cells and evaluates the empty map as SOLVED', () => {
|
||||||
|
const map = service.createMap(5, 5);
|
||||||
|
|
||||||
|
expect(map.regions).toEqual([]);
|
||||||
|
expect(service.evaluate(map.regions)).toBe('SOLVED');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cycleRegionColor goes 0, 1, 2, 3, 4, 1 and colors all nodes of the region', () => {
|
||||||
|
const map = handBuiltMap([region(0, 0, [])]);
|
||||||
|
const seen: number[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
service.cycleRegionColor(map, 0);
|
||||||
|
seen.push(map.regions[0].color);
|
||||||
|
expect(map.grid[0][0].color).toBe(map.regions[0].color);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(seen).toEqual([1, 2, 3, 4, 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resetColors sets every region and node to 0', () => {
|
||||||
|
const map = handBuiltMap(complete(3));
|
||||||
|
service.solve(map);
|
||||||
|
|
||||||
|
service.resetColors(map);
|
||||||
|
|
||||||
|
expect(map.regions.every(r => r.color === 0)).toBe(true);
|
||||||
|
expect(map.grid.flat().every(node => node.color === 0)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('solve', () => {
|
||||||
|
it('colors K4 with four distinct colors and mirrors them to the grid', () => {
|
||||||
|
const map = handBuiltMap(complete(4));
|
||||||
|
|
||||||
|
expect(service.solve(map)).toBe(true);
|
||||||
|
|
||||||
|
expect(new Set(map.regions.map(r => r.color)).size).toBe(4);
|
||||||
|
map.regions.forEach((r, i) => expect(map.grid[0][i].color).toBe(r.color));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails on K5 and leaves every color at 0', () => {
|
||||||
|
const map = handBuiltMap(complete(5));
|
||||||
|
|
||||||
|
expect(service.solve(map)).toBe(false);
|
||||||
|
|
||||||
|
expect(map.regions.every(r => r.color === 0)).toBe(true);
|
||||||
|
expect(map.grid.flat().every(node => node.color === 0)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('solves a generated map', () => {
|
||||||
|
const map = service.createMap(25, 25);
|
||||||
|
|
||||||
|
expect(service.solve(map)).toBe(true);
|
||||||
|
expect(service.evaluate(map.regions)).toBe('SOLVED');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('evaluate', () => {
|
||||||
|
it('is INCOMPLETE when nothing is colored', () => {
|
||||||
|
expect(service.evaluate(complete(3))).toBe('INCOMPLETE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is SOLVED for a full valid coloring', () => {
|
||||||
|
const regions = complete(3);
|
||||||
|
regions.forEach((r, i) => r.color = i + 1);
|
||||||
|
|
||||||
|
expect(service.evaluate(regions)).toBe('SOLVED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is CONFLICTS for a partial coloring with a clash', () => {
|
||||||
|
const regions = complete(3);
|
||||||
|
regions[0].color = 1;
|
||||||
|
regions[1].color = 1;
|
||||||
|
|
||||||
|
expect(service.evaluate(regions)).toBe('CONFLICTS');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is INVALID for a full coloring with a clash', () => {
|
||||||
|
const regions = complete(3);
|
||||||
|
regions[0].color = 1;
|
||||||
|
regions[1].color = 1;
|
||||||
|
regions[2].color = 2;
|
||||||
|
|
||||||
|
expect(service.evaluate(regions)).toBe('INVALID');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import {Injectable} from '@angular/core';
|
||||||
|
import {CELLS_PER_REGION, COLOR_COUNT, FourColorMap, FourColorNode, Region, SolutionStatus} from '../four-color.models';
|
||||||
|
import {GridPos} from '../../../../shared/components/generic-grid/generic-grid';
|
||||||
|
import {SharedFunctions} from '../../../../shared/SharedFunctions';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class FourColorService {
|
||||||
|
|
||||||
|
createMap(rows: number, cols: number): FourColorMap {
|
||||||
|
const grid: FourColorNode[][] = [];
|
||||||
|
for (let r = 0; r < rows; r++) {
|
||||||
|
const row: FourColorNode[] = [];
|
||||||
|
for (let c = 0; c < cols; c++) {
|
||||||
|
row.push({row: r, col: c, regionId: -1, color: 0});
|
||||||
|
}
|
||||||
|
grid.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
const map: FourColorMap = {grid, regions: []};
|
||||||
|
const numRegions = Math.floor((rows * cols) / CELLS_PER_REGION);
|
||||||
|
const seeds = this.placeSeeds(map, numRegions);
|
||||||
|
this.growRegions(map, seeds);
|
||||||
|
this.linkNeighbors(map);
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
cycleRegionColor(map: FourColorMap, regionId: number): void {
|
||||||
|
const region = map.regions[regionId];
|
||||||
|
region.color = (region.color % COLOR_COUNT) + 1;
|
||||||
|
this.applyRegionColors(map);
|
||||||
|
}
|
||||||
|
|
||||||
|
resetColors(map: FourColorMap): void {
|
||||||
|
for (const region of map.regions) {
|
||||||
|
region.color = 0;
|
||||||
|
}
|
||||||
|
this.applyRegionColors(map);
|
||||||
|
}
|
||||||
|
|
||||||
|
solve(map: FourColorMap): boolean {
|
||||||
|
const solved = this.assignColors(map.regions, 0);
|
||||||
|
this.applyRegionColors(map);
|
||||||
|
return solved;
|
||||||
|
}
|
||||||
|
|
||||||
|
evaluate(regions: Region[]): SolutionStatus {
|
||||||
|
let allColored = true;
|
||||||
|
let hasConflicts = false;
|
||||||
|
|
||||||
|
for (const region of regions) {
|
||||||
|
if (region.color === 0) {
|
||||||
|
allColored = false;
|
||||||
|
}
|
||||||
|
if (region.color > 0 && !this.isColorValid(regions, region, region.color)) {
|
||||||
|
hasConflicts = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasConflicts) {
|
||||||
|
return allColored ? 'INVALID' : 'CONFLICTS';
|
||||||
|
}
|
||||||
|
return allColored ? 'SOLVED' : 'INCOMPLETE';
|
||||||
|
}
|
||||||
|
|
||||||
|
private placeSeeds(map: FourColorMap, numRegions: number): GridPos[] {
|
||||||
|
const rows = map.grid.length;
|
||||||
|
const cols = map.grid[0].length;
|
||||||
|
const seeds: GridPos[] = [];
|
||||||
|
for (let i = 0; i < numRegions; i++) {
|
||||||
|
let r = SharedFunctions.randomIntFromInterval(0, rows - 1);
|
||||||
|
let c = SharedFunctions.randomIntFromInterval(0, cols - 1);
|
||||||
|
while (map.grid[r][c].regionId !== -1) {
|
||||||
|
r = SharedFunctions.randomIntFromInterval(0, rows - 1);
|
||||||
|
c = SharedFunctions.randomIntFromInterval(0, cols - 1);
|
||||||
|
}
|
||||||
|
map.grid[r][c].regionId = i;
|
||||||
|
seeds.push({row: r, col: c});
|
||||||
|
map.regions.push({id: i, color: 0, neighbors: new Set<number>()});
|
||||||
|
}
|
||||||
|
return seeds;
|
||||||
|
}
|
||||||
|
|
||||||
|
private growRegions(map: FourColorMap, seeds: GridPos[]): void {
|
||||||
|
const queue: GridPos[] = [...seeds];
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const {row, col} = queue.shift()!;
|
||||||
|
const regionId = map.grid[row][col].regionId;
|
||||||
|
|
||||||
|
for (const neighbor of this.neighborsOf(map.grid, row, col)) {
|
||||||
|
if (map.grid[neighbor.row][neighbor.col].regionId === -1) {
|
||||||
|
map.grid[neighbor.row][neighbor.col].regionId = regionId;
|
||||||
|
queue.push(neighbor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private linkNeighbors(map: FourColorMap): void {
|
||||||
|
for (let row = 0; row < map.grid.length; row++) {
|
||||||
|
for (let col = 0; col < map.grid[0].length; col++) {
|
||||||
|
const currentRegionId = map.grid[row][col].regionId;
|
||||||
|
for (const neighbor of this.neighborsOf(map.grid, row, col)) {
|
||||||
|
const neighborRegionId = map.grid[neighbor.row][neighbor.col].regionId;
|
||||||
|
if (neighborRegionId !== -1 && neighborRegionId !== currentRegionId) {
|
||||||
|
map.regions[currentRegionId].neighbors.add(neighborRegionId);
|
||||||
|
map.regions[neighborRegionId].neighbors.add(currentRegionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private neighborsOf(grid: FourColorNode[][], row: number, col: number): GridPos[] {
|
||||||
|
const neighbors: GridPos[] = [];
|
||||||
|
if (row > 0) {
|
||||||
|
neighbors.push({row: row - 1, col});
|
||||||
|
}
|
||||||
|
if (row < grid.length - 1) {
|
||||||
|
neighbors.push({row: row + 1, col});
|
||||||
|
}
|
||||||
|
if (col > 0) {
|
||||||
|
neighbors.push({row, col: col - 1});
|
||||||
|
}
|
||||||
|
if (col < grid[0].length - 1) {
|
||||||
|
neighbors.push({row, col: col + 1});
|
||||||
|
}
|
||||||
|
return neighbors;
|
||||||
|
}
|
||||||
|
|
||||||
|
private assignColors(regions: Region[], regionIndex: number): boolean {
|
||||||
|
if (regionIndex === regions.length) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const region = regions[regionIndex];
|
||||||
|
for (let color = 1; color <= COLOR_COUNT; color++) {
|
||||||
|
if (this.isColorValid(regions, region, color)) {
|
||||||
|
region.color = color;
|
||||||
|
if (this.assignColors(regions, regionIndex + 1)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
region.color = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isColorValid(regions: Region[], region: Region, color: number): boolean {
|
||||||
|
for (const neighborId of region.neighbors) {
|
||||||
|
if (regions[neighborId].color === color) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyRegionColors(map: FourColorMap): void {
|
||||||
|
for (const row of map.grid) {
|
||||||
|
for (const node of row) {
|
||||||
|
if (node.regionId !== -1) {
|
||||||
|
node.color = map.regions[node.regionId].color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import {clampZoom, initialView, iterationsForZoom, zoomAroundPoint} from './fractal-view';
|
||||||
|
|
||||||
|
describe('fractal-view', () => {
|
||||||
|
describe('iterationsForZoom', () => {
|
||||||
|
it('is 100 up to zoom 1', () => {
|
||||||
|
expect(iterationsForZoom(0.2)).toBe(100);
|
||||||
|
expect(iterationsForZoom(1)).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds 200 iterations per decade of zoom, floored', () => {
|
||||||
|
expect(iterationsForZoom(10)).toBe(300);
|
||||||
|
expect(iterationsForZoom(100)).toBe(500);
|
||||||
|
expect(iterationsForZoom(2)).toBe(160);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clampZoom keeps the zoom inside the bounds', () => {
|
||||||
|
expect(clampZoom(0.01, 0.2, 64000)).toBe(0.2);
|
||||||
|
expect(clampZoom(1e6, 0.2, 64000)).toBe(64000);
|
||||||
|
expect(clampZoom(5, 0.2, 64000)).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('zoomAroundPoint', () => {
|
||||||
|
it('keeps the world point under the cursor fixed', () => {
|
||||||
|
const view = {zoom: 2, offsetX: 0.3, offsetY: -0.4};
|
||||||
|
const point = {x: 0.25, y: -0.1};
|
||||||
|
|
||||||
|
const next = zoomAroundPoint(view, point, 5);
|
||||||
|
|
||||||
|
expect(next.zoom).toBe(5);
|
||||||
|
expect(point.x / next.zoom + next.offsetX).toBeCloseTo(point.x / view.zoom + view.offsetX);
|
||||||
|
expect(point.y / next.zoom + next.offsetY).toBeCloseTo(point.y / view.zoom + view.offsetY);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the offsets when zooming at the view center', () => {
|
||||||
|
const next = zoomAroundPoint({zoom: 1, offsetX: 0.3, offsetY: -0.4}, {x: 0, y: 0}, 3);
|
||||||
|
|
||||||
|
expect(next).toEqual({zoom: 3, offsetX: 0.3, offsetY: -0.4});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('initialView', () => {
|
||||||
|
it('returns the Burning Ship preset for algorithm 2', () => {
|
||||||
|
expect(initialView(2)).toEqual({zoom: 8, offsetX: -1.75, offsetY: -0.03});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the Mandelbrot preset for algorithm 0', () => {
|
||||||
|
expect(initialView(0)).toEqual({zoom: 0.2, offsetX: -0.5, offsetY: 0});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('centers Julia, Newton and unknown algorithms', () => {
|
||||||
|
for (const algorithm of [1, 3, 99]) {
|
||||||
|
expect(initialView(algorithm)).toEqual({zoom: 0.2, offsetX: 0, offsetY: 0});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
export interface FractalView {
|
||||||
|
zoom: number;
|
||||||
|
offsetX: number;
|
||||||
|
offsetY: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function iterationsForZoom(zoom: number): number {
|
||||||
|
const baseIterations = 100;
|
||||||
|
const factor = 200;
|
||||||
|
|
||||||
|
if (zoom <= 1) {
|
||||||
|
return baseIterations;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.floor(baseIterations + Math.log10(zoom) * factor);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampZoom(zoom: number, min: number, max: number): number {
|
||||||
|
return Math.max(Math.min(zoom, max), min);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zooms to newZoom and moves the offset so the world point under `point` (view units) stays fixed. */
|
||||||
|
export function zoomAroundPoint(view: FractalView, point: { x: number; y: number }, newZoom: number): FractalView {
|
||||||
|
const worldX = point.x / view.zoom + view.offsetX;
|
||||||
|
const worldY = point.y / view.zoom + view.offsetY;
|
||||||
|
return {
|
||||||
|
zoom: newZoom,
|
||||||
|
offsetX: worldX - point.x / newZoom,
|
||||||
|
offsetY: worldY - point.y / newZoom
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initialView(algorithm: number): FractalView {
|
||||||
|
switch (algorithm) {
|
||||||
|
case 0: return {zoom: 0.2, offsetX: -0.5, offsetY: 0};
|
||||||
|
case 2: return {zoom: 8, offsetX: -1.75, offsetY: -0.03};
|
||||||
|
default: return {zoom: 0.2, offsetX: 0, offsetY: 0};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import {PointerEventTypes, PointerInfo, ShaderMaterial, Vector2} from '@babylonj
|
|||||||
import {MatButton} from '@angular/material/button';
|
import {MatButton} from '@angular/material/button';
|
||||||
import {MatIcon} from '@angular/material/icon';
|
import {MatIcon} from '@angular/material/icon';
|
||||||
import {NgxSliderModule, Options} from '@angular-slider/ngx-slider';
|
import {NgxSliderModule, Options} from '@angular-slider/ngx-slider';
|
||||||
|
import {clampZoom, initialView, iterationsForZoom, zoomAroundPoint} from './fractal-view';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-fractal',
|
selector: 'app-fractal',
|
||||||
@@ -141,18 +142,8 @@ export class FractalComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onReset(): void {
|
onReset(): void {
|
||||||
|
({zoom: this.zoom, offsetX: this.offsetX, offsetY: this.offsetY} = initialView(this.selectedAlgorithm));
|
||||||
this.offsetY = 0.0;
|
|
||||||
this.zoom = 0.2
|
|
||||||
this.maxIterations = 100;
|
this.maxIterations = 100;
|
||||||
|
|
||||||
switch(this.selectedAlgorithm) {
|
|
||||||
case 0: this.offsetX = -0.5; break;
|
|
||||||
case 1: this.offsetX = 0; break;
|
|
||||||
case 2: this.offsetX = -1.75; this.zoom = 8; this.offsetY = -0.03;break;
|
|
||||||
case 3: this.offsetX = 0; break;
|
|
||||||
default: this.offsetX = 0.0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onSceneReady(event: SceneEventData): void {
|
onSceneReady(event: SceneEventData): void {
|
||||||
@@ -226,40 +217,24 @@ export class FractalComponent implements OnInit {
|
|||||||
const mouseXView = mouseXPixels / rect.height;
|
const mouseXView = mouseXPixels / rect.height;
|
||||||
const mouseYView = mouseYPixels / rect.height;
|
const mouseYView = mouseYPixels / rect.height;
|
||||||
|
|
||||||
const mouseXWorld = mouseXView / this.zoom + this.offsetX;
|
|
||||||
const mouseYWorld = mouseYView / this.zoom + this.offsetY;
|
|
||||||
|
|
||||||
const zoomFactor = 1.1;
|
const zoomFactor = 1.1;
|
||||||
if (event.deltaY < 0) {
|
const newZoom = clampZoom(
|
||||||
this.zoom *= zoomFactor;
|
event.deltaY < 0 ? this.zoom * zoomFactor : this.zoom / zoomFactor,
|
||||||
} else {
|
this.minZoomValue,
|
||||||
this.zoom /= zoomFactor;
|
this.maxZoomValue
|
||||||
}
|
);
|
||||||
|
|
||||||
this.zoom = Math.max(Math.min(this.zoom, this.maxZoomValue), this.minZoomValue);
|
({zoom: this.zoom, offsetX: this.offsetX, offsetY: this.offsetY} = zoomAroundPoint(
|
||||||
|
{zoom: this.zoom, offsetX: this.offsetX, offsetY: this.offsetY},
|
||||||
|
{x: mouseXView, y: mouseYView},
|
||||||
|
newZoom
|
||||||
|
));
|
||||||
this.sliderValue.set(this.zoom);
|
this.sliderValue.set(this.zoom);
|
||||||
const optimalIterations = this.getIterationsForZoom(this.zoom);
|
this.maxIterations = Math.min(iterationsForZoom(this.zoom), 3000);
|
||||||
this.maxIterations = Math.min(optimalIterations, 3000);
|
|
||||||
|
|
||||||
this.offsetX = mouseXWorld - mouseXView / this.zoom;
|
|
||||||
this.offsetY = mouseYWorld - mouseYView / this.zoom;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onSliderChange(newValue: number): void {
|
onSliderChange(newValue: number): void {
|
||||||
|
this.zoom = clampZoom(newValue, this.minZoomValue, this.maxZoomValue);
|
||||||
this.zoom = newValue;
|
this.maxIterations = iterationsForZoom(this.zoom);
|
||||||
this.zoom = Math.max(Math.min(this.zoom, this.maxZoomValue), this.minZoomValue);
|
|
||||||
this.maxIterations = this.getIterationsForZoom(this.zoom);
|
|
||||||
}
|
|
||||||
|
|
||||||
private getIterationsForZoom(zoom: number): number {
|
|
||||||
const baseIterations = 100;
|
|
||||||
const factor = 200;
|
|
||||||
|
|
||||||
if (zoom <= 1) {
|
|
||||||
return baseIterations;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Math.floor(baseIterations + Math.log10(zoom) * factor);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,11 +5,12 @@ import {TranslatePipe} from '@ngx-translate/core';
|
|||||||
import {GenericGridComponent, GridPos} from '../../../../shared/components/generic-grid/generic-grid';
|
import {GenericGridComponent, GridPos} from '../../../../shared/components/generic-grid/generic-grid';
|
||||||
import {AlgorithmInformation} from '../../information/information.models';
|
import {AlgorithmInformation} from '../../information/information.models';
|
||||||
import {UrlConstants} from '../../../../constants/UrlConstants';
|
import {UrlConstants} from '../../../../constants/UrlConstants';
|
||||||
import {Node} from '../pathfinding.models';
|
import {createPathfindingNode, Node, PathfindingAlgorithm} from '../pathfinding.models';
|
||||||
import {SharedFunctions} from '../../../../shared/SharedFunctions';
|
|
||||||
import {MatButton} from '@angular/material/button';
|
import {MatButton} from '@angular/material/button';
|
||||||
import {DecimalPipe} from '@angular/common';
|
import {DecimalPipe} from '@angular/common';
|
||||||
import {PathfindingService} from '../service/pathfinding.service';
|
import {PathfindingService} from '../service/pathfinding.service';
|
||||||
|
import {PathfindingAnimator} from '../pathfinding-animator';
|
||||||
|
import {MazeGeneratorService} from './service/maze-generator.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-labyrinth',
|
selector: 'app-labyrinth',
|
||||||
@@ -35,7 +36,7 @@ export class LabyrinthComponent implements AfterViewInit {
|
|||||||
protected readonly MAX_GRID_PX = 1000;
|
protected readonly MAX_GRID_PX = 1000;
|
||||||
protected readonly MIN_GRID_SIZE = 101;
|
protected readonly MIN_GRID_SIZE = 101;
|
||||||
private readonly pathfindingService = inject(PathfindingService);
|
private readonly pathfindingService = inject(PathfindingService);
|
||||||
|
private readonly mazeGenerator = inject(MazeGeneratorService);
|
||||||
|
|
||||||
algoInformation: AlgorithmInformation = {
|
algoInformation: AlgorithmInformation = {
|
||||||
title: 'LABYRINTH.EXPLANATION.TITLE',
|
title: 'LABYRINTH.EXPLANATION.TITLE',
|
||||||
@@ -66,7 +67,10 @@ export class LabyrinthComponent implements AfterViewInit {
|
|||||||
readonly pathLength = signal("0");
|
readonly pathLength = signal("0");
|
||||||
readonly executionTime = signal(0);
|
readonly executionTime = signal(0);
|
||||||
|
|
||||||
private timeoutIds: number[] = [];
|
private readonly animator = new PathfindingAnimator(
|
||||||
|
node => this.genericGridComponent?.drawNode(node),
|
||||||
|
() => this.animationSpeed
|
||||||
|
);
|
||||||
protected mazeNodesInOrder: Node[] = [];
|
protected mazeNodesInOrder: Node[] = [];
|
||||||
readonly isAnimationRunning = signal(false);
|
readonly isAnimationRunning = signal(false);
|
||||||
|
|
||||||
@@ -93,247 +97,31 @@ export class LabyrinthComponent implements AfterViewInit {
|
|||||||
|
|
||||||
createRandom(prim: boolean): void {
|
createRandom(prim: boolean): void {
|
||||||
this.isAnimationRunning.set(true);
|
this.isAnimationRunning.set(true);
|
||||||
this.stopAnimations();
|
this.animator.stop();
|
||||||
this.clearPath();
|
this.clearPath();
|
||||||
this.startNode = null;
|
const result = prim ? this.mazeGenerator.generatePrim(this.grid) : this.mazeGenerator.generateKruskal(this.grid);
|
||||||
this.endNode = null;
|
this.startNode = result.startNode;
|
||||||
if (prim)
|
this.endNode = result.endNode;
|
||||||
{
|
this.mazeNodesInOrder = result.nodesToCarveInOrder;
|
||||||
this.createPrimMaze();
|
|
||||||
}
|
|
||||||
else{
|
|
||||||
this.createKruskalMaze();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.cleanupGrid();
|
|
||||||
this.genericGridComponent.drawGrid();
|
this.genericGridComponent.drawGrid();
|
||||||
}
|
|
||||||
// ------- Kuskal -------
|
|
||||||
private createKruskalMaze(): void {
|
|
||||||
this.initKuskal();
|
|
||||||
this.mazeNodesInOrder = [];
|
|
||||||
const walls = this.findWallsWithADistanceOfTwoRooms();
|
|
||||||
SharedFunctions.shuffleArray(walls);
|
|
||||||
for (const wallInfo of walls) {
|
|
||||||
const { row, col, roomA, roomB } = wallInfo;
|
|
||||||
|
|
||||||
if (roomA.nodeData !== roomB.nodeData) {
|
|
||||||
const wallNode = this.grid[row][col];
|
|
||||||
wallNode.isWall = true;
|
|
||||||
this.mazeNodesInOrder.push(wallNode);
|
|
||||||
|
|
||||||
const oldId = roomB.nodeData;
|
|
||||||
const newId = roomA.nodeData;
|
|
||||||
|
|
||||||
this.mergeSets(oldId, newId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.setRandomStartAndEnd();
|
|
||||||
this.animateMazeGeneration();
|
this.animateMazeGeneration();
|
||||||
}
|
}
|
||||||
|
|
||||||
private initKuskal() {
|
visualize(algorithm: PathfindingAlgorithm): void {
|
||||||
let roomId = 0;
|
this.animator.stop();
|
||||||
|
|
||||||
for (let row = 0; row < this.gridRows; row++) {
|
|
||||||
for (let col = 0; col < this.gridCols; col++) {
|
|
||||||
const node = this.grid[row][col];
|
|
||||||
node.isStart = false;
|
|
||||||
node.isEnd = false;
|
|
||||||
|
|
||||||
if (row % 2 === 0 && col % 2 === 0) {
|
|
||||||
node.isWall = false;
|
|
||||||
node.nodeData = roomId++;
|
|
||||||
} else {
|
|
||||||
node.isWall = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private mergeSets(oldId: number, newId: number): void {
|
|
||||||
for (let r = 0; r < this.gridRows; r += 2) {
|
|
||||||
for (let c = 0; c < this.gridCols; c += 2) {
|
|
||||||
if (this.grid[r][c].nodeData === oldId) {
|
|
||||||
this.grid[r][c].nodeData = newId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private findWallsWithADistanceOfTwoRooms() {
|
|
||||||
const walls: { row: number, col: number, roomA: Node, roomB: Node }[] = [];
|
|
||||||
for (let row = 0; row < this.gridRows; row++) {
|
|
||||||
for (let col = 0; col < this.gridCols; col++) {
|
|
||||||
if (row % 2 === 0 && col % 2 !== 0 && col > 0 && col < this.gridCols - 1) {
|
|
||||||
walls.push({
|
|
||||||
row, col,
|
|
||||||
roomA: this.grid[row][col - 1],
|
|
||||||
roomB: this.grid[row][col + 1]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (row % 2 !== 0 && col % 2 === 0 && row > 0 && row < this.gridRows - 1) {
|
|
||||||
walls.push({
|
|
||||||
row, col,
|
|
||||||
roomA: this.grid[row - 1][col],
|
|
||||||
roomB: this.grid[row + 1][col]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return walls;
|
|
||||||
}
|
|
||||||
|
|
||||||
private setRandomStartAndEnd(): void {
|
|
||||||
const lastRow = Math.floor((this.gridRows - 1) / 2) * 2;
|
|
||||||
const lastCol = Math.floor((this.gridCols - 1) / 2) * 2;
|
|
||||||
|
|
||||||
const corners = [
|
|
||||||
{ r: 0, c: 0 },
|
|
||||||
{ r: 0, c: lastCol },
|
|
||||||
{ r: lastRow, c: 0 },
|
|
||||||
{ r: lastRow, c: lastCol }
|
|
||||||
];
|
|
||||||
|
|
||||||
const startIndex = Math.floor(Math.random() * corners.length);
|
|
||||||
let endIndex = Math.floor(Math.random() * corners.length);
|
|
||||||
while (endIndex === startIndex) {
|
|
||||||
endIndex = Math.floor(Math.random() * corners.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
const start = corners[startIndex];
|
|
||||||
const end = corners[endIndex];
|
|
||||||
|
|
||||||
this.startNode = this.grid[start.r][start.c];
|
|
||||||
this.startNode.isStart = true;
|
|
||||||
this.startNode.isWall = false;
|
|
||||||
|
|
||||||
this.endNode =this.grid[end.r][end.c];
|
|
||||||
this.endNode.isEnd = true;
|
|
||||||
this.endNode.isWall = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------- PRIM -------
|
|
||||||
private createPrimMaze(): void {
|
|
||||||
this.initPrim();
|
|
||||||
this.mazeNodesInOrder = [];
|
|
||||||
const frontier: Node[] = [];
|
|
||||||
|
|
||||||
const {startRow, startCol, startNode} = this.findStartNode();
|
|
||||||
this.mazeNodesInOrder.push(startNode);
|
|
||||||
|
|
||||||
this.getNeighborWalls(startRow, startCol, frontier);
|
|
||||||
|
|
||||||
while (frontier.length > 0) {
|
|
||||||
const randomIndex = SharedFunctions.randomIntFromInterval(0, frontier.length - 1);
|
|
||||||
const lastIndex = frontier.length - 1;
|
|
||||||
[frontier[randomIndex], frontier[lastIndex]] = [frontier[lastIndex], frontier[randomIndex]];
|
|
||||||
|
|
||||||
const wallNode = frontier.pop()!;
|
|
||||||
const target = wallNode.linkedNode;
|
|
||||||
|
|
||||||
if (!target || target.isVisited) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
wallNode.isVisited = true;
|
|
||||||
target.isVisited = true;
|
|
||||||
|
|
||||||
this.mazeNodesInOrder.push(wallNode, target);
|
|
||||||
|
|
||||||
this.getNeighborWalls(target.row, target.col, frontier);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.setRandomStartAndEnd();
|
|
||||||
this.animateMazeGeneration();
|
|
||||||
}
|
|
||||||
|
|
||||||
private initPrim() {
|
|
||||||
for (let row = 0; row < this.grid.length; row++) {
|
|
||||||
for (let col = 0; col < this.grid[row].length; col++) {
|
|
||||||
this.grid[row][col].isWall = true;
|
|
||||||
this.grid[row][col].isStart = false;
|
|
||||||
this.grid[row][col].isEnd = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private cleanupGrid() {
|
|
||||||
for (let row = 0; row < this.grid.length; row++) {
|
|
||||||
for (let col = 0; col < this.grid[row].length; col++) {
|
|
||||||
this.grid[row][col].isVisited = false;
|
|
||||||
this.grid[row][col].linkedNode = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private findStartNode() {
|
|
||||||
const startRow: number = SharedFunctions.randomEventIntFromInterval(this.gridRows - 1);
|
|
||||||
const startCol: number = SharedFunctions.randomEventIntFromInterval(this.gridCols - 1);
|
|
||||||
|
|
||||||
const startNode = this.grid[startRow][startCol];
|
|
||||||
startNode.isWall = false;
|
|
||||||
startNode.isVisited = true;
|
|
||||||
return {startRow, startCol, startNode};
|
|
||||||
}
|
|
||||||
|
|
||||||
visualize(algorithm: string): void {
|
|
||||||
this.stopAnimations();
|
|
||||||
this.clearPath();
|
this.clearPath();
|
||||||
|
|
||||||
const startTime = performance.now();
|
const startTime = performance.now();
|
||||||
let result;
|
const result = this.pathfindingService.findPath(algorithm, this.grid, this.startNode!, this.endNode!);
|
||||||
|
|
||||||
switch (algorithm) {
|
|
||||||
case 'dijkstra': result = this.pathfindingService.dijkstra(
|
|
||||||
this.grid,
|
|
||||||
this.grid[this.startNode!.row][this.startNode!.col],
|
|
||||||
this.grid[this.endNode!.row][this.endNode!.col]
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
case 'astar': result = this.pathfindingService.aStar(
|
|
||||||
this.grid,
|
|
||||||
this.grid[this.startNode!.row][this.startNode!.col],
|
|
||||||
this.grid[this.endNode!.row][this.endNode!.col]
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!result)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const endTime = performance.now();
|
const endTime = performance.now();
|
||||||
const lengthOfShortestPath = result.nodesInShortestPathOrder.length;
|
|
||||||
if (lengthOfShortestPath === 0)
|
this.pathLength.set(PathfindingAnimator.pathLengthLabel(result));
|
||||||
{
|
|
||||||
this.pathLength.set("∞");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
this.pathLength.set(result.nodesInShortestPathOrder.length + "");
|
|
||||||
}
|
|
||||||
this.executionTime.set(endTime - startTime);
|
this.executionTime.set(endTime - startTime);
|
||||||
|
|
||||||
this.animateAlgorithm(result.visitedNodesInOrder, result.nodesInShortestPathOrder);
|
this.animator.animate(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
createMazeNode = (row: number, col: number): Node => {
|
readonly createMazeNode = createPathfindingNode;
|
||||||
return {
|
|
||||||
row,
|
|
||||||
col,
|
|
||||||
isStart: false,
|
|
||||||
isEnd: false,
|
|
||||||
isWall: false,
|
|
||||||
isVisited: false,
|
|
||||||
isPath: false,
|
|
||||||
nodeData: Infinity,
|
|
||||||
linkedNode: null,
|
|
||||||
hScore: 0,
|
|
||||||
fScore: Infinity,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
getMazeColor = (node: Node): string => {
|
getMazeColor = (node: Node): string => {
|
||||||
if (node.isStart) return 'green';
|
if (node.isStart) return 'green';
|
||||||
@@ -349,110 +137,27 @@ export class LabyrinthComponent implements AfterViewInit {
|
|||||||
//dont need a selection for the maze case
|
//dont need a selection for the maze case
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Animation (adapted to use genericGridComponent for redraw) ---
|
|
||||||
private stopAnimations(): void {
|
|
||||||
for (const id of this.timeoutIds) {
|
|
||||||
clearTimeout(id);
|
|
||||||
}
|
|
||||||
this.timeoutIds = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
private clearPath(): void {
|
private clearPath(): void {
|
||||||
for (let row = 0; row < this.gridRows; row++) {
|
this.pathfindingService.resetSearchState(this.grid);
|
||||||
for (let col = 0; col < this.gridCols; col++) {
|
|
||||||
const node = this.grid[row][col];
|
|
||||||
node.isVisited = false;
|
|
||||||
node.isPath = false;
|
|
||||||
node.nodeData = Infinity;
|
|
||||||
node.linkedNode = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.genericGridComponent?.drawGrid(); // Redraw the grid via generic grid component
|
this.genericGridComponent?.drawGrid(); // Redraw the grid via generic grid component
|
||||||
}
|
}
|
||||||
|
|
||||||
private animateAlgorithm(visited: Node[], path: Node[]): void {
|
|
||||||
for (let i = 0; i <= visited.length; i++) {
|
|
||||||
if (i === visited.length) {
|
|
||||||
const id = globalThis.setTimeout(() => this.animateShortestPath(path), this.animationSpeed * i);
|
|
||||||
this.timeoutIds.push(id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const node = visited[i];
|
|
||||||
const id = globalThis.setTimeout(() => {
|
|
||||||
if (!node.isStart && !node.isEnd) {
|
|
||||||
node.isVisited = true;
|
|
||||||
this.genericGridComponent?.drawNode(node);
|
|
||||||
}
|
|
||||||
}, this.animationSpeed * i);
|
|
||||||
|
|
||||||
this.timeoutIds.push(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private animateShortestPath(path: Node[]): void {
|
|
||||||
for (let i = 0; i < path.length; i++) {
|
|
||||||
const node = path[i];
|
|
||||||
const id = globalThis.setTimeout(() => {
|
|
||||||
if (!node.isStart && !node.isEnd) {
|
|
||||||
node.isPath = true;
|
|
||||||
this.genericGridComponent?.drawNode(node); // Redraw single node
|
|
||||||
}
|
|
||||||
}, this.animationSpeed * i);
|
|
||||||
|
|
||||||
this.timeoutIds.push(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
private animateMazeGeneration(): void {
|
private animateMazeGeneration(): void {
|
||||||
for (let i = 0; i < this.mazeNodesInOrder.length; i++) {
|
const last = this.mazeNodesInOrder.length - 1;
|
||||||
const id = globalThis.setTimeout(() => {
|
this.mazeNodesInOrder.forEach((node, i) => {
|
||||||
const node = this.mazeNodesInOrder[i];
|
this.animator.schedule(this.mazeAnimationSpeed * i, () => {
|
||||||
node.isWall = false;
|
node.isWall = false;
|
||||||
this.genericGridComponent?.drawNode(node);
|
this.genericGridComponent?.drawNode(node);
|
||||||
if (i === this.mazeNodesInOrder.length - 1) {
|
if (i === last) {
|
||||||
this.cleanupGrid();
|
|
||||||
if (this.startNode) {
|
if (this.startNode) {
|
||||||
this.genericGridComponent?.drawNode(this.startNode);
|
this.genericGridComponent?.drawNode(this.startNode);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.endNode) {
|
if (this.endNode) {
|
||||||
this.genericGridComponent?.drawNode(this.endNode);
|
this.genericGridComponent?.drawNode(this.endNode);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if (i == this.mazeNodesInOrder.length - 1) {
|
|
||||||
this.isAnimationRunning.set(false);
|
this.isAnimationRunning.set(false);
|
||||||
}
|
}
|
||||||
}, this.mazeAnimationSpeed * i);
|
});
|
||||||
|
});
|
||||||
this.timeoutIds.push(id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
//utility
|
|
||||||
private getNeighborWalls(row: number, col: number, frontier: Node[]): void{
|
|
||||||
|
|
||||||
const directions = [
|
|
||||||
[0, 2], [0, -2], [2, 0], [-2, 0]
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const [dr, dc] of directions) {
|
|
||||||
const nextRow = row + dr;
|
|
||||||
const nextCol = col + dc;
|
|
||||||
|
|
||||||
|
|
||||||
if (this.isValid(nextRow, nextCol) && this.grid[nextRow][nextCol].isWall && !this.grid[nextRow][nextCol].isVisited) {
|
|
||||||
const wallRow = row + dr / 2;
|
|
||||||
const wallCol = col + dc / 2;
|
|
||||||
|
|
||||||
const node = this.grid[wallRow][wallCol];
|
|
||||||
node.linkedNode = this.grid[nextRow][nextCol];
|
|
||||||
frontier.push(node);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
isValid = (row: number, col: number): boolean => {
|
|
||||||
return row >= 0 && row < this.gridRows && col >= 0 && col < this.gridCols;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import {MazeGeneratorService, MazeResult} from './maze-generator.service';
|
||||||
|
import {PathfindingService} from '../../service/pathfinding.service';
|
||||||
|
import {createPathfindingNode, Node} from '../../pathfinding.models';
|
||||||
|
|
||||||
|
const SIZE = 11;
|
||||||
|
const ROOMS = 36; // 6 x 6 rooms on even/even cells
|
||||||
|
|
||||||
|
function createGrid(): Node[][] {
|
||||||
|
return Array.from({length: SIZE}, (_, row) =>
|
||||||
|
Array.from({length: SIZE}, (_, col) => createPathfindingNode(row, col)));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MazeGeneratorService', () => {
|
||||||
|
const service = new MazeGeneratorService();
|
||||||
|
const generators = [
|
||||||
|
['prim', (grid: Node[][]) => service.generatePrim(grid)],
|
||||||
|
['kruskal', (grid: Node[][]) => service.generateKruskal(grid)],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
describe.each(generators)('%s', (_name, generate) => {
|
||||||
|
let grid: Node[][];
|
||||||
|
let result: MazeResult;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
grid = createGrid();
|
||||||
|
result = generate(grid);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('picks two different corner rooms as open start and end', () => {
|
||||||
|
expect(result.startNode).not.toBe(result.endNode);
|
||||||
|
expect(result.startNode.isStart).toBe(true);
|
||||||
|
expect(result.endNode.isEnd).toBe(true);
|
||||||
|
expect(result.startNode.isWall).toBe(false);
|
||||||
|
expect(result.endNode.isWall).toBe(false);
|
||||||
|
for (const node of [result.startNode, result.endNode]) {
|
||||||
|
expect([0, SIZE - 1]).toContain(node.row);
|
||||||
|
expect([0, SIZE - 1]).toContain(node.col);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves no search marks behind', () => {
|
||||||
|
for (const node of grid.flat()) {
|
||||||
|
expect(node.isVisited).toBe(false);
|
||||||
|
expect(node.linkedNode).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('connects start and end once the carve list is applied', () => {
|
||||||
|
for (const node of result.nodesToCarveInOrder) {
|
||||||
|
node.isWall = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let row = 0; row < SIZE; row += 2) {
|
||||||
|
for (let col = 0; col < SIZE; col += 2) {
|
||||||
|
expect(grid[row][col].isWall).toBe(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const path = new PathfindingService().dijkstra(grid, result.startNode, result.endNode);
|
||||||
|
expect(path.nodesInShortestPathOrder.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prim starts with every cell walled except the start room and the two corners', () => {
|
||||||
|
const grid = createGrid();
|
||||||
|
const result = service.generatePrim(grid);
|
||||||
|
|
||||||
|
const startRoom = result.nodesToCarveInOrder[0];
|
||||||
|
const open = new Set([startRoom, result.startNode, result.endNode]);
|
||||||
|
for (const node of grid.flat()) {
|
||||||
|
expect(node.isWall).toBe(!open.has(node));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('kruskal opens every room and carves exactly rooms - 1 walls', () => {
|
||||||
|
const grid = createGrid();
|
||||||
|
const result = service.generateKruskal(grid);
|
||||||
|
|
||||||
|
expect(result.nodesToCarveInOrder.length).toBe(ROOMS - 1);
|
||||||
|
for (const node of grid.flat()) {
|
||||||
|
const isRoom = node.row % 2 === 0 && node.col % 2 === 0;
|
||||||
|
expect(node.isWall).toBe(!isRoom);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prim carves the start room plus a wall and a room per other room', () => {
|
||||||
|
const result = service.generatePrim(createGrid());
|
||||||
|
|
||||||
|
expect(result.nodesToCarveInOrder.length).toBe(1 + 2 * (ROOMS - 1));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import {Injectable} from '@angular/core';
|
||||||
|
import {Node} from '../../pathfinding.models';
|
||||||
|
import {SharedFunctions} from '../../../../../shared/SharedFunctions';
|
||||||
|
|
||||||
|
export interface MazeResult {
|
||||||
|
/** Walls (and rooms for Prim) to open one after the other to animate the carving. */
|
||||||
|
nodesToCarveInOrder: Node[];
|
||||||
|
startNode: Node;
|
||||||
|
endNode: Node;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WallBetweenRooms {
|
||||||
|
row: number;
|
||||||
|
col: number;
|
||||||
|
roomA: Node;
|
||||||
|
roomB: Node;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class MazeGeneratorService {
|
||||||
|
|
||||||
|
generatePrim(grid: Node[][]): MazeResult {
|
||||||
|
this.fillWithWalls(grid);
|
||||||
|
const frontier: Node[] = [];
|
||||||
|
|
||||||
|
const startRoom = this.pickRandomStartRoom(grid);
|
||||||
|
const nodesToCarveInOrder: Node[] = [startRoom];
|
||||||
|
this.collectNeighborWalls(grid, startRoom.row, startRoom.col, frontier);
|
||||||
|
|
||||||
|
while (frontier.length > 0) {
|
||||||
|
const randomIndex = SharedFunctions.randomIntFromInterval(0, frontier.length - 1);
|
||||||
|
const lastIndex = frontier.length - 1;
|
||||||
|
[frontier[randomIndex], frontier[lastIndex]] = [frontier[lastIndex], frontier[randomIndex]];
|
||||||
|
|
||||||
|
const wallNode = frontier.pop()!;
|
||||||
|
const target = wallNode.linkedNode;
|
||||||
|
|
||||||
|
if (!target || target.isVisited) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
wallNode.isVisited = true;
|
||||||
|
target.isVisited = true;
|
||||||
|
|
||||||
|
nodesToCarveInOrder.push(wallNode, target);
|
||||||
|
|
||||||
|
this.collectNeighborWalls(grid, target.row, target.col, frontier);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.finish(grid, nodesToCarveInOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
generateKruskal(grid: Node[][]): MazeResult {
|
||||||
|
this.initRooms(grid);
|
||||||
|
const nodesToCarveInOrder: Node[] = [];
|
||||||
|
const walls = this.findWallsBetweenRooms(grid);
|
||||||
|
SharedFunctions.shuffleArray(walls);
|
||||||
|
for (const {row, col, roomA, roomB} of walls) {
|
||||||
|
if (roomA.nodeData !== roomB.nodeData) {
|
||||||
|
nodesToCarveInOrder.push(grid[row][col]);
|
||||||
|
|
||||||
|
this.mergeSets(grid, roomB.nodeData, roomA.nodeData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.finish(grid, nodesToCarveInOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
private finish(grid: Node[][], nodesToCarveInOrder: Node[]): MazeResult {
|
||||||
|
const {startNode, endNode} = this.pickRandomCorners(grid);
|
||||||
|
this.clearSearchMarks(grid);
|
||||||
|
return {nodesToCarveInOrder, startNode, endNode};
|
||||||
|
}
|
||||||
|
|
||||||
|
private fillWithWalls(grid: Node[][]): void {
|
||||||
|
for (const row of grid) {
|
||||||
|
for (const node of row) {
|
||||||
|
node.isWall = true;
|
||||||
|
node.isStart = false;
|
||||||
|
node.isEnd = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private initRooms(grid: Node[][]): void {
|
||||||
|
let roomId = 0;
|
||||||
|
|
||||||
|
for (const [row, nodes] of grid.entries()) {
|
||||||
|
for (const [col, node] of nodes.entries()) {
|
||||||
|
node.isStart = false;
|
||||||
|
node.isEnd = false;
|
||||||
|
|
||||||
|
if (row % 2 === 0 && col % 2 === 0) {
|
||||||
|
node.isWall = false;
|
||||||
|
node.nodeData = roomId++;
|
||||||
|
} else {
|
||||||
|
node.isWall = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mergeSets(grid: Node[][], oldId: number, newId: number): void {
|
||||||
|
for (let r = 0; r < grid.length; r += 2) {
|
||||||
|
for (let c = 0; c < grid[0].length; c += 2) {
|
||||||
|
if (grid[r][c].nodeData === oldId) {
|
||||||
|
grid[r][c].nodeData = newId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private findWallsBetweenRooms(grid: Node[][]): WallBetweenRooms[] {
|
||||||
|
const rows = grid.length;
|
||||||
|
const cols = grid[0].length;
|
||||||
|
const walls: WallBetweenRooms[] = [];
|
||||||
|
for (let row = 0; row < rows; row++) {
|
||||||
|
for (let col = 0; col < cols; col++) {
|
||||||
|
if (row % 2 === 0 && col % 2 !== 0 && col > 0 && col < cols - 1) {
|
||||||
|
walls.push({row, col, roomA: grid[row][col - 1], roomB: grid[row][col + 1]});
|
||||||
|
}
|
||||||
|
if (row % 2 !== 0 && col % 2 === 0 && row > 0 && row < rows - 1) {
|
||||||
|
walls.push({row, col, roomA: grid[row - 1][col], roomB: grid[row + 1][col]});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return walls;
|
||||||
|
}
|
||||||
|
|
||||||
|
private pickRandomCorners(grid: Node[][]): { startNode: Node; endNode: Node } {
|
||||||
|
const lastRow = Math.floor((grid.length - 1) / 2) * 2;
|
||||||
|
const lastCol = Math.floor((grid[0].length - 1) / 2) * 2;
|
||||||
|
|
||||||
|
const corners = [
|
||||||
|
{r: 0, c: 0},
|
||||||
|
{r: 0, c: lastCol},
|
||||||
|
{r: lastRow, c: 0},
|
||||||
|
{r: lastRow, c: lastCol}
|
||||||
|
];
|
||||||
|
|
||||||
|
const lastIndex = corners.length - 1;
|
||||||
|
const startIndex = SharedFunctions.randomIntFromInterval(0, lastIndex);
|
||||||
|
let endIndex = SharedFunctions.randomIntFromInterval(0, lastIndex);
|
||||||
|
while (endIndex === startIndex) {
|
||||||
|
endIndex = SharedFunctions.randomIntFromInterval(0, lastIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
const startNode = grid[corners[startIndex].r][corners[startIndex].c];
|
||||||
|
startNode.isStart = true;
|
||||||
|
startNode.isWall = false;
|
||||||
|
|
||||||
|
const endNode = grid[corners[endIndex].r][corners[endIndex].c];
|
||||||
|
endNode.isEnd = true;
|
||||||
|
endNode.isWall = false;
|
||||||
|
return {startNode, endNode};
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearSearchMarks(grid: Node[][]): void {
|
||||||
|
for (const row of grid) {
|
||||||
|
for (const node of row) {
|
||||||
|
node.isVisited = false;
|
||||||
|
node.linkedNode = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private pickRandomStartRoom(grid: Node[][]): Node {
|
||||||
|
const startRow = SharedFunctions.randomEventIntFromInterval(grid.length - 1);
|
||||||
|
const startCol = SharedFunctions.randomEventIntFromInterval(grid[0].length - 1);
|
||||||
|
|
||||||
|
const startNode = grid[startRow][startCol];
|
||||||
|
startNode.isWall = false;
|
||||||
|
startNode.isVisited = true;
|
||||||
|
return startNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
private collectNeighborWalls(grid: Node[][], row: number, col: number, frontier: Node[]): void {
|
||||||
|
const directions = [
|
||||||
|
[0, 2], [0, -2], [2, 0], [-2, 0]
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [dr, dc] of directions) {
|
||||||
|
const nextRow = row + dr;
|
||||||
|
const nextCol = col + dc;
|
||||||
|
|
||||||
|
if (this.isInside(grid, nextRow, nextCol) && grid[nextRow][nextCol].isWall && !grid[nextRow][nextCol].isVisited) {
|
||||||
|
const node = grid[row + dr / 2][col + dc / 2];
|
||||||
|
node.linkedNode = grid[nextRow][nextCol];
|
||||||
|
frontier.push(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private isInside(grid: Node[][], row: number, col: number): boolean {
|
||||||
|
return row >= 0 && row < grid.length && col >= 0 && col < grid[0].length;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import {Node, PathfindingResult} from './pathfinding.models';
|
||||||
|
|
||||||
|
/** Plays a search result node by node with timeouts. Shared by the pathfinding and labyrinth pages. */
|
||||||
|
export class PathfindingAnimator {
|
||||||
|
private timeoutIds: number[] = [];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly drawNode: (node: Node) => void,
|
||||||
|
private readonly getSpeed: () => number
|
||||||
|
) {}
|
||||||
|
|
||||||
|
static pathLengthLabel(result: PathfindingResult): string {
|
||||||
|
const length = result.nodesInShortestPathOrder.length;
|
||||||
|
return length === 0 ? '∞' : String(length);
|
||||||
|
}
|
||||||
|
|
||||||
|
schedule(delayMs: number, action: () => void): void {
|
||||||
|
this.timeoutIds.push(globalThis.setTimeout(action, delayMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
for (const id of this.timeoutIds) {
|
||||||
|
clearTimeout(id);
|
||||||
|
}
|
||||||
|
this.timeoutIds = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
animate(result: PathfindingResult): void {
|
||||||
|
const speed = this.getSpeed();
|
||||||
|
const visited = result.visitedNodesInOrder;
|
||||||
|
visited.forEach((node, i) => {
|
||||||
|
this.schedule(speed * i, () => {
|
||||||
|
if (!node.isStart && !node.isEnd) {
|
||||||
|
node.isVisited = true;
|
||||||
|
this.drawNode(node);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
this.schedule(speed * visited.length, () => this.animateShortestPath(result.nodesInShortestPathOrder));
|
||||||
|
}
|
||||||
|
|
||||||
|
private animateShortestPath(path: Node[]): void {
|
||||||
|
const speed = this.getSpeed();
|
||||||
|
path.forEach((node, i) => {
|
||||||
|
this.schedule(speed * i, () => {
|
||||||
|
if (!node.isStart && !node.isEnd) {
|
||||||
|
node.isPath = true;
|
||||||
|
this.drawNode(node);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,14 +10,15 @@ import {MatSnackBar} from '@angular/material/snack-bar';
|
|||||||
|
|
||||||
import {TranslatePipe, TranslateService} from '@ngx-translate/core';
|
import {TranslatePipe, TranslateService} from '@ngx-translate/core';
|
||||||
|
|
||||||
import {DEFAULT_GRID_COLS, DEFAULT_GRID_ROWS, MAX_GRID_PX, MAX_GRID_SIZE, MAX_RANDOM_WALLS_FACTORS, MIN_GRID_SIZE, Node} from './pathfinding.models';
|
import {createPathfindingNode, DEFAULT_GRID_COLS, DEFAULT_GRID_ROWS, MAX_GRID_PX, MAX_GRID_SIZE, MIN_GRID_SIZE, Node, PathfindingAlgorithm, PathfindingScenario} from './pathfinding.models';
|
||||||
import {PathfindingService} from './service/pathfinding.service';
|
import {PathfindingService} from './service/pathfinding.service';
|
||||||
|
import {PathfindingGridService} from './service/pathfinding-grid.service';
|
||||||
|
import {PathfindingAnimator} from './pathfinding-animator';
|
||||||
import {UrlConstants} from '../../../constants/UrlConstants';
|
import {UrlConstants} from '../../../constants/UrlConstants';
|
||||||
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
|
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
|
||||||
import {Information} from '../information/information';
|
import {Information} from '../information/information';
|
||||||
import {AlgorithmInformation} from '../information/information.models';
|
import {AlgorithmInformation} from '../information/information.models';
|
||||||
import {GenericGridComponent, GridPos} from '../../../shared/components/generic-grid/generic-grid';
|
import {GenericGridComponent, GridPos} from '../../../shared/components/generic-grid/generic-grid';
|
||||||
import {SharedFunctions} from '../../../shared/SharedFunctions';
|
|
||||||
|
|
||||||
enum NodeType {
|
enum NodeType {
|
||||||
Start = 'start',
|
Start = 'start',
|
||||||
@@ -47,6 +48,7 @@ enum NodeType {
|
|||||||
})
|
})
|
||||||
export class PathfindingComponent implements AfterViewInit {
|
export class PathfindingComponent implements AfterViewInit {
|
||||||
private readonly pathfindingService = inject(PathfindingService);
|
private readonly pathfindingService = inject(PathfindingService);
|
||||||
|
private readonly gridService = inject(PathfindingGridService);
|
||||||
private readonly translate = inject(TranslateService);
|
private readonly translate = inject(TranslateService);
|
||||||
private readonly snackBar = inject(MatSnackBar);
|
private readonly snackBar = inject(MatSnackBar);
|
||||||
|
|
||||||
@@ -88,7 +90,10 @@ export class PathfindingComponent implements AfterViewInit {
|
|||||||
readonly pathLength = signal("0");
|
readonly pathLength = signal("0");
|
||||||
readonly executionTime = 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;
|
@ViewChild(GenericGridComponent) genericGridComponent!: GenericGridComponent;
|
||||||
|
|
||||||
@@ -121,21 +126,7 @@ export class PathfindingComponent implements AfterViewInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Callbacks for GenericGridComponent ---
|
// --- Callbacks for GenericGridComponent ---
|
||||||
createPathfindingNode = (row: number, col: number): Node => {
|
readonly createPathfindingNode = createPathfindingNode;
|
||||||
return {
|
|
||||||
row,
|
|
||||||
col,
|
|
||||||
isStart: false,
|
|
||||||
isEnd: false,
|
|
||||||
isWall: false,
|
|
||||||
isVisited: false,
|
|
||||||
isPath: false,
|
|
||||||
nodeData: Infinity,
|
|
||||||
linkedNode: null,
|
|
||||||
hScore: 0,
|
|
||||||
fScore: Infinity,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
getPathfindingNodeColor = (node: Node): string => {
|
getPathfindingNodeColor = (node: Node): string => {
|
||||||
if (node.isStart) return 'green';
|
if (node.isStart) return 'green';
|
||||||
@@ -157,337 +148,80 @@ export class PathfindingComponent implements AfterViewInit {
|
|||||||
|
|
||||||
switch (this.selectedNodeType()) {
|
switch (this.selectedNodeType()) {
|
||||||
case NodeType.Start:
|
case NodeType.Start:
|
||||||
this.trySetStart(node);
|
if (this.gridService.placeStart(node, this.startNode)) {
|
||||||
|
if (this.startNode) {
|
||||||
|
this.genericGridComponent.drawNode(this.startNode); // Redraw old start node
|
||||||
|
}
|
||||||
|
this.startNode = node;
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case NodeType.End:
|
case NodeType.End:
|
||||||
this.trySetEnd(node);
|
if (this.gridService.placeEnd(node, this.endNode)) {
|
||||||
|
if (this.endNode) {
|
||||||
|
this.genericGridComponent.drawNode(this.endNode); // Redraw old end node
|
||||||
|
}
|
||||||
|
this.endNode = node;
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case NodeType.Wall:
|
case NodeType.Wall:
|
||||||
this.tryToggleWall(node, this.shouldAddWall);
|
this.gridService.setWall(node, this.shouldAddWall);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case NodeType.None:
|
case NodeType.None:
|
||||||
this.tryClearNode(node);
|
this.gridService.clearNode(node);
|
||||||
|
if (node === this.startNode) {
|
||||||
|
this.startNode = null;
|
||||||
|
}
|
||||||
|
if (node === this.endNode) {
|
||||||
|
this.endNode = null;
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
visualize(algorithm: string): void {
|
visualize(algorithm: PathfindingAlgorithm): void {
|
||||||
if (!this.ensureStartAndEnd()) {
|
if (!this.ensureStartAndEnd()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.stopAnimations();
|
this.animator.stop();
|
||||||
this.clearPath();
|
this.clearPath();
|
||||||
|
|
||||||
const startTime = performance.now();
|
const startTime = performance.now();
|
||||||
let result;
|
const result = this.pathfindingService.findPath(algorithm, this.grid, this.startNode!, this.endNode!);
|
||||||
|
|
||||||
switch (algorithm) {
|
|
||||||
case 'dijkstra': result = this.pathfindingService.dijkstra(
|
|
||||||
this.grid,
|
|
||||||
this.grid[this.startNode!.row][this.startNode!.col],
|
|
||||||
this.grid[this.endNode!.row][this.endNode!.col]
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
case 'astar': result = this.pathfindingService.aStar(
|
|
||||||
this.grid,
|
|
||||||
this.grid[this.startNode!.row][this.startNode!.col],
|
|
||||||
this.grid[this.endNode!.row][this.endNode!.col]
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!result)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const endTime = performance.now();
|
const endTime = performance.now();
|
||||||
const lengthOfShortestPath = result.nodesInShortestPathOrder.length;
|
|
||||||
if (lengthOfShortestPath === 0)
|
this.pathLength.set(PathfindingAnimator.pathLengthLabel(result));
|
||||||
{
|
|
||||||
this.pathLength.set("∞");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
this.pathLength.set(result.nodesInShortestPathOrder.length + "");
|
|
||||||
}
|
|
||||||
this.executionTime.set(endTime - startTime);
|
this.executionTime.set(endTime - startTime);
|
||||||
|
|
||||||
this.animateAlgorithm(result.visitedNodesInOrder, result.nodesInShortestPathOrder);
|
this.animator.animate(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
initializePathfindingGrid = (grid: Node[][]): void => {
|
initializePathfindingGrid = (grid: Node[][]): void => {
|
||||||
this.grid = grid; // Update the component's grid reference
|
this.setupGrid(grid, 'normal', true);
|
||||||
const {start, end} = this.getScenarioStartEnd('normal'); // Default scenario
|
|
||||||
this.startNode = this.grid[start.row][start.col];
|
|
||||||
this.endNode = this.grid[end.row][end.col];
|
|
||||||
|
|
||||||
this.startNode.isStart = true;
|
|
||||||
this.endNode.isEnd = true;
|
|
||||||
|
|
||||||
this.placeDefaultDiagonalWall('normal');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Helper methods for node manipulation (kept local) ---
|
createCase({withWalls, scenario}: { withWalls: boolean, scenario: PathfindingScenario }): void {
|
||||||
private trySetStart(node: Node): void {
|
this.animator.stop();
|
||||||
if (!this.canBeStart(node)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.startNode) {
|
|
||||||
this.startNode.isStart = false;
|
|
||||||
this.genericGridComponent.drawNode(this.startNode); // Redraw old start node
|
|
||||||
}
|
|
||||||
|
|
||||||
node.isStart = true;
|
|
||||||
this.startNode = node;
|
|
||||||
}
|
|
||||||
|
|
||||||
private trySetEnd(node: Node): void {
|
|
||||||
if (!this.canBeEnd(node)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.endNode) {
|
|
||||||
this.endNode.isEnd = false;
|
|
||||||
this.genericGridComponent.drawNode(this.endNode); // Redraw old end node
|
|
||||||
}
|
|
||||||
|
|
||||||
node.isEnd = true;
|
|
||||||
this.endNode = node;
|
|
||||||
}
|
|
||||||
|
|
||||||
private tryToggleWall(node: Node, shouldBeWall: boolean): void {
|
|
||||||
if (!this.canBeWall(node)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
node.isWall = shouldBeWall;
|
|
||||||
}
|
|
||||||
|
|
||||||
private tryClearNode(node: Node): void {
|
|
||||||
if (node.isStart) {
|
|
||||||
node.isStart = false;
|
|
||||||
this.startNode = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node.isEnd) {
|
|
||||||
node.isEnd = false;
|
|
||||||
this.endNode = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node.isWall) {
|
|
||||||
node.isWall = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private canBeStart(node: Node): boolean {
|
|
||||||
return !node.isEnd && !node.isWall;
|
|
||||||
}
|
|
||||||
|
|
||||||
private canBeEnd(node: Node): boolean {
|
|
||||||
return !node.isStart && !node.isWall;
|
|
||||||
}
|
|
||||||
|
|
||||||
private canBeWall(node: Node): boolean {
|
|
||||||
return !node.isStart && !node.isEnd;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Grid manipulation for scenarios (kept local) ---
|
|
||||||
createCase({withWalls, scenario}: { withWalls: boolean, scenario: "normal" | "edge" | "random" }): void {
|
|
||||||
this.stopAnimations();
|
|
||||||
// Reinitialize grid through the generic component
|
// Reinitialize grid through the generic component
|
||||||
this.genericGridComponent.initializationFn = (grid) => {
|
this.genericGridComponent.initializationFn = (grid) => this.setupGrid(grid, scenario, withWalls);
|
||||||
this.grid = grid;
|
|
||||||
const {start, end} = this.getScenarioStartEnd(scenario);
|
|
||||||
this.startNode = this.grid[start.row][start.col];
|
|
||||||
this.endNode = this.grid[end.row][end.col];
|
|
||||||
this.startNode.isStart = true;
|
|
||||||
this.endNode.isEnd = true;
|
|
||||||
|
|
||||||
if (withWalls) {
|
|
||||||
this.placeDefaultDiagonalWall(scenario);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
this.genericGridComponent.initializeGrid(); // Trigger re-initialization and redraw
|
this.genericGridComponent.initializeGrid(); // Trigger re-initialization and redraw
|
||||||
}
|
}
|
||||||
|
|
||||||
private getScenarioStartEnd(scenario: 'normal' | 'edge' | 'random'): { start: GridPos; end: GridPos } {
|
private setupGrid(grid: Node[][], scenario: PathfindingScenario, withWalls: boolean): void {
|
||||||
if (scenario === 'edge') {
|
this.grid = grid;
|
||||||
return {
|
const {startNode, endNode} = this.gridService.setupScenario(grid, scenario, withWalls);
|
||||||
start: {row: 0, col: 0},
|
this.startNode = startNode;
|
||||||
end: {row: this.gridRows() - 1, col: this.gridCols() - 1}
|
this.endNode = endNode;
|
||||||
};
|
|
||||||
} else if (scenario === 'random') {
|
|
||||||
return this.createRandomStartEndPosition();
|
|
||||||
} else {
|
|
||||||
// normal: mid-left -> mid-right
|
|
||||||
const midRow = Math.floor(this.gridRows() / 2);
|
|
||||||
return {
|
|
||||||
start: {row: midRow, col: 0},
|
|
||||||
end: {row: midRow, col: this.gridCols() - 1}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private createRandomStartEndPosition(): { start: GridPos; end: GridPos } {
|
|
||||||
const midCol = Math.floor(this.gridCols() / 2);
|
|
||||||
|
|
||||||
const startRow: number = SharedFunctions.randomIntFromInterval(0, this.gridRows() - 1);
|
|
||||||
const startCol: number = SharedFunctions.randomIntFromInterval(0, this.gridCols() - 1);
|
|
||||||
|
|
||||||
const endRow: number = SharedFunctions.randomIntFromInterval(0, this.gridRows() - 1);
|
|
||||||
let endCol: number;
|
|
||||||
|
|
||||||
if (startCol <= midCol) {
|
|
||||||
endCol = SharedFunctions.randomIntFromInterval(midCol + 1, this.gridCols() - 1);
|
|
||||||
} else {
|
|
||||||
endCol = SharedFunctions.randomIntFromInterval(0, midCol);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
start: {row: startRow, col: startCol},
|
|
||||||
end: {row: endRow, col: endCol}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private placeDefaultDiagonalWall(scenario: 'normal' | 'edge' | 'random'): void {
|
|
||||||
if (scenario === 'edge') {
|
|
||||||
this.createDiagonalWall();
|
|
||||||
} else if (scenario === 'normal') {
|
|
||||||
this.createVerticalWall();
|
|
||||||
} else if (scenario === 'random') {
|
|
||||||
this.createRandomWalls();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private createRandomWalls() {
|
|
||||||
const maxNumberOfWalls = Math.floor(MAX_RANDOM_WALLS_FACTORS * this.gridCols() * this.gridRows());
|
|
||||||
|
|
||||||
for (let wall = 0; wall < maxNumberOfWalls; wall++) {
|
|
||||||
|
|
||||||
const row: number = SharedFunctions.randomIntFromInterval(0, this.gridRows() - 1);
|
|
||||||
const col: number = SharedFunctions.randomIntFromInterval(0, this.gridCols() - 1);
|
|
||||||
|
|
||||||
if (!this.grid[row][col]) { // Use the grid passed from GenericGrid
|
|
||||||
wall--;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const node = this.grid[row][col];
|
|
||||||
if (node.isStart || node.isEnd) {
|
|
||||||
wall--;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
node.isWall = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
private createVerticalWall() {
|
|
||||||
const height = this.gridRows();
|
|
||||||
const startCol = Math.floor(this.gridCols() / 2);
|
|
||||||
|
|
||||||
for (let i = 5; i < (height - 5); i++) {
|
|
||||||
const row = i;
|
|
||||||
|
|
||||||
if (!this.grid[row]?.[startCol]) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const node = this.grid[row][startCol];
|
|
||||||
if (node.isStart || node.isEnd) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
node.isWall = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
private createDiagonalWall() {
|
|
||||||
// Diagonal-ish wall; avoids start/end
|
|
||||||
const len = Math.min(this.gridRows(), this.gridCols());
|
|
||||||
const startCol = Math.floor((this.gridCols() - len) / 2);
|
|
||||||
|
|
||||||
for (let i = 0; i < Math.max(0, len - 10); i++) {
|
|
||||||
const row = len - i - 1;
|
|
||||||
const col = startCol + i;
|
|
||||||
|
|
||||||
if (!this.grid[row]?.[col]) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const node = this.grid[row][col];
|
|
||||||
if (node.isStart || node.isEnd) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
node.isWall = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Animation (adapted to use genericGridComponent for redraw) ---
|
|
||||||
private stopAnimations(): void {
|
|
||||||
for (const id of this.timeoutIds) {
|
|
||||||
clearTimeout(id);
|
|
||||||
}
|
|
||||||
this.timeoutIds = [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private clearPath(): void {
|
private clearPath(): void {
|
||||||
for (let row = 0; row < this.gridRows(); row++) {
|
this.pathfindingService.resetSearchState(this.grid);
|
||||||
for (let col = 0; col < this.gridCols(); col++) {
|
|
||||||
const node = this.grid[row][col];
|
|
||||||
node.isVisited = false;
|
|
||||||
node.isPath = false;
|
|
||||||
node.nodeData = Infinity;
|
|
||||||
node.linkedNode = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.genericGridComponent?.drawGrid(); // Redraw the grid via generic grid component
|
this.genericGridComponent?.drawGrid(); // Redraw the grid via generic grid component
|
||||||
}
|
}
|
||||||
|
|
||||||
private animateAlgorithm(visited: Node[], path: Node[]): void {
|
|
||||||
for (let i = 0; i <= visited.length; i++) {
|
|
||||||
if (i === visited.length) {
|
|
||||||
const id = globalThis.setTimeout(() => this.animateShortestPath(path), this.animationSpeed * i);
|
|
||||||
this.timeoutIds.push(id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const node = visited[i];
|
|
||||||
const id = globalThis.setTimeout(() => {
|
|
||||||
if (!node.isStart && !node.isEnd) {
|
|
||||||
node.isVisited = true;
|
|
||||||
this.genericGridComponent?.drawNode(node); // Redraw single node
|
|
||||||
}
|
|
||||||
}, this.animationSpeed * i);
|
|
||||||
|
|
||||||
this.timeoutIds.push(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private animateShortestPath(path: Node[]): void {
|
|
||||||
for (let i = 0; i < path.length; i++) {
|
|
||||||
const node = path[i];
|
|
||||||
const id = globalThis.setTimeout(() => {
|
|
||||||
if (!node.isStart && !node.isEnd) {
|
|
||||||
node.isPath = true;
|
|
||||||
this.genericGridComponent?.drawNode(node); // Redraw single node
|
|
||||||
}
|
|
||||||
}, this.animationSpeed * i);
|
|
||||||
|
|
||||||
this.timeoutIds.push(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Validation ---
|
// --- Validation ---
|
||||||
private ensureStartAndEnd(): boolean {
|
private ensureStartAndEnd(): boolean {
|
||||||
if (this.startNode && this.endNode) {
|
if (this.startNode && this.endNode) {
|
||||||
|
|||||||
@@ -12,6 +12,30 @@ export interface Node {
|
|||||||
hScore: number;
|
hScore: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PathfindingScenario = 'normal' | 'edge' | 'random';
|
||||||
|
export type PathfindingAlgorithm = 'dijkstra' | 'astar';
|
||||||
|
|
||||||
|
export interface PathfindingResult {
|
||||||
|
visitedNodesInOrder: Node[];
|
||||||
|
nodesInShortestPathOrder: Node[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPathfindingNode(row: number, col: number): Node {
|
||||||
|
return {
|
||||||
|
row,
|
||||||
|
col,
|
||||||
|
isStart: false,
|
||||||
|
isEnd: false,
|
||||||
|
isWall: false,
|
||||||
|
isVisited: false,
|
||||||
|
isPath: false,
|
||||||
|
nodeData: Infinity,
|
||||||
|
linkedNode: null,
|
||||||
|
hScore: 0,
|
||||||
|
fScore: Infinity,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const DEFAULT_GRID_ROWS = 50;
|
export const DEFAULT_GRID_ROWS = 50;
|
||||||
export const DEFAULT_GRID_COLS = 50;
|
export const DEFAULT_GRID_COLS = 50;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import {PathfindingGridService} from './pathfinding-grid.service';
|
||||||
|
import {createPathfindingNode, Node} from '../pathfinding.models';
|
||||||
|
|
||||||
|
function createGrid(rows: number, cols: number): Node[][] {
|
||||||
|
return Array.from({length: rows}, (_, row) =>
|
||||||
|
Array.from({length: cols}, (_, col) => createPathfindingNode(row, col)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function countWalls(grid: Node[][]): number {
|
||||||
|
return grid.flat().filter(node => node.isWall).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PathfindingGridService', () => {
|
||||||
|
let service: PathfindingGridService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
service = new PathfindingGridService();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setupScenario', () => {
|
||||||
|
it('places start and end mid-left and mid-right with a vertical wall for "normal"', () => {
|
||||||
|
const grid = createGrid(20, 20);
|
||||||
|
|
||||||
|
const {startNode, endNode} = service.setupScenario(grid, 'normal', true);
|
||||||
|
|
||||||
|
expect(startNode).toBe(grid[10][0]);
|
||||||
|
expect(endNode).toBe(grid[10][19]);
|
||||||
|
expect(startNode.isStart).toBe(true);
|
||||||
|
expect(endNode.isEnd).toBe(true);
|
||||||
|
for (let row = 0; row < 20; row++) {
|
||||||
|
expect(grid[row][10].isWall).toBe(row >= 5 && row < 15);
|
||||||
|
}
|
||||||
|
expect(startNode.isWall).toBe(false);
|
||||||
|
expect(endNode.isWall).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('places start and end in opposite corners with a diagonal wall for "edge"', () => {
|
||||||
|
const grid = createGrid(20, 20);
|
||||||
|
|
||||||
|
const {startNode, endNode} = service.setupScenario(grid, 'edge', true);
|
||||||
|
|
||||||
|
expect(startNode).toBe(grid[0][0]);
|
||||||
|
expect(endNode).toBe(grid[19][19]);
|
||||||
|
expect(countWalls(grid)).toBe(10);
|
||||||
|
expect(startNode.isWall).toBe(false);
|
||||||
|
expect(endNode.isWall).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('puts start and end on different sides and places up to 30% walls for "random"', () => {
|
||||||
|
const grid = createGrid(20, 20);
|
||||||
|
|
||||||
|
const {startNode, endNode} = service.setupScenario(grid, 'random', true);
|
||||||
|
|
||||||
|
const mid = 10;
|
||||||
|
expect(startNode.col <= mid).not.toBe(endNode.col <= mid);
|
||||||
|
// 120 random picks; repeated picks of one cell make the wall count lower.
|
||||||
|
expect(countWalls(grid)).toBeGreaterThan(60);
|
||||||
|
expect(countWalls(grid)).toBeLessThanOrEqual(120);
|
||||||
|
expect(startNode.isWall).toBe(false);
|
||||||
|
expect(endNode.isWall).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['normal', 'edge', 'random'] as const)('adds no walls without walls flag for "%s"', (scenario) => {
|
||||||
|
const grid = createGrid(20, 20);
|
||||||
|
|
||||||
|
service.setupScenario(grid, scenario, false);
|
||||||
|
|
||||||
|
expect(countWalls(grid)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['normal', 'edge', 'random'] as const)('stays inside a small grid for "%s"', (scenario) => {
|
||||||
|
const grid = createGrid(10, 10);
|
||||||
|
|
||||||
|
const {startNode, endNode} = service.setupScenario(grid, scenario, true);
|
||||||
|
|
||||||
|
expect(grid.flat()).toContain(startNode);
|
||||||
|
expect(grid.flat()).toContain(endNode);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('node placement', () => {
|
||||||
|
it('placeStart refuses end and wall nodes', () => {
|
||||||
|
const grid = createGrid(1, 3);
|
||||||
|
grid[0][1].isEnd = true;
|
||||||
|
grid[0][2].isWall = true;
|
||||||
|
|
||||||
|
expect(service.placeStart(grid[0][1], null)).toBe(false);
|
||||||
|
expect(service.placeStart(grid[0][2], null)).toBe(false);
|
||||||
|
expect(grid[0][1].isStart).toBe(false);
|
||||||
|
expect(grid[0][2].isStart).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('placeStart moves the start flag from the previous node', () => {
|
||||||
|
const grid = createGrid(1, 2);
|
||||||
|
grid[0][0].isStart = true;
|
||||||
|
|
||||||
|
expect(service.placeStart(grid[0][1], grid[0][0])).toBe(true);
|
||||||
|
|
||||||
|
expect(grid[0][0].isStart).toBe(false);
|
||||||
|
expect(grid[0][1].isStart).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('placeEnd refuses start and wall nodes and moves the end flag', () => {
|
||||||
|
const grid = createGrid(1, 4);
|
||||||
|
grid[0][0].isStart = true;
|
||||||
|
grid[0][1].isWall = true;
|
||||||
|
grid[0][2].isEnd = true;
|
||||||
|
|
||||||
|
expect(service.placeEnd(grid[0][0], grid[0][2])).toBe(false);
|
||||||
|
expect(service.placeEnd(grid[0][1], grid[0][2])).toBe(false);
|
||||||
|
expect(grid[0][2].isEnd).toBe(true);
|
||||||
|
|
||||||
|
expect(service.placeEnd(grid[0][3], grid[0][2])).toBe(true);
|
||||||
|
expect(grid[0][2].isEnd).toBe(false);
|
||||||
|
expect(grid[0][3].isEnd).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setWall refuses start and end nodes', () => {
|
||||||
|
const grid = createGrid(1, 3);
|
||||||
|
grid[0][0].isStart = true;
|
||||||
|
grid[0][1].isEnd = true;
|
||||||
|
|
||||||
|
expect(service.setWall(grid[0][0], true)).toBe(false);
|
||||||
|
expect(service.setWall(grid[0][1], true)).toBe(false);
|
||||||
|
expect(service.setWall(grid[0][2], true)).toBe(true);
|
||||||
|
expect(grid[0][2].isWall).toBe(true);
|
||||||
|
expect(service.setWall(grid[0][2], false)).toBe(true);
|
||||||
|
expect(grid[0][2].isWall).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clearNode clears exactly the flag the node has', () => {
|
||||||
|
const grid = createGrid(1, 3);
|
||||||
|
grid[0][0].isStart = true;
|
||||||
|
grid[0][1].isEnd = true;
|
||||||
|
grid[0][2].isWall = true;
|
||||||
|
|
||||||
|
service.clearNode(grid[0][0]);
|
||||||
|
expect(grid[0][0].isStart).toBe(false);
|
||||||
|
expect(grid[0][1].isEnd).toBe(true);
|
||||||
|
expect(grid[0][2].isWall).toBe(true);
|
||||||
|
|
||||||
|
service.clearNode(grid[0][1]);
|
||||||
|
service.clearNode(grid[0][2]);
|
||||||
|
expect(grid[0][1].isEnd).toBe(false);
|
||||||
|
expect(grid[0][2].isWall).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the random scenario inside a 2-column grid', () => {
|
||||||
|
const grid = createGrid(5, 2);
|
||||||
|
|
||||||
|
const {endNode} = service.setupScenario(grid, 'random', false);
|
||||||
|
|
||||||
|
expect(grid.flat()).toContain(endNode);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('picks different start and end nodes for the random scenario', () => {
|
||||||
|
for (let run = 0; run < 300; run++) {
|
||||||
|
const grid = createGrid(2, 2);
|
||||||
|
|
||||||
|
const {startNode, endNode} = service.setupScenario(grid, 'random', false);
|
||||||
|
|
||||||
|
expect(endNode).not.toBe(startNode);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import {Injectable} from '@angular/core';
|
||||||
|
import {MAX_RANDOM_WALLS_FACTORS, Node, PathfindingScenario} from '../pathfinding.models';
|
||||||
|
import {GridPos} from '../../../../shared/components/generic-grid/generic-grid';
|
||||||
|
import {SharedFunctions} from '../../../../shared/SharedFunctions';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class PathfindingGridService {
|
||||||
|
|
||||||
|
setupScenario(grid: Node[][], scenario: PathfindingScenario, withWalls: boolean): { startNode: Node; endNode: Node } {
|
||||||
|
const rows = grid.length;
|
||||||
|
const cols = grid[0].length;
|
||||||
|
const {start, end} = this.getScenarioStartEnd(rows, cols, scenario);
|
||||||
|
const startNode = grid[start.row][start.col];
|
||||||
|
const endNode = grid[end.row][end.col];
|
||||||
|
startNode.isStart = true;
|
||||||
|
endNode.isEnd = true;
|
||||||
|
|
||||||
|
if (withWalls) {
|
||||||
|
this.placeScenarioWalls(grid, scenario);
|
||||||
|
}
|
||||||
|
return {startNode, endNode};
|
||||||
|
}
|
||||||
|
|
||||||
|
placeStart(node: Node, previousStart: Node | null): boolean {
|
||||||
|
if (node.isEnd || node.isWall) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (previousStart) {
|
||||||
|
previousStart.isStart = false;
|
||||||
|
}
|
||||||
|
node.isStart = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
placeEnd(node: Node, previousEnd: Node | null): boolean {
|
||||||
|
if (node.isStart || node.isWall) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (previousEnd) {
|
||||||
|
previousEnd.isEnd = false;
|
||||||
|
}
|
||||||
|
node.isEnd = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
setWall(node: Node, isWall: boolean): boolean {
|
||||||
|
if (node.isStart || node.isEnd) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
node.isWall = isWall;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearNode(node: Node): void {
|
||||||
|
if (node.isStart) {
|
||||||
|
node.isStart = false;
|
||||||
|
} else if (node.isEnd) {
|
||||||
|
node.isEnd = false;
|
||||||
|
} else if (node.isWall) {
|
||||||
|
node.isWall = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getScenarioStartEnd(rows: number, cols: number, scenario: PathfindingScenario): { start: GridPos; end: GridPos } {
|
||||||
|
if (scenario === 'edge') {
|
||||||
|
return {
|
||||||
|
start: {row: 0, col: 0},
|
||||||
|
end: {row: rows - 1, col: cols - 1}
|
||||||
|
};
|
||||||
|
} else if (scenario === 'random') {
|
||||||
|
return this.createRandomStartEndPosition(rows, cols);
|
||||||
|
} else {
|
||||||
|
const midRow = Math.floor(rows / 2);
|
||||||
|
return {
|
||||||
|
start: {row: midRow, col: 0},
|
||||||
|
end: {row: midRow, col: cols - 1}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private createRandomStartEndPosition(rows: number, cols: number): { start: GridPos; end: GridPos } {
|
||||||
|
const midCol = Math.floor(cols / 2);
|
||||||
|
|
||||||
|
const startRow = SharedFunctions.randomIntFromInterval(0, rows - 1);
|
||||||
|
const startCol = SharedFunctions.randomIntFromInterval(0, cols - 1);
|
||||||
|
|
||||||
|
let endRow = SharedFunctions.randomIntFromInterval(0, rows - 1);
|
||||||
|
const endCol = startCol <= midCol
|
||||||
|
? SharedFunctions.randomIntFromInterval(Math.min(midCol + 1, cols - 1), cols - 1)
|
||||||
|
: SharedFunctions.randomIntFromInterval(0, midCol);
|
||||||
|
while (endRow === startRow && endCol === startCol) {
|
||||||
|
endRow = SharedFunctions.randomIntFromInterval(0, rows - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
start: {row: startRow, col: startCol},
|
||||||
|
end: {row: endRow, col: endCol}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private placeScenarioWalls(grid: Node[][], scenario: PathfindingScenario): void {
|
||||||
|
if (scenario === 'edge') {
|
||||||
|
this.createDiagonalWall(grid);
|
||||||
|
} else if (scenario === 'normal') {
|
||||||
|
this.createVerticalWall(grid);
|
||||||
|
} else {
|
||||||
|
this.createRandomWalls(grid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private createRandomWalls(grid: Node[][]): void {
|
||||||
|
const rows = grid.length;
|
||||||
|
const cols = grid[0].length;
|
||||||
|
const maxNumberOfWalls = Math.floor(MAX_RANDOM_WALLS_FACTORS * cols * rows);
|
||||||
|
|
||||||
|
for (let wall = 0; wall < maxNumberOfWalls; wall++) {
|
||||||
|
const row = SharedFunctions.randomIntFromInterval(0, rows - 1);
|
||||||
|
const col = SharedFunctions.randomIntFromInterval(0, cols - 1);
|
||||||
|
|
||||||
|
const node = grid[row][col];
|
||||||
|
if (node.isStart || node.isEnd) {
|
||||||
|
wall--;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
node.isWall = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private createVerticalWall(grid: Node[][]): void {
|
||||||
|
const height = grid.length;
|
||||||
|
const startCol = Math.floor(grid[0].length / 2);
|
||||||
|
|
||||||
|
for (let row = 5; row < (height - 5); row++) {
|
||||||
|
const node = grid[row][startCol];
|
||||||
|
if (node.isStart || node.isEnd) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
node.isWall = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private createDiagonalWall(grid: Node[][]): void {
|
||||||
|
const rows = grid.length;
|
||||||
|
const cols = grid[0].length;
|
||||||
|
const len = Math.min(rows, cols);
|
||||||
|
const startCol = Math.floor((cols - len) / 2);
|
||||||
|
|
||||||
|
for (let i = 0; i < Math.max(0, len - 10); i++) {
|
||||||
|
const node = grid[len - i - 1][startCol + i];
|
||||||
|
if (node.isStart || node.isEnd) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
node.isWall = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -118,4 +118,34 @@ describe('PathfindingService', () => {
|
|||||||
expect(nodesInShortestPathOrder[nodesInShortestPathOrder.length - 1]).toBe(end);
|
expect(nodesInShortestPathOrder[nodesInShortestPathOrder.length - 1]).toBe(end);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('findPath', () => {
|
||||||
|
it.each(['dijkstra', 'astar'] as const)('%s finds the shortest path on an open grid', (algorithm) => {
|
||||||
|
const grid = createGrid(5, 5);
|
||||||
|
|
||||||
|
const { nodesInShortestPathOrder } = service.findPath(algorithm, grid, grid[0][0], grid[4][4]);
|
||||||
|
|
||||||
|
expect(nodesInShortestPathOrder.length).toBe(9);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resetSearchState', () => {
|
||||||
|
it('clears the search marks and keeps the node roles', () => {
|
||||||
|
const grid = createGrid(2, 2);
|
||||||
|
const node = grid[0][1];
|
||||||
|
Object.assign(node, { isVisited: true, isPath: true, nodeData: 3, linkedNode: grid[0][0], isWall: true });
|
||||||
|
grid[0][0].isStart = true;
|
||||||
|
grid[1][1].isEnd = true;
|
||||||
|
|
||||||
|
service.resetSearchState(grid);
|
||||||
|
|
||||||
|
expect(node.isVisited).toBe(false);
|
||||||
|
expect(node.isPath).toBe(false);
|
||||||
|
expect(node.nodeData).toBe(Infinity);
|
||||||
|
expect(node.linkedNode).toBeNull();
|
||||||
|
expect(node.isWall).toBe(true);
|
||||||
|
expect(grid[0][0].isStart).toBe(true);
|
||||||
|
expect(grid[1][1].isEnd).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { Node} from '../pathfinding.models';
|
import { Node, PathfindingAlgorithm, PathfindingResult } from '../pathfinding.models';
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
@@ -30,8 +30,25 @@ export class PathfindingService {
|
|||||||
return shortestPathNodes;
|
return shortestPathNodes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
findPath(algorithm: PathfindingAlgorithm, grid: Node[][], startNode: Node, endNode: Node): PathfindingResult {
|
||||||
|
return algorithm === 'astar'
|
||||||
|
? this.aStar(grid, startNode, endNode)
|
||||||
|
: this.dijkstra(grid, startNode, endNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
resetSearchState(grid: Node[][]): void {
|
||||||
|
for (const row of grid) {
|
||||||
|
for (const node of row) {
|
||||||
|
node.isVisited = false;
|
||||||
|
node.isPath = false;
|
||||||
|
node.nodeData = Infinity;
|
||||||
|
node.linkedNode = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Dijkstra's Algorithm
|
// Dijkstra's Algorithm
|
||||||
dijkstra(grid: Node[][], startNode: Node, endNode: Node): { visitedNodesInOrder: Node[], nodesInShortestPathOrder: Node[] } {
|
dijkstra(grid: Node[][], startNode: Node, endNode: Node): PathfindingResult {
|
||||||
const visitedNodesInOrder: Node[] = [];
|
const visitedNodesInOrder: Node[] = [];
|
||||||
startNode.nodeData = 0;
|
startNode.nodeData = 0;
|
||||||
const unvisitedNodes: Node[] = this.getAllNodes(grid);
|
const unvisitedNodes: Node[] = this.getAllNodes(grid);
|
||||||
@@ -77,7 +94,7 @@ export class PathfindingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A* Search Algorithm
|
// A* Search Algorithm
|
||||||
aStar(grid: Node[][], startNode: Node, endNode: Node): { visitedNodesInOrder: Node[], nodesInShortestPathOrder: Node[] } {
|
aStar(grid: Node[][], startNode: Node, endNode: Node): PathfindingResult {
|
||||||
const visitedNodesInOrder: Node[] = [];
|
const visitedNodesInOrder: Node[] = [];
|
||||||
startNode.nodeData = 0;
|
startNode.nodeData = 0;
|
||||||
startNode['hScore'] = this.calculateHeuristic(startNode, endNode);
|
startNode['hScore'] = this.calculateHeuristic(startNode, endNode);
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import {PendulumPhysicsParams, PendulumState, stepPendulumPhysics} from './pendulum-cpu-physics';
|
||||||
|
|
||||||
|
const params: PendulumPhysicsParams = {
|
||||||
|
dt: 0.01, g: 9.81, m1: 1, m2: 1, l1: 1, l2: 1, damping: 1, impulseM1: 0, impulseM2: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
function step(state: PendulumState, overrides: Partial<PendulumPhysicsParams> = {}): PendulumState {
|
||||||
|
const next = {...state};
|
||||||
|
stepPendulumPhysics(next, {...params, ...overrides});
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('stepPendulumPhysics', () => {
|
||||||
|
it('keeps a pendulum hanging at rest', () => {
|
||||||
|
expect(step({theta1: 0, theta2: 0, v1: 0, v2: 0})).toEqual({theta1: 0, theta2: 0, v1: 0, v2: 0});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pulls a displaced first arm back (negative angular velocity)', () => {
|
||||||
|
const next = step({theta1: 0.3, theta2: 0, v1: 0, v2: 0});
|
||||||
|
|
||||||
|
expect(next.v1).toBeLessThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds the impulse to the velocities at rest with damping 1', () => {
|
||||||
|
const next = step({theta1: 0, theta2: 0, v1: 0, v2: 0}, {impulseM1: 0.5, impulseM2: -0.25});
|
||||||
|
|
||||||
|
expect(next.v1).toBeCloseTo(0.5);
|
||||||
|
expect(next.v2).toBeCloseTo(-0.25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is mirror symmetric', () => {
|
||||||
|
const state = {theta1: 0.7, theta2: -0.2, v1: 0.3, v2: 0.1};
|
||||||
|
const mirrored = {theta1: -0.7, theta2: 0.2, v1: -0.3, v2: -0.1};
|
||||||
|
|
||||||
|
const a = step(state);
|
||||||
|
const b = step(mirrored);
|
||||||
|
|
||||||
|
expect(b.theta1).toBeCloseTo(-a.theta1);
|
||||||
|
expect(b.theta2).toBeCloseTo(-a.theta2);
|
||||||
|
expect(b.v1).toBeCloseTo(-a.v1);
|
||||||
|
expect(b.v2).toBeCloseTo(-a.v2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('damping below 1 reduces the speed', () => {
|
||||||
|
const state = {theta1: 0, theta2: 0, v1: 2, v2: -2};
|
||||||
|
|
||||||
|
const free = step(state);
|
||||||
|
const damped = step(state, {damping: 0.9});
|
||||||
|
|
||||||
|
expect(Math.abs(damped.v1)).toBeLessThan(Math.abs(free.v1));
|
||||||
|
expect(Math.abs(damped.v2)).toBeLessThan(Math.abs(free.v2));
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user