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.
This commit is contained in:
2026-02-02 10:06:59 +01:00
parent 17a787b0f1
commit e0f0a0ed04
8 changed files with 591 additions and 317 deletions

View File

@@ -1,4 +1,6 @@
export class UrlConstants {
static readonly LINKED_IN = 'https://www.linkedin.com/in/andreas-dahm-2395991ba';
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">
<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">
<mat-button-toggle-group [(ngModel)]="selectedNodeType" aria-label="Node Type Selection">
@@ -10,11 +24,43 @@
<mat-button-toggle [value]="NodeType.None">{{ 'PATHFINDING.CLEAR_NODE' | translate }}</mat-button-toggle>
</mat-button-toggle-group>
</div>
<div class="controls">
<button matButton="filled" class="primary" (click)="visualizeDijkstra()">{{ 'PATHFINDING.DIJKSTRA' | translate }}</button>
<button matButton="filled" class="accent" (click)="visualizeAStar()">{{ 'PATHFINDING.ASTAR' | translate }}</button>
<button matButton="filled" class="warn" (click)="resetBoard()">{{ 'PATHFINDING.RESET_BOARD' | translate }}</button>
<button matButton="filled" class="warn" (click)="clearBoard()">{{ 'PATHFINDING.CLEAR_BOARD' | translate }}</button>
<button matButton="filled" (click)="visualizeDijkstra()">{{ 'PATHFINDING.DIJKSTRA' | translate }}</button>
<button matButton="filled" (click)="visualizeAStar()">{{ 'PATHFINDING.ASTAR' | 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 class="legend">
@@ -26,10 +72,10 @@
</div>
</div>
<canvas #gridCanvas></canvas>
<div class="results-container">
<p>{{ 'PATHFINDING.PATH_LENGTH' | translate }}: {{ pathLength }}</p>
<p>{{ 'PATHFINDING.EXECUTION_TIME' | translate }}: {{ executionTime | number:'1.2-2' }} ms</p>
</div>
<canvas #gridCanvas></canvas>
</div>

View File

@@ -2,6 +2,25 @@
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 {
display: flex;
flex-direction: column;
@@ -13,6 +32,7 @@
flex-wrap: wrap;
gap: 1rem;
margin-bottom: 1rem;
align-items: center;
mat-button-toggle-group {
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 {
display: flex;
flex-wrap: wrap;
@@ -46,4 +77,5 @@
canvas {
border: 1px solid #ccc;
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 { 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 { PathfindingService } from './service/pathfinding.service';
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';
// Define an enum for node types that can be placed by the user
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 {
Start = 'start',
End = 'end',
@@ -15,58 +20,240 @@ enum NodeType {
None = 'none'
}
interface GridPos { row: number; col: number }
@Component({
selector: 'app-pathfinding',
standalone: true,
imports: [CommonModule, MatButtonModule, MatButtonToggleModule, FormsModule, TranslateModule],
imports: [
CommonModule,
FormsModule,
MatButtonModule,
MatButtonToggleModule,
MatFormFieldModule,
MatInputModule,
TranslateModule
],
templateUrl: './pathfinding.component.html',
styleUrls: ['./pathfinding.component.scss']
})
export class PathfindingComponent implements AfterViewInit {
private readonly pathfindingService = inject(PathfindingService);
private readonly translate = inject(TranslateService);
private lastRow = -1;
private lastCol = -1;
private timeoutIds: any[] = [];
readonly NodeType = NodeType;
readonly MIN_GRID_SIZE = MIN_GRID_SIZE;
readonly MAX_GRID_SIZE = MAX_GRID_SIZE;
@ViewChild('gridCanvas', { static: true })
canvas!: ElementRef<HTMLCanvasElement>;
ctx!: CanvasRenderingContext2D;
private ctx!: CanvasRenderingContext2D;
gridRows = DEFAULT_GRID_ROWS;
gridCols = DEFAULT_GRID_COLS;
nodeSize = 10;
grid: Node[][] = [];
startNode: Node | null = null;
endNode: Node | null = null;
selectedNodeType: NodeType = NodeType.None;
isDrawing = false;
shouldAddWall = true;
selectedNodeType: NodeType = NodeType.None; // Default to no selection
animationSpeed = 3; // milliseconds
private lastCell: GridPos | null = null;
private shouldAddWall = true;
animationSpeed = 3;
pathLength = 0;
executionTime = 0;
readonly NodeType = NodeType;
private timeoutIds: number[] = [];
ngAfterViewInit(): void {
this.ctx = this.canvas.nativeElement.getContext('2d') as CanvasRenderingContext2D;
this.canvas.nativeElement.width = GRID_COLS * NODE_SIZE;
this.canvas.nativeElement.height = GRID_ROWS * NODE_SIZE;
this.initializeGrid(true);
this.drawGrid();
this.ctx = this.getContextOrThrow();
this.applyGridSize(true);
// 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
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());
}
initializeGrid(withWalls: boolean): void {
this.grid = [];
for (let row = 0; row < GRID_ROWS; row++) {
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();
}
edgeCase(): void {
this.stopAnimations();
this.initializeGrid(true, 'edge');
this.drawGrid();
}
clearBoard(): void {
this.stopAnimations();
this.initializeGrid(false, 'edge');
this.drawGrid();
}
visualizeDijkstra(): void {
if (!this.ensureStartAndEnd()) {
return;
}
this.stopAnimations();
this.clearPath();
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 < GRID_COLS; col++) {
currentRow.push({
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,
@@ -77,272 +264,51 @@ export class PathfindingComponent implements AfterViewInit {
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;
}
}
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 }
};
}
stopAnimations(): void {
this.timeoutIds.forEach((id) => clearTimeout(id));
this.timeoutIds = [];
// 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 }
};
}
drawGrid(): void {
if (!this.ctx) {
return;
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;
}
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;
if (node.isStart || node.isEnd) {
continue;
}
this.drawNode(node);
}
visualizeDijkstra(): 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.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);
node.isWall = true;
}
}
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();
}
clearBoard(): void {
this.stopAnimations();
this.initializeGrid(false);
this.drawGrid();
}
clearPath(): void {
this.stopAnimations();
for (let row = 0; row < GRID_ROWS; row++) {
for (let col = 0; col < GRID_COLS; col++) {
// 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];
node.isVisited = false;
node.isPath = false;
@@ -353,4 +319,211 @@ export class PathfindingComponent implements AfterViewInit {
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;
}
export const GRID_ROWS = 150;
export const GRID_COLS = 100;
export const NODE_SIZE = 10; // in pixels
export const DEFAULT_GRID_ROWS = 50;
export const DEFAULT_GRID_COLS = 50;
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,5 +1,5 @@
import { Injectable } from '@angular/core';
import { Node, GRID_ROWS, GRID_COLS } from '../pathfinding.models';
import { Node} from '../pathfinding.models';
@Injectable({
providedIn: 'root'
@@ -12,9 +12,9 @@ export class PathfindingService {
const { col, row } = node;
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 < 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);
}

View File

@@ -301,12 +301,18 @@
"CLEAR_NODE": "Löschen",
"DIJKSTRA": "Dijkstra",
"ASTAR": "A*",
"RESET_BOARD": "Board zurücksetzten",
"NORMAL_CASE": "Testaufbau",
"EDGE_CASE": "A* Grenzfall",
"CLEAR_BOARD": "Board leeren",
"VISITED": "Besucht",
"PATH": "Pfad",
"PATH_LENGTH": "Pfadlänge",
"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": {
"START_END_NODES": "Bitte wählen Sie einen Start- und Endknoten aus, bevor Sie den Algorithmus starten."
}
@@ -315,7 +321,9 @@
"TITLE": "Algorithmen",
"PATHFINDING": {
"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",
"DIJKSTRA": "Dijkstra",
"ASTAR": "A*",
"RESET_BOARD": "Reset Board",
"NORMAL_CASE": "Test Scenario",
"EDGE_CASE": "A* Edge Case",
"CLEAR_BOARD": "Clear Board",
"VISITED": "Visited",
"PATH": "Path",
"PATH_LENGTH": "Path length",
"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": {
"START_END_NODES": "Please select a start and end node before running the algorithm."
}
@@ -315,7 +321,9 @@
"TITLE": "Algorithms",
"PATHFINDING": {
"TITLE": "Pathfinding",
"DESCRIPTION": "Comparing of Dijkstra vs. A*."
"DESCRIPTION": "Comparing of Dijkstra vs. A*.",
"GRID_HEIGHT": "Height",
"GRID_WIDTH": "Width"
}
}
}