|
|
|
|
@@ -1,13 +1,18 @@
|
|
|
|
|
import { AfterViewInit, Component, ElementRef, ViewChild, inject } 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 {AfterViewInit, Component, ElementRef, inject, ViewChild} from '@angular/core';
|
|
|
|
|
import {CommonModule} from '@angular/common';
|
|
|
|
|
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 {
|
|
|
|
|
Start = 'start',
|
|
|
|
|
End = 'end',
|
|
|
|
|
@@ -15,334 +20,295 @@ 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.ctx = this.getContextOrThrow();
|
|
|
|
|
this.applyGridSize(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();
|
|
|
|
|
|
|
|
|
|
// 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 {
|
|
|
|
|
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 {
|
|
|
|
|
edgeCase(): 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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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.initializeGrid(true, 'edge');
|
|
|
|
|
this.drawGrid();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
clearBoard(): void {
|
|
|
|
|
this.stopAnimations();
|
|
|
|
|
this.initializeGrid(false);
|
|
|
|
|
this.initializeGrid(false, 'edge');
|
|
|
|
|
this.drawGrid();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
clearPath(): void {
|
|
|
|
|
visualizeDijkstra(): void {
|
|
|
|
|
if (!this.ensureStartAndEnd()) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.stopAnimations();
|
|
|
|
|
for (let row = 0; row < GRID_ROWS; row++) {
|
|
|
|
|
for (let col = 0; col < GRID_COLS; col++) {
|
|
|
|
|
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 < 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];
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|