Extract generic grid component and refactor uses

Add a reusable GenericGridComponent (canvas + input handling) and migrate Conway Game of Life and Pathfinding pages to use it. New files: shared/components/generic-grid/{html,scss,ts} implement canvas rendering, resizing, input listeners and a callback API (createNodeFn, getNodeColorFn, applySelectionFn, initializationFn) plus gridChange events. Updated templates to replace raw <canvas> with <app-generic-grid> and switched grid size inputs to ngModelChange bindings. Conway and Pathfinding components: remove direct canvas/mouse handling, wire the generic grid callbacks, keep algorithm-specific logic (node creation, coloring, selection, scenarios and animations) but delegate drawing and interaction to GenericGridComponent. This centralizes grid rendering/interaction and simplifies per-algorithm components.
This commit is contained in:
2026-02-06 20:59:56 +01:00
parent 3795090cea
commit 930f0110b0
7 changed files with 581 additions and 685 deletions

View File

@@ -33,8 +33,7 @@
[(ngModel)]="gridRows"
[min]="MIN_GRID_SIZE"
[max]="MAX_GRID_SIZE"
(blur)="applyGridSize()"
(keyup.enter)="applyGridSize()"
(ngModelChange)="genericGridComponent.gridRows = gridRows; genericGridComponent.applyGridSize()"
/>
</mat-form-field>
<mat-form-field appearance="outline" class="grid-field">
@@ -45,8 +44,7 @@
[(ngModel)]="gridCols"
[min]="MIN_GRID_SIZE"
[max]="MAX_GRID_SIZE"
(blur)="applyGridSize()"
(keyup.enter)="applyGridSize()"
(ngModelChange)="genericGridComponent.gridCols = gridCols; genericGridComponent.applyGridSize()"
/>
</mat-form-field>
<mat-form-field appearance="outline" class="grid-field">
@@ -67,6 +65,16 @@
<span><span class="legend-color empty"></span> {{ 'GOL.DEAD' | translate }}</span>
</div>
</div>
<canvas #gridCanvas></canvas>
<app-generic-grid
[gridRows]="gridRows"
[gridCols]="gridCols"
[minGridSize]="MIN_GRID_SIZE"
[maxGridSize]="MAX_GRID_SIZE"
[maxGridPx]="MAX_GRID_PX"
[createNodeFn]="createConwayNode"
[getNodeColorFn]="getConwayNodeColor"
[applySelectionFn]="applyConwaySelection"
(gridChange)="grid = $event"
></app-generic-grid>
</mat-card-content>
</mat-card>

View File

@@ -1,4 +1,4 @@
import {AfterViewInit, Component, ElementRef, ViewChild} from '@angular/core';
import {AfterViewInit, Component, ViewChild} from '@angular/core';
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from "@angular/material/card";
import {TranslatePipe} from "@ngx-translate/core";
import {UrlConstants} from '../../../constants/UrlConstants';
@@ -9,8 +9,7 @@ import {MatIcon} from '@angular/material/icon';
import {MatFormField, MatInput, MatLabel} from '@angular/material/input';
import {FormsModule, ReactiveFormsModule} from '@angular/forms';
import {DEFAULT_GRID_COLS, DEFAULT_GRID_ROWS, MAX_GRID_SIZE, MIN_GRID_SIZE, MAX_GRID_PX, Node, LIVE_SPAWN_PROBABILITY, Scenario, MAX_TIME_PER_GENERATION, MIN_TIME_PER_GENERATION, DEFAULT_TIME_PER_GENERATION} from './conway-gol.models';
interface GridPos { row: number; col: number }
import {GenericGridComponent, GridPos} from '../../../shared/components/generic-grid/generic-grid';
@Component({
selector: 'app-conway-gol',
@@ -27,7 +26,8 @@ interface GridPos { row: number; col: number }
MatInput,
MatLabel,
ReactiveFormsModule,
FormsModule
FormsModule,
GenericGridComponent
],
templateUrl: './conway-gol.html',
})
@@ -52,45 +52,68 @@ export class ConwayGol implements AfterViewInit {
protected lifeSpeed = DEFAULT_TIME_PER_GENERATION;
protected readonly MIN_GRID_SIZE = MIN_GRID_SIZE;
protected readonly MAX_GRID_SIZE = MAX_GRID_SIZE;
nodeSize = 10;
protected readonly MAX_GRID_PX = MAX_GRID_PX;
grid: Node[][] = [];
currentScenario: Scenario = 0;
@ViewChild('gridCanvas', { static: true })
canvas!: ElementRef<HTMLCanvasElement>;
private ctx!: CanvasRenderingContext2D;
private lastCell: GridPos | null = null;
isDrawing = false;
@ViewChild(GenericGridComponent) genericGridComponent!: GenericGridComponent;
ngAfterViewInit(): void {
this.ctx = this.getContextOrThrow();
this.applyGridSize();
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());
el.addEventListener('touchstart', (e) => {
if(e.cancelable) e.preventDefault();
this.onMouseDown(e as never);
}, { passive: false });
el.addEventListener('touchmove', (e) => {
if(e.cancelable) e.preventDefault();
this.onMouseMove(e as never);
}, { passive: false });
el.addEventListener('touchend', () => {
this.onMouseUp();
});
if (this.genericGridComponent) {
this.genericGridComponent.initializationFn = this.initializeConwayGrid;
this.genericGridComponent.createNodeFn = this.createConwayNode;
this.genericGridComponent.getNodeColorFn = this.getConwayNodeColor;
this.genericGridComponent.applySelectionFn = this.applyConwaySelection;
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 = this.MAX_GRID_PX;
this.genericGridComponent.initializeGrid();
}
}
generate(scene: Scenario): void {
this.currentScenario = scene;
this.initializeGrid();
this.genericGridComponent.initializationFn = this.initializeConwayGrid;
this.genericGridComponent.initializeGrid();
}
applySpeed(): void {
this.lifeSpeed = Math.min(Math.max(this.lifeSpeed, MIN_TIME_PER_GENERATION), MAX_TIME_PER_GENERATION);
}
// --- Callbacks for GenericGridComponent ---
createConwayNode = (row: number, col: number): Node => {
return {
row,
col,
alive: false
};
};
getConwayNodeColor = (node: Node): string => {
if (node.alive) {
return 'black';
}
return 'lightgray';
};
applyConwaySelection = (pos: GridPos, grid: Node[][]): void => {
this.grid = grid; // Keep internal grid in sync
const node = grid[pos.row][pos.col];
node.alive = !node.alive; // Toggle alive status
};
initializeConwayGrid = (grid: Node[][]): void => {
this.grid = grid;
if (this.currentScenario === Scenario.RANDOM) {
this.setupRandomLives();
}
};
// --- Conway-specific logic (kept local) ---
setupRandomLives(): void {
for (let row = 0; row < this.gridRows; row++) {
for (let col = 0; col < this.gridCols; col++) {
@@ -99,189 +122,7 @@ export class ConwayGol implements AfterViewInit {
}
}
applyGridSize(): 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 (this.gridRows === this.grid.length && this.gridCols === this.grid[0].length)
{
this.drawGrid();
return;
}
this.initializeGrid();
}
applySpeed(): void {
this.lifeSpeed = Math.min(Math.max(this.lifeSpeed, MIN_TIME_PER_GENERATION), MAX_TIME_PER_GENERATION);
}
private initializeGrid(): void {
this.grid = this.createEmptyGrid();
if (this.currentScenario === Scenario.RANDOM) {
this.setupRandomLives();
}
this.drawGrid();
}
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, false));
}
grid.push(currentRow);
}
return grid;
}
private createNode(row: number, col: number, alive: boolean): Node {
return {
row,
col,
alive
};
}
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.alive)
{
return 'black';
}
return 'lightgray';
}
private getContextOrThrow(): CanvasRenderingContext2D {
const ctx = this.canvas.nativeElement.getContext('2d');
if (!ctx) {
throw new Error('CanvasRenderingContext2D not available.');
}
return ctx;
}
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 listener
private onMouseDown(event: MouseEvent): void {
const pos = this.getGridPosition(event);
if (!pos) {
return;
}
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;
}
// Mouse -> grid cell
private getGridPosition(event: MouseEvent | TouchEvent): GridPos | null {
const canvas = this.canvas.nativeElement;
const rect = canvas.getBoundingClientRect();
let clientX, clientY;
if (event instanceof MouseEvent) {
clientX = event.clientX;
clientY = event.clientY;
} else if (event instanceof TouchEvent && event.touches.length > 0) {
clientX = event.touches[0].clientX;
clientY = event.touches[0].clientY;
} else {
return null;
}
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
const x = (clientX - rect.left) * scaleX;
const y = (clientY - rect.top) * scaleY;
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 applySelectionAt(pos: GridPos): void {
const node = this.grid[pos.row][pos.col];
node.alive = !node.alive;
this.lastCell = pos;
this.drawNode(node);
}
// --- Other methods ---
protected readonly Scenario = Scenario;
protected readonly MIN_TIME_PER_GENERATION = MIN_TIME_PER_GENERATION;
protected readonly MAX_TIME_PER_GENERATION = MAX_TIME_PER_GENERATION;

View File

@@ -35,10 +35,8 @@
[min]="MIN_GRID_SIZE"
[max]="MAX_GRID_SIZE"
[(ngModel)]="gridRows"
(blur)="applyGridSize()"
(keyup.enter)="applyGridSize()"
/>
</mat-form-field>
(ngModelChange)="genericGridComponent.gridRows = gridRows; genericGridComponent.applyGridSize()"
/> </mat-form-field>
<mat-form-field appearance="outline" class="grid-field">
<mat-label>{{ 'ALGORITHM.GRID_WIDTH' | translate }}</mat-label>
@@ -48,10 +46,8 @@
[min]="MIN_GRID_SIZE"
[max]="MAX_GRID_SIZE"
[(ngModel)]="gridCols"
(blur)="applyGridSize()"
(keyup.enter)="applyGridSize()"
/>
</mat-form-field>
(ngModelChange)="genericGridComponent.gridCols = gridCols; genericGridComponent.applyGridSize()"
/> </mat-form-field>
</div>
</div>
@@ -68,6 +64,16 @@
</div>
</div>
<canvas #gridCanvas></canvas>
<app-generic-grid
[gridRows]="gridRows"
[gridCols]="gridCols"
[minGridSize]="MIN_GRID_SIZE"
[maxGridSize]="MAX_GRID_SIZE"
[maxGridPx]="MAX_GRID_PX"
[createNodeFn]="createPathfindingNode"
[getNodeColorFn]="getPathfindingNodeColor"
[applySelectionFn]="applyPathfindingSelection"
(gridChange)="grid = $event"
></app-generic-grid>
</mat-card-content>
</mat-card>

View File

@@ -1,4 +1,4 @@
import {AfterViewInit, Component, ElementRef, inject, ViewChild} from '@angular/core';
import {AfterViewInit, Component, inject, ViewChild} from '@angular/core';
import {CommonModule} from '@angular/common';
import {FormsModule} from '@angular/forms';
@@ -15,6 +15,7 @@ import {UrlConstants} from '../../../constants/UrlConstants';
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
import {Information} from '../information/information';
import {AlgorithmInformation} from '../information/information.models';
import {GenericGridComponent, GridPos} from '../../../shared/components/generic-grid/generic-grid';
enum NodeType {
Start = 'start',
@@ -23,8 +24,6 @@ enum NodeType {
None = 'none'
}
interface GridPos { row: number; col: number }
@Component({
selector: 'app-pathfinding',
standalone: true,
@@ -40,7 +39,8 @@ interface GridPos { row: number; col: number }
MatCardHeader,
MatCardTitle,
MatCardContent,
Information
Information,
GenericGridComponent
],
templateUrl: './pathfinding.component.html',
})
@@ -51,6 +51,7 @@ export class PathfindingComponent implements AfterViewInit {
readonly NodeType = NodeType;
readonly MIN_GRID_SIZE = MIN_GRID_SIZE;
readonly MAX_GRID_SIZE = MAX_GRID_SIZE;
readonly MAX_GRID_PX = MAX_GRID_PX;
algoInformation: AlgorithmInformation = {
title: 'PATHFINDING.EXPLANATION.TITLE',
@@ -71,24 +72,15 @@ export class PathfindingComponent implements AfterViewInit {
disclaimerListEntry: []
};
@ViewChild('gridCanvas', { static: true })
canvas!: ElementRef<HTMLCanvasElement>;
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;
private lastCell: GridPos | null = null;
private shouldAddWall = true;
private shouldAddWall = true; // Moved here
animationSpeed = 3;
pathLength = "0";
@@ -96,58 +88,79 @@ export class PathfindingComponent implements AfterViewInit {
private timeoutIds: number[] = [];
@ViewChild(GenericGridComponent) genericGridComponent!: GenericGridComponent;
ngAfterViewInit(): void {
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());
el.addEventListener('touchstart', (e) => {
if(e.cancelable) e.preventDefault();
this.onMouseDown(e as never);
}, { passive: false });
el.addEventListener('touchmove', (e) => {
if(e.cancelable) e.preventDefault();
this.onMouseMove(e as never);
}, { passive: false });
el.addEventListener('touchend', () => {
this.onMouseUp();
});
// Canvas logic is now handled by GenericGridComponent
// Ensure genericGridComponent is initialized
if (this.genericGridComponent) {
this.genericGridComponent.initializationFn = this.initializePathfindingGrid;
this.genericGridComponent.createNodeFn = this.createPathfindingNode;
this.genericGridComponent.getNodeColorFn = this.getPathfindingNodeColor;
this.genericGridComponent.applySelectionFn = this.applyPathfindingSelection;
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 = MAX_GRID_PX;
this.genericGridComponent.applyGridSize(); // Trigger initial grid setup
}
this.createCase({withWalls: true, scenario: "normal"});
}
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();
// --- Callbacks for GenericGridComponent ---
createPathfindingNode = (row: number, col: number): Node => {
return {
row,
col,
isStart: false,
isEnd: false,
isWall: false,
isVisited: false,
isPath: false,
distance: Infinity,
previousNode: null,
hScore: 0,
fScore: Infinity,
};
};
if (this.gridRows === this.grid.length && this.gridCols === this.grid[0].length)
{
this.drawGrid();
return;
getPathfindingNodeColor = (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';
};
applyPathfindingSelection = (pos: GridPos, grid: Node[][]): void => {
this.grid = grid; // Keep internal grid in sync
const node = grid[pos.row][pos.col];
// Determine if we should add or remove a wall
if (this.selectedNodeType === NodeType.Wall && this.genericGridComponent.isDrawing && this.genericGridComponent['lastCell'] === null) {
this.shouldAddWall = !node.isWall;
}
if (skipReset) {
this.initializeGrid({withWalls: true, scenario: 'normal'});
this.drawGrid();
return;
}
switch (this.selectedNodeType) {
case NodeType.Start:
this.trySetStart(node);
break;
this.createCase({withWalls: true, scenario: 'normal'});
}
case NodeType.End:
this.trySetEnd(node);
break;
createCase({withWalls, scenario}: { withWalls: boolean, scenario: "normal" | "edge" | "random" }): void
{
this.stopAnimations();
this.initializeGrid({withWalls, scenario});
this.drawGrid();
case NodeType.Wall:
this.tryToggleWall(node, this.shouldAddWall);
break;
case NodeType.None:
this.tryClearNode(node);
break;
}
};
visualize(algorithm: string): void {
if (!this.ensureStartAndEnd()) {
@@ -195,320 +208,19 @@ export class PathfindingComponent implements AfterViewInit {
this.animateAlgorithm(result.visitedNodesInOrder, result.nodesInShortestPathOrder);
}
// Mouse interactions
private onMouseDown(event: MouseEvent): void {
this.stopAnimations();
this.clearPath();
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, scenario}: { withWalls: boolean, scenario: "normal" | "edge" | "random" }): void {
this.grid = this.createEmptyGrid();
const { start, end } = this.getScenarioStartEnd(scenario);
initializePathfindingGrid = (grid: Node[][]): void => {
this.grid = grid; // Update the component's grid reference
const {start, end} = this.getScenarioStartEnd('normal'); // Default 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(scenario);
}
}
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,
hScore: 0,
fScore: Infinity,
this.placeDefaultDiagonalWall('normal');
};
}
private getScenarioStartEnd(scenario: 'normal' | 'edge' | 'random'): { start: GridPos; end: GridPos } {
if (scenario === 'edge') {
return {
start: { row: 0, col: 0 },
end: { row: this.gridRows - 1, col: this.gridCols - 1 }
};
}
else if (scenario === 'random') {
return this.createRandomStartEndPosition();
}
else {
// 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 createRandomStartEndPosition() {
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 endRow: number = this.randomIntFromInterval(0, this.gridRows - 1);
let endCol: number;
if (startCol <= midCol) {
endCol = this.randomIntFromInterval(midCol + 1, this.gridCols - 1);
} else {
endCol = this.randomIntFromInterval(0, midCol);
}
return {
start: {row: startRow, col: startCol},
end: {row: endRow, col: endCol}
};
}
private placeDefaultDiagonalWall(scenario: 'normal' | 'edge' | 'random'): void {
if (scenario === 'edge') {
this.createDiagonalWall();
}
else if (scenario === 'normal') {
this.createVerticalWall();
}
else if (scenario === 'random') {
this.createRandomWalls();
}
}
private createRandomWalls(){
const maxNumberOfWalls = Math.floor(MAX_RANDOM_WALLS_FACTORS * this.gridCols * this.gridRows);
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);
if (!this.isValidPosition(row, col)) {
wall--;
continue;
}
const node = this.grid[row][col];
if (node.isStart || node.isEnd) {
wall--;
continue;
}
node.isWall = true;
}
}
private createVerticalWall() {
const height = this.gridRows;
const startCol = Math.floor(this.gridCols / 2);
for (let i = 5; i < (height - 5); i++) {
const row = i;
if (!this.isValidPosition(row, startCol)) {
continue;
}
const node = this.grid[row][startCol];
if (node.isStart || node.isEnd) {
continue;
}
node.isWall = true;
}
}
private createDiagonalWall() {
// 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;
node.distance = Infinity;
node.previousNode = null;
}
}
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)
// --- Helper methods for node manipulation (kept local) ---
private trySetStart(node: Node): void {
if (!this.canBeStart(node)) {
return;
@@ -516,7 +228,7 @@ export class PathfindingComponent implements AfterViewInit {
if (this.startNode) {
this.startNode.isStart = false;
this.drawNode(this.startNode);
this.genericGridComponent.drawNode(this.startNode); // Redraw old start node
}
node.isStart = true;
@@ -530,7 +242,7 @@ export class PathfindingComponent implements AfterViewInit {
if (this.endNode) {
this.endNode.isEnd = false;
this.drawNode(this.endNode);
this.genericGridComponent.drawNode(this.endNode); // Redraw old end node
}
node.isEnd = true;
@@ -574,16 +286,197 @@ export class PathfindingComponent implements AfterViewInit {
return !node.isStart && !node.isEnd;
}
private shouldStartWallStroke(pos: GridPos): boolean {
if (this.selectedNodeType !== NodeType.Wall) {
return true;
// --- Grid manipulation for scenarios (kept local) ---
createCase({withWalls, scenario}: { withWalls: boolean, scenario: "normal" | "edge" | "random" }): void {
this.stopAnimations();
// Reinitialize grid through the generic component
this.genericGridComponent.initializationFn = (grid) => {
this.grid = grid;
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(scenario);
}
};
this.genericGridComponent.initializeGrid(); // Trigger re-initialization and redraw
}
const node = this.grid[pos.row][pos.col];
return !node.isWall;
private getScenarioStartEnd(scenario: 'normal' | 'edge' | 'random'): { start: GridPos; end: GridPos } {
if (scenario === 'edge') {
return {
start: {row: 0, col: 0},
end: {row: this.gridRows - 1, col: this.gridCols - 1}
};
} else if (scenario === 'random') {
return this.createRandomStartEndPosition();
} else {
// 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}
};
}
}
// Validation
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 endRow: number = this.randomIntFromInterval(0, this.gridRows - 1);
let endCol: number;
if (startCol <= midCol) {
endCol = this.randomIntFromInterval(midCol + 1, this.gridCols - 1);
} else {
endCol = this.randomIntFromInterval(0, midCol);
}
return {
start: {row: startRow, col: startCol},
end: {row: endRow, col: endCol}
};
}
private placeDefaultDiagonalWall(scenario: 'normal' | 'edge' | 'random'): void {
if (scenario === 'edge') {
this.createDiagonalWall();
} else if (scenario === 'normal') {
this.createVerticalWall();
} else if (scenario === 'random') {
this.createRandomWalls();
}
}
private createRandomWalls() {
const maxNumberOfWalls = Math.floor(MAX_RANDOM_WALLS_FACTORS * this.gridCols * this.gridRows);
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);
if (!this.grid[row][col]) { // Use the grid passed from GenericGrid
wall--;
continue;
}
const node = this.grid[row][col];
if (node.isStart || node.isEnd) {
wall--;
continue;
}
node.isWall = true;
}
}
private createVerticalWall() {
const height = this.gridRows;
const startCol = Math.floor(this.gridCols / 2);
for (let i = 5; i < (height - 5); i++) {
const row = i;
if (!this.grid[row]?.[startCol]) {
continue;
}
const node = this.grid[row][startCol];
if (node.isStart || node.isEnd) {
continue;
}
node.isWall = true;
}
}
private createDiagonalWall() {
// 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.grid[row]?.[col]) {
continue;
}
const node = this.grid[row][col];
if (node.isStart || node.isEnd) {
continue;
}
node.isWall = true;
}
}
// --- 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.previousNode = 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);
}
}
// --- Validation ---
private ensureStartAndEnd(): boolean {
if (this.startNode && this.endNode) {
return true;
@@ -593,73 +486,7 @@ export class PathfindingComponent implements AfterViewInit {
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 | TouchEvent): GridPos | null {
const canvas = this.canvas.nativeElement;
const rect = canvas.getBoundingClientRect();
let clientX, clientY;
if (event instanceof MouseEvent) {
clientX = event.clientX;
clientY = event.clientY;
} else if (event instanceof TouchEvent && event.touches.length > 0) {
clientX = event.touches[0].clientX;
clientY = event.touches[0].clientY;
} else {
return null;
}
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
const x = (clientX - rect.left) * scaleX;
const y = (clientY - rect.top) * scaleY;
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;
}
// --- Utility ---
private randomIntFromInterval(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1) + min);
}

View File

@@ -0,0 +1 @@
<canvas #gridCanvas></canvas>

View File

@@ -0,0 +1,213 @@
import {AfterViewInit, Component, ElementRef, EventEmitter, Input, Output, ViewChild} from '@angular/core';
import {CommonModule} from '@angular/common';
export interface GridPos { row: number; col: number }
@Component({
selector: 'app-generic-grid',
standalone: true,
imports: [CommonModule],
templateUrl: './generic-grid.html',
styleUrl: './generic-grid.scss',
})
export class GenericGridComponent implements AfterViewInit {
@ViewChild('gridCanvas', { static: true })
canvas!: ElementRef<HTMLCanvasElement>;
@Input() gridRows: number = 10;
@Input() gridCols: number = 10;
@Input() nodeSize: number = 10; // Default node size, can be overridden by computeNodeSize
@Input() maxGridPx: number = 500; // Max pixels for grid dimension
@Input() minGridSize: number = 5;
@Input() maxGridSize: number = 50;
@Input() drawNodeBorderColor: string = '#ccc';
// Callbacks from parent component
@Input() createNodeFn!: (row: number, col: number) => any;
@Input() getNodeColorFn!: (node: any) => string;
@Input() applySelectionFn!: (pos: GridPos, grid: any[][]) => void;
@Input() initializationFn!: (grid: any[][]) => void;
@Output() gridChange = new EventEmitter<any[][]>();
@Output() nodeClick = new EventEmitter<GridPos>();
private ctx!: CanvasRenderingContext2D;
grid: any[][] = [];
isDrawing = false;
private lastCell: GridPos | null = null;
ngAfterViewInit(): void {
this.ctx = this.getContextOrThrow();
this.setupCanvasListeners();
this.applyGridSize();
}
setupCanvasListeners(): void {
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());
el.addEventListener('touchstart', (e) => {
if (e.cancelable) e.preventDefault();
this.onMouseDown(e as never);
}, { passive: false });
el.addEventListener('touchmove', (e) => {
if (e.cancelable) e.preventDefault();
this.onMouseMove(e as never);
}, { passive: false });
el.addEventListener('touchend', () => {
this.onMouseUp();
});
}
applyGridSize(): void {
this.gridRows = this.clampGridSize(this.gridRows);
this.gridCols = this.clampGridSize(this.gridCols);
this.nodeSize = this.computeNodeSize(this.gridRows, this.gridCols);
this.resizeCanvas();
if (this.gridRows === this.grid.length && this.gridCols === this.grid[0]?.length) {
this.drawGrid();
return;
}
this.initializeGrid();
}
initializeGrid(): void {
this.grid = this.createEmptyGrid();
if (this.initializationFn) {
this.initializationFn(this.grid);
}
this.drawGrid();
this.gridChange.emit(this.grid);
}
createEmptyGrid(): any[][] {
const grid: any[][] = [];
for (let row = 0; row < this.gridRows; row++) {
const currentRow: any[] = [];
for (let col = 0; col < this.gridCols; col++) {
currentRow.push(this.createNodeFn(row, col));
}
grid.push(currentRow);
}
return grid;
}
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]);
}
}
}
drawNode(node: any): void {
this.ctx.fillStyle = this.getNodeColorFn(node);
this.ctx.fillRect(node.col * this.nodeSize, node.row * this.nodeSize, this.nodeSize, this.nodeSize);
this.ctx.strokeStyle = this.drawNodeBorderColor;
this.ctx.strokeRect(node.col * this.nodeSize, node.row * this.nodeSize, this.nodeSize, this.nodeSize);
}
private getContextOrThrow(): CanvasRenderingContext2D {
const ctx = this.canvas.nativeElement.getContext('2d');
if (!ctx) {
throw new Error('CanvasRenderingContext2D not available.');
}
return ctx;
}
private clampGridSize(value: number): number {
const parsed = Math.floor(Number(value));
const safe = Number.isFinite(parsed) ? parsed : this.minGridSize; // Use minGridSize as fallback
return Math.min(Math.max(this.minGridSize, safe), this.maxGridSize);
}
private computeNodeSize(rows: number, cols: number): number {
const sizeByWidth = Math.floor(this.maxGridPx / cols);
const sizeByHeight = Math.floor(this.maxGridPx / 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;
}
onMouseDown(event: MouseEvent | TouchEvent): void {
this.isDrawing = true;
this.lastCell = null;
const pos = this.getGridPosition(event);
if (pos) {
this.handleInteraction(pos);
}
}
onMouseMove(event: MouseEvent | TouchEvent): void {
if (!this.isDrawing) {
return;
}
const pos = this.getGridPosition(event);
if (pos && !this.isSameCell(pos, this.lastCell)) {
this.handleInteraction(pos);
}
}
onMouseUp(): void {
this.isDrawing = false;
this.lastCell = null;
}
private handleInteraction(pos: GridPos): void {
this.applySelectionFn(pos, this.grid);
this.drawNode(this.grid[pos.row][pos.col]);
this.lastCell = pos;
this.nodeClick.emit(pos);
this.gridChange.emit(this.grid);
}
private getGridPosition(event: MouseEvent | TouchEvent): GridPos | null {
const canvas = this.canvas.nativeElement;
const rect = canvas.getBoundingClientRect();
let clientX, clientY;
if (event instanceof MouseEvent) {
clientX = event.clientX;
clientY = event.clientY;
} else if (event instanceof TouchEvent && event.touches.length > 0) {
clientX = event.touches[0].clientX;
clientY = event.touches[0].clientY;
} else {
return null;
}
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
const x = (clientX - rect.left) * scaleX;
const y = (clientY - rect.top) * scaleY;
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;
}
}