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:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,4 @@ import {AlgorithmInformation} from './information.models';
|
||||
export class Information {
|
||||
|
||||
@Input({ required: true }) algorithmInformation!: AlgorithmInformation;
|
||||
|
||||
protected readonly UrlConstants = UrlConstants;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,6 +171,4 @@ export class SortingComponent implements OnInit {
|
||||
this.stopAnimations();
|
||||
this.resetSortState();
|
||||
}
|
||||
|
||||
protected readonly UrlConstants = UrlConstants;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user