Merge remote-tracking branch 'origin/main'
All checks were successful
Build, Test & Push Frontend / quality-check (push) Successful in 1m21s
Build, Test & Push Frontend / docker (push) Successful in 1m16s

This commit is contained in:
Andreas Dahm
2026-03-26 09:09:25 +01:00
40 changed files with 2146 additions and 2343 deletions

View File

@@ -101,10 +101,10 @@
}
</div>
@if(entry.key !== xpKeys.at(xpKeys.length-1)?.key)
{
<mat-divider></mat-divider>
}
@if(entry.key !== xpKeys.at(xpKeys.length-1)?.key)
{
<mat-divider></mat-divider>
}
}
</div>
</mat-card>

View File

@@ -14,7 +14,6 @@ import {SharedFunctions} from '../../shared/SharedFunctions';
@Component({
selector: 'app-about',
standalone: true,
imports: [
NgOptimizedImage,
MatCardModule,

View File

@@ -3,4 +3,5 @@ export interface AlgorithmCategory {
title: string;
description: string;
routerLink: string;
icon: string;
}

View File

@@ -1,15 +1,16 @@
<div class="card-grid">
<h1>{{ 'ALGORITHM.TITLE' |translate }}</h1>
<h1 class="algo-page-title">{{ 'ALGORITHM.TITLE' | translate }}</h1>
</div>
<div class="card-grid">
@for (category of categories$ | async; track category.id) {
@for (category of categories; track category.id) {
<mat-card class="algo-card" [routerLink]="[category.routerLink]">
<mat-card-header>
<mat-card-title>{{ category.title | translate }}</mat-card-title>
</mat-card-header>
<mat-card-content>
<p>{{ category.description | translate}}</p>
<div class="algo-icon-wrap">
<mat-icon>{{ category.icon }}</mat-icon>
</div>
<h3 class="algo-card-title">{{ category.title | translate }}</h3>
<p class="algo-card-desc">{{ category.description | translate }}</p>
</mat-card-content>
</mat-card>
}
</div>
</div>

View File

@@ -1,25 +1,19 @@
import { Component, OnInit, inject } from '@angular/core';
import { Component, inject } from '@angular/core';
import { AlgorithmsService } from './algorithms.service';
import { AlgorithmCategory } from './algorithm-category';
import { Observable } from 'rxjs';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon';
import {TranslatePipe} from '@ngx-translate/core';
@Component({
selector: 'app-algorithms',
templateUrl: './algorithms.component.html',
styleUrls: ['./algorithms.component.scss'],
standalone: true,
imports: [CommonModule, RouterLink, MatCardModule, TranslatePipe],
styleUrl: './algorithms.component.scss',
imports: [RouterLink, MatCardModule, MatIconModule, TranslatePipe],
})
export class AlgorithmsComponent implements OnInit {
export class AlgorithmsComponent {
private readonly algorithmsService = inject(AlgorithmsService);
categories$: Observable<AlgorithmCategory[]> | undefined;
ngOnInit(): void {
this.categories$ = this.algorithmsService.getCategories();
}
readonly categories: AlgorithmCategory[] = this.algorithmsService.getCategories();
}

View File

@@ -1,6 +1,5 @@
import { Injectable } from '@angular/core';
import { AlgorithmCategory } from './algorithm-category';
import { Observable, of } from 'rxjs';
import {RouterConstants} from '../../constants/RouterConstants';
@Injectable({
@@ -13,53 +12,68 @@ export class AlgorithmsService {
id: 'pathfinding',
title: 'ALGORITHM.PATHFINDING.TITLE',
description: 'ALGORITHM.PATHFINDING.DESCRIPTION',
routerLink: RouterConstants.PATHFINDING.LINK
routerLink: RouterConstants.PATHFINDING.LINK,
icon: 'route'
},
{
id: 'sorting',
title: 'ALGORITHM.SORTING.TITLE',
description: 'ALGORITHM.SORTING.DESCRIPTION',
routerLink: RouterConstants.SORTING.LINK
routerLink: RouterConstants.SORTING.LINK,
icon: 'sort'
},
{
id: 'gameOfLife',
title: 'ALGORITHM.GOL.TITLE',
description: 'ALGORITHM.GOL.DESCRIPTION',
routerLink: RouterConstants.GOL.LINK
routerLink: RouterConstants.GOL.LINK,
icon: 'grid_on'
},
{
id: 'labyrinth',
title: 'ALGORITHM.LABYRINTH.TITLE',
description: 'ALGORITHM.LABYRINTH.DESCRIPTION',
routerLink: RouterConstants.LABYRINTH.LINK
routerLink: RouterConstants.LABYRINTH.LINK,
icon: 'grid_view'
},
{
id: 'fractal',
title: 'ALGORITHM.FRACTAL.TITLE',
description: 'ALGORITHM.FRACTAL.DESCRIPTION',
routerLink: RouterConstants.FRACTAL.LINK
routerLink: RouterConstants.FRACTAL.LINK,
icon: 'blur_on'
},
{
id: 'fractal3d',
title: 'ALGORITHM.FRACTAL3D.TITLE',
description: 'ALGORITHM.FRACTAL3D.DESCRIPTION',
routerLink: RouterConstants.FRACTAL3d.LINK
routerLink: RouterConstants.FRACTAL3d.LINK,
icon: 'view_in_ar'
},
{
id: 'pendulum',
title: 'ALGORITHM.PENDULUM.TITLE',
description: 'ALGORITHM.PENDULUM.DESCRIPTION',
routerLink: RouterConstants.PENDULUM.LINK
routerLink: RouterConstants.PENDULUM.LINK,
icon: 'rotate_right'
},
{
id: 'cloth',
title: 'ALGORITHM.CLOTH.TITLE',
description: 'ALGORITHM.CLOTH.DESCRIPTION',
routerLink: RouterConstants.CLOTH.LINK
routerLink: RouterConstants.CLOTH.LINK,
icon: 'texture'
},
{
id: 'fourColor',
title: 'ALGORITHM.FOUR_COLOR.TITLE',
description: 'ALGORITHM.FOUR_COLOR.DESCRIPTION',
routerLink: RouterConstants.FOUR_COLOR.LINK,
icon: 'palette'
}
];
getCategories(): Observable<AlgorithmCategory[]> {
return of(this.categories);
getCategories(): AlgorithmCategory[] {
return this.categories;
}
}

View File

@@ -12,6 +12,15 @@
<button mat-raised-button color="primary" (click)="toggleMesh()">
{{ isOutlineActive ? ('CLOTH.OUTLINE_OFF' | translate) : ('CLOTH.OUTLINE_ON' | translate) }}
</button>
<button mat-raised-button color="accent" (click)="restartSimulation()">
{{ 'CLOTH.RESTART_SIMULATION' | translate }}
</button>
</div>
<div class="sliders-panel">
<span>{{ 'CLOTH.ELONGATION' | translate }}: {{ elongation }}</span>
<mat-slider min="0.5" max="2.0" step="0.1">
<input matSliderThumb [(ngModel)]="elongation">
</mat-slider>
</div>
</div>
<app-babylon-canvas

View File

@@ -4,7 +4,9 @@
*/
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatCard, MatCardContent, MatCardHeader, MatCardTitle } from '@angular/material/card';
import { MatSliderModule } from '@angular/material/slider';
import { TranslatePipe } from '@ngx-translate/core';
import { BabylonCanvas, RenderConfig, SceneEventData } from '../../../shared/components/render-canvas/babylon-canvas.component';
import {ComputeShader, StorageBuffer, MeshBuilder, ShaderMaterial, ShaderLanguage, ArcRotateCamera, GroundMesh, WebGPUEngine, Scene} from '@babylonjs/core';
@@ -31,6 +33,8 @@ import {UrlConstants} from '../../../constants/UrlConstants';
TranslatePipe,
BabylonCanvas,
MatButton,
MatSliderModule,
FormsModule,
Information
],
templateUrl: './cloth.component.html',
@@ -42,6 +46,9 @@ export class ClothComponent {
private clothMesh: GroundMesh | null = null;
public isWindActive: boolean = false;
public isOutlineActive: boolean = false;
public stiffness: number = 80;
// Elongation along the vertical (Y) axis, 0.5 = compressed, 2.0 = stretched
public elongation: number = 1.0;
public renderConfig: RenderConfig = {
mode: '3D',
@@ -103,6 +110,11 @@ export class ClothComponent {
this.clothMesh.material.wireframe = this.isOutlineActive;
}
public restartSimulation(): void {
this.simulationTime = 0;
this.createSimulation();
}
/**
* Initializes and starts the cloth simulation.
*/
@@ -164,9 +176,13 @@ export class ClothComponent {
const constraintsP2: number[] = [];
const constraintsP3: number[] = [];
const addConstraint = (arr: number[], a: number, b: number): void => {
// Type 1.0 = horizontal/diagonal (no elongation), Type 2.0 = vertical (elongation applies)
const addHorizontalConstraint = (arr: number[], a: number, b: number): void => {
arr.push(a, b, config.spacing, 1.0);
};
const addVerticalConstraint = (arr: number[], a: number, b: number): void => {
arr.push(a, b, config.spacing, 2.0);
};
// Fill positions (Pin top row)
for (let y = 0; y < config.gridHeight; y++) {
@@ -186,14 +202,14 @@ export class ClothComponent {
// Graph Coloring (4 Phases)
for (let y = 0; y < config.gridHeight; y++) {
for (let x = 0; x < config.gridWidth - 1; x += 2) addConstraint(constraintsP0, y * config.gridWidth + x, y * config.gridWidth + x + 1);
for (let x = 1; x < config.gridWidth - 1; x += 2) addConstraint(constraintsP1, y * config.gridWidth + x, y * config.gridWidth + x + 1);
for (let x = 0; x < config.gridWidth - 1; x += 2) addHorizontalConstraint(constraintsP0, y * config.gridWidth + x, y * config.gridWidth + x + 1);
for (let x = 1; x < config.gridWidth - 1; x += 2) addHorizontalConstraint(constraintsP1, y * config.gridWidth + x, y * config.gridWidth + x + 1);
}
for (let y = 0; y < config.gridHeight - 1; y += 2) {
for (let x = 0; x < config.gridWidth; x++) addConstraint(constraintsP2, y * config.gridWidth + x, (y + 1) * config.gridWidth + x);
for (let x = 0; x < config.gridWidth; x++) addVerticalConstraint(constraintsP2, y * config.gridWidth + x, (y + 1) * config.gridWidth + x);
}
for (let y = 1; y < config.gridHeight - 1; y += 2) {
for (let x = 0; x < config.gridWidth; x++) addConstraint(constraintsP3, y * config.gridWidth + x, (y + 1) * config.gridWidth + x);
for (let x = 0; x < config.gridWidth; x++) addVerticalConstraint(constraintsP3, y * config.gridWidth + x, (y + 1) * config.gridWidth + x);
}
const constraintsP4: number[] = [];
@@ -228,7 +244,7 @@ export class ClothComponent {
constraintsP0, constraintsP1, constraintsP2, constraintsP3,
constraintsP4, constraintsP5, constraintsP6, constraintsP7
],
params: new Float32Array(8)
params: new Float32Array(9)
};
}
@@ -331,7 +347,7 @@ export class ClothComponent {
// 6. RENDER LOOP
// ========================================================================
private startRenderLoop(engine: WebGPUEngine, scene: Scene, config: ClothConfig, buffers: ClothBuffers, pipelines: ClothPipelines): void {
const paramsData = new Float32Array(8);
const paramsData = new Float32Array(9);
// Pre-calculate constraint dispatch sizes for the 4 phases
const constraintsLength = buffers.constraints.map(b => (b as any)._buffer.capacity / 4 / 4); // Elements / vec4 length
@@ -347,16 +363,23 @@ export class ClothComponent {
const windX = this.isWindActive ? 5.0 : 0.0;
const windY = 0.0;
const windZ = this.isWindActive ? 15.0 : 0.0;
const scaledCompliance = 0.00001 * config.particleInvMass * config.spacing;
// Logarithmic compliance: stiffness=1 → very soft fabric, stiffness=100 → rigid metal sheet.
// alpha = compliance / dt² must be >> wSum (≈800) to be soft, << wSum to be rigid.
const softCompliance = 10.0;
const rigidCompliance = 0.00001;
const t = (this.stiffness - 1) / 99.0;
const compliance = softCompliance * Math.pow(rigidCompliance / softCompliance, t);
paramsData[0] = 0.016; // dt
paramsData[1] = -9.81; // gravity
paramsData[2] = scaledCompliance;
paramsData[2] = compliance;
paramsData[3] = config.numVertices;
paramsData[4] = windX;
paramsData[5] = windY;
paramsData[6] = windZ;
paramsData[7] = this.simulationTime;
paramsData[8] = this.elongation;
buffers.params.update(paramsData);

View File

@@ -13,7 +13,8 @@ export const CLOTH_SHARED_STRUCTS = `
wind_x: f32,
wind_y: f32,
wind_z: f32,
time: f32
time: f32,
elongation: f32
};
`;
@@ -26,9 +27,8 @@ export const CLOTH_VERTEX_SHADER_WGSL = `
uniform viewProjection : mat4x4<f32>;
// Varyings, um Daten an den Fragment-Shader zu senden
varying vUV : vec2<f32>;
varying vWorldPos : vec3<f32>; // NEU: Wir brauchen die 3D-Position für das Licht!
varying vWorldPos : vec3<f32>;
@vertex
fn main(input : VertexInputs) -> FragmentInputs {
@@ -38,7 +38,7 @@ export const CLOTH_VERTEX_SHADER_WGSL = `
output.position = uniforms.viewProjection * vec4<f32>(worldPos, 1.0);
output.vUV = input.uv;
output.vWorldPos = worldPos; // Position weitergeben
output.vWorldPos = worldPos;
return output;
}
@@ -133,13 +133,15 @@ export const CLOTH_SOLVE_COMPUTE_WGSL = CLOTH_SHARED_STRUCTS + `
if (idx >= arrayLength(&constraints)) { return; }
let constraint = constraints[idx];
let isActive = constraint.w;
if (isActive < 0.5) { return; }
// constraint.w: 0.0 = inactive, 1.0 = horizontal/diagonal, 2.0 = vertical
if (constraint.w < 0.5) { return; }
let idA = u32(constraint.x);
let idB = u32(constraint.y);
let restLength = constraint.z;
// constraint.w encodes type: 1.0 = horizontal/diagonal, 2.0 = vertical (elongation applies)
let restLength =constraint.z * p.elongation;
var pA = positions[idA];
var pB = positions[idB];

View File

@@ -178,10 +178,9 @@ export class ConwayGolComponent implements AfterViewInit {
async startGame(): Promise<void> {
this.gameStarted.set(true);
let lifeIsDead = false;
while (this.gameStarted()){
const startTime = performance.now();
lifeIsDead = true;
let lifeIsDead = true;
for (let row = 0; row < this.gridRows; row++) {
for (let col = 0; col < this.gridCols; col++) {
lifeIsDead = this.checkLifeRules(row, col, this.writeGrid) && lifeIsDead;

View File

@@ -0,0 +1,65 @@
<mat-card class="algo-container">
<mat-card-header>
<mat-card-title>{{ 'FOUR_COLOR.TITLE' | translate }}</mat-card-title>
</mat-card-header>
<mat-card-content>
<app-information [algorithmInformation]="algoInformation"/>
<div class="controls-container">
<div class="controls-panel">
<button mat-flat-button color="primary" (click)="generateNewMap()">{{ 'FOUR_COLOR.GENERATE' | translate }}</button>
<button mat-flat-button color="accent" (click)="autoSolve()">{{ 'FOUR_COLOR.SOLVE' | translate }}</button>
<button mat-stroked-button (click)="resetColors()">{{ 'FOUR_COLOR.CLEAR' | translate }}</button>
</div>
<div class="controls-panel">
<div class="input-container">
<mat-form-field appearance="outline" class="input-field">
<mat-label>{{ 'ALGORITHM.GRID_HEIGHT' | translate }}</mat-label>
<input
matInput
type="number"
[min]="MIN_GRID_SIZE"
[max]="MAX_GRID_SIZE"
[(ngModel)]="gridRows"
(ngModelChange)="applyGridSize()"
/>
</mat-form-field>
<mat-form-field appearance="outline" class="input-field">
<mat-label>{{ 'ALGORITHM.GRID_WIDTH' | translate }}</mat-label>
<input
matInput
type="number"
[min]="MIN_GRID_SIZE"
[max]="MAX_GRID_SIZE"
[(ngModel)]="gridCols"
(ngModelChange)="applyGridSize()"
/>
</mat-form-field>
</div>
</div>
<div class="legend">
<span><span class="legend-color color1"></span> {{ 'FOUR_COLOR.COLOR_1' | translate }}</span>
<span><span class="legend-color color2"></span> {{ 'FOUR_COLOR.COLOR_2' | translate }}</span>
<span><span class="legend-color color3"></span> {{ 'FOUR_COLOR.COLOR_3' | translate }}</span>
<span><span class="legend-color color4"></span> {{ 'FOUR_COLOR.COLOR_4' | translate }}</span>
</div>
<div class="status-panel" [ngClass]="solutionStatus.toLowerCase()">
<span class="status-label">{{ 'FOUR_COLOR.STATUS.LABEL' | translate }}:</span>
<span class="status-message">{{ 'FOUR_COLOR.STATUS.' + solutionStatus | translate }}</span>
</div>
</div>
<div class="canvas-container">
<canvas #fourColorCanvas
(mousedown)="onMouseDown($event)"
(mousemove)="onMouseMove($event)"
(touchstart)="onTouchStart($event)"
(touchmove)="onTouchMove($event)"
></canvas>
</div>
</mat-card-content>
</mat-card>

View File

@@ -0,0 +1,54 @@
.status-panel {
margin-top: 20px;
display: flex;
gap: 10px;
padding: 10px 15px;
border-radius: 4px;
background-color: var(--app-fg);
border-left: 5px solid #9e9e9e;
font-weight: 500;
min-width: 300px;
align-items: center;
}
.status-panel.incomplete {
border-left-color: #9e9e9e;
background-color: var(--app-bg);
}
.status-panel.solved {
border-left-color: #4CAF50;
background-color: #e8f5e9;
color: #2e7d32;
}
.status-panel.conflicts {
border-left-color: #ff9800;
background-color: #fff3e0;
color: #ef6c00;
}
.status-panel.invalid {
border-left-color: #f44336;
background-color: #ffebee;
color: #c62828;
}
.status-label {
text-transform: uppercase;
font-size: 0.8em;
opacity: 0.7;
}
.color1 { background-color: #FF5252; }
.color2 { background-color: #448AFF; }
.color3 { background-color: #4CAF50; }
.color4 { background-color: #FFEB3B; }
canvas {
cursor: pointer;
max-width: 100%;
height: auto;
image-rendering: pixelated;
}

View File

@@ -0,0 +1,374 @@
import {AfterViewInit, Component, ElementRef, inject, ViewChild} from '@angular/core';
import {CommonModule} from '@angular/common';
import {FormsModule} from '@angular/forms';
import {MatButtonModule} from '@angular/material/button';
import {MatCardModule} from '@angular/material/card';
import {MatFormFieldModule} from '@angular/material/form-field';
import {MatInputModule} from '@angular/material/input';
import {TranslateModule, TranslateService} from '@ngx-translate/core';
import {MatSnackBar} from '@angular/material/snack-bar';
import {DEFAULT_GRID_COLS, DEFAULT_GRID_ROWS, MAX_GRID_PX, MAX_GRID_SIZE, MIN_GRID_SIZE, FourColorNode, Region} from './four-color.models';
import {AlgorithmInformation} from '../information/information.models';
import {Information} from '../information/information';
import {GridPos} from '../../../shared/components/generic-grid/generic-grid';
import {SharedFunctions} from '../../../shared/SharedFunctions';
import {UrlConstants} from '../../../constants/UrlConstants';
@Component({
selector: 'app-four-color',
standalone: true,
imports: [
CommonModule,
FormsModule,
MatButtonModule,
MatCardModule,
MatFormFieldModule,
MatInputModule,
TranslateModule,
Information
],
templateUrl: './four-color.component.html',
styleUrl: './four-color.component.scss'
})
export class FourColorComponent implements AfterViewInit {
private readonly translate = inject(TranslateService);
private readonly snackBar = inject(MatSnackBar);
readonly MIN_GRID_SIZE = MIN_GRID_SIZE;
readonly MAX_GRID_SIZE = MAX_GRID_SIZE;
algoInformation: AlgorithmInformation = {
title: 'FOUR_COLOR.EXPLANATION.TITLE',
entries: [
{
name: 'FOUR_COLOR.TITLE',
translateName: true,
description: 'FOUR_COLOR.EXPLANATION.EXPLANATION',
link: UrlConstants.FOUR_COLOR_THEOREM
}
],
disclaimer: 'FOUR_COLOR.EXPLANATION.DISCLAIMER',
disclaimerBottom: 'FOUR_COLOR.EXPLANATION.DISCLAIMER_BOTTOM',
disclaimerListEntry: [
'FOUR_COLOR.EXPLANATION.DISCLAIMER_1',
'FOUR_COLOR.EXPLANATION.DISCLAIMER_2',
'FOUR_COLOR.EXPLANATION.DISCLAIMER_3',
'FOUR_COLOR.EXPLANATION.DISCLAIMER_4'
]
};
gridRows = DEFAULT_GRID_ROWS;
gridCols = DEFAULT_GRID_COLS;
grid: FourColorNode[][] = [];
regions: Region[] = [];
executionTime = 0;
solutionStatus: 'INCOMPLETE' | 'SOLVED' | 'CONFLICTS' | 'INVALID' = 'INCOMPLETE';
@ViewChild('fourColorCanvas') canvasRef!: ElementRef<HTMLCanvasElement>;
private ctx!: CanvasRenderingContext2D;
private nodeSize = 0;
ngAfterViewInit(): void {
this.ctx = this.canvasRef.nativeElement.getContext('2d')!;
this.initializeGrid();
}
applyGridSize(): void {
if (this.gridRows < MIN_GRID_SIZE) this.gridRows = MIN_GRID_SIZE;
if (this.gridRows > MAX_GRID_SIZE) this.gridRows = MAX_GRID_SIZE;
if (this.gridCols < MIN_GRID_SIZE) this.gridCols = MIN_GRID_SIZE;
if (this.gridCols > MAX_GRID_SIZE) this.gridCols = MAX_GRID_SIZE;
this.initializeGrid();
}
initializeGrid(): void {
this.grid = [];
this.solutionStatus = 'INCOMPLETE';
for (let r = 0; r < this.gridRows; r++) {
const row: FourColorNode[] = [];
for (let c = 0; c < this.gridCols; c++) {
row.push({
row: r,
col: c,
regionId: -1,
color: 0,
});
}
this.grid.push(row);
}
this.generateRegions();
this.resizeCanvas();
this.drawGrid();
}
private resizeCanvas(): void {
const canvas = this.canvasRef.nativeElement;
const maxDim = Math.max(this.gridRows, this.gridCols);
this.nodeSize = Math.floor(MAX_GRID_PX / maxDim);
canvas.width = this.gridCols * this.nodeSize;
canvas.height = this.gridRows * this.nodeSize;
}
generateRegions(): void {
const numRegions = Math.floor((this.gridRows * this.gridCols) / 30);
this.regions = [];
const seeds = this.determineSeeds(numRegions);
this.regionGrowth(seeds);
this.determineAdjacency();
}
private determineAdjacency() {
for (let row = 0; row < this.gridRows; row++) {
for (let col = 0; col < this.gridCols; col++) {
const currentRegionId = this.grid[row][col].regionId;
const neighbors = this.getNeighbors(row, col);
for (const neighbor of neighbors) {
const neighborRegionId = this.grid[neighbor.row][neighbor.col].regionId;
if (neighborRegionId !== -1 && neighborRegionId !== currentRegionId) {
this.regions[currentRegionId].neighbors.add(neighborRegionId);
this.regions[neighborRegionId].neighbors.add(currentRegionId);
}
}
}
}
}
private regionGrowth(seeds: GridPos[]) {
const queue: GridPos[] = [...seeds];
while (queue.length > 0) {
const {row, col} = queue.shift()!;
const regionId = this.grid[row][col].regionId;
const neighbors = this.getNeighbors(row, col);
for (const neighbor of neighbors) {
if (this.grid[neighbor.row][neighbor.col].regionId === -1) {
this.grid[neighbor.row][neighbor.col].regionId = regionId;
queue.push(neighbor);
}
}
}
}
private determineSeeds(numRegions: number) {
const seeds: GridPos[] = [];
for (let i = 0; i < numRegions; i++) {
let r = SharedFunctions.randomIntFromInterval(0, this.gridRows - 1);
let c = SharedFunctions.randomIntFromInterval(0, this.gridCols - 1);
while (this.grid[r][c].regionId !== -1) {
r = SharedFunctions.randomIntFromInterval(0, this.gridRows - 1);
c = SharedFunctions.randomIntFromInterval(0, this.gridCols - 1);
}
this.grid[r][c].regionId = i;
seeds.push({row: r, col: c});
this.regions.push({id: i, color: 0, neighbors: new Set<number>()});
}
return seeds;
}
private getNeighbors(row: number, col: number): GridPos[] {
const res: GridPos[] = [];
if (row > 0) res.push({row: row - 1, col});
if (row < this.gridRows - 1) res.push({row: row + 1, col});
if (col > 0) res.push({row, col: col - 1});
if (col < this.gridCols - 1) res.push({row, col: col + 1});
return res;
}
drawGrid(): void {
if (!this.ctx) return;
this.ctx.clearRect(0, 0, this.canvasRef.nativeElement.width, this.canvasRef.nativeElement.height);
// 1. Draw Cell Backgrounds
for (let r = 0; r < this.gridRows; r++) {
for (let c = 0; c < this.gridCols; c++) {
const node = this.grid[r][c];
this.ctx.fillStyle = this.getNodeColor(node);
this.ctx.fillRect(c * this.nodeSize, r * this.nodeSize, this.nodeSize, this.nodeSize);
}
}
// 2. Draw Region Borders
this.ctx.strokeStyle = '#000';
this.ctx.lineWidth = 2;
this.ctx.beginPath();
for (let r = 0; r < this.gridRows; r++) {
for (let c = 0; c < this.gridCols; c++) {
const currentRegion = this.grid[r][c].regionId;
// Right border
if (c < this.gridCols - 1 && this.grid[r][c+1].regionId !== currentRegion) {
this.ctx.moveTo((c + 1) * this.nodeSize, r * this.nodeSize);
this.ctx.lineTo((c + 1) * this.nodeSize, (r + 1) * this.nodeSize);
}
// Bottom border
if (r < this.gridRows - 1 && this.grid[r+1][c].regionId !== currentRegion) {
this.ctx.moveTo(c * this.nodeSize, (r + 1) * this.nodeSize);
this.ctx.lineTo((c + 1) * this.nodeSize, (r + 1) * this.nodeSize);
}
}
}
this.ctx.stroke();
// 3. Draw Outer Border
this.ctx.strokeStyle = '#000';
this.ctx.lineWidth = 2;
this.ctx.strokeRect(0, 0, this.gridCols * this.nodeSize, this.gridRows * this.nodeSize);
}
private getNodeColor(node: FourColorNode): string {
switch (node.color) {
case 1: return '#FF5252'; // Red
case 2: return '#448AFF'; // Blue
case 3: return '#4CAF50'; // Green
case 4: return '#FFEB3B'; // Yellow
default: return 'white';
}
}
onMouseDown(event: MouseEvent): void {
const pos = this.getGridPos(event);
if (pos) this.handleInteraction(pos);
}
onMouseMove(event: MouseEvent): void {
if (event.buttons !== 1){
return;
}
this.getGridPos(event);
}
onTouchStart(event: TouchEvent): void {
event.preventDefault();
const touch = event.touches[0];
const pos = this.getGridPos(touch);
if (pos) this.handleInteraction(pos);
}
onTouchMove(event: TouchEvent): void {
event.preventDefault();
}
private getGridPos(event: MouseEvent | Touch): GridPos | null {
const rect = this.canvasRef.nativeElement.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
const col = Math.floor(x / (rect.width / this.gridCols));
const row = Math.floor(y / (rect.height / this.gridRows));
if (row >= 0 && row < this.gridRows && col >= 0 && col < this.gridCols) {
return {row, col};
}
return null;
}
private handleInteraction(pos: GridPos): void {
const node = this.grid[pos.row][pos.col];
if (node.regionId === -1){
return;
}
const region = this.regions[node.regionId];
region.color = (region.color % 4) + 1;
this.updateRegionColors(region);
this.checkSolution();
this.drawGrid();
}
private updateRegionColors(region: Region): void {
for (let row = 0; row < this.gridRows; row++) {
for (let col = 0; col < this.gridCols; col++) {
if (this.grid[row][col].regionId === region.id) {
this.grid[row][col].color = region.color;
}
}
}
}
resetColors(): void {
for (const region of this.regions) {
region.color = 0;
this.updateRegionColors(region);
}
this.solutionStatus = 'INCOMPLETE';
this.drawGrid();
}
autoSolve(): void {
const startTime = performance.now();
this.resetColors();
const success = this.backtrackSolve(0);
const endTime = performance.now();
this.executionTime = endTime - startTime;
if (success) {
this.checkSolution();
this.drawGrid();
} else {
const message = this.translate.instant('FOUR_COLOR.ALERT.NO_SOLUTION');
this.snackBar.open(message, 'ALERT');
}
}
private backtrackSolve(regionIndex: number): boolean {
if (regionIndex === this.regions.length) return true;
const region = this.regions[regionIndex];
const availableColors = [1, 2, 3, 4];
for (const color of availableColors) {
if (this.isColorValid(region, color)) {
region.color = color;
this.updateRegionColors(region);
if (this.backtrackSolve(regionIndex + 1)) {
return true;
}
region.color = 0;
//this.updateRegionColors(region);
}
}
return false;
}
private isColorValid(region: Region, color: number): boolean {
for (const neighborId of region.neighbors) {
if (this.regions[neighborId].color === color){
return false;
}
}
return true;
}
checkSolution(): void {
let allColored = true;
let hasConflicts = false;
for (const region of this.regions) {
if (region.color === 0) {
allColored = false;
}
if (region.color > 0 && !this.isColorValid(region, region.color)) {
hasConflicts = true;
}
}
if (hasConflicts) {
this.solutionStatus = allColored ? 'INVALID' : 'CONFLICTS';
} else {
this.solutionStatus = allColored ? 'SOLVED' : 'INCOMPLETE';
}
}
generateNewMap(): void {
this.initializeGrid();
}
}

View File

@@ -0,0 +1,18 @@
export interface FourColorNode {
row: number;
col: number;
regionId: number;
color: number;
}
export interface Region {
id: number;
color: number;
neighbors: Set<number>;
}
export const DEFAULT_GRID_ROWS = 25;
export const DEFAULT_GRID_COLS = 25;
export const MIN_GRID_SIZE = 20;
export const MAX_GRID_SIZE = 38;
export const MAX_GRID_PX = 600;

View File

@@ -6,6 +6,7 @@ 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 {MatSnackBar} from '@angular/material/snack-bar';
import {TranslateModule, TranslateService} from '@ngx-translate/core';
@@ -27,7 +28,6 @@ enum NodeType {
@Component({
selector: 'app-pathfinding',
standalone: true,
imports: [
CommonModule,
FormsModule,
@@ -48,6 +48,7 @@ enum NodeType {
export class PathfindingComponent implements AfterViewInit {
private readonly pathfindingService = inject(PathfindingService);
private readonly translate = inject(TranslateService);
private readonly snackBar = inject(MatSnackBar);
readonly NodeType = NodeType;
readonly MIN_GRID_SIZE = MIN_GRID_SIZE;
@@ -483,7 +484,8 @@ export class PathfindingComponent implements AfterViewInit {
return true;
}
alert(this.translate.instant('PATHFINDING.ALERT.START_END_NODES'));
const message = this.translate.instant('PATHFINDING.ALERT.START_END_NODES');
this.snackBar.open(message, 'OK', { duration: 5000, horizontalPosition: 'center', verticalPosition: 'top' });
return false;
}

View File

@@ -0,0 +1,80 @@
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class SortingAudioService {
private audioContext: AudioContext | null = null;
private ensureContext(): AudioContext {
this.audioContext ??= new AudioContext();
if (this.audioContext.state === 'suspended') {
this.audioContext.resume();
}
return this.audioContext;
}
// Call this on the user gesture (button click) so the AudioContext can be created/resumed.
initOnUserGesture(): void {
this.ensureContext();
}
playTone(value: number, maxValue: number, animationSpeedMs: number): void {
const ctx = this.ensureContext();
const frequency = this.valueToFrequency(value, maxValue);
// Keep tone duration slightly shorter than the animation frame to avoid overlap
const duration = Math.min(0.1, (animationSpeedMs * 0.75) / 1000);
const oscillator = ctx.createOscillator();
const gainNode = ctx.createGain();
oscillator.connect(gainNode);
gainNode.connect(ctx.destination);
oscillator.type = 'sawtooth';
oscillator.frequency.setValueAtTime(frequency, ctx.currentTime);
gainNode.gain.setValueAtTime(0.15, ctx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration);
oscillator.start(ctx.currentTime);
oscillator.stop(ctx.currentTime + duration);
}
playSortedSweep(sortedValues: number[], maxValue: number): void {
const ctx = this.ensureContext();
// Play a quick ascending sweep through all sorted bar values
const step = Math.ceil(sortedValues.length / 40);
sortedValues.forEach((value, i) => {
if (i % step !== 0) {
return;
}
const delayMs = (i / step) * 18;
setTimeout(() => {
const frequency = this.valueToFrequency(value, maxValue);
const oscillator = ctx.createOscillator();
const gainNode = ctx.createGain();
oscillator.connect(gainNode);
gainNode.connect(ctx.destination);
oscillator.type = 'sine';
oscillator.frequency.setValueAtTime(frequency, ctx.currentTime);
gainNode.gain.setValueAtTime(0.15, ctx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.06);
oscillator.start(ctx.currentTime);
oscillator.stop(ctx.currentTime + 0.06);
}, delayMs);
});
}
// Maps a bar value linearly to the frequency range 1801100 Hz (roughly 3 octaves)
private valueToFrequency(value: number, maxValue: number): number {
const minFreq = 400;
const maxFreq = 800;
return minFreq + (value / maxValue) * (maxFreq - minFreq);
}
}

View File

@@ -56,7 +56,7 @@ export class SortingService {
let start = -1;
let end = array.length-1;
let changed = false;
let changed: boolean;
do {
changed = false;
start += 1;
@@ -224,6 +224,103 @@ export class SortingService {
}
}
// --- TIM SORT ---
timSort(array: SortData[]): SortSnapshot[] {
const snapshots: SortSnapshot[] = [];
const arr = array.map(item => ({ ...item }));
const n = arr.length;
const RUN = 32;
snapshots.push(this.createSnapshot(arr));
// Step 1: Sort small runs with insertion sort
for (let i = 0; i < n; i += RUN) {
const end = Math.min(i + RUN - 1, n - 1);
this.insertionSortRange(arr, i, end, snapshots);
}
// Step 2: Merge the sorted runs
for (let size = RUN; size < n; size *= 2) {
for (let left = 0; left < n; left += 2 * size) {
const mid = Math.min(left + size - 1, n - 1);
const right = Math.min(left + 2 * size - 1, n - 1);
if (mid < right) {
this.mergeRanges(arr, left, mid, right, snapshots);
}
}
}
arr.forEach(item => item.state = 'sorted');
snapshots.push(this.createSnapshot(arr));
return snapshots;
}
private insertionSortRange(arr: SortData[], left: number, right: number, snapshots: SortSnapshot[]): void {
for (let i = left + 1; i <= right; i++) {
const tempValue = arr[i].value;
arr[i].state = 'comparing';
snapshots.push(this.createSnapshot(arr));
let j = i - 1;
while (j >= left && arr[j].value > tempValue) {
arr[j].state = 'comparing';
arr[j + 1].value = arr[j].value;
arr[j].state = 'unsorted';
snapshots.push(this.createSnapshot(arr));
j--;
}
arr[j + 1].value = tempValue;
arr[i].state = 'unsorted';
snapshots.push(this.createSnapshot(arr));
}
}
private mergeRanges(arr: SortData[], left: number, mid: number, right: number, snapshots: SortSnapshot[]): void {
const leftPart = arr.slice(left, mid + 1).map(item => ({ ...item }));
const rightPart = arr.slice(mid + 1, right + 1).map(item => ({ ...item }));
let i = 0;
let j = 0;
let k = left;
while (i < leftPart.length && j < rightPart.length) {
// Highlight the write target and the right-side source (arr[mid+1+j] is still
// untouched in the array since k never overtakes it during a merge)
const rightSourceIdx = mid + 1 + j;
arr[k].state = 'comparing';
arr[rightSourceIdx].state = 'comparing';
snapshots.push(this.createSnapshot(arr));
arr[rightSourceIdx].state = 'unsorted';
if (leftPart[i].value <= rightPart[j].value) {
arr[k].value = leftPart[i].value;
i++;
} else {
arr[k].value = rightPart[j].value;
j++;
}
arr[k].state = 'unsorted';
k++;
snapshots.push(this.createSnapshot(arr));
}
while (i < leftPart.length) {
arr[k].value = leftPart[i].value;
i++;
k++;
}
while (j < rightPart.length) {
arr[k].value = rightPart[j].value;
j++;
k++;
}
}
private swap(arr: SortData[], i: number, j: number) {
const temp = arr[i].value;
arr[i].value = arr[j].value;

View File

@@ -37,6 +37,10 @@
<button mat-raised-button (click)="generateNewArray()">
<mat-icon>add_box</mat-icon> {{ 'SORTING.GENERATE_NEW_ARRAY' | translate }}
</button>
<button mat-raised-button (click)="toggleSound()">
<mat-icon>{{ isSoundEnabled ? 'volume_up' : 'volume_off' }}</mat-icon>
{{ isSoundEnabled ? ('SORTING.SOUND_OFF' | translate) : ('SORTING.SOUND_ON' | translate) }}
</button>
</div>
<div class="controls-panel">

View File

@@ -7,6 +7,7 @@ import {MatButtonModule} from "@angular/material/button";
import {MatIconModule} from "@angular/material/icon";
import {TranslateModule} from "@ngx-translate/core";
import { SortingService } from './service/sorting.service';
import { SortingAudioService } from './service/sorting-audio.service';
import {SortData, SortSnapshot} from './sorting.models';
import { FormsModule } from '@angular/forms';
import {MatInput} from '@angular/material/input';
@@ -15,14 +16,14 @@ import {AlgorithmInformation} from '../information/information.models';
import {Information} from '../information/information';
@Component({
selector: 'app-sorting',
standalone: true,
imports: [CommonModule, MatCardModule, MatFormFieldModule, MatSelectModule, MatButtonModule, MatIconModule, TranslateModule, FormsModule, MatInput, Information],
templateUrl: './sorting.component.html',
styleUrls: ['./sorting.component.scss']
styleUrl: './sorting.component.scss'
})
export class SortingComponent implements OnInit {
private readonly sortingService: SortingService = inject(SortingService);
private readonly audioService: SortingAudioService = inject(SortingAudioService);
private readonly cdr: ChangeDetectorRef = inject(ChangeDetectorRef);
readonly MAX_ARRAY_SIZE: number = 200;
@@ -50,6 +51,11 @@ export class SortingComponent implements OnInit {
name: 'Heap Sort',
description: 'SORTING.EXPLANATION.HEAP_SORT_EXPLANATION',
link: UrlConstants.HEAP_SORT_WIKI
},
{
name: 'Tim Sort',
description: 'SORTING.EXPLANATION.TIMSORT_EXPLANATION',
link: UrlConstants.TIM_SORT_WIKI
}
],
disclaimer: 'SORTING.EXPLANATION.DISCLAIMER',
@@ -66,9 +72,10 @@ export class SortingComponent implements OnInit {
unsortedArrayCopy: SortData[] = [];
arraySize = 50;
maxArrayValue = 100;
animationSpeed = 50; // Milliseconds per step
animationSpeed = 100; // Milliseconds per step
selectedAlgorithm: string = this.algoInformation.entries[0].name;
executionTime = 0;
isSoundEnabled = false;
ngOnInit(): void {
this.generateNewArray();
@@ -113,8 +120,14 @@ export class SortingComponent implements OnInit {
}
}
toggleSound(): void {
this.isSoundEnabled = !this.isSoundEnabled;
}
async startSorting(): Promise<void> {
this.resetSorting();
// Init the AudioContext on this user gesture so the browser allows sound
this.audioService.initOnUserGesture();
const startTime = performance.now();
let snapshots: SortSnapshot[] = [];
@@ -131,6 +144,9 @@ export class SortingComponent implements OnInit {
case 'Cocktail Sort':
snapshots = this.sortingService.cocktailSort(this.sortArray);
break;
case 'Tim Sort':
snapshots = this.sortingService.timSort(this.sortArray);
break;
}
const endTime = performance.now();
@@ -149,11 +165,22 @@ export class SortingComponent implements OnInit {
}
}
if (this.isSoundEnabled) {
// Play a tone for each comparing bar (max 2 at once to avoid noise)
snapshot.array
.filter(item => item.state === 'comparing')
.slice(0, 2)
.forEach(item => this.audioService.playTone(item.value, this.maxArrayValue, this.animationSpeed));
}
this.cdr.detectChanges();
if (index === snapshots.length - 1) {
this.sortArray.forEach(item => item.state = 'sorted');
this.cdr.detectChanges();
if (this.isSoundEnabled) {
this.audioService.playSortedSweep(this.sortArray.map(item => item.value), this.maxArrayValue);
}
}
}, index * this.animationSpeed);

View File

@@ -15,8 +15,7 @@ import {MatButton} from '@angular/material/button';
@Component({
selector: 'app-project-dialog',
templateUrl: './project-dialog.component.html',
styleUrls: ['./project-dialog.component.scss'],
standalone: true,
styleUrl: './project-dialog.component.scss',
imports: [
MatDialogTitle,
MatDialogContent,

View File

@@ -34,7 +34,6 @@ export interface Projects {
@Component({
selector: 'app-projects',
standalone: true,
imports: [
MatCardModule,
MatChipsModule,