From 718bc28bd0c4d798aed265a1eb7133c60a3dfd7f Mon Sep 17 00:00:00 2001 From: lobo Date: Wed, 14 Oct 2026 15:40:20 +0200 Subject: [PATCH] feat(pathfinding): add A* vs Dijkstra compare mode Show A* and Dijkstra side by side on the same grid with visited count and path length. Also clamp typed grid sizes above the max instead of crashing. --- .../pathfinding/pathfinding.component.html | 54 ++++-- .../pathfinding/pathfinding.component.spec.ts | 171 ++++++++++++++++++ .../pathfinding/pathfinding.component.ts | 131 ++++++++++++-- .../pathfinding/pathfinding.models.ts | 5 + .../service/pathfinding-grid.service.spec.ts | 29 +++ .../service/pathfinding-grid.service.ts | 16 ++ src/assets/i18n/de.json | 2 + src/assets/i18n/en.json | 2 + 8 files changed, 383 insertions(+), 27 deletions(-) create mode 100644 src/app/pages/algorithms/pathfinding/pathfinding.component.spec.ts diff --git a/src/app/pages/algorithms/pathfinding/pathfinding.component.html b/src/app/pages/algorithms/pathfinding/pathfinding.component.html index a95f25b..252d71e 100644 --- a/src/app/pages/algorithms/pathfinding/pathfinding.component.html +++ b/src/app/pages/algorithms/pathfinding/pathfinding.component.html @@ -9,6 +9,7 @@
+
@@ -58,23 +59,44 @@ {{ 'PATHFINDING.VISITED' | translate }} {{ 'PATHFINDING.PATH' | translate }}
-
-

{{ 'PATHFINDING.PATH_LENGTH' | translate }}: {{ pathLength() }}

-

{{ 'PATHFINDING.EXECUTION_TIME' | translate }}: {{ executionTime() | number:'1.2-2' }} ms

-
+ @if (!compareMode()) { +
+

{{ 'PATHFINDING.PATH_LENGTH' | translate }}: {{ pathLength() }}

+

{{ 'PATHFINDING.EXECUTION_TIME' | translate }}: {{ executionTime() | number:'1.2-2' }} ms

+
+ } - +
+
+ @if (compareMode()) { +

A* · {{ 'PATHFINDING.VISITED_NODES' | translate }}: {{ astarStats()?.visitedCount }} · {{ 'PATHFINDING.PATH_LENGTH' | translate }}: {{ astarStats()?.pathLength }}

+ } + +
+
+

Dijkstra · {{ 'PATHFINDING.VISITED_NODES' | translate }}: {{ dijkstraStats()?.visitedCount }} · {{ 'PATHFINDING.PATH_LENGTH' | translate }}: {{ dijkstraStats()?.pathLength }}

+ +
+
diff --git a/src/app/pages/algorithms/pathfinding/pathfinding.component.spec.ts b/src/app/pages/algorithms/pathfinding/pathfinding.component.spec.ts new file mode 100644 index 0000000..496c8f7 --- /dev/null +++ b/src/app/pages/algorithms/pathfinding/pathfinding.component.spec.ts @@ -0,0 +1,171 @@ +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 {PathfindingComponent} from './pathfinding.component'; +import {Node} from './pathfinding.models'; + +function countWalls(grid: Node[][]): number { + return grid.flat().filter(node => node.isWall).length; +} + +describe('PathfindingComponent compare mode', () => { + let fixture: ComponentFixture; + let component: PathfindingComponent; + const originalGetContext = HTMLCanvasElement.prototype.getContext; + + const compareGrid = (): Node[][] => component.compareGridComponent.grid as Node[][]; + + beforeEach(async () => { + vi.useFakeTimers({toFake: ['setTimeout', 'clearTimeout']}); + // 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: [PathfindingComponent], + providers: [ + provideZonelessChangeDetection(), + provideNoopAnimations(), + provideTranslateService({lang: 'en', fallbackLang: 'en'}), + ], + }).compileComponents(); + + fixture = TestBed.createComponent(PathfindingComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => { + vi.useRealTimers(); + Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', {configurable: true, value: originalGetContext}); + }); + + it('runs both algorithms and reports stats', () => { + component.visualizeCompare(); + vi.runAllTimers(); + + const astar = component.astarStats()!; + const dijkstra = component.dijkstraStats()!; + expect(component.compareMode()).toBe(true); + expect(astar).not.toBeNull(); + expect(dijkstra).not.toBeNull(); + expect(astar.pathLength).toBe(dijkstra.pathLength); + expect(astar.pathLength).not.toBe('∞'); + expect(astar.visitedCount).toBeLessThanOrEqual(dijkstra.visitedCount); + }); + + it('mirrors an edit from the primary canvas', () => { + component.visualizeCompare(); + vi.runAllTimers(); + component.selectedNodeType.set(component.NodeType.Wall); + + component.applyPathfindingSelection({row: 0, col: 0}, component.grid); + + expect(component.grid[0][0].isWall).toBe(true); + expect(compareGrid()[0][0].isWall).toBe(true); + }); + + it('mirrors an edit from the compare canvas', () => { + component.visualizeCompare(); + vi.runAllTimers(); + component.selectedNodeType.set(component.NodeType.Wall); + + component.applyCompareSelection({row: 1, col: 1}); + + expect(component.grid[1][1].isWall).toBe(true); + expect(compareGrid()[1][1].isWall).toBe(true); + }); + + it('moves the start on both grids', () => { + component.visualizeCompare(); + vi.runAllTimers(); + const oldStart = component.startNode!; + const {row, col} = oldStart; + component.selectedNodeType.set(component.NodeType.Start); + + component.applyCompareSelection({row: 2, col: 2}); + + expect(component.grid[row][col].isStart).toBe(false); + expect(compareGrid()[row][col].isStart).toBe(false); + expect(component.grid[2][2].isStart).toBe(true); + expect(compareGrid()[2][2].isStart).toBe(true); + }); + + it('syncs the compare grid when a test case is created', () => { + component.visualizeCompare(); + vi.runAllTimers(); + + component.createCase({withWalls: false, scenario: 'edge'}); + + const grid = compareGrid(); + expect(grid[0][0].isStart).toBe(true); + expect(grid[49][49].isEnd).toBe(true); + expect(countWalls(grid)).toBe(0); + }); + + it('leaves compare mode on a single run and keeps the primary grid', () => { + component.visualizeCompare(); + vi.runAllTimers(); + const {row, col} = component.startNode!; + + component.visualize('astar'); + vi.runAllTimers(); + + expect(component.compareMode()).toBe(false); + expect(countWalls(component.grid)).toBeGreaterThan(0); + expect(component.startNode).toBe(component.grid[row][col]); + }); + + it('stops the compare animation when the grid is resized', () => { + component.visualizeCompare(); + const drawn: Node[] = []; + vi.spyOn(component.compareGridComponent, 'drawNode').mockImplementation(node => { + drawn.push(node); + }); + + component['applyGridRows'](60); + vi.runAllTimers(); + + const mirror = compareGrid(); + const stale = drawn.filter(node => mirror[node.row]?.[node.col] !== node); + expect(stale).toHaveLength(0); + }); + + it('clamps a typed grid size above the maximum and keeps the grids usable', async () => { + component.gridRows.set(200); + component['applyGridRows'](200); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(component.gridRows()).toBe(component.MAX_GRID_SIZE); + expect(component.genericGridComponent.gridRows).toBe(component.MAX_GRID_SIZE); + expect(() => component.createCase({withWalls: true, scenario: 'random'})).not.toThrow(); + expect(() => component.visualizeCompare()).not.toThrow(); + expect(compareGrid()).toHaveLength(component.MAX_GRID_SIZE); + }); + + it('shows the clamped height in the input after a second overflow', async () => { + const input = fixture.nativeElement.querySelector('input[type=number]') as HTMLInputElement; + const type = async (value: string) => { + input.value = value; + input.dispatchEvent(new Event('input', {bubbles: true})); + await fixture.whenStable(); + fixture.detectChanges(); + }; + + await type('200'); + expect(input.value).toBe('150'); + + await type('1500'); + + expect(component.genericGridComponent.gridRows).toBe(150); + expect(input.value).toBe('150'); + }); +}); diff --git a/src/app/pages/algorithms/pathfinding/pathfinding.component.ts b/src/app/pages/algorithms/pathfinding/pathfinding.component.ts index 1a2e71d..8919372 100644 --- a/src/app/pages/algorithms/pathfinding/pathfinding.component.ts +++ b/src/app/pages/algorithms/pathfinding/pathfinding.component.ts @@ -1,4 +1,4 @@ -import {AfterViewInit, Component, inject, signal, ViewChild} from '@angular/core'; +import {afterNextRender, AfterViewInit, Component, inject, Injector, signal, ViewChild} from '@angular/core'; import {CommonModule} from '@angular/common'; import {FormsModule} from '@angular/forms'; @@ -10,7 +10,7 @@ import {MatSnackBar} from '@angular/material/snack-bar'; import {TranslatePipe, TranslateService} from '@ngx-translate/core'; -import {createPathfindingNode, DEFAULT_GRID_COLS, DEFAULT_GRID_ROWS, MAX_GRID_PX, MAX_GRID_SIZE, MIN_GRID_SIZE, Node, PathfindingAlgorithm, PathfindingScenario} from './pathfinding.models'; +import {createPathfindingNode, DEFAULT_GRID_COLS, DEFAULT_GRID_ROWS, MAX_GRID_PX, MAX_GRID_SIZE, MIN_GRID_SIZE, Node, PathfindingAlgorithm, PathfindingResult, PathfindingRunStats, PathfindingScenario} from './pathfinding.models'; import {PathfindingService} from './service/pathfinding.service'; import {PathfindingGridService} from './service/pathfinding-grid.service'; import {PathfindingAnimator} from './pathfinding-animator'; @@ -51,6 +51,7 @@ export class PathfindingComponent implements AfterViewInit { private readonly gridService = inject(PathfindingGridService); private readonly translate = inject(TranslateService); private readonly snackBar = inject(MatSnackBar); + private readonly injector = inject(Injector); readonly NodeType = NodeType; readonly MIN_GRID_SIZE = MIN_GRID_SIZE; @@ -90,12 +91,22 @@ export class PathfindingComponent implements AfterViewInit { readonly pathLength = signal("0"); readonly executionTime = signal(0); + readonly compareMode = signal(false); + readonly astarStats = signal(null); + readonly dijkstraStats = signal(null); + private readonly animator = new PathfindingAnimator( node => this.genericGridComponent?.drawNode(node), () => this.animationSpeed ); - @ViewChild(GenericGridComponent) genericGridComponent!: GenericGridComponent; + private readonly compareAnimator = new PathfindingAnimator( + node => this.compareGridComponent?.drawNode(node), + () => this.animationSpeed + ); + + @ViewChild('primaryGrid') genericGridComponent!: GenericGridComponent; + @ViewChild('compareGrid') compareGridComponent!: GenericGridComponent; ngAfterViewInit(): void { // Canvas logic is now handled by GenericGridComponent @@ -112,17 +123,37 @@ export class PathfindingComponent implements AfterViewInit { this.genericGridComponent.maxGridPx = MAX_GRID_PX; this.genericGridComponent.applyGridSize(); // Trigger initial grid setup } + if (this.compareGridComponent) { + this.compareGridComponent.initializationFn = this.initializeCompareGrid; + } this.createCase({withWalls: true, scenario: "normal"}); } protected applyGridRows(rows: number): void { + this.stopAnimations(); this.genericGridComponent.gridRows = rows; - this.genericGridComponent.applyGridSize(); + this.applyClampedGridSize(); } protected applyGridCols(cols: number): void { + this.stopAnimations(); 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". + // After the next render: ngModel needs to see the typed value once, else a repeated overflow never rewrites the input. + afterNextRender(() => { + this.gridRows.update(rows => this.clampDown(rows, this.genericGridComponent.gridRows)); + this.gridCols.update(cols => this.clampDown(cols, this.genericGridComponent.gridCols)); + }, {injector: this.injector}); + this.syncCompareGridIfActive(); + } + + private clampDown(value: number, max: number): number { + return Number.isFinite(value) && value > max ? max : value; } // --- Callbacks for GenericGridComponent --- @@ -139,18 +170,27 @@ export class PathfindingComponent implements AfterViewInit { applyPathfindingSelection = (pos: GridPos, grid: Node[][]): void => { this.grid = grid; // Keep internal grid in sync - const node = grid[pos.row][pos.col]; + this.applySelection(pos, this.genericGridComponent); + }; + + applyCompareSelection = (pos: GridPos): void => { + this.applySelection(pos, this.compareGridComponent); + }; + + private applySelection(pos: GridPos, source: GenericGridComponent): void { + const node = this.grid[pos.row][pos.col]; // Determine if we should add or remove a wall - if (this.selectedNodeType() === NodeType.Wall && this.genericGridComponent.isDrawing && this.genericGridComponent['lastCell'] === null) { + if (this.selectedNodeType() === NodeType.Wall && source.isDrawing && source['lastCell'] === null) { this.shouldAddWall = !node.isWall; } + const changed: Node[] = [node]; switch (this.selectedNodeType()) { case NodeType.Start: if (this.gridService.placeStart(node, this.startNode)) { if (this.startNode) { - this.genericGridComponent.drawNode(this.startNode); // Redraw old start node + changed.push(this.startNode); } this.startNode = node; } @@ -159,7 +199,7 @@ export class PathfindingComponent implements AfterViewInit { case NodeType.End: if (this.gridService.placeEnd(node, this.endNode)) { if (this.endNode) { - this.genericGridComponent.drawNode(this.endNode); // Redraw old end node + changed.push(this.endNode); } this.endNode = node; } @@ -179,14 +219,29 @@ export class PathfindingComponent implements AfterViewInit { } break; } - }; + + for (const changedNode of changed) { + this.redrawEverywhere(changedNode); + } + } + + private redrawEverywhere(node: Node): void { + this.genericGridComponent.drawNode(node); + if (!this.compareMode()) { + return; + } + const mirror = this.compareNodeOf(node); + this.gridService.copyNodeStructure(node, mirror); + this.compareGridComponent.drawNode(mirror); + } visualize(algorithm: PathfindingAlgorithm): void { if (!this.ensureStartAndEnd()) { return; } - this.animator.stop(); + this.stopAnimations(); + this.compareMode.set(false); this.clearPath(); const startTime = performance.now(); @@ -199,15 +254,69 @@ export class PathfindingComponent implements AfterViewInit { this.animator.animate(result); } + visualizeCompare(): void { + if (!this.ensureStartAndEnd()) { + return; + } + + this.stopAnimations(); + this.compareMode.set(true); + this.clearPath(); + this.syncCompareGrid(); + const compareGrid = this.compareGridComponent.grid as Node[][]; + const compareStart = this.compareNodeOf(this.startNode!); + const compareEnd = this.compareNodeOf(this.endNode!); + + const astar = this.pathfindingService.aStar(this.grid, this.startNode!, this.endNode!); + const dijkstra = this.pathfindingService.dijkstra(compareGrid, compareStart, compareEnd); + + this.astarStats.set(this.toRunStats(astar)); + this.dijkstraStats.set(this.toRunStats(dijkstra)); + this.animator.animate(astar); + this.compareAnimator.animate(dijkstra); + } + initializePathfindingGrid = (grid: Node[][]): void => { this.setupGrid(grid, 'normal', true); }; + initializeCompareGrid = (grid: Node[][]): void => { + this.gridService.copyGridStructure(this.grid, grid); + }; + createCase({withWalls, scenario}: { withWalls: boolean, scenario: PathfindingScenario }): void { - this.animator.stop(); + this.stopAnimations(); // Reinitialize grid through the generic component this.genericGridComponent.initializationFn = (grid) => this.setupGrid(grid, scenario, withWalls); this.genericGridComponent.initializeGrid(); // Trigger re-initialization and redraw + this.syncCompareGridIfActive(); + } + + private syncCompareGrid(): void { + const compare = this.compareGridComponent; + compare.gridRows = this.genericGridComponent.gridRows; // clamped values, not the raw typed signals + compare.gridCols = this.genericGridComponent.gridCols; + compare.applyGridSize(); // resizes the canvas; re-inits only when the size changed + compare.initializeGrid(); // always re-init: fresh nodes = no stale search state + } + + private compareNodeOf(node: Node): Node { + return this.compareGridComponent.grid[node.row][node.col] as Node; + } + + private syncCompareGridIfActive(): void { + if (this.compareMode()) { + this.syncCompareGrid(); + } + } + + private stopAnimations(): void { + this.animator.stop(); + this.compareAnimator.stop(); + } + + private toRunStats(result: PathfindingResult): PathfindingRunStats { + return {visitedCount: result.visitedNodesInOrder.length, pathLength: PathfindingAnimator.pathLengthLabel(result)}; } private setupGrid(grid: Node[][], scenario: PathfindingScenario, withWalls: boolean): void { diff --git a/src/app/pages/algorithms/pathfinding/pathfinding.models.ts b/src/app/pages/algorithms/pathfinding/pathfinding.models.ts index 5cbe13a..7a66202 100644 --- a/src/app/pages/algorithms/pathfinding/pathfinding.models.ts +++ b/src/app/pages/algorithms/pathfinding/pathfinding.models.ts @@ -45,3 +45,8 @@ export const MAX_GRID_SIZE = 150; // Canvas max size (px) export const MAX_GRID_PX = 1000; export const MAX_RANDOM_WALLS_FACTORS = 0.3; + +export interface PathfindingRunStats { + visitedCount: number; + pathLength: string; +} 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 index 405057e..bb72bd9 100644 --- a/src/app/pages/algorithms/pathfinding/service/pathfinding-grid.service.spec.ts +++ b/src/app/pages/algorithms/pathfinding/service/pathfinding-grid.service.spec.ts @@ -17,6 +17,35 @@ describe('PathfindingGridService', () => { service = new PathfindingGridService(); }); + describe('copyGridStructure', () => { + it('copies start, end and walls to the same positions', () => { + const source = createGrid(5, 5); + const target = createGrid(5, 5); + source[0][0].isStart = true; + source[4][4].isEnd = true; + source[2][3].isWall = true; + + service.copyGridStructure(source, target); + + expect(target[0][0].isStart).toBe(true); + expect(target[4][4].isEnd).toBe(true); + expect(target[2][3].isWall).toBe(true); + expect(countWalls(target)).toBe(1); + }); + + it('does not copy search state', () => { + const source = createGrid(3, 3); + const target = createGrid(3, 3); + source[1][1].isVisited = true; + source[1][2].isPath = true; + + service.copyGridStructure(source, target); + + expect(target[1][1].isVisited).toBe(false); + expect(target[1][2].isPath).toBe(false); + }); + }); + describe('setupScenario', () => { it('places start and end mid-left and mid-right with a vertical wall for "normal"', () => { const grid = createGrid(20, 20); diff --git a/src/app/pages/algorithms/pathfinding/service/pathfinding-grid.service.ts b/src/app/pages/algorithms/pathfinding/service/pathfinding-grid.service.ts index 5c1ff76..d081a1f 100644 --- a/src/app/pages/algorithms/pathfinding/service/pathfinding-grid.service.ts +++ b/src/app/pages/algorithms/pathfinding/service/pathfinding-grid.service.ts @@ -63,6 +63,22 @@ export class PathfindingGridService { } } + /** Copies the flags the user edits. Search state (isVisited, isPath, ...) is not copied. */ + copyNodeStructure(source: Node, target: Node): void { + target.isStart = source.isStart; + target.isEnd = source.isEnd; + target.isWall = source.isWall; + } + + /** Both grids must have the same size. */ + copyGridStructure(source: Node[][], target: Node[][]): void { + for (let row = 0; row < source.length; row++) { + for (let col = 0; col < source[row].length; col++) { + this.copyNodeStructure(source[row][col], target[row][col]); + } + } + } + private getScenarioStartEnd(rows: number, cols: number, scenario: PathfindingScenario): { start: GridPos; end: GridPos } { if (scenario === 'edge') { return { diff --git a/src/assets/i18n/de.json b/src/assets/i18n/de.json index 05bc8f1..218b2ae 100644 --- a/src/assets/i18n/de.json +++ b/src/assets/i18n/de.json @@ -430,6 +430,8 @@ "CLEAR_BOARD": "Leeres Gitter", "VISITED": "Besucht", "PATH": "Pfad", + "COMPARE": "Vergleich (A* vs Dijkstra)", + "VISITED_NODES": "Besuchte Knoten", "PATH_LENGTH": "Pfadlänge", "EXECUTION_TIME": "Ausführungszeit", "EXPLANATION": { diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index d4f971f..65f6f7c 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -430,6 +430,8 @@ "CLEAR_BOARD": "Empty Board", "VISITED": "Visited", "PATH": "Path", + "COMPARE": "Compare (A* vs Dijkstra)", + "VISITED_NODES": "Visited nodes", "PATH_LENGTH": "Path length", "EXECUTION_TIME": "Execution Time", "EXPLANATION": {