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:
lobo committed 2026-02-06 20:59:56 +01:00
1 parent 3795090cea
commit 930f0110b0
7 files changed
+581 -685

No files matched your search

@@ -0,0 +1 @@
<canvas #gridCanvas></canvas>
Whitespace-only changes.
@@ -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;
}
}