Compare commits

..

3 Commits

Author SHA1 Message Date
e0f0a0ed04 Enhance pathfinding UI with grid resizing and scenarios
Added controls for dynamic grid size adjustment and scenario presets (normal and edge case) to the pathfinding component. Improved UI/UX with algorithm explanations, Wikipedia links, and reorganized controls. Refactored grid logic for flexibility, updated translations, and improved code structure for maintainability.
2026-02-02 10:06:59 +01:00
17a787b0f1 Small refactoring for better readabilty 2026-02-02 09:28:05 +01:00
01f66d4b8f Fixed smaller rounting bugs 2026-02-02 08:56:51 +01:00
10 changed files with 643 additions and 351 deletions

View File

@@ -8,31 +8,31 @@ export class RouterConstants {
static readonly ABOUT = { static readonly ABOUT = {
PATH: 'about', PATH: 'about',
LINK: ['/about'] as const, LINK: '/about',
COMPONENT: AboutComponent COMPONENT: AboutComponent
}; };
static readonly PROJECTS = { static readonly PROJECTS = {
PATH: 'projects', PATH: 'projects',
LINK: ['/projects'] as const, LINK: '/projects',
COMPONENT: ProjectsComponent COMPONENT: ProjectsComponent
}; };
static readonly ALGORITHMS = { static readonly ALGORITHMS = {
PATH: 'algorithms', PATH: 'algorithms',
LINK: ['/algorithms'] as const, LINK: '/algorithms',
COMPONENT: AlgorithmsComponent COMPONENT: AlgorithmsComponent
}; };
static readonly PATHFINDING = { static readonly PATHFINDING = {
PATH: 'algorithms/pathfinding', PATH: 'algorithms/pathfinding',
LINK: ['/algorithms', 'pathfinding'] as const, LINK: '/algorithms/pathfinding',
COMPONENT: PathfindingComponent COMPONENT: PathfindingComponent
}; };
static readonly IMPRINT = { static readonly IMPRINT = {
PATH: 'imprint', PATH: 'imprint',
LINK: ['/imprint'] as const, LINK: '/imprint',
COMPONENT: ImprintComponent COMPONENT: ImprintComponent
}; };
} }

View File

@@ -1,4 +1,6 @@
export class UrlConstants { export class UrlConstants {
static readonly LINKED_IN = 'https://www.linkedin.com/in/andreas-dahm-2395991ba'; static readonly LINKED_IN = 'https://www.linkedin.com/in/andreas-dahm-2395991ba';
static readonly GIT_HUB = 'https://github.com/LoboTheDark'; static readonly GIT_HUB = 'https://github.com/LoboTheDark';
static readonly DIJKSTRA_WIKI = 'https://de.wikipedia.org/wiki/Dijkstra-Algorithmus'
static readonly ASTAR_WIKI = 'https://de.wikipedia.org/wiki/A*-Algorithmus'
} }

View File

@@ -1,6 +1,20 @@
<div class="container"> <div class="container">
<h1>{{ 'PATHFINDING.TITLE' | translate }}</h1> <h1>{{ 'PATHFINDING.TITLE' | translate }}</h1>
<div class="algo-info">
<h3>{{ 'PATHFINDING.EXPLANATION.TITLE' | translate }}</h3>
<p>
<strong>Dijkstra</strong> {{ 'PATHFINDING.EXPLANATION.DIJKSTRA_EXPLANATION' | translate }}
<a href="{{UrlConstants.DIJKSTRA_WIKI}}" target="_blank" rel="noopener noreferrer">Wikipedia</a>
</p>
<p>
<strong>A*</strong> {{ 'PATHFINDING.EXPLANATION.ASTAR_EXPLANATION' | translate}}
<a href="{{UrlConstants.ASTAR_WIKI}}" target="_blank" rel="noopener noreferrer">Wikipedia</a>
</p>
</div>
<div class="controls-container"> <div class="controls-container">
<div class="controls"> <div class="controls">
<mat-button-toggle-group [(ngModel)]="selectedNodeType" aria-label="Node Type Selection"> <mat-button-toggle-group [(ngModel)]="selectedNodeType" aria-label="Node Type Selection">
@@ -9,11 +23,44 @@
<mat-button-toggle [value]="NodeType.Wall">{{ 'PATHFINDING.WALL' | translate }}</mat-button-toggle> <mat-button-toggle [value]="NodeType.Wall">{{ 'PATHFINDING.WALL' | translate }}</mat-button-toggle>
<mat-button-toggle [value]="NodeType.None">{{ 'PATHFINDING.CLEAR_NODE' | translate }}</mat-button-toggle> <mat-button-toggle [value]="NodeType.None">{{ 'PATHFINDING.CLEAR_NODE' | translate }}</mat-button-toggle>
</mat-button-toggle-group> </mat-button-toggle-group>
</div>
<button mat-raised-button color="primary" (click)="visualizeDijkstra()">{{ 'PATHFINDING.DIJKSTRA' | translate }}</button> <div class="controls">
<button mat-raised-button color="accent" (click)="visualizeAStar()">{{ 'PATHFINDING.ASTAR' | translate }}</button> <button matButton="filled" (click)="visualizeDijkstra()">{{ 'PATHFINDING.DIJKSTRA' | translate }}</button>
<button mat-raised-button color="warn" (click)="resetBoard()">{{ 'PATHFINDING.RESET_BOARD' | translate }}</button> <button matButton="filled" (click)="visualizeAStar()">{{ 'PATHFINDING.ASTAR' | translate }}</button>
<button mat-raised-button color="warn" (click)="clearBoard()">{{ 'PATHFINDING.CLEAR_BOARD' | translate }}</button> <button matButton="filled" (click)="normalCase()">{{ 'PATHFINDING.NORMAL_CASE' | translate }}</button>
<button matButton="filled" (click)="edgeCase()">{{ 'PATHFINDING.EDGE_CASE' | translate }}</button>
<button matButton="filled" (click)="clearBoard()">{{ 'PATHFINDING.CLEAR_BOARD' | translate }}</button>
</div>
<div class="controls">
<div class="grid-size">
<mat-form-field appearance="outline" class="grid-field">
<mat-label>{{ 'ALGORITHM.PATHFINDING.GRID_HEIGHT' | translate }}</mat-label>
<input
matInput
type="number"
[min]="MIN_GRID_SIZE"
[max]="MAX_GRID_SIZE"
[(ngModel)]="gridRows"
(blur)="applyGridSize()"
(keyup.enter)="applyGridSize()"
/>
</mat-form-field>
<mat-form-field appearance="outline" class="grid-field">
<mat-label>{{ 'ALGORITHM.PATHFINDING.GRID_WIDTH' | translate }}</mat-label>
<input
matInput
type="number"
[min]="MIN_GRID_SIZE"
[max]="MAX_GRID_SIZE"
[(ngModel)]="gridCols"
(blur)="applyGridSize()"
(keyup.enter)="applyGridSize()"
/>
</mat-form-field>
</div>
</div> </div>
<div class="legend"> <div class="legend">
@@ -25,10 +72,10 @@
</div> </div>
</div> </div>
<canvas #gridCanvas></canvas>
<div class="results-container"> <div class="results-container">
<p>{{ 'PATHFINDING.PATH_LENGTH' | translate }}: {{ pathLength }}</p> <p>{{ 'PATHFINDING.PATH_LENGTH' | translate }}: {{ pathLength }}</p>
<p>{{ 'PATHFINDING.EXECUTION_TIME' | translate }}: {{ executionTime | number:'1.2-2' }} ms</p> <p>{{ 'PATHFINDING.EXECUTION_TIME' | translate }}: {{ executionTime | number:'1.2-2' }} ms</p>
</div> </div>
<canvas #gridCanvas></canvas>
</div> </div>

View File

@@ -2,6 +2,25 @@
padding: 2rem; padding: 2rem;
} }
.algo-info {
margin: 0 0 1rem 0;
padding: 0.75rem 1rem;
border: 1px solid #ddd;
border-radius: 8px;
h3 {
margin: 0 0 0.5rem 0;
}
p {
margin: 0.5rem 0;
}
a {
margin-left: 0.25rem;
}
}
.controls-container { .controls-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -13,6 +32,7 @@
flex-wrap: wrap; flex-wrap: wrap;
gap: 1rem; gap: 1rem;
margin-bottom: 1rem; margin-bottom: 1rem;
align-items: center;
mat-button-toggle-group { mat-button-toggle-group {
border-radius: 4px; border-radius: 4px;
@@ -20,6 +40,17 @@
} }
} }
.grid-size {
display: flex;
gap: 0.75rem;
align-items: center;
flex-wrap: wrap;
}
.grid-field {
width: 150px;
}
.legend { .legend {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -46,4 +77,5 @@
canvas { canvas {
border: 1px solid #ccc; border: 1px solid #ccc;
display: block; display: block;
max-width: 100%;
} }

View File

@@ -1,13 +1,18 @@
import { AfterViewInit, Component, ElementRef, ViewChild, inject } from '@angular/core'; import {AfterViewInit, Component, ElementRef, inject, ViewChild} from '@angular/core';
import { CommonModule } from '@angular/common'; import {CommonModule} from '@angular/common';
import { MatButtonModule } from '@angular/material/button';
import {GRID_COLS, GRID_ROWS, NODE_SIZE, Node} from './pathfinding.models';
import {MatButtonToggleModule} from '@angular/material/button-toggle';
import {FormsModule} from '@angular/forms'; import {FormsModule} from '@angular/forms';
import { PathfindingService } from './service/pathfinding.service';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
// Define an enum for node types that can be placed by the user import {MatButtonModule} from '@angular/material/button';
import {MatButtonToggleModule} from '@angular/material/button-toggle';
import {MatFormFieldModule} from '@angular/material/form-field';
import {MatInputModule} from '@angular/material/input';
import {TranslateModule, TranslateService} from '@ngx-translate/core';
import {DEFAULT_GRID_COLS, DEFAULT_GRID_ROWS, MAX_GRID_PX, MAX_GRID_SIZE, MIN_GRID_SIZE, Node} from './pathfinding.models';
import {PathfindingService} from './service/pathfinding.service';
import {UrlConstants} from '../../../constants/UrlConstants';
enum NodeType { enum NodeType {
Start = 'start', Start = 'start',
End = 'end', End = 'end',
@@ -15,334 +20,295 @@ enum NodeType {
None = 'none' None = 'none'
} }
interface GridPos { row: number; col: number }
@Component({ @Component({
selector: 'app-pathfinding', selector: 'app-pathfinding',
standalone: true, standalone: true,
imports: [CommonModule, MatButtonModule, MatButtonToggleModule, FormsModule, TranslateModule], imports: [
CommonModule,
FormsModule,
MatButtonModule,
MatButtonToggleModule,
MatFormFieldModule,
MatInputModule,
TranslateModule
],
templateUrl: './pathfinding.component.html', templateUrl: './pathfinding.component.html',
styleUrls: ['./pathfinding.component.scss'] styleUrls: ['./pathfinding.component.scss']
}) })
export class PathfindingComponent implements AfterViewInit { export class PathfindingComponent implements AfterViewInit {
private readonly pathfindingService = inject(PathfindingService); private readonly pathfindingService = inject(PathfindingService);
private readonly translate = inject(TranslateService); private readonly translate = inject(TranslateService);
private lastRow = -1;
private lastCol = -1; readonly NodeType = NodeType;
private timeoutIds: any[] = []; readonly MIN_GRID_SIZE = MIN_GRID_SIZE;
readonly MAX_GRID_SIZE = MAX_GRID_SIZE;
@ViewChild('gridCanvas', { static: true }) @ViewChild('gridCanvas', { static: true })
canvas!: ElementRef<HTMLCanvasElement>; canvas!: ElementRef<HTMLCanvasElement>;
ctx!: CanvasRenderingContext2D;
private ctx!: CanvasRenderingContext2D;
gridRows = DEFAULT_GRID_ROWS;
gridCols = DEFAULT_GRID_COLS;
nodeSize = 10;
grid: Node[][] = []; grid: Node[][] = [];
startNode: Node | null = null; startNode: Node | null = null;
endNode: Node | null = null; endNode: Node | null = null;
selectedNodeType: NodeType = NodeType.None;
isDrawing = false; isDrawing = false;
shouldAddWall = true; private lastCell: GridPos | null = null;
selectedNodeType: NodeType = NodeType.None; // Default to no selection private shouldAddWall = true;
animationSpeed = 3; // milliseconds
animationSpeed = 3;
pathLength = 0; pathLength = 0;
executionTime = 0; executionTime = 0;
private timeoutIds: number[] = [];
readonly NodeType = NodeType;
ngAfterViewInit(): void { ngAfterViewInit(): void {
this.ctx = this.canvas.nativeElement.getContext('2d') as CanvasRenderingContext2D; this.ctx = this.getContextOrThrow();
this.canvas.nativeElement.width = GRID_COLS * NODE_SIZE; this.applyGridSize(true);
this.canvas.nativeElement.height = GRID_ROWS * NODE_SIZE;
this.initializeGrid(true); const el = this.canvas.nativeElement;
el.addEventListener('mousedown', (e) => this.onMouseDown(e));
el.addEventListener('mousemove', (e) => this.onMouseMove(e));
el.addEventListener('mouseup', () => this.onMouseUp());
el.addEventListener('mouseleave', () => this.onMouseUp());
}
applyGridSize(skipReset?: boolean): void {
this.gridRows = this.clampGridSize(this.gridRows, DEFAULT_GRID_ROWS);
this.gridCols = this.clampGridSize(this.gridCols, DEFAULT_GRID_COLS);
this.nodeSize = this.computeNodeSize(this.gridRows, this.gridCols);
this.resizeCanvas();
if (skipReset) {
this.initializeGrid(true, 'edge');
this.drawGrid();
return;
}
// Default after size changes: pick one consistent scenario
this.edgeCase();
}
// Scenarios (buttons)
normalCase(): void {
this.stopAnimations();
this.initializeGrid(true, 'normal');
this.drawGrid(); this.drawGrid();
// Add event listeners for mouse interactions
this.canvas.nativeElement.addEventListener('mousedown', this.onMouseDown.bind(this));
this.canvas.nativeElement.addEventListener('mousemove', this.onMouseMove.bind(this));
this.canvas.nativeElement.addEventListener('mouseup', this.onMouseUp.bind(this));
this.canvas.nativeElement.addEventListener('mouseleave', this.onMouseUp.bind(this)); // Stop drawing if mouse leaves canvas
} }
initializeGrid(withWalls: boolean): void { edgeCase(): void {
this.grid = [];
for (let row = 0; row < GRID_ROWS; row++) {
const currentRow: Node[] = [];
for (let col = 0; col < GRID_COLS; col++) {
currentRow.push({
row,
col,
isStart: false,
isEnd: false,
isWall: false,
isVisited: false,
isPath: false,
distance: Infinity,
previousNode: null,
fScore: 0
});
}
this.grid.push(currentRow);
}
// Set default start and end nodes
this.startNode = this.grid[0][Math.floor(GRID_COLS / 2)];
this.startNode.isStart = true;
this.endNode = this.grid[this.grid.length-1][Math.floor(GRID_COLS / 2)];
this.endNode.isEnd = true;
if (withWalls)
{
//setting walls
let offset = Math.floor(GRID_COLS / 4);
for (let startWall = 0; startWall < Math.floor(GRID_COLS /2 ); startWall++){
this.grid[Math.floor(GRID_ROWS / 2)][offset + startWall].isWall = true;
}
}
}
stopAnimations(): void {
this.timeoutIds.forEach((id) => clearTimeout(id));
this.timeoutIds = [];
}
drawGrid(): void {
if (!this.ctx) {
return;
}
this.ctx.clearRect(0, 0, this.canvas.nativeElement.width, this.canvas.nativeElement.height);
for (let row = 0; row < GRID_ROWS; row++) {
for (let col = 0; col < GRID_COLS; col++) {
const node = this.grid[row][col];
let color = 'lightgray'; // Default color
if (node.isStart) {
color = 'green';
} else if (node.isEnd) {
color = 'red';
} else if (node.isPath) {
color = 'gold';
} else if (node.isVisited) {
color = 'skyblue';
} else if (node.isWall) {
color = 'black';
}
this.ctx.fillStyle = color;
this.ctx.fillRect(col * NODE_SIZE, row * NODE_SIZE, NODE_SIZE, NODE_SIZE);
this.ctx.strokeStyle = '#ccc';
this.ctx.strokeRect(col * NODE_SIZE, row * NODE_SIZE, NODE_SIZE, NODE_SIZE);
}
}
}
onMouseDown(event: MouseEvent): void {
const { row, col } = this.getGridPosition(event);
if (this.isValidPosition(row, col)) {
const node = this.grid[row][col];
this.shouldAddWall = !node.isWall;
}
this.isDrawing = true;
this.placeNode(event);
}
onMouseMove(event: MouseEvent): void {
if (this.isDrawing) {
this.placeNode(event);
}
}
getGridPosition(event: MouseEvent): { row: number, col: number } {
const rect = this.canvas.nativeElement.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
const col = Math.floor(x / NODE_SIZE);
const row = Math.floor(y / NODE_SIZE);
return { row, col };
}
isValidPosition(row: number, col: number): boolean {
return row >= 0 && row < GRID_ROWS && col >= 0 && col < GRID_COLS;
}
onMouseUp(): void {
this.isDrawing = false;
this.lastRow = -1;
this.lastCol = -1;
}
placeNode(event: MouseEvent): void {
const rect = this.canvas.nativeElement.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
const col = Math.floor(x / NODE_SIZE);
const row = Math.floor(y / NODE_SIZE);
if (row < 0 || row >= GRID_ROWS || col < 0 || col >= GRID_COLS) {
return;
}
if (this.lastRow === row && this.lastCol === col) {
return;
}
this.lastRow = row;
this.lastCol = col;
const node = this.grid[row][col];
switch (this.selectedNodeType) {
case NodeType.Start:
if (!node.isEnd && !node.isWall) {
if (this.startNode) {
this.startNode.isStart = false;
this.drawNode(this.startNode);
}
node.isStart = true;
this.startNode = node;
}
break;
case NodeType.End:
if (!node.isStart && !node.isWall) {
if (this.endNode) {
this.endNode.isEnd = false;
this.drawNode(this.endNode);
}
node.isEnd = true;
this.endNode = node;
}
break;
case NodeType.Wall:
if (!node.isStart && !node.isEnd) {
if (node.isWall !== this.shouldAddWall) {
node.isWall = this.shouldAddWall;
}
}
break;
case NodeType.None:
if (node.isStart) {
node.isStart = false;
this.startNode = null;
} else if (node.isEnd) {
node.isEnd = false;
this.endNode = null;
} else if (node.isWall) {
node.isWall = false;
}
break;
}
this.drawNode(node);
}
visualizeDijkstra(): void {
this.stopAnimations(); this.stopAnimations();
if (!this.startNode || !this.endNode) { this.initializeGrid(true, 'edge');
alert(this.translate.instant('PATHFINDING.ALERT.START_END_NODES'));
return;
}
this.clearPath();
const startTime = performance.now();
const { visitedNodesInOrder, nodesInShortestPathOrder } = this.pathfindingService.dijkstra(this.grid,
this.grid[this.startNode.row][this.startNode.col],
this.grid[this.endNode.row][this.endNode.col]
);
const endTime = performance.now();
this.pathLength = nodesInShortestPathOrder.length;
this.executionTime = endTime - startTime;
this.animateAlgorithm(visitedNodesInOrder, nodesInShortestPathOrder);
}
visualizeAStar(): void {
this.stopAnimations();
if (!this.startNode || !this.endNode) {
alert(this.translate.instant('PATHFINDING.ALERT.START_END_NODES'));
return;
}
this.clearPath();
const startTime = performance.now();
const { visitedNodesInOrder, nodesInShortestPathOrder } = this.pathfindingService.aStar(this.grid,
this.grid[this.startNode.row][this.startNode.col],
this.grid[this.endNode.row][this.endNode.col]
);
const endTime = performance.now();
this.pathLength = nodesInShortestPathOrder.length;
this.executionTime = endTime - startTime;
this.animateAlgorithm(visitedNodesInOrder, nodesInShortestPathOrder);
}
animateAlgorithm(visitedNodesInOrder: Node[], nodesInShortestPathOrder: Node[]): void {
for (let i = 0; i <= visitedNodesInOrder.length; i++) {
if (i === visitedNodesInOrder.length) {
const timeoutId = setTimeout(() => {
this.animateShortestPath(nodesInShortestPathOrder);
}, this.animationSpeed * i);
this.timeoutIds.push(timeoutId);
return;
}
const node = visitedNodesInOrder[i];
const timeoutId = setTimeout(() => {
if (!node.isStart && !node.isEnd) {
node.isVisited = true;
this.drawNode(node);
}
}, this.animationSpeed * i);
this.timeoutIds.push(timeoutId);
}
}
animateShortestPath(nodesInShortestPathOrder: Node[]): void {
for (let i = 0; i < nodesInShortestPathOrder.length; i++) {
const node = nodesInShortestPathOrder[i];
const timeoutId = setTimeout(() => {
if (!node.isStart && !node.isEnd) {
node.isPath = true;
this.drawNode(node);
}
}, this.animationSpeed * i);
this.timeoutIds.push(timeoutId);
}
}
drawNode(node: Node): void {
if (!this.ctx) return;
let color = 'lightgray';
if (node.isStart) color = 'green';
else if (node.isEnd) color = 'red';
else if (node.isPath) color = 'gold';
else if (node.isVisited) color = 'skyblue';
else if (node.isWall) color = 'black';
this.ctx.fillStyle = color;
this.ctx.fillRect(node.col * NODE_SIZE, node.row * NODE_SIZE, NODE_SIZE, NODE_SIZE);
this.ctx.strokeStyle = '#ccc';
this.ctx.strokeRect(node.col * NODE_SIZE, node.row * NODE_SIZE, NODE_SIZE, NODE_SIZE);
}
resetBoard(): void {
this.stopAnimations();
this.initializeGrid(true);
this.drawGrid(); this.drawGrid();
} }
clearBoard(): void { clearBoard(): void {
this.stopAnimations(); this.stopAnimations();
this.initializeGrid(false); this.initializeGrid(false, 'edge');
this.drawGrid(); this.drawGrid();
} }
clearPath(): void { visualizeDijkstra(): void {
if (!this.ensureStartAndEnd()) {
return;
}
this.stopAnimations(); this.stopAnimations();
for (let row = 0; row < GRID_ROWS; row++) { this.clearPath();
for (let col = 0; col < GRID_COLS; col++) {
const startTime = performance.now();
const result = this.pathfindingService.dijkstra(
this.grid,
this.grid[this.startNode!.row][this.startNode!.col],
this.grid[this.endNode!.row][this.endNode!.col]
);
const endTime = performance.now();
this.pathLength = result.nodesInShortestPathOrder.length;
this.executionTime = endTime - startTime;
this.animateAlgorithm(result.visitedNodesInOrder, result.nodesInShortestPathOrder);
}
visualizeAStar(): void {
if (!this.ensureStartAndEnd()) {
return;
}
this.stopAnimations();
this.clearPath();
const startTime = performance.now();
const result = this.pathfindingService.aStar(
this.grid,
this.grid[this.startNode!.row][this.startNode!.col],
this.grid[this.endNode!.row][this.endNode!.col]
);
const endTime = performance.now();
this.pathLength = result.nodesInShortestPathOrder.length;
this.executionTime = endTime - startTime;
this.animateAlgorithm(result.visitedNodesInOrder, result.nodesInShortestPathOrder);
}
// Mouse interactions
private onMouseDown(event: MouseEvent): void {
const pos = this.getGridPosition(event);
if (!pos) {
return;
}
this.shouldAddWall = this.shouldStartWallStroke(pos);
this.isDrawing = true;
this.lastCell = null;
this.applySelectionAt(pos);
}
private onMouseMove(event: MouseEvent): void {
if (!this.isDrawing) {
return;
}
const pos = this.getGridPosition(event);
if (!pos) {
return;
}
if (this.isSameCell(pos, this.lastCell)) {
return;
}
this.applySelectionAt(pos);
}
private onMouseUp(): void {
this.isDrawing = false;
this.lastCell = null;
}
private applySelectionAt(pos: GridPos): void {
const node = this.grid[pos.row][pos.col];
switch (this.selectedNodeType) {
case NodeType.Start:
this.trySetStart(node);
break;
case NodeType.End:
this.trySetEnd(node);
break;
case NodeType.Wall:
this.tryToggleWall(node, this.shouldAddWall);
break;
case NodeType.None:
this.tryClearNode(node);
break;
}
this.lastCell = pos;
this.drawNode(node);
}
// Grid init
private initializeGrid(withWalls: boolean, scenario: 'normal' | 'edge'): void {
this.grid = this.createEmptyGrid();
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();
}
}
private createEmptyGrid(): Node[][] {
const grid: Node[][] = [];
for (let row = 0; row < this.gridRows; row++) {
const currentRow: Node[] = [];
for (let col = 0; col < this.gridCols; col++) {
currentRow.push(this.createNode(row, col));
}
grid.push(currentRow);
}
return grid;
}
private createNode(row: number, col: number): Node {
return {
row,
col,
isStart: false,
isEnd: false,
isWall: false,
isVisited: false,
isPath: false,
distance: Infinity,
previousNode: null,
fScore: 0
};
}
private getScenarioStartEnd(scenario: 'normal' | 'edge'): { start: GridPos; end: GridPos } {
if (scenario === 'edge') {
return {
start: { row: 0, col: 0 },
end: { row: this.gridRows - 1, col: this.gridCols - 1 }
};
}
// 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 placeDefaultDiagonalWall(): void {
// 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.isValidPosition(row, col)) {
continue;
}
const node = this.grid[row][col];
if (node.isStart || node.isEnd) {
continue;
}
node.isWall = true;
}
}
// Path state
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]; const node = this.grid[row][col];
node.isVisited = false; node.isVisited = false;
node.isPath = false; node.isPath = false;
@@ -353,4 +319,211 @@ export class PathfindingComponent implements AfterViewInit {
this.drawGrid(); this.drawGrid();
} }
// Animation
private stopAnimations(): void {
for (const id of this.timeoutIds) {
clearTimeout(id);
}
this.timeoutIds = [];
}
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.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.drawNode(node);
}
}, this.animationSpeed * i);
this.timeoutIds.push(id);
}
}
// Drawing
private drawGrid(): void {
this.ctx.clearRect(0, 0, this.canvas.nativeElement.width, this.canvas.nativeElement.height);
for (let row = 0; row < this.gridRows; row++) {
for (let col = 0; col < this.gridCols; col++) {
this.drawNode(this.grid[row][col]);
}
}
}
private drawNode(node: Node): void {
this.ctx.fillStyle = this.getNodeColor(node);
this.ctx.fillRect(node.col * this.nodeSize, node.row * this.nodeSize, this.nodeSize, this.nodeSize);
this.ctx.strokeStyle = '#ccc';
this.ctx.strokeRect(node.col * this.nodeSize, node.row * this.nodeSize, this.nodeSize, this.nodeSize);
}
private getNodeColor(node: Node): string {
if (node.isStart) return 'green';
if (node.isEnd) return 'red';
if (node.isPath) return 'gold';
if (node.isVisited) return 'skyblue';
if (node.isWall) return 'black';
return 'lightgray';
}
// Placement rules (readability helpers)
private trySetStart(node: Node): void {
if (!this.canBeStart(node)) {
return;
}
if (this.startNode) {
this.startNode.isStart = false;
this.drawNode(this.startNode);
}
node.isStart = true;
this.startNode = node;
}
private trySetEnd(node: Node): void {
if (!this.canBeEnd(node)) {
return;
}
if (this.endNode) {
this.endNode.isEnd = false;
this.drawNode(this.endNode);
}
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;
}
private shouldStartWallStroke(pos: GridPos): boolean {
if (this.selectedNodeType !== NodeType.Wall) {
return true;
}
const node = this.grid[pos.row][pos.col];
return !node.isWall;
}
// Validation
private ensureStartAndEnd(): boolean {
if (this.startNode && this.endNode) {
return true;
}
alert(this.translate.instant('PATHFINDING.ALERT.START_END_NODES'));
return false;
}
// Grid sizing
private clampGridSize(value: number, fallback: number): number {
const parsed = Math.floor(Number(value));
const safe = Number.isFinite(parsed) ? parsed : fallback;
return Math.min(Math.max(MIN_GRID_SIZE, safe), MAX_GRID_SIZE);
}
private computeNodeSize(rows: number, cols: number): number {
const sizeByWidth = Math.floor(MAX_GRID_PX / cols);
const sizeByHeight = Math.floor(MAX_GRID_PX / rows);
return Math.max(1, Math.min(sizeByWidth, sizeByHeight));
}
private resizeCanvas(): void {
const el = this.canvas.nativeElement;
el.width = this.gridCols * this.nodeSize;
el.height = this.gridRows * this.nodeSize;
}
// Mouse -> grid cell
private getGridPosition(event: MouseEvent): GridPos | null {
const rect = this.canvas.nativeElement.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
const col = Math.floor(x / this.nodeSize);
const row = Math.floor(y / this.nodeSize);
if (!this.isValidPosition(row, col)) {
return null;
}
return { row, col };
}
private isValidPosition(row: number, col: number): boolean {
return row >= 0 && row < this.gridRows && col >= 0 && col < this.gridCols;
}
private isSameCell(a: GridPos, b: GridPos | null): boolean {
return !!b && a.row === b.row && a.col === b.col;
}
private getContextOrThrow(): CanvasRenderingContext2D {
const ctx = this.canvas.nativeElement.getContext('2d');
if (!ctx) {
throw new Error('CanvasRenderingContext2D not available.');
}
return ctx;
}
protected readonly UrlConstants = UrlConstants;
} }

View File

@@ -11,6 +11,11 @@ export interface Node {
fScore: number; fScore: number;
} }
export const GRID_ROWS = 150; export const DEFAULT_GRID_ROWS = 50;
export const GRID_COLS = 100; export const DEFAULT_GRID_COLS = 50;
export const NODE_SIZE = 10; // in pixels
export const MIN_GRID_SIZE = 2;
export const MAX_GRID_SIZE = 150;
// Canvas max size (px)
export const MAX_GRID_PX = 1000;

View File

@@ -1,22 +1,20 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Node, GRID_ROWS, GRID_COLS } from '../pathfinding.models'; import { Node} from '../pathfinding.models';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
}) })
export class PathfindingService { export class PathfindingService {
// Helper function to get all unvisited neighbors of a given node // Helper function to get all unvisited neighbors of a given node
getUnvisitedNeighbors(node: Node, grid: Node[][]): Node[] { getUnvisitedNeighbors(node: Node, grid: Node[][]): Node[] {
const neighbors: Node[] = []; const neighbors: Node[] = [];
const { col, row } = node; const { col, row } = node;
if (row > 0) neighbors.push(grid[row - 1][col]); if (row > 0) neighbors.push(grid[row - 1][col]);
if (row < GRID_ROWS - 1) neighbors.push(grid[row + 1][col]); if (row < grid.length - 1) neighbors.push(grid[row + 1][col]);
if (col > 0) neighbors.push(grid[row][col - 1]); if (col > 0) neighbors.push(grid[row][col - 1]);
if (col < GRID_COLS - 1) neighbors.push(grid[row][col + 1]); if (col < grid[0].length - 1) neighbors.push(grid[row][col + 1]);
return neighbors.filter(neighbor => !neighbor.isVisited && !neighbor.isWall); return neighbors.filter(neighbor => !neighbor.isVisited && !neighbor.isWall);
} }
@@ -42,16 +40,23 @@ export class PathfindingService {
this.sortNodesByDistance(unvisitedNodes); this.sortNodesByDistance(unvisitedNodes);
const closestNode = unvisitedNodes.shift() as Node; const closestNode = unvisitedNodes.shift() as Node;
// If we encounter a wall, skip it if (closestNode.isWall) {
if (closestNode.isWall) continue; continue;
}
// If the closest node is at an infinite distance, we're trapped const isTrapped = closestNode.distance === Infinity;
if (closestNode.distance === Infinity) return { visitedNodesInOrder, nodesInShortestPathOrder: [] }; if (isTrapped)
{
return { visitedNodesInOrder, nodesInShortestPathOrder: [] };
}
closestNode.isVisited = true; closestNode.isVisited = true;
visitedNodesInOrder.push(closestNode); visitedNodesInOrder.push(closestNode);
if (closestNode === endNode) return { visitedNodesInOrder, nodesInShortestPathOrder: this.getNodesInShortestPath(endNode) }; const reachedTheEnd = closestNode === endNode;
if (reachedTheEnd) {
return {visitedNodesInOrder, nodesInShortestPathOrder: this.getNodesInShortestPath(endNode)};
}
this.updateUnvisitedNeighbors(closestNode, grid); this.updateUnvisitedNeighbors(closestNode, grid);
} }
@@ -75,7 +80,6 @@ export class PathfindingService {
aStar(grid: Node[][], startNode: Node, endNode: Node): { visitedNodesInOrder: Node[], nodesInShortestPathOrder: Node[] } { aStar(grid: Node[][], startNode: Node, endNode: Node): { visitedNodesInOrder: Node[], nodesInShortestPathOrder: Node[] } {
const visitedNodesInOrder: Node[] = []; const visitedNodesInOrder: Node[] = [];
startNode.distance = 0; startNode.distance = 0;
// hueristic distance
startNode['distance'] = this.calculateHeuristic(startNode, endNode); startNode['distance'] = this.calculateHeuristic(startNode, endNode);
// fScore = gScore + hScore // fScore = gScore + hScore
startNode['fScore'] = startNode.distance + startNode['distance']; startNode['fScore'] = startNode.distance + startNode['distance'];
@@ -83,29 +87,28 @@ export class PathfindingService {
const openSet: Node[] = [startNode]; const openSet: Node[] = [startNode];
const allNodes = this.getAllNodes(grid); const allNodes = this.getAllNodes(grid);
// Initialize all nodes' fScore to infinity except for the startNode this.initNodesForAStar(allNodes, startNode);
for (const node of allNodes) {
if (node !== startNode) {
node['fScore'] = Infinity;
node.distance = Infinity; // gScore
}
}
while (openSet.length > 0) { while (openSet.length > 0) {
openSet.sort((nodeA, nodeB) => nodeA['fScore'] - nodeB['fScore']); openSet.sort((nodeA, nodeB) => nodeA['fScore'] - nodeB['fScore']);
const currentNode = openSet.shift() as Node; const currentNode = openSet.shift() as Node;
if (currentNode.isWall) continue; if (currentNode.isWall) {
continue;
}
// If the closest node is at an infinite distance, we're trapped const isTrapped = currentNode.distance === Infinity;
if (currentNode.distance === Infinity) return { visitedNodesInOrder, nodesInShortestPathOrder: [] }; if (isTrapped)
{
return {visitedNodesInOrder, nodesInShortestPathOrder: []};
}
currentNode.isVisited = true; currentNode.isVisited = true;
visitedNodesInOrder.push(currentNode); visitedNodesInOrder.push(currentNode);
if (currentNode === endNode) { const reachedTheEnd = currentNode === endNode;
if (reachedTheEnd) {
return { visitedNodesInOrder, nodesInShortestPathOrder: this.getNodesInShortestPath(endNode) }; return { visitedNodesInOrder, nodesInShortestPathOrder: this.getNodesInShortestPath(endNode) };
} }
@@ -114,14 +117,7 @@ export class PathfindingService {
const tentativeGScore = currentNode.distance + 1; // Distance from start to neighbor const tentativeGScore = currentNode.distance + 1; // Distance from start to neighbor
if (tentativeGScore < neighbor.distance) { if (tentativeGScore < neighbor.distance) {
neighbor.previousNode = currentNode; this.updateNeighborNode(neighbor, currentNode, tentativeGScore, endNode, openSet);
neighbor.distance = tentativeGScore;
neighbor['distance'] = this.calculateHeuristic(neighbor, endNode);
neighbor['fScore'] = neighbor.distance + neighbor['distance'];
if (!openSet.includes(neighbor)) {
openSet.push(neighbor);
}
} }
} }
} }
@@ -129,6 +125,26 @@ export class PathfindingService {
return { visitedNodesInOrder, nodesInShortestPathOrder: [] }; return { visitedNodesInOrder, nodesInShortestPathOrder: [] };
} }
private updateNeighborNode(neighbor: Node, currentNode: Node, tentativeGScore: number, endNode: Node, openSet: Node[]) {
neighbor.previousNode = currentNode;
neighbor.distance = tentativeGScore;
neighbor['distance'] = this.calculateHeuristic(neighbor, endNode);
neighbor['fScore'] = neighbor.distance + neighbor['distance'];
if (!openSet.includes(neighbor)) {
openSet.push(neighbor);
}
}
private initNodesForAStar(allNodes: Node[], startNode: Node) {
for (const node of allNodes) {
if (node !== startNode) {
node['fScore'] = Infinity;
node.distance = Infinity; // gScore
}
}
}
private calculateHeuristic(node: Node, endNode: Node): number { private calculateHeuristic(node: Node, endNode: Node): number {
// Manhattan distance heuristic // Manhattan distance heuristic
return Math.abs(node.row - endNode.row) + Math.abs(node.col - endNode.col); return Math.abs(node.row - endNode.row) + Math.abs(node.col - endNode.col);

View File

@@ -1,6 +1,7 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { AlgorithmCategory } from '../models/algorithm-category'; import { AlgorithmCategory } from '../models/algorithm-category';
import { Observable, of } from 'rxjs'; import { Observable, of } from 'rxjs';
import {RouterConstants} from '../../../constants/RouterConstants';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@@ -12,7 +13,7 @@ export class AlgorithmsService {
id: 'pathfinding', id: 'pathfinding',
title: 'ALGORITHM.PATHFINDING.TITLE', title: 'ALGORITHM.PATHFINDING.TITLE',
description: 'ALGORITHM.PATHFINDING.DESCRIPTION', description: 'ALGORITHM.PATHFINDING.DESCRIPTION',
routerLink: 'pathfinding' routerLink: RouterConstants.PATHFINDING.LINK
} }
]; ];

View File

@@ -301,12 +301,18 @@
"CLEAR_NODE": "Löschen", "CLEAR_NODE": "Löschen",
"DIJKSTRA": "Dijkstra", "DIJKSTRA": "Dijkstra",
"ASTAR": "A*", "ASTAR": "A*",
"RESET_BOARD": "Board zurücksetzten", "NORMAL_CASE": "Testaufbau",
"EDGE_CASE": "A* Grenzfall",
"CLEAR_BOARD": "Board leeren", "CLEAR_BOARD": "Board leeren",
"VISITED": "Besucht", "VISITED": "Besucht",
"PATH": "Pfad", "PATH": "Pfad",
"PATH_LENGTH": "Pfadlänge", "PATH_LENGTH": "Pfadlänge",
"EXECUTION_TIME": "Ausführungszeit", "EXECUTION_TIME": "Ausführungszeit",
"EXPLANATION": {
"TITLE": "Algorithmen",
"DIJKSTRA_EXPLANATION": " findet garantiert den kürzesten Weg, wenn alle Kantenkosten nicht-negativ sind. Vorteil: optimal und ohne Heuristik. Nachteil: besucht oft sehr viele Knoten (kann bei großen Grids langsamer wirken).",
"ASTAR_EXPLANATION": " erweitert Dijkstra um eine Heuristik (z.B. Manhattan-Distanz) und kann dadurch wesentlich zielgerichteter suchen. Vorteil: oft deutlich schneller bei guter Heuristik; bei zulässiger Heuristik bleibt der Weg optimal. Nachteil: hängt stark von der Heuristik ab (schlechte Heuristik ≈ Dijkstra)."
},
"ALERT": { "ALERT": {
"START_END_NODES": "Bitte wählen Sie einen Start- und Endknoten aus, bevor Sie den Algorithmus starten." "START_END_NODES": "Bitte wählen Sie einen Start- und Endknoten aus, bevor Sie den Algorithmus starten."
} }
@@ -315,7 +321,9 @@
"TITLE": "Algorithmen", "TITLE": "Algorithmen",
"PATHFINDING": { "PATHFINDING": {
"TITLE": "Wegfindung", "TITLE": "Wegfindung",
"DESCRIPTION": "Vergleich von Dijkstra vs. A*." "DESCRIPTION": "Vergleich von Dijkstra vs. A*.",
"GRID_HEIGHT": "Höhe",
"GRID_WIDTH": "Beite"
} }
} }
} }

View File

@@ -301,12 +301,18 @@
"CLEAR_NODE": "Clear", "CLEAR_NODE": "Clear",
"DIJKSTRA": "Dijkstra", "DIJKSTRA": "Dijkstra",
"ASTAR": "A*", "ASTAR": "A*",
"RESET_BOARD": "Reset Board", "NORMAL_CASE": "Test Scenario",
"EDGE_CASE": "A* Edge Case",
"CLEAR_BOARD": "Clear Board", "CLEAR_BOARD": "Clear Board",
"VISITED": "Visited", "VISITED": "Visited",
"PATH": "Path", "PATH": "Path",
"PATH_LENGTH": "Path length", "PATH_LENGTH": "Path length",
"EXECUTION_TIME": "Execution Time", "EXECUTION_TIME": "Execution Time",
"EXPLANATION": {
"TITLE": "Algorithms",
"DIJKSTRA_EXPLANATION": " is guaranteed to find the shortest path if all edge costs are non-negative. Advantage: optimal and without heuristics. Disadvantage: often visits a large number of nodes (can be slower for large grids).",
"ASTAR_EXPLANATION": " extends Dijkstra with a heuristic (e.g. Manhattan distance) and can therefore search in a much more targeted manner. Advantage: often significantly faster with good heuristics; with permissible heuristics, the path remains optimal. Disadvantage: highly dependent on heuristics (poor heuristics ≈ Dijkstra)."
},
"ALERT": { "ALERT": {
"START_END_NODES": "Please select a start and end node before running the algorithm." "START_END_NODES": "Please select a start and end node before running the algorithm."
} }
@@ -315,7 +321,9 @@
"TITLE": "Algorithms", "TITLE": "Algorithms",
"PATHFINDING": { "PATHFINDING": {
"TITLE": "Pathfinding", "TITLE": "Pathfinding",
"DESCRIPTION": "Comparing of Dijkstra vs. A*." "DESCRIPTION": "Comparing of Dijkstra vs. A*.",
"GRID_HEIGHT": "Height",
"GRID_WIDTH": "Width"
} }
} }
} }