Enhance Conway's Game of Life UI & interaction

Add interactive controls and drawing support for Conway's Game of Life: introduce Node.alive, Scenario enum, spawn/speed/time constants, random/empty generation, and mouse/touch drawing (click-drag/touch to toggle cells). Update template to include control buttons, speed input, legend, and expose Scenario constants. Implement grid initialization, random seeding, grid position mapping, and optimized node drawing/color logic.

Also update i18n (de/en) with GOL strings and move GRID label keys to ALGORITHM, switch some label usages accordingly. Move generic container/legend styles into global styles.scss (adjust canvas border color), and simplify component SCSS files. Change CONWAYS_WIKI URL to German wiki and remove now-unused UrlConstants references from components.
This commit is contained in:
2026-02-06 14:40:49 +01:00
parent a22dd17869
commit 59148db295
14 changed files with 276 additions and 98 deletions

View File

@@ -4,36 +4,68 @@
</mat-card-header>
<mat-card-content>
<app-information [algorithmInformation]="algoInformation"/>
<div class="controls-panel">
<button mat-raised-button >
<mat-icon>play_arrow</mat-icon> {{ 'GOL.START' | translate }}
</button>
</div>
<div class="grid-size">
<mat-form-field appearance="outline" class="grid-field">
<mat-label>{{ 'PATHFINDING.GRID_HEIGHT' | translate }}</mat-label>
<input
matInput
type="number"
[(ngModel)]="gridRows"
[min]="MIN_GRID_SIZE"
[max]="MAX_GRID_SIZE"
(blur)="applyGridSize()"
(keyup.enter)="applyGridSize()"
/>
</mat-form-field>
<mat-form-field appearance="outline" class="grid-field">
<mat-label>{{ 'PATHFINDING.GRID_WIDTH' | translate }}</mat-label>
<input
matInput
type="number"
[(ngModel)]="gridCols"
[min]="MIN_GRID_SIZE"
[max]="MAX_GRID_SIZE"
(blur)="applyGridSize()"
(keyup.enter)="applyGridSize()"
/>
</mat-form-field>
<div class="controls-container">
<div class="controls-panel">
<button mat-raised-button (click)="generate(Scenario.RANDOM)">
<mat-icon>shuffle</mat-icon> {{ 'GOL.RANDOM_SCENE' | translate }}
</button>
<button mat-raised-button (click)="generate(Scenario.EMPTY)">
<mat-icon>check_box_outline_blank</mat-icon> {{ 'GOL.EMPTY_SCENE' | translate }}
</button>
<button mat-raised-button >
<mat-icon>play_arrow</mat-icon> {{ 'GOL.START' | translate }}
</button>
<button mat-raised-button >
<mat-icon>play_arrow</mat-icon> {{ 'GOL.START' | translate }}
</button>
</div>
<div class="controls-panel">
<button mat-raised-button >
<mat-icon>play_arrow</mat-icon> {{ 'GOL.START' | translate }}
</button>
</div>
<div class="grid-size">
<mat-form-field appearance="outline" class="grid-field">
<mat-label>{{ 'ALGORITHM.GRID_HEIGHT' | translate }}</mat-label>
<input
matInput
type="number"
[(ngModel)]="gridRows"
[min]="MIN_GRID_SIZE"
[max]="MAX_GRID_SIZE"
(blur)="applyGridSize()"
(keyup.enter)="applyGridSize()"
/>
</mat-form-field>
<mat-form-field appearance="outline" class="grid-field">
<mat-label>{{ 'ALGORITHM.GRID_WIDTH' | translate }}</mat-label>
<input
matInput
type="number"
[(ngModel)]="gridCols"
[min]="MIN_GRID_SIZE"
[max]="MAX_GRID_SIZE"
(blur)="applyGridSize()"
(keyup.enter)="applyGridSize()"
/>
</mat-form-field>
<mat-form-field appearance="outline" class="grid-field">
<mat-label>{{ 'GOL.SPEED' | translate }}</mat-label>
<input
matInput
type="number"
[(ngModel)]="lifeSpeed"
[min]="MIN_TIME_PER_GENERATION"
[max]="MAX_TIME_PER_GENERATION"
(blur)="applySpeed()"
(keyup.enter)="applySpeed()"
/>
</mat-form-field>
</div>
<div class="legend">
<span><span class="legend-color alive"></span> {{ 'GOL.ALIVE' | translate }}</span>
<span><span class="legend-color empty"></span> {{ 'GOL.DEAD' | translate }}</span>
</div>
</div>
<canvas #gridCanvas></canvas>
</mat-card-content>

View File

@@ -1,6 +1,12 @@
export interface Node {
row: number;
col: number;
alive: boolean;
}
export enum Scenario {
RANDOM = 0,
EMPTY
}
export const DEFAULT_GRID_ROWS = 100;
@@ -8,4 +14,10 @@ export const DEFAULT_GRID_COLS = 100;
export const MIN_GRID_SIZE = 20;
export const MAX_GRID_SIZE = 200;
export const DEFAULT_TIME_PER_GENERATION = 50;
export const MIN_TIME_PER_GENERATION = 20;
export const MAX_TIME_PER_GENERATION = 200;
export const MAX_GRID_PX = 1000;
export const LIVE_SPAWN_PROBABILITY = 0.37;

View File

@@ -8,7 +8,9 @@ import {MatButton} from '@angular/material/button';
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} from './conway-gol.models';
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 }
@Component({
selector: 'app-conway-gol',
@@ -45,20 +47,57 @@ export class ConwayGol implements AfterViewInit {
disclaimerBottom: '',
disclaimerListEntry: ['GOL.EXPLANATION.DISCLAIMER_1', 'GOL.EXPLANATION.DISCLAIMER_2', 'GOL.EXPLANATION.DISCLAIMER_3', 'GOL.EXPLANATION.DISCLAIMER_4']
};
protected gridCols = DEFAULT_GRID_COLS;
protected gridRows = DEFAULT_GRID_ROWS;
protected lifeSpeed = DEFAULT_TIME_PER_GENERATION;
protected readonly MIN_GRID_SIZE = MIN_GRID_SIZE;
protected readonly MAX_GRID_SIZE = MAX_GRID_SIZE;
nodeSize = 10;
grid: Node[][] = [];
currentScenario: Scenario = 0;
@ViewChild('gridCanvas', { static: true })
canvas!: ElementRef<HTMLCanvasElement>;
private ctx!: CanvasRenderingContext2D;
private lastCell: GridPos | null = null;
isDrawing = false;
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();
});
}
generate(scene: Scenario): void {
this.currentScenario = scene;
this.initializeGrid();
}
setupRandomLives(): void {
for (let row = 0; row < this.gridRows; row++) {
for (let col = 0; col < this.gridCols; col++) {
this.grid[row][col].alive = Math.random() <= LIVE_SPAWN_PROBABILITY;
}
}
}
applyGridSize(): void {
@@ -73,11 +112,19 @@ export class ConwayGol implements AfterViewInit {
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();
}
@@ -87,7 +134,7 @@ export class ConwayGol implements AfterViewInit {
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));
currentRow.push(this.createNode(row, col, false));
}
grid.push(currentRow);
}
@@ -95,10 +142,11 @@ export class ConwayGol implements AfterViewInit {
return grid;
}
private createNode(row: number, col: number): Node {
private createNode(row: number, col: number, alive: boolean): Node {
return {
row,
col
col,
alive
};
}
@@ -113,14 +161,18 @@ export class ConwayGol implements AfterViewInit {
}
private drawNode(node: Node): void {
this.ctx.fillStyle = this.getNodeColor();
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(): string {
private getNodeColor(node: Node): string {
if (node.alive)
{
return 'black';
}
return 'lightgray';
}
@@ -150,4 +202,88 @@ export class ConwayGol implements AfterViewInit {
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);
}
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

@@ -14,6 +14,4 @@ import {AlgorithmInformation} from './information.models';
export class Information {
@Input({ required: true }) algorithmInformation!: AlgorithmInformation;
protected readonly UrlConstants = UrlConstants;
}

View File

@@ -29,7 +29,7 @@
<div class="controls">
<div class="grid-size">
<mat-form-field appearance="outline" class="grid-field">
<mat-label>{{ 'PATHFINDING.GRID_HEIGHT' | translate }}</mat-label>
<mat-label>{{ 'ALGORITHM.GRID_HEIGHT' | translate }}</mat-label>
<input
matInput
type="number"
@@ -42,7 +42,7 @@
</mat-form-field>
<mat-form-field appearance="outline" class="grid-field">
<mat-label>{{ 'PATHFINDING.GRID_WIDTH' | translate }}</mat-label>
<mat-label>{{ 'ALGORITHM.GRID_WIDTH' | translate }}</mat-label>
<input
matInput
type="number"

View File

@@ -1,13 +1,3 @@
.container {
padding: 2rem;
}
.controls-container {
display: flex;
flex-direction: column;
margin-bottom: 1rem;
}
.controls {
display: flex;
flex-wrap: wrap;
@@ -24,26 +14,3 @@
.grid-field {
width: 150px;
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: center;
font-size: 0.9em;
.legend-color {
display: inline-block;
width: 15px;
height: 15px;
border: 1px solid #ccc;
vertical-align: middle;
margin-right: 5px;
&.start { background-color: green; }
&.end { background-color: red; }
&.wall { background-color: black; }
&.visited { background-color: skyblue; }
&.path { background-color: gold; }
}
}

View File

@@ -664,6 +664,4 @@ export class PathfindingComponent implements AfterViewInit {
private randomIntFromInterval(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1) + min);
}
protected readonly UrlConstants = UrlConstants;
}

View File

@@ -1,4 +1,4 @@
<div class="sorting-container">
<div class="container">
<mat-card class="sorting-card">
<mat-card-header>
<mat-card-title>{{ 'SORTING.TITLE' | translate }}</mat-card-title>

View File

@@ -1,11 +1,3 @@
.sorting-container {
display: flex;
justify-content: center;
align-items: flex-start;
padding: 20px;
height: 100%;
box-sizing: border-box;
.sorting-card {
width: 100%;
max-width: 1200px;
@@ -59,4 +51,3 @@
color: #FFFFFF;
}
}
}

View File

@@ -171,6 +171,4 @@ export class SortingComponent implements OnInit {
this.stopAnimations();
this.resetSortState();
}
protected readonly UrlConstants = UrlConstants;
}