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