Add labyrinth maze generator and integrate routes

Introduce a new Labyrinth feature: add LabyrinthComponent (TS/HTML/SCSS) implementing maze generation (Prim's/Kruskal) and visualization using the existing generic grid. Wire the component into RouterConstants and app.routes, and add the algorithm entry to AlgorithmsService. Refactor pathfinding internals: rename Node.previousNode -> Node.linkedNode and update PathfindingService and PathfindingComponent accordingly. Add SharedFunctions.random helpers and replace local random utilities. Rename Conway component files/class to ConwayGolComponent and update template path. Add i18n entries for labyrinth (en/de). Minor housekeeping: bump package version to 1.0.0 and disable @typescript-eslint/prefer-for-of in ESLint config.
This commit is contained in:
2026-02-09 10:57:24 +01:00
parent 950ec75f07
commit bbec113f5d
17 changed files with 444 additions and 23 deletions

View File

@@ -29,9 +29,9 @@ import {GenericGridComponent, GridPos} from '../../../shared/components/generic-
FormsModule,
GenericGridComponent
],
templateUrl: './conway-gol.html',
templateUrl: './conway-gol.component.html',
})
export class ConwayGol implements AfterViewInit {
export class ConwayGolComponent implements AfterViewInit {
algoInformation: AlgorithmInformation = {
title: 'GOL.EXPLANATION.TITLE',

View File

@@ -0,0 +1,41 @@
<mat-card class="container">
<mat-card-header>
<mat-card-title>{{ 'LABYRINTH.TITLE' | translate }}</mat-card-title>
</mat-card-header>
<mat-card-content>
<app-information [algorithmInformation]="algoInformation"/>
<div class="controls-container">
<div class="controls-panel">
<button matButton="filled" (click)="visualize('dijkstra')">{{ 'PATHFINDING.DIJKSTRA' | translate }}</button>
<button matButton="filled" (click)="visualize('astar')">{{ 'PATHFINDING.ASTAR' | translate }}</button>
</div>
<div class="controls-panel">
<button matButton="filled" (click)="createRandom()">{{ 'PATHFINDING.CLEAR_BOARD' | translate }}</button>
</div>
<div class="legend">
<span><span class="legend-color start"></span> {{ 'PATHFINDING.START_NODE' | translate }}</span>
<span><span class="legend-color end"></span> {{ 'PATHFINDING.END_NODE' | translate }}</span>
<span><span class="legend-color wall"></span> {{ 'PATHFINDING.WALL' | translate }}</span>
<span><span class="legend-color visited"></span> {{ 'PATHFINDING.VISITED' | translate }}</span>
<span><span class="legend-color path"></span> {{ 'PATHFINDING.PATH' | translate }}</span>
</div>
<div class="controls-panel">
<p>{{ 'PATHFINDING.PATH_LENGTH' | translate }}: {{ pathLength }}</p>
<p>{{ 'PATHFINDING.EXECUTION_TIME' | translate }}: {{ executionTime | number:'1.2-2' }} ms</p>
</div>
</div>
<app-generic-grid
[gridRows]="gridRows"
[gridCols]="gridCols"
[minGridSize]="MIN_GRID_SIZE"
[maxGridSize]="MAX_GRID_SIZE"
[maxGridPx]="MAX_GRID_PX"
[createNodeFn]="createMazeNode"
[getNodeColorFn]="getMazeColor"
[applySelectionFn]="applyNoSelection"
[backgroundColor]="'lightgray'"
(gridChange)="grid = $event"
></app-generic-grid>
</mat-card-content>
</mat-card>

View File

@@ -0,0 +1,325 @@
import {AfterViewInit, Component, inject, ViewChild} from '@angular/core';
import {Information} from '../../information/information';
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
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 {MatButton} from '@angular/material/button';
import {DecimalPipe} from '@angular/common';
import {PathfindingService} from '../service/pathfinding.service';
@Component({
selector: 'app-labyrinth',
imports: [
Information,
MatCard,
MatCardContent,
MatCardHeader,
MatCardTitle,
TranslatePipe,
GenericGridComponent,
MatButton,
DecimalPipe
],
templateUrl: './labyrinth.component.html',
styleUrl: './labyrinth.component.scss',
})
export class LabyrinthComponent implements AfterViewInit {
protected readonly gridRows = 101;
protected readonly gridCols = 101;
protected readonly MAX_GRID_SIZE = 101;
protected readonly MAX_GRID_PX = 1000;
protected readonly MIN_GRID_SIZE = 101;
private readonly pathfindingService = inject(PathfindingService);
algoInformation: AlgorithmInformation = {
title: 'LABYRINTH.EXPLANATION.TITLE',
entries: [
{
name: 'Prims',
description: 'LABYRINTH.EXPLANATION.PRIM_EXPLANATION',
link: UrlConstants.PRIMS_WIKI
},
{
name: 'Kruskals',
description: 'LABYRINTH.EXPLANATION.KRUSKAL_EXPLANATION',
link: UrlConstants.KRUSKAL_WIKI
}
],
disclaimer: 'LABYRINTH.EXPLANATION.DISCLAIMER',
disclaimerBottom: '',
disclaimerListEntry: ['LABYRINTH.EXPLANATION.DISCLAIMER_1', 'LABYRINTH.EXPLANATION.DISCLAIMER_2', 'LABYRINTH.EXPLANATION.DISCLAIMER_3', 'LABYRINTH.EXPLANATION.DISCLAIMER_4']
};
@ViewChild(GenericGridComponent) genericGridComponent!: GenericGridComponent;
grid: Node[][] = [];
startNode: Node | null = null;
endNode: Node | null = null;
animationSpeed = 3;
pathLength = "0";
executionTime = 0;
private timeoutIds: number[] = [];
ngAfterViewInit(): void {
if (this.genericGridComponent) {
this.genericGridComponent.initializationFn = this.initializeMazeGrid;
this.genericGridComponent.createNodeFn = this.createMazeNode;
this.genericGridComponent.getNodeColorFn = this.getMazeColor;
this.genericGridComponent.applySelectionFn = this.applyNoSelection;
this.genericGridComponent.gridRows = this.gridRows;
this.genericGridComponent.gridCols = this.gridCols;
this.genericGridComponent.minGridSize = this.MIN_GRID_SIZE;
this.genericGridComponent.maxGridSize = this.MAX_GRID_SIZE;
this.genericGridComponent.maxGridPx = 1000;
this.genericGridComponent.applyGridSize();
this.genericGridComponent.initializeGrid();
}
}
initializeMazeGrid = (grid: Node[][]): void => {
this.grid = grid;
this.createRandom();
};
createRandom(): void {
this.stopAnimations();
this.clearPath();
this.startNode = null;
this.endNode = null;
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;
}
}
const frontier: Node[] = [];
const {startRow, startCol, startNode} = this.findStartNode();
this.startNode = startNode;
this.getNeighborWalls(startRow, startCol, frontier);
while (frontier.length > 0) {
const randomIndex = SharedFunctions.randomIntFromInterval(0, frontier.length - 1);
//swap and pop from array
const lastIndex = frontier.length - 1;
[frontier[randomIndex], frontier[lastIndex]] = [frontier[lastIndex], frontier[randomIndex]];
const wallFromFrontierList = frontier.pop()!;
const target = wallFromFrontierList.linkedNode;
if (!target || target.isVisited) {
continue;
}
wallFromFrontierList.isWall = false;
wallFromFrontierList.isVisited = true;
target.isWall = false;
target.isVisited = true;
this.getNeighborWalls(target.row, target.col, frontier);
}
this.findEndNode(startNode);
this.cleanupGrid();
this.genericGridComponent.drawGrid();
}
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 findEndNode(startNode: Node) {
let endFound = false;
while (!endFound) {
const endRow: number = SharedFunctions.randomEventIntFromInterval(this.gridRows - 1);
const endCol: number = SharedFunctions.randomEventIntFromInterval(this.gridCols - 1);
const endNode = this.grid[endRow][endCol];
if (endNode != startNode && !endNode.isWall) {
endNode.isWall = false;
endNode.isEnd = true;
endNode.isVisited = true;
this.endNode = endNode;
endFound = true;
}
}
}
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.isStart = true;
startNode.isVisited = true;
return {startRow, startCol, startNode};
}
visualize(algorithm: string): void {
this.stopAnimations();
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 endTime = performance.now();
const lengthOfShortestPath = result.nodesInShortestPathOrder.length;
if (lengthOfShortestPath === 0)
{
this.pathLength = "∞"
}
else
{
this.pathLength = result.nodesInShortestPathOrder.length + "";
}
this.executionTime = endTime - startTime;
this.animateAlgorithm(result.visitedNodesInOrder, result.nodesInShortestPathOrder);
}
createMazeNode = (row: number, col: number): Node => {
return {
row,
col,
isStart: false,
isEnd: false,
isWall: false,
isVisited: false,
isPath: false,
distance: Infinity,
linkedNode: null,
hScore: 0,
fScore: Infinity,
};
};
getMazeColor = (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';
};
applyNoSelection = (pos: GridPos, grid: Node[][]): void => {
this.grid = grid;
//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.distance = Infinity;
node.linkedNode = null;
}
}
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);
}
}
//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;
};
}

View File

@@ -16,6 +16,7 @@ import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/mat
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',
@@ -119,7 +120,7 @@ export class PathfindingComponent implements AfterViewInit {
isVisited: false,
isPath: false,
distance: Infinity,
previousNode: null,
linkedNode: null,
hScore: 0,
fScore: Infinity,
};
@@ -326,16 +327,16 @@ export class PathfindingComponent implements AfterViewInit {
private createRandomStartEndPosition(): { start: GridPos; end: GridPos } {
const midCol = Math.floor(this.gridCols / 2);
const startRow: number = this.randomIntFromInterval(0, this.gridRows - 1);
const startCol: number = this.randomIntFromInterval(0, this.gridCols - 1);
const startRow: number = SharedFunctions.randomIntFromInterval(0, this.gridRows - 1);
const startCol: number = SharedFunctions.randomIntFromInterval(0, this.gridCols - 1);
const endRow: number = this.randomIntFromInterval(0, this.gridRows - 1);
const endRow: number = SharedFunctions.randomIntFromInterval(0, this.gridRows - 1);
let endCol: number;
if (startCol <= midCol) {
endCol = this.randomIntFromInterval(midCol + 1, this.gridCols - 1);
endCol = SharedFunctions.randomIntFromInterval(midCol + 1, this.gridCols - 1);
} else {
endCol = this.randomIntFromInterval(0, midCol);
endCol = SharedFunctions.randomIntFromInterval(0, midCol);
}
return {
@@ -359,8 +360,8 @@ export class PathfindingComponent implements AfterViewInit {
for (let wall = 0; wall < maxNumberOfWalls; wall++) {
const row: number = this.randomIntFromInterval(0, this.gridRows - 1);
const col: number = this.randomIntFromInterval(0, this.gridCols - 1);
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--;
@@ -436,7 +437,7 @@ export class PathfindingComponent implements AfterViewInit {
node.isVisited = false;
node.isPath = false;
node.distance = Infinity;
node.previousNode = null;
node.linkedNode = null;
}
}
this.genericGridComponent?.drawGrid(); // Redraw the grid via generic grid component
@@ -486,8 +487,4 @@ export class PathfindingComponent implements AfterViewInit {
return false;
}
// --- Utility ---
private randomIntFromInterval(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1) + min);
}
}

View File

@@ -7,7 +7,7 @@ export interface Node {
isVisited: boolean;
isPath: boolean;
distance: number;
previousNode: Node | null;
linkedNode: Node | null;
fScore: number;
hScore: number;
}

View File

@@ -25,7 +25,7 @@ export class PathfindingService {
let currentNode: Node | null = endNode;
while (currentNode !== null) {
shortestPathNodes.unshift(currentNode);
currentNode = currentNode.previousNode;
currentNode = currentNode.linkedNode;
}
return shortestPathNodes;
}
@@ -72,7 +72,7 @@ export class PathfindingService {
const unvisitedNeighbors = this.getUnvisitedNeighbors(node, grid);
for (const neighbor of unvisitedNeighbors) {
neighbor.distance = node.distance + 1;
neighbor.previousNode = node;
neighbor.linkedNode = node;
}
}
@@ -136,7 +136,7 @@ export class PathfindingService {
}
private updateNeighborNode(neighbor: Node, currentNode: Node, tentativeGScore: number, endNode: Node, openSet: Node[]) {
neighbor.previousNode = currentNode;
neighbor.linkedNode = currentNode;
neighbor.distance = tentativeGScore;
neighbor['distance'] = this.calculateHeuristic(neighbor, endNode);
neighbor['hScore'] = this.calculateHeuristic(neighbor, endNode);

View File

@@ -26,6 +26,12 @@ export class AlgorithmsService {
title: 'ALGORITHM.GOL.TITLE',
description: 'ALGORITHM.GOL.DESCRIPTION',
routerLink: RouterConstants.GOL.LINK
},
{
id: 'labyrinth',
title: 'ALGORITHM.LABYRINTH.TITLE',
description: 'ALGORITHM.LABYRINTH.DESCRIPTION',
routerLink: RouterConstants.LABYRINTH.LINK
}
];