Compare commits

..
4 Commits
Author SHA1 Message Date
lobo 2224774e00 fix(four-color): stop solver freeze on large maps
Build, Test & Push Frontend / docker (push) Successful in 49s
Build, Test & Push Frontend / quality-check (push) Successful in 2m20s
Plain backtracking froze the tab on big maps. Pick the most
constrained region first (DSATUR), and stop after 100,000 attempts.
Measured on 52,000 maps (38x38): max 48 ms, about 1 in 2,900 give up.
2026-10-21 15:07:34 +02:00
lobo 07e0fc1192 fix(fractal): keep non-escaping points black
Points that never escape kept the last loop index as iter, so they
never reached the black color. Start iter at maxIterations and set it
only when a point escapes. Update the ITEM_4 text (en, de) to match.
2026-10-21 14:43:52 +02:00
lobo 833e1ec8dc feat(algorithms): add live metrics to algorithm pages
Each algorithm page shows its numbers with app-metrics.
FpsCounter publishes a rate every 500 ms, not every frame.
Fractal 2D reset now also resets the zoom slider.
2026-10-21 14:35:15 +02:00
lobo 4b19245c6c feat(algorithms): add how-it-works and source links
Add idea, complexity, data structures, GPU use to 10 algorithm pages.
Link logic files on Gitea; a spec fails on a missing path.
Fix wrong texts: cloth phases, pendulum dt, fractal lighting, typos.
2026-10-21 11:51:15 +02:00
49 changed files with 994 additions and 119 deletions

No files matched your search

+1
View File
@@ -26,5 +26,6 @@
static readonly FOUR_COLOR_THEOREM = 'https://de.wikipedia.org/wiki/Vier-Farben-Satz' static readonly FOUR_COLOR_THEOREM = 'https://de.wikipedia.org/wiki/Vier-Farben-Satz'
static readonly FOURIER_SERIES_WIKI = 'https://de.wikipedia.org/wiki/Fourierreihe' static readonly FOURIER_SERIES_WIKI = 'https://de.wikipedia.org/wiki/Fourierreihe'
static readonly GITEA = 'https://git.andreas-dahm.eu' static readonly GITEA = 'https://git.andreas-dahm.eu'
static readonly PLAYGROUND_SOURCE_BASE = `${UrlConstants.GITEA}/Lobo_Corp/playground-frontend/src/branch/main/`
} }
@@ -23,6 +23,7 @@
</mat-slider> </mat-slider>
</div> </div>
</div> </div>
<app-metrics [metrics]="metrics()"/>
<app-babylon-canvas <app-babylon-canvas
[config]="renderConfig" [config]="renderConfig"
(sceneReady)="onSceneReady($event)" (sceneReady)="onSceneReady($event)"
@@ -1,17 +1,20 @@
import {Component, signal} from '@angular/core'; import {Component, computed, signal} from '@angular/core';
import {FormsModule} from '@angular/forms'; import {FormsModule} from '@angular/forms';
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card'; import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
import {MatSliderModule} from '@angular/material/slider'; import {MatSliderModule} from '@angular/material/slider';
import {TranslatePipe} from '@ngx-translate/core'; import {TranslatePipe} from '@ngx-translate/core';
import {BabylonCanvas, RenderConfig, SceneEventData} from '../../../shared/components/render-canvas/babylon-canvas.component'; import {BabylonCanvas, RenderConfig, SceneEventData} from '../../../shared/components/render-canvas/babylon-canvas.component';
import {MatButton} from '@angular/material/button'; import {MatButton} from '@angular/material/button';
import {ClothConfig} from './cloth.model'; import {CLOTH_SUBSTEPS, ClothConfig, ClothStats} from './cloth.model';
import {Information} from '../information/information'; import {Information} from '../information/information';
import {AlgorithmInformation} from '../information/information.models'; import {AlgorithmInformation} from '../information/information.models';
import {SOURCE_PATHS} from '../information/source-paths';
import {UrlConstants} from '../../../constants/UrlConstants'; import {UrlConstants} from '../../../constants/UrlConstants';
import {ClothSimulationStrategy} from './strategies/cloth-simulation.strategy'; import {ClothSimulationStrategy} from './strategies/cloth-simulation.strategy';
import {ClothGpuStrategy} from './strategies/cloth-gpu.strategy'; import {ClothGpuStrategy} from './strategies/cloth-gpu.strategy';
import {ClothCpuStrategy} from './strategies/cloth-cpu.strategy'; import {ClothCpuStrategy} from './strategies/cloth-cpu.strategy';
import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics';
import {FpsCounter} from '../../../shared/fps-counter';
@Component({ @Component({
selector: 'app-cloth', selector: 'app-cloth',
@@ -25,7 +28,8 @@ import {ClothCpuStrategy} from './strategies/cloth-cpu.strategy';
MatButton, MatButton,
MatSliderModule, MatSliderModule,
FormsModule, FormsModule,
Information Information,
MetricsComponent
], ],
templateUrl: './cloth.component.html', templateUrl: './cloth.component.html',
styleUrl: './cloth.component.scss', styleUrl: './cloth.component.scss',
@@ -40,6 +44,19 @@ export class ClothComponent {
public stiffness: number = 80; public stiffness: number = 80;
public readonly elongation = signal(1.0); public readonly elongation = signal(1.0);
private readonly fpsCounter = new FpsCounter();
private readonly clothConfig = this.getClothConfig();
readonly backend = signal('–');
readonly stats = signal<ClothStats>({constraints: 0, phases: 0});
readonly metrics = computed<Metric[]>(() => [
{label: 'METRICS.FPS', value: this.fpsCounter.fps()},
{label: 'METRICS.BACKEND', value: this.backend()},
{label: 'METRICS.PARTICLES', value: this.clothConfig.numVertices},
{label: 'METRICS.CONSTRAINTS', value: this.stats().constraints},
{label: 'METRICS.PHASES', value: this.stats().phases},
{label: 'METRICS.SUBSTEPS', value: CLOTH_SUBSTEPS}
]);
public renderConfig: RenderConfig = { public renderConfig: RenderConfig = {
mode: '3D', mode: '3D',
initialViewSize: 20 initialViewSize: 20
@@ -75,7 +92,9 @@ export class ClothComponent {
], ],
disclaimer: 'CLOTH.EXPLANATION.DISCLAIMER', disclaimer: 'CLOTH.EXPLANATION.DISCLAIMER',
disclaimerBottom: '', disclaimerBottom: '',
disclaimerListEntry: ['CLOTH.EXPLANATION.DISCLAIMER_1', 'CLOTH.EXPLANATION.DISCLAIMER_2', 'CLOTH.EXPLANATION.DISCLAIMER_3', 'CLOTH.EXPLANATION.DISCLAIMER_4'] disclaimerListEntry: ['CLOTH.EXPLANATION.DISCLAIMER_1', 'CLOTH.EXPLANATION.DISCLAIMER_2', 'CLOTH.EXPLANATION.DISCLAIMER_3', 'CLOTH.EXPLANATION.DISCLAIMER_4'],
howItWorks: {intro: 'CLOTH.HOW_IT_WORKS.INTRO', items: ['CLOTH.HOW_IT_WORKS.ITEM_1', 'CLOTH.HOW_IT_WORKS.ITEM_2', 'CLOTH.HOW_IT_WORKS.ITEM_3', 'CLOTH.HOW_IT_WORKS.ITEM_4', 'CLOTH.HOW_IT_WORKS.ITEM_5']},
sources: SOURCE_PATHS.CLOTH
}; };
public onSceneReady(event: SceneEventData): void { public onSceneReady(event: SceneEventData): void {
@@ -107,7 +126,7 @@ export class ClothComponent {
} }
const {engine, scene, gpuTier} = this.currentSceneData; const {engine, scene, gpuTier} = this.currentSceneData;
const config = this.getClothConfig(); const config = this.clothConfig;
if (this.strategy) { if (this.strategy) {
this.strategy.dispose(); this.strategy.dispose();
@@ -117,13 +136,16 @@ export class ClothComponent {
? new ClothGpuStrategy() ? new ClothGpuStrategy()
: new ClothCpuStrategy(); : new ClothCpuStrategy();
this.backend.set(gpuTier === 'webgpu' ? 'WebGPU (Compute)' : 'WebGL + CPU');
this.strategy.init(scene, engine, config); this.strategy.init(scene, engine, config);
this.stats.set(this.strategy.getStats());
this.startParamUpdateLoop(scene, engine); this.startParamUpdateLoop(scene, engine);
} }
private startParamUpdateLoop(scene: any, engine: any): void { private startParamUpdateLoop(scene: any, engine: any): void {
scene.onAfterRenderObservable.clear(); scene.onAfterRenderObservable.clear();
scene.onAfterRenderObservable.add(() => { scene.onAfterRenderObservable.add(() => {
this.fpsCounter.tick();
this.simulationTime += engine.getDeltaTime() / 1000.0; this.simulationTime += engine.getDeltaTime() / 1000.0;
if (this.strategy) { if (this.strategy) {
@@ -7,6 +7,21 @@ export interface ClothConfig {
particleInvMass: number; particleInvMass: number;
} }
export const CLOTH_SUBSTEPS = 15;
export interface ClothStats {
constraints: number;
phases: number;
}
/** Each constraint takes 4 numbers in its phase array. */
export function clothStatsOf(constraintPhases: number[][]): ClothStats {
return {
constraints: constraintPhases.reduce((sum, phase) => sum + phase.length / 4, 0),
phases: constraintPhases.length
};
}
export interface ClothData { export interface ClothData {
positions: Float32Array; positions: Float32Array;
prevPositions: Float32Array; prevPositions: Float32Array;
@@ -1,5 +1,5 @@
import {ArcRotateCamera, Engine, GroundMesh, MeshBuilder, Scene, ShaderMaterial, VertexBuffer, WebGPUEngine} from '@babylonjs/core'; import {ArcRotateCamera, Engine, GroundMesh, MeshBuilder, Scene, ShaderMaterial, VertexBuffer, WebGPUEngine} from '@babylonjs/core';
import {ClothConfig, ClothData} from '../cloth.model'; import {CLOTH_SUBSTEPS, ClothConfig, ClothData, ClothStats, clothStatsOf} from '../cloth.model';
import {CLOTH_FRAGMENT_SHADER_GLSL, CLOTH_VERTEX_SHADER_GLSL} from '../cloth-glsl.shader'; import {CLOTH_FRAGMENT_SHADER_GLSL, CLOTH_VERTEX_SHADER_GLSL} from '../cloth-glsl.shader';
import {ClothPhysicsParams, integratePositions, solveConstraints, updateVelocities} from './cloth-cpu-physics'; import {ClothPhysicsParams, integratePositions, solveConstraints, updateVelocities} from './cloth-cpu-physics';
import {ClothSimulationParams, ClothSimulationStrategy} from './cloth-simulation.strategy'; import {ClothSimulationParams, ClothSimulationStrategy} from './cloth-simulation.strategy';
@@ -13,6 +13,7 @@ export class ClothCpuStrategy implements ClothSimulationStrategy {
private prevPositions!: Float32Array; private prevPositions!: Float32Array;
private velocities!: Float32Array; private velocities!: Float32Array;
private constraintPhases!: Float32Array[]; private constraintPhases!: Float32Array[];
private stats: ClothStats = {constraints: 0, phases: 0};
private physicsParams: ClothPhysicsParams = { private physicsParams: ClothPhysicsParams = {
dt: 0.016, dt: 0.016,
@@ -36,11 +37,16 @@ export class ClothCpuStrategy implements ClothSimulationStrategy {
this.prevPositions = clothData.prevPositions; this.prevPositions = clothData.prevPositions;
this.velocities = clothData.velocities; this.velocities = clothData.velocities;
this.constraintPhases = clothData.constraints.map(c => new Float32Array(c)); this.constraintPhases = clothData.constraints.map(c => new Float32Array(c));
this.stats = clothStatsOf(clothData.constraints);
this.setupRenderMesh(scene, config); this.setupRenderMesh(scene, config);
this.startRenderLoop(scene, config); this.startRenderLoop(scene, config);
} }
getStats(): ClothStats {
return this.stats;
}
updateParams(params: ClothSimulationParams): void { updateParams(params: ClothSimulationParams): void {
const windX = params.isWindActive ? 5.0 : 0.0; const windX = params.isWindActive ? 5.0 : 0.0;
const windZ = params.isWindActive ? 15.0 : 0.0; const windZ = params.isWindActive ? 15.0 : 0.0;
@@ -191,8 +197,6 @@ export class ClothCpuStrategy implements ClothSimulationStrategy {
} }
private startRenderLoop(scene: Scene, config: ClothConfig): void { private startRenderLoop(scene: Scene, config: ClothConfig): void {
const substeps = 15;
scene.onBeforeRenderObservable.clear(); scene.onBeforeRenderObservable.clear();
scene.onBeforeRenderObservable.add(() => { scene.onBeforeRenderObservable.add(() => {
if (!this.clothMesh) { if (!this.clothMesh) {
@@ -201,7 +205,7 @@ export class ClothCpuStrategy implements ClothSimulationStrategy {
integratePositions(this.positions, this.prevPositions, this.velocities, this.physicsParams); integratePositions(this.positions, this.prevPositions, this.velocities, this.physicsParams);
for (let i = 0; i < substeps; i++) { for (let i = 0; i < CLOTH_SUBSTEPS; i++) {
for (const phase of this.constraintPhases) { for (const phase of this.constraintPhases) {
solveConstraints(this.positions, phase, this.physicsParams); solveConstraints(this.positions, phase, this.physicsParams);
} }
@@ -1,5 +1,5 @@
import {ArcRotateCamera, ComputeShader, Engine, GroundMesh, MeshBuilder, Scene, ShaderLanguage, ShaderMaterial, StorageBuffer, WebGPUEngine} from '@babylonjs/core'; import {ArcRotateCamera, ComputeShader, Engine, GroundMesh, MeshBuilder, Scene, ShaderLanguage, ShaderMaterial, StorageBuffer, WebGPUEngine} from '@babylonjs/core';
import {ClothConfig, ClothData} from '../cloth.model'; import {CLOTH_SUBSTEPS, ClothConfig, ClothData, ClothStats, clothStatsOf} from '../cloth.model';
import { import {
CLOTH_FRAGMENT_SHADER_WGSL, CLOTH_FRAGMENT_SHADER_WGSL,
CLOTH_INTEGRATE_COMPUTE_WGSL, CLOTH_INTEGRATE_COMPUTE_WGSL,
@@ -32,6 +32,7 @@ export class ClothGpuStrategy implements ClothSimulationStrategy {
private dispatchXConstraints: number[] = []; private dispatchXConstraints: number[] = [];
private dispatchXVertices = 0; private dispatchXVertices = 0;
private numVertices = 0; private numVertices = 0;
private stats: ClothStats = {constraints: 0, phases: 0};
init(scene: Scene, engine: WebGPUEngine | Engine, config: ClothConfig): void { init(scene: Scene, engine: WebGPUEngine | Engine, config: ClothConfig): void {
this.scene = scene; this.scene = scene;
@@ -39,6 +40,7 @@ export class ClothGpuStrategy implements ClothSimulationStrategy {
const gpuEngine = engine as WebGPUEngine; const gpuEngine = engine as WebGPUEngine;
const clothData = this.generateClothData(config); const clothData = this.generateClothData(config);
this.stats = clothStatsOf(clothData.constraints);
this.buffers = this.createStorageBuffers(gpuEngine, clothData); this.buffers = this.createStorageBuffers(gpuEngine, clothData);
this.pipelines = this.setupComputePipelines(gpuEngine, this.buffers); this.pipelines = this.setupComputePipelines(gpuEngine, this.buffers);
this.setupRenderMesh(scene, config, this.buffers.positions); this.setupRenderMesh(scene, config, this.buffers.positions);
@@ -46,6 +48,10 @@ export class ClothGpuStrategy implements ClothSimulationStrategy {
this.startRenderLoop(scene); this.startRenderLoop(scene);
} }
getStats(): ClothStats {
return this.stats;
}
updateParams(params: ClothSimulationParams): void { updateParams(params: ClothSimulationParams): void {
if (!this.buffers) { if (!this.buffers) {
return; return;
@@ -251,8 +257,6 @@ export class ClothGpuStrategy implements ClothSimulationStrategy {
} }
private startRenderLoop(scene: Scene): void { private startRenderLoop(scene: Scene): void {
const substeps = 15;
scene.onBeforeRenderObservable.clear(); scene.onBeforeRenderObservable.clear();
scene.onBeforeRenderObservable.add(() => { scene.onBeforeRenderObservable.add(() => {
if (!this.buffers || !this.pipelines) { if (!this.buffers || !this.pipelines) {
@@ -263,7 +267,7 @@ export class ClothGpuStrategy implements ClothSimulationStrategy {
this.pipelines.integrate.dispatch(this.dispatchXVertices, 1, 1); this.pipelines.integrate.dispatch(this.dispatchXVertices, 1, 1);
for (let i = 0; i < substeps; i++) { for (let i = 0; i < CLOTH_SUBSTEPS; i++) {
for (let phase = 0; phase < this.pipelines.solvers.length; phase++) { for (let phase = 0; phase < this.pipelines.solvers.length; phase++) {
this.pipelines.solvers[phase].dispatch(this.dispatchXConstraints[phase], 1, 1); this.pipelines.solvers[phase].dispatch(this.dispatchXConstraints[phase], 1, 1);
} }
@@ -1,5 +1,5 @@
import {Engine, GroundMesh, Scene, WebGPUEngine} from '@babylonjs/core'; import {Engine, GroundMesh, Scene, WebGPUEngine} from '@babylonjs/core';
import {ClothConfig} from '../cloth.model'; import {ClothConfig, ClothStats} from '../cloth.model';
export interface ClothSimulationParams { export interface ClothSimulationParams {
stiffness: number; stiffness: number;
@@ -13,5 +13,6 @@ export interface ClothSimulationStrategy {
init(scene: Scene, engine: WebGPUEngine | Engine, config: ClothConfig): void; init(scene: Scene, engine: WebGPUEngine | Engine, config: ClothConfig): void;
updateParams(params: ClothSimulationParams): void; updateParams(params: ClothSimulationParams): void;
getMesh(): GroundMesh | null; getMesh(): GroundMesh | null;
getStats(): ClothStats;
dispose(): void; dispose(): void;
} }
@@ -33,8 +33,8 @@
<mat-icon>play_arrow</mat-icon> {{ 'GOL.START' | translate }} <mat-icon>play_arrow</mat-icon> {{ 'GOL.START' | translate }}
</button> </button>
} }
<p>{{ 'SORTING.EXECUTION_TIME' | translate }}: {{ executionTime() }} ms</p>
</div> </div>
<app-metrics [metrics]="metrics()"/>
<div class="flex gap-3 items-center flex-wrap"> <div class="flex gap-3 items-center flex-wrap">
<mat-form-field appearance="outline" class="w-[150px]"> <mat-form-field appearance="outline" class="w-[150px]">
<mat-label>{{ 'ALGORITHM.GRID_HEIGHT' | translate }}</mat-label> <mat-label>{{ 'ALGORITHM.GRID_HEIGHT' | translate }}</mat-label>
@@ -65,4 +65,16 @@ describe('ConwayGolComponent', () => {
expect(component['gridCols']()).toBeNull(); expect(component['gridCols']()).toBeNull();
expect(component.readGrid[0].length).toBe(20); expect(component.readGrid[0].length).toBe(20);
}); });
it('counts generations while running and restarts at 0 on generate', () => {
const run = component.startGame();
component.pauseGame();
expect(component.generation()).toBeGreaterThanOrEqual(1);
component.generate(component.currentScenario);
expect(component.generation()).toBe(0);
return run;
});
}); });
@@ -1,9 +1,10 @@
import {AfterViewInit, Component, inject, signal, ViewChild} from '@angular/core'; import {AfterViewInit, Component, computed, inject, signal, ViewChild} from '@angular/core';
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from "@angular/material/card"; import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from "@angular/material/card";
import {TranslatePipe} from "@ngx-translate/core"; import {TranslatePipe} from "@ngx-translate/core";
import {UrlConstants} from '../../../constants/UrlConstants'; import {UrlConstants} from '../../../constants/UrlConstants';
import {Information} from '../information/information'; import {Information} from '../information/information';
import {AlgorithmInformation} from '../information/information.models'; import {AlgorithmInformation} from '../information/information.models';
import {SOURCE_PATHS} from '../information/source-paths';
import {MatButton} from '@angular/material/button'; import {MatButton} from '@angular/material/button';
import {MatIcon} from '@angular/material/icon'; import {MatIcon} from '@angular/material/icon';
import {MatFormField, MatInput, MatLabel} from '@angular/material/input'; import {MatFormField, MatInput, MatLabel} from '@angular/material/input';
@@ -11,6 +12,8 @@ import {FormsModule, ReactiveFormsModule} from '@angular/forms';
import {DEFAULT_GRID_COLS, DEFAULT_GRID_ROWS, DEFAULT_TIME_PER_GENERATION, MAX_GRID_PX, MAX_GRID_SIZE, MAX_TIME_PER_GENERATION, MIN_GRID_SIZE, MIN_TIME_PER_GENERATION, Node, Scenario} from './conway-gol.models'; import {DEFAULT_GRID_COLS, DEFAULT_GRID_ROWS, DEFAULT_TIME_PER_GENERATION, MAX_GRID_PX, MAX_GRID_SIZE, MAX_TIME_PER_GENERATION, MIN_GRID_SIZE, MIN_TIME_PER_GENERATION, Node, Scenario} from './conway-gol.models';
import {GenericGridComponent, GridPos} from '../../../shared/components/generic-grid/generic-grid'; import {GenericGridComponent, GridPos} from '../../../shared/components/generic-grid/generic-grid';
import {ConwayGolService} from './service/conway-gol.service'; import {ConwayGolService} from './service/conway-gol.service';
import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics';
import {FpsCounter} from '../../../shared/fps-counter';
@Component({ @Component({
selector: 'app-conway-gol', selector: 'app-conway-gol',
@@ -28,7 +31,8 @@ import {ConwayGolService} from './service/conway-gol.service';
MatLabel, MatLabel,
ReactiveFormsModule, ReactiveFormsModule,
FormsModule, FormsModule,
GenericGridComponent GenericGridComponent,
MetricsComponent
], ],
templateUrl: './conway-gol.component.html', templateUrl: './conway-gol.component.html',
}) })
@@ -46,7 +50,9 @@ export class ConwayGolComponent implements AfterViewInit {
], ],
disclaimer: 'GOL.EXPLANATION.DISCLAIMER', disclaimer: 'GOL.EXPLANATION.DISCLAIMER',
disclaimerBottom: '', disclaimerBottom: '',
disclaimerListEntry: ['GOL.EXPLANATION.DISCLAIMER_1', 'GOL.EXPLANATION.DISCLAIMER_2', 'GOL.EXPLANATION.DISCLAIMER_3', 'GOL.EXPLANATION.DISCLAIMER_4'] disclaimerListEntry: ['GOL.EXPLANATION.DISCLAIMER_1', 'GOL.EXPLANATION.DISCLAIMER_2', 'GOL.EXPLANATION.DISCLAIMER_3', 'GOL.EXPLANATION.DISCLAIMER_4'],
howItWorks: {intro: 'GOL.HOW_IT_WORKS.INTRO', items: ['GOL.HOW_IT_WORKS.ITEM_1', 'GOL.HOW_IT_WORKS.ITEM_2', 'GOL.HOW_IT_WORKS.ITEM_3', 'GOL.HOW_IT_WORKS.ITEM_4']},
sources: SOURCE_PATHS.GOL
}; };
protected readonly gridCols = signal(DEFAULT_GRID_COLS); protected readonly gridCols = signal(DEFAULT_GRID_COLS);
@@ -59,6 +65,13 @@ export class ConwayGolComponent implements AfterViewInit {
readGrid: Node[][] = []; readGrid: Node[][] = [];
writeGrid: Node[][] = []; writeGrid: Node[][] = [];
readonly executionTime = signal(0); readonly executionTime = signal(0);
readonly generation = signal(0);
private readonly generationRate = new FpsCounter();
readonly metrics = computed<Metric[]>(() => [
{label: 'METRICS.GENERATION', value: this.generation()},
{label: 'METRICS.GENERATIONS_PER_SECOND', value: this.generationRate.fps()},
{label: 'METRICS.STEP_TIME', value: this.executionTime(), unit: 'ms'}
]);
currentScenario: Scenario = Scenario.SIMPLE; currentScenario: Scenario = Scenario.SIMPLE;
readonly gameStarted = signal(false); readonly gameStarted = signal(false);
@@ -140,6 +153,8 @@ export class ConwayGolComponent implements AfterViewInit {
initializeConwayGrid = (grid: Node[][]): void => { initializeConwayGrid = (grid: Node[][]): void => {
this.gameStarted.set(false); this.gameStarted.set(false);
this.generation.set(0);
this.generationRate.reset();
this.readGrid = grid; this.readGrid = grid;
this.golService.applyScenario(grid, this.currentScenario); this.golService.applyScenario(grid, this.currentScenario);
this.writeGrid = structuredClone(this.readGrid); this.writeGrid = structuredClone(this.readGrid);
@@ -158,6 +173,8 @@ export class ConwayGolComponent implements AfterViewInit {
const changed = this.golService.nextGeneration(this.readGrid, this.writeGrid); const changed = this.golService.nextGeneration(this.readGrid, this.writeGrid);
this.swapGrids(); this.swapGrids();
this.generation.update(count => count + 1);
this.generationRate.tick();
const endTime = performance.now(); const endTime = performance.now();
const elapsed = Number.parseFloat((endTime - startTime).toFixed(4)); const elapsed = Number.parseFloat((endTime - startTime).toFixed(4));
this.executionTime.set(elapsed); this.executionTime.set(elapsed);
@@ -168,6 +185,7 @@ export class ConwayGolComponent implements AfterViewInit {
await this.delay(delta); await this.delay(delta);
} }
this.executionTime.set(0); this.executionTime.set(0);
this.generationRate.reset();
} }
private swapGrids() { private swapGrids() {
@@ -47,6 +47,8 @@
<span><span class="legend-swatch bg-[#FFEB3B]"></span> {{ 'FOUR_COLOR.COLOR_4' | translate }}</span> <span><span class="legend-swatch bg-[#FFEB3B]"></span> {{ 'FOUR_COLOR.COLOR_4' | translate }}</span>
</div> </div>
<app-metrics [metrics]="metrics()"/>
<div class="mt-5 flex gap-2.5 py-2.5 px-4 rounded-sm border-l-[5px] font-medium min-w-[300px] items-center" <div class="mt-5 flex gap-2.5 py-2.5 px-4 rounded-sm border-l-[5px] font-medium min-w-[300px] items-center"
[ngClass]="{ [ngClass]="{
'border-l-gray-500 bg-app-bg': solutionStatus() === 'INCOMPLETE', 'border-l-gray-500 bg-app-bg': solutionStatus() === 'INCOMPLETE',
@@ -1,4 +1,4 @@
import {AfterViewInit, Component, ElementRef, inject, PLATFORM_ID, signal, ViewChild} from '@angular/core'; import {AfterViewInit, Component, computed, ElementRef, inject, PLATFORM_ID, signal, ViewChild} from '@angular/core';
import {CommonModule, isPlatformBrowser} from '@angular/common'; import {CommonModule, isPlatformBrowser} from '@angular/common';
import {FormsModule} from '@angular/forms'; import {FormsModule} from '@angular/forms';
import {MatButtonModule} from '@angular/material/button'; import {MatButtonModule} from '@angular/material/button';
@@ -8,12 +8,15 @@ import {MatInputModule} from '@angular/material/input';
import {TranslatePipe, TranslateService} from '@ngx-translate/core'; import {TranslatePipe, TranslateService} from '@ngx-translate/core';
import {MatSnackBar} from '@angular/material/snack-bar'; import {MatSnackBar} from '@angular/material/snack-bar';
import {DEFAULT_GRID_COLS, DEFAULT_GRID_ROWS, MAX_GRID_PX, MAX_GRID_SIZE, MIN_GRID_SIZE, FourColorMap, FourColorNode, SolutionStatus} from './four-color.models'; import {DEFAULT_GRID_COLS, DEFAULT_GRID_ROWS, MAX_GRID_PX, MAX_ATTEMPTS, MAX_GRID_SIZE, MIN_GRID_SIZE, FourColorMap, FourColorNode, SolutionStatus} from './four-color.models';
import {AlgorithmInformation} from '../information/information.models'; import {AlgorithmInformation} from '../information/information.models';
import {SOURCE_PATHS} from '../information/source-paths';
import {Information} from '../information/information'; import {Information} from '../information/information';
import {GridPos} from '../../../shared/components/generic-grid/generic-grid'; import {GridPos} from '../../../shared/components/generic-grid/generic-grid';
import {FourColorService} from './service/four-color.service'; import {FourColorService} from './service/four-color.service';
import {UrlConstants} from '../../../constants/UrlConstants'; import {UrlConstants} from '../../../constants/UrlConstants';
import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics';
import {LanguageService} from '../../../service/language.service';
@Component({ @Component({
selector: 'app-four-color', selector: 'app-four-color',
@@ -26,7 +29,8 @@ import {UrlConstants} from '../../../constants/UrlConstants';
MatFormFieldModule, MatFormFieldModule,
MatInputModule, MatInputModule,
TranslatePipe, TranslatePipe,
Information Information,
MetricsComponent
], ],
templateUrl: './four-color.component.html', templateUrl: './four-color.component.html',
styleUrl: './four-color.component.scss' styleUrl: './four-color.component.scss'
@@ -36,6 +40,7 @@ export class FourColorComponent implements AfterViewInit {
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
private readonly translate = inject(TranslateService); private readonly translate = inject(TranslateService);
private readonly snackBar = inject(MatSnackBar); private readonly snackBar = inject(MatSnackBar);
private readonly language = inject(LanguageService);
readonly MIN_GRID_SIZE = MIN_GRID_SIZE; readonly MIN_GRID_SIZE = MIN_GRID_SIZE;
readonly MAX_GRID_SIZE = MAX_GRID_SIZE; readonly MAX_GRID_SIZE = MAX_GRID_SIZE;
@@ -57,13 +62,31 @@ export class FourColorComponent implements AfterViewInit {
'FOUR_COLOR.EXPLANATION.DISCLAIMER_2', 'FOUR_COLOR.EXPLANATION.DISCLAIMER_2',
'FOUR_COLOR.EXPLANATION.DISCLAIMER_3', 'FOUR_COLOR.EXPLANATION.DISCLAIMER_3',
'FOUR_COLOR.EXPLANATION.DISCLAIMER_4' 'FOUR_COLOR.EXPLANATION.DISCLAIMER_4'
] ],
howItWorks: {intro: 'FOUR_COLOR.HOW_IT_WORKS.INTRO', items: ['FOUR_COLOR.HOW_IT_WORKS.ITEM_1', 'FOUR_COLOR.HOW_IT_WORKS.ITEM_2', 'FOUR_COLOR.HOW_IT_WORKS.ITEM_3', 'FOUR_COLOR.HOW_IT_WORKS.ITEM_4']},
sources: SOURCE_PATHS.FOUR_COLOR
}; };
readonly gridRows = signal(DEFAULT_GRID_ROWS); readonly gridRows = signal(DEFAULT_GRID_ROWS);
readonly gridCols = signal(DEFAULT_GRID_COLS); readonly gridCols = signal(DEFAULT_GRID_COLS);
map: FourColorMap = {grid: [], regions: []}; map: FourColorMap = {grid: [], regions: []};
executionTime = 0; readonly executionTime = signal(0);
readonly attempts = signal(0);
readonly gaveUp = signal(false);
readonly regionCount = signal(0);
readonly borderCount = signal(0);
private readonly attemptsLabel = computed(() => {
if (!this.gaveUp()) {
return this.attempts();
}
return `> ${this.attempts().toLocaleString(this.language.lang())}`;
});
readonly metrics = computed<Metric[]>(() => [
{label: 'METRICS.REGIONS', value: this.regionCount()},
{label: 'METRICS.BORDERS', value: this.borderCount()},
{label: 'METRICS.ATTEMPTS', value: this.attemptsLabel()},
{label: 'FOUR_COLOR.EXECUTION_TIME', value: this.executionTime(), unit: 'ms'}
]);
readonly solutionStatus = signal<SolutionStatus>('INCOMPLETE'); readonly solutionStatus = signal<SolutionStatus>('INCOMPLETE');
@ViewChild('fourColorCanvas') canvasRef!: ElementRef<HTMLCanvasElement>; @ViewChild('fourColorCanvas') canvasRef!: ElementRef<HTMLCanvasElement>;
@@ -88,6 +111,11 @@ export class FourColorComponent implements AfterViewInit {
initializeGrid(): void { initializeGrid(): void {
this.map = this.fourColorService.createMap(this.gridRows(), this.gridCols()); this.map = this.fourColorService.createMap(this.gridRows(), this.gridCols());
this.solutionStatus.set('INCOMPLETE'); this.solutionStatus.set('INCOMPLETE');
this.regionCount.set(this.map.regions.length);
this.borderCount.set(this.fourColorService.countBorders(this.map.regions));
this.attempts.set(0);
this.gaveUp.set(false);
this.executionTime.set(0);
this.resizeCanvas(); this.resizeCanvas();
this.drawGrid(); this.drawGrid();
} }
@@ -212,19 +240,29 @@ export class FourColorComponent implements AfterViewInit {
const startTime = performance.now(); const startTime = performance.now();
this.resetColors(); this.resetColors();
const success = this.fourColorService.solve(this.map); const {solved, gaveUp, attempts} = this.fourColorService.solve(this.map);
const endTime = performance.now(); const endTime = performance.now();
this.executionTime = endTime - startTime; this.executionTime.set(endTime - startTime);
this.attempts.set(attempts);
this.gaveUp.set(gaveUp);
if (success) { if (solved) {
this.solutionStatus.set(this.fourColorService.evaluate(this.map.regions)); this.solutionStatus.set(this.fourColorService.evaluate(this.map.regions));
this.drawGrid(); this.drawGrid();
} else if (gaveUp) {
this.showLimitReachedAlert();
} else { } else {
const message = this.translate.instant('FOUR_COLOR.ALERT.NO_SOLUTION'); const message = this.translate.instant('FOUR_COLOR.ALERT.NO_SOLUTION');
this.snackBar.open(message, 'ALERT'); this.snackBar.open(message, 'ALERT');
} }
} }
private showLimitReachedAlert(): void {
const limit = MAX_ATTEMPTS.toLocaleString(this.language.lang());
const message = this.translate.instant('FOUR_COLOR.ALERT.LIMIT_REACHED', {limit});
this.snackBar.open(message, 'OK', {duration: 5000, horizontalPosition: 'center', verticalPosition: 'top'});
}
generateNewMap(): void { generateNewMap(): void {
this.initializeGrid(); this.initializeGrid();
} }
@@ -26,3 +26,4 @@ export interface FourColorMap {
export const CELLS_PER_REGION = 30; export const CELLS_PER_REGION = 30;
export const COLOR_COUNT = 4; export const COLOR_COUNT = 4;
export const MAX_ATTEMPTS = 100000;
@@ -80,7 +80,7 @@ describe('FourColorService', () => {
it('colors K4 with four distinct colors and mirrors them to the grid', () => { it('colors K4 with four distinct colors and mirrors them to the grid', () => {
const map = handBuiltMap(complete(4)); const map = handBuiltMap(complete(4));
expect(service.solve(map)).toBe(true); expect(service.solve(map).solved).toBe(true);
expect(new Set(map.regions.map(r => r.color)).size).toBe(4); expect(new Set(map.regions.map(r => r.color)).size).toBe(4);
map.regions.forEach((r, i) => expect(map.grid[0][i].color).toBe(r.color)); map.regions.forEach((r, i) => expect(map.grid[0][i].color).toBe(r.color));
@@ -89,20 +89,97 @@ describe('FourColorService', () => {
it('fails on K5 and leaves every color at 0', () => { it('fails on K5 and leaves every color at 0', () => {
const map = handBuiltMap(complete(5)); const map = handBuiltMap(complete(5));
expect(service.solve(map)).toBe(false); expect(service.solve(map).solved).toBe(false);
expect(map.regions.every(r => r.color === 0)).toBe(true); expect(map.regions.every(r => r.color === 0)).toBe(true);
expect(map.grid.flat().every(node => node.color === 0)).toBe(true); expect(map.grid.flat().every(node => node.color === 0)).toBe(true);
}); });
it('counts one attempt per region on a chain of three regions', () => {
const map = handBuiltMap([region(0, 0, [1]), region(1, 0, [0, 2]), region(2, 0, [1])]);
expect(service.solve(map).attempts).toBe(3);
});
it('counts undone assignments as attempts when the map is unsolvable', () => {
const map = handBuiltMap(complete(5));
expect(service.solve(map).attempts).toBeGreaterThan(5);
});
it('gives up at the attempt limit and resets every color', () => {
const map = handBuiltMap(complete(4));
const result = service.solve(map, 2);
expect(result.gaveUp).toBe(true);
expect(result.solved).toBe(false);
expect(result.attempts).toBe(2);
expect(map.regions.every(r => r.color === 0)).toBe(true);
expect(map.grid.flat().every(node => node.color === 0)).toBe(true);
});
it('does not give up on an unsolvable map that exhausts before the limit', () => {
const result = service.solve(handBuiltMap(complete(5)));
expect(result.solved).toBe(false);
expect(result.gaveUp).toBe(false);
});
it('reports gaveUp false when solved', () => {
expect(service.solve(handBuiltMap(complete(4))).gaveUp).toBe(false);
});
it('colors a wheel graph with few attempts', () => {
const rim = [1, 2, 3, 4, 5, 6];
const regions = [
region(0, 0, rim),
...rim.map((id, i) => region(id, 0, [0, rim[(i + 5) % 6], rim[(i + 1) % 6]]))
];
const result = service.solve(handBuiltMap(regions));
expect(result.solved).toBe(true);
expect(result.attempts).toBeLessThanOrEqual(2 * regions.length);
expect(service.evaluate(regions)).toBe('SOLVED');
});
it('solves 20 random 38 x 38 maps with a valid coloring or gives up', () => {
for (let i = 0; i < 20; i++) {
const map = service.createMap(38, 38);
const {solved, gaveUp} = service.solve(map);
expect(solved && gaveUp).toBe(false);
if (solved) {
expect(service.evaluate(map.regions)).toBe('SOLVED');
}
}
});
it('overwrites existing manual colors', () => {
const map = handBuiltMap([region(0, 1, [1]), region(1, 1, [0])]);
const result = service.solve(map);
expect(result.solved).toBe(true);
expect(service.evaluate(map.regions)).toBe('SOLVED');
});
it('solves a generated map', () => { it('solves a generated map', () => {
const map = service.createMap(25, 25); const map = service.createMap(25, 25);
expect(service.solve(map)).toBe(true); expect(service.solve(map).solved).toBe(true);
expect(service.evaluate(map.regions)).toBe('SOLVED'); expect(service.evaluate(map.regions)).toBe('SOLVED');
}); });
}); });
it('countBorders counts every shared border once', () => {
const chain = [region(0, 0, [1]), region(1, 0, [0, 2]), region(2, 0, [1])];
expect(service.countBorders(chain)).toBe(2);
});
describe('evaluate', () => { describe('evaluate', () => {
it('is INCOMPLETE when nothing is colored', () => { it('is INCOMPLETE when nothing is colored', () => {
expect(service.evaluate(complete(3))).toBe('INCOMPLETE'); expect(service.evaluate(complete(3))).toBe('INCOMPLETE');
@@ -1,13 +1,23 @@
import {Injectable} from '@angular/core'; import {Injectable} from '@angular/core';
import {CELLS_PER_REGION, COLOR_COUNT, FourColorMap, FourColorNode, Region, SolutionStatus} from '../four-color.models'; import {CELLS_PER_REGION, COLOR_COUNT, MAX_ATTEMPTS, FourColorMap, FourColorNode, Region, SolutionStatus} from '../four-color.models';
import {GridPos} from '../../../../shared/components/generic-grid/generic-grid'; import {GridPos} from '../../../../shared/components/generic-grid/generic-grid';
import {SharedFunctions} from '../../../../shared/SharedFunctions'; import {SharedFunctions} from '../../../../shared/SharedFunctions';
export interface SolveResult {
solved: boolean;
/** true when the attempt limit stopped the search; colors are reset to 0 */
gaveUp: boolean;
attempts: number;
}
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
}) })
export class FourColorService { export class FourColorService {
private attempts = 0;
private gaveUp = false;
createMap(rows: number, cols: number): FourColorMap { createMap(rows: number, cols: number): FourColorMap {
const grid: FourColorNode[][] = []; const grid: FourColorNode[][] = [];
for (let r = 0; r < rows; r++) { for (let r = 0; r < rows; r++) {
@@ -39,10 +49,17 @@ export class FourColorService {
this.applyRegionColors(map); this.applyRegionColors(map);
} }
solve(map: FourColorMap): boolean { solve(map: FourColorMap, maxAttempts = MAX_ATTEMPTS): SolveResult {
const solved = this.assignColors(map.regions, 0); this.attempts = 0;
this.gaveUp = false;
this.resetColors(map);
const solved = this.assignColors(map.regions, maxAttempts);
this.applyRegionColors(map); this.applyRegionColors(map);
return solved; return {solved, gaveUp: this.gaveUp, attempts: this.attempts};
}
countBorders(regions: Region[]): number {
return regions.reduce((sum, region) => sum + region.neighbors.size, 0) / 2;
} }
evaluate(regions: Region[]): SolutionStatus { evaluate(regions: Region[]): SolutionStatus {
@@ -129,24 +146,59 @@ export class FourColorService {
return neighbors; return neighbors;
} }
private assignColors(regions: Region[], regionIndex: number): boolean { private assignColors(regions: Region[], maxAttempts: number): boolean {
if (regionIndex === regions.length) { const region = this.nextRegion(regions);
if (region === undefined) {
return true; return true;
} }
const region = regions[regionIndex];
for (let color = 1; color <= COLOR_COUNT; color++) { for (let color = 1; color <= COLOR_COUNT; color++) {
if (this.isColorValid(regions, region, color)) { if (!this.isColorValid(regions, region, color)) {
region.color = color; continue;
if (this.assignColors(regions, regionIndex + 1)) {
return true;
}
region.color = 0;
} }
if (this.attempts >= maxAttempts) {
this.gaveUp = true;
return false;
}
this.attempts++;
region.color = color;
if (this.assignColors(regions, maxAttempts)) {
return true;
}
region.color = 0;
} }
return false; return false;
} }
// DSATUR: most constrained region first, so dead ends show up early
private nextRegion(regions: Region[]): Region | undefined {
let best: Region | undefined;
let bestColors = -1;
for (const region of regions) {
if (region.color !== 0) {
continue;
}
const colors = this.countNeighborColors(regions, region);
const winsTie = colors === bestColors && region.neighbors.size > best!.neighbors.size;
if (colors > bestColors || winsTie) {
best = region;
bestColors = colors;
}
}
return best;
}
private countNeighborColors(regions: Region[], region: Region): number {
const colors = new Set<number>();
for (const neighborId of region.neighbors) {
const color = regions[neighborId].color;
if (color !== 0) {
colors.add(color);
}
}
return colors.size;
}
private isColorValid(regions: Region[], region: Region, color: number): boolean { private isColorValid(regions: Region[], region: Region, color: number): boolean {
for (const neighborId of region.neighbors) { for (const neighborId of region.neighbors) {
if (regions[neighborId].color === color) { if (regions[neighborId].color === color) {
@@ -40,6 +40,8 @@
</span> </span>
</div> </div>
<app-metrics [metrics]="metrics()"/>
<div class="w-full max-w-[800px] mx-auto"> <div class="w-full max-w-[800px] mx-auto">
<canvas #fourierCanvas <canvas #fourierCanvas
class="touch-none cursor-crosshair max-w-full" class="touch-none cursor-crosshair max-w-full"
@@ -8,9 +8,12 @@ import {MatSliderModule} from '@angular/material/slider';
import {TranslatePipe} from '@ngx-translate/core'; import {TranslatePipe} from '@ngx-translate/core';
import {Information} from '../information/information'; import {Information} from '../information/information';
import {AlgorithmInformation} from '../information/information.models'; import {AlgorithmInformation} from '../information/information.models';
import {SOURCE_PATHS} from '../information/source-paths';
import {UrlConstants} from '../../../constants/UrlConstants'; import {UrlConstants} from '../../../constants/UrlConstants';
import {ThemeService} from '../../../service/theme.service'; import {ThemeService} from '../../../service/theme.service';
import {FourierService} from './service/fourier.service'; import {FourierService} from './service/fourier.service';
import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics';
import {FpsCounter} from '../../../shared/fps-counter';
import { import {
DEFAULT_SPEED, EpicycleColors, EpicycleTerm, MAX_CANVAS_PX, MAX_SPEED, MIN_DRAW_LENGTH, MIN_DRAW_POINTS, MIN_SPEED, Point, Preset, SAMPLE_COUNT, THEME_COLORS DEFAULT_SPEED, EpicycleColors, EpicycleTerm, MAX_CANVAS_PX, MAX_SPEED, MIN_DRAW_LENGTH, MIN_DRAW_POINTS, MIN_SPEED, Point, Preset, SAMPLE_COUNT, THEME_COLORS
} from './fourier.models'; } from './fourier.models';
@@ -27,7 +30,8 @@ import {
MatButton, MatButton,
MatIcon, MatIcon,
MatSliderModule, MatSliderModule,
FormsModule FormsModule,
MetricsComponent
], ],
templateUrl: './fourier.component.html' templateUrl: './fourier.component.html'
}) })
@@ -62,7 +66,9 @@ export class FourierComponent implements AfterViewInit, OnDestroy {
'FOURIER.EXPLANATION.DISCLAIMER_1', 'FOURIER.EXPLANATION.DISCLAIMER_1',
'FOURIER.EXPLANATION.DISCLAIMER_2', 'FOURIER.EXPLANATION.DISCLAIMER_2',
'FOURIER.EXPLANATION.DISCLAIMER_3' 'FOURIER.EXPLANATION.DISCLAIMER_3'
] ],
howItWorks: {intro: 'FOURIER.HOW_IT_WORKS.INTRO', items: ['FOURIER.HOW_IT_WORKS.ITEM_1', 'FOURIER.HOW_IT_WORKS.ITEM_2', 'FOURIER.HOW_IT_WORKS.ITEM_3', 'FOURIER.HOW_IT_WORKS.ITEM_4', 'FOURIER.HOW_IT_WORKS.ITEM_5']},
sources: SOURCE_PATHS.FOURIER
}; };
readonly terms = signal<EpicycleTerm[]>([]); readonly terms = signal<EpicycleTerm[]>([]);
@@ -71,6 +77,11 @@ export class FourierComponent implements AfterViewInit, OnDestroy {
readonly showCircles = signal(true); readonly showCircles = signal(true);
readonly maxCircles = computed(() => Math.max(this.terms().length, 1)); readonly maxCircles = computed(() => Math.max(this.terms().length, 1));
readonly usedTerms = computed(() => this.terms().slice(0, this.circleCount())); readonly usedTerms = computed(() => this.terms().slice(0, this.circleCount()));
private readonly fpsCounter = new FpsCounter();
readonly metrics = computed<Metric[]>(() => [
{label: 'METRICS.FPS', value: this.fpsCounter.fps()},
{label: 'METRICS.SAMPLES', value: SAMPLE_COUNT}
]);
protected readonly Preset = Preset; protected readonly Preset = Preset;
protected readonly MIN_SPEED = MIN_SPEED; protected readonly MIN_SPEED = MIN_SPEED;
@@ -160,6 +171,7 @@ export class FourierComponent implements AfterViewInit, OnDestroy {
} }
private readonly frame = (now: number): void => { private readonly frame = (now: number): void => {
this.fpsCounter.tick(now);
const dt = this.lastFrameTime ? (now - this.lastFrameTime) / 1000 : 0; const dt = this.lastFrameTime ? (now - this.lastFrameTime) / 1000 : 0;
this.lastFrameTime = now; this.lastFrameTime = now;
const positions = this.advance(dt); const positions = this.advance(dt);
@@ -40,6 +40,7 @@
</div> </div>
</div> </div>
<app-metrics [metrics]="metrics()"/>
<app-babylon-canvas <app-babylon-canvas
[config]="renderConfig" [config]="renderConfig"
[renderCallback]="onRender" [renderCallback]="onRender"
@@ -1,4 +1,4 @@
import {afterNextRender, Component, OnInit, signal} from '@angular/core'; import {afterNextRender, Component, computed, OnInit, signal} from '@angular/core';
import {Information} from '../information/information'; import {Information} from '../information/information';
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card'; import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
import {TranslatePipe} from '@ngx-translate/core'; import {TranslatePipe} from '@ngx-translate/core';
@@ -6,6 +6,7 @@ import {MatFormField, MatLabel} from '@angular/material/input';
import {MatOption} from '@angular/material/core'; import {MatOption} from '@angular/material/core';
import {MatSelect} from '@angular/material/select'; import {MatSelect} from '@angular/material/select';
import {AlgorithmInformation} from '../information/information.models'; import {AlgorithmInformation} from '../information/information.models';
import {SOURCE_PATHS} from '../information/source-paths';
import {UrlConstants} from '../../../constants/UrlConstants'; import {UrlConstants} from '../../../constants/UrlConstants';
import {FormsModule} from '@angular/forms'; import {FormsModule} from '@angular/forms';
import {BabylonCanvas, RenderCallback, RenderConfig, SceneEventData} from '../../../shared/components/render-canvas/babylon-canvas.component'; import {BabylonCanvas, RenderCallback, RenderConfig, SceneEventData} from '../../../shared/components/render-canvas/babylon-canvas.component';
@@ -15,6 +16,8 @@ import {MatButton} from '@angular/material/button';
import {MatIcon} from '@angular/material/icon'; import {MatIcon} from '@angular/material/icon';
import {NgxSliderModule, Options} from '@angular-slider/ngx-slider'; import {NgxSliderModule, Options} from '@angular-slider/ngx-slider';
import {clampZoom, initialView, iterationsForZoom, zoomAroundPoint} from './fractal-view'; import {clampZoom, initialView, iterationsForZoom, zoomAroundPoint} from './fractal-view';
import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics';
import {FpsCounter} from '../../../shared/fps-counter';
@Component({ @Component({
selector: 'app-fractal', selector: 'app-fractal',
@@ -33,7 +36,8 @@ import {clampZoom, initialView, iterationsForZoom, zoomAroundPoint} from './frac
BabylonCanvas, BabylonCanvas,
MatButton, MatButton,
MatIcon, MatIcon,
NgxSliderModule NgxSliderModule,
MetricsComponent
], ],
templateUrl: './fractal.component.html', templateUrl: './fractal.component.html',
styleUrl: './fractal.component.scss', styleUrl: './fractal.component.scss',
@@ -77,7 +81,9 @@ export class FractalComponent implements OnInit {
'FRACTAL.EXPLANATION.DISCLAIMER_2', 'FRACTAL.EXPLANATION.DISCLAIMER_2',
'FRACTAL.EXPLANATION.DISCLAIMER_3', 'FRACTAL.EXPLANATION.DISCLAIMER_3',
'FRACTAL.EXPLANATION.DISCLAIMER_4' 'FRACTAL.EXPLANATION.DISCLAIMER_4'
] ],
howItWorks: {intro: 'FRACTAL.HOW_IT_WORKS.INTRO', items: ['FRACTAL.HOW_IT_WORKS.ITEM_1', 'FRACTAL.HOW_IT_WORKS.ITEM_2', 'FRACTAL.HOW_IT_WORKS.ITEM_3', 'FRACTAL.HOW_IT_WORKS.ITEM_4', 'FRACTAL.HOW_IT_WORKS.ITEM_5']},
sources: SOURCE_PATHS.FRACTAL
}; };
renderConfig: RenderConfig = { renderConfig: RenderConfig = {
@@ -110,16 +116,26 @@ export class FractalComponent implements OnInit {
zoom = 0; zoom = 0;
offsetX = 0; offsetX = 0;
offsetY = 0; offsetY = 0;
maxIterations = 0; readonly maxIterations = signal(100);
private readonly fpsCounter = new FpsCounter();
readonly backend = signal('–');
readonly metrics = computed<Metric[]>(() => [
{label: 'METRICS.FPS', value: this.fpsCounter.fps()},
{label: 'METRICS.BACKEND', value: this.backend()},
{label: 'METRICS.ZOOM', value: this.sliderValue(), unit: '×'},
{label: 'METRICS.MAX_ITERATIONS', value: this.maxIterations()}
]);
juliaReal = -0.7; juliaReal = -0.7;
juliaImag = 0.27015; juliaImag = 0.27015;
// --- Render Callback --- // --- Render Callback ---
onRender: RenderCallback = (material: ShaderMaterial) => { onRender: RenderCallback = (material: ShaderMaterial) => {
this.fpsCounter.tick();
material.setVector2("center", new Vector2(this.offsetX, this.offsetY)); material.setVector2("center", new Vector2(this.offsetX, this.offsetY));
material.setFloat("zoom", this.zoom); material.setFloat("zoom", this.zoom);
material.setInt("maxIterations", this.maxIterations); material.setInt("maxIterations", this.maxIterations());
material.setInt("algorithm", this.selectedAlgorithm); material.setInt("algorithm", this.selectedAlgorithm);
material.setInt("colorScheme", this.selectedColorScheme); material.setInt("colorScheme", this.selectedColorScheme);
material.setVector2("juliaC", new Vector2(this.juliaReal, this.juliaImag)); material.setVector2("juliaC", new Vector2(this.juliaReal, this.juliaImag));
@@ -150,10 +166,12 @@ export class FractalComponent implements OnInit {
onReset(): void { onReset(): void {
({zoom: this.zoom, offsetX: this.offsetX, offsetY: this.offsetY} = initialView(this.selectedAlgorithm)); ({zoom: this.zoom, offsetX: this.offsetX, offsetY: this.offsetY} = initialView(this.selectedAlgorithm));
this.maxIterations = 100; this.maxIterations.set(100);
this.sliderValue.set(this.zoom);
} }
onSceneReady(event: SceneEventData): void { onSceneReady(event: SceneEventData): void {
this.backend.set(event.gpuTier === 'webgpu' ? 'WebGPU' : 'WebGL');
event.scene.onPointerObservable.add((pointerInfo) => { event.scene.onPointerObservable.add((pointerInfo) => {
switch (pointerInfo.type) { switch (pointerInfo.type) {
@@ -237,11 +255,11 @@ export class FractalComponent implements OnInit {
newZoom newZoom
)); ));
this.sliderValue.set(this.zoom); this.sliderValue.set(this.zoom);
this.maxIterations = Math.min(iterationsForZoom(this.zoom), 3000); this.maxIterations.set(Math.min(iterationsForZoom(this.zoom), 3000));
} }
onSliderChange(newValue: number): void { onSliderChange(newValue: number): void {
this.zoom = clampZoom(newValue, this.minZoomValue, this.maxZoomValue); this.zoom = clampZoom(newValue, this.minZoomValue, this.maxZoomValue);
this.maxIterations = iterationsForZoom(this.zoom); this.maxIterations.set(iterationsForZoom(this.zoom));
} }
} }
@@ -124,6 +124,7 @@ export const FRACTAL2D_FRAGMENT = `
} }
} }
else { // Mandelbrot (0), Julia (1), Burning Ship (2) else { // Mandelbrot (0), Julia (1), Burning Ship (2)
iter = maxIterations; // stays at max for points that never escape (black interior)
for(int i=0; i<2000; i++) { for(int i=0; i<2000; i++) {
if (i >= maxIterations) break; if (i >= maxIterations) break;
@@ -134,7 +135,6 @@ export const FRACTAL2D_FRAGMENT = `
iter = i; iter = i;
break; break;
} }
iter = i;
if (algorithm == 2) { // Burning Ship if (algorithm == 2) { // Burning Ship
z.y = abs(z.y); z.y = abs(z.y);
@@ -11,9 +11,11 @@
<button matButton="filled" (click)="onFractalTypeChange(2)">{{ 'FRACTAL3D.JULIA' | translate }}</button> <button matButton="filled" (click)="onFractalTypeChange(2)">{{ 'FRACTAL3D.JULIA' | translate }}</button>
</div> </div>
</div> </div>
<app-metrics [metrics]="metrics()"/>
<app-babylon-canvas <app-babylon-canvas
[config]="fractalConfig" [config]="fractalConfig"
[renderCallback]="onRender" [renderCallback]="onRender"
(sceneReady)="onSceneReady($event)"
/> />
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
@@ -1,13 +1,16 @@
import {Component} from '@angular/core'; import {Component, computed, signal} from '@angular/core';
import {ArcRotateCamera, Camera, ShaderMaterial} from '@babylonjs/core'; import {ArcRotateCamera, Camera, ShaderMaterial} from '@babylonjs/core';
import {MANDELBULB_FRAGMENT, MANDELBULB_VERTEX} from './fractal3d.shader'; import {MANDELBULB_FRAGMENT, MANDELBULB_VERTEX, MAX_RAY_STEPS} from './fractal3d.shader';
import {Information} from '../information/information'; import {Information} from '../information/information';
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card'; import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
import {TranslatePipe} from '@ngx-translate/core'; import {TranslatePipe} from '@ngx-translate/core';
import {AlgorithmInformation} from '../information/information.models'; import {AlgorithmInformation} from '../information/information.models';
import {SOURCE_PATHS} from '../information/source-paths';
import {UrlConstants} from '../../../constants/UrlConstants'; import {UrlConstants} from '../../../constants/UrlConstants';
import {MatButton} from '@angular/material/button'; import {MatButton} from '@angular/material/button';
import {BabylonCanvas, RenderCallback, RenderConfig} from '../../../shared/components/render-canvas/babylon-canvas.component'; import {BabylonCanvas, RenderCallback, RenderConfig, SceneEventData} from '../../../shared/components/render-canvas/babylon-canvas.component';
import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics';
import {FpsCounter} from '../../../shared/fps-counter';
@Component({ @Component({
selector: 'app-fractal3d', selector: 'app-fractal3d',
@@ -19,7 +22,8 @@ import {BabylonCanvas, RenderCallback, RenderConfig} from '../../../shared/compo
MatCardTitle, MatCardTitle,
TranslatePipe, TranslatePipe,
MatButton, MatButton,
BabylonCanvas BabylonCanvas,
MetricsComponent
], ],
templateUrl: './fractal3d.component.html', templateUrl: './fractal3d.component.html',
styleUrl: './fractal3d.component.scss', styleUrl: './fractal3d.component.scss',
@@ -47,7 +51,9 @@ export class Fractal3dComponent {
], ],
disclaimer: 'FRACTAL3D.EXPLANATION.DISCLAIMER', disclaimer: 'FRACTAL3D.EXPLANATION.DISCLAIMER',
disclaimerBottom: '', disclaimerBottom: '',
disclaimerListEntry: ['FRACTAL3D.EXPLANATION.DISCLAIMER_1', 'FRACTAL3D.EXPLANATION.DISCLAIMER_2', 'FRACTAL3D.EXPLANATION.DISCLAIMER_3', 'FRACTAL3D.EXPLANATION.DISCLAIMER_4'] disclaimerListEntry: ['FRACTAL3D.EXPLANATION.DISCLAIMER_1', 'FRACTAL3D.EXPLANATION.DISCLAIMER_2', 'FRACTAL3D.EXPLANATION.DISCLAIMER_3', 'FRACTAL3D.EXPLANATION.DISCLAIMER_4'],
howItWorks: {intro: 'FRACTAL3D.HOW_IT_WORKS.INTRO', items: ['FRACTAL3D.HOW_IT_WORKS.ITEM_1', 'FRACTAL3D.HOW_IT_WORKS.ITEM_2', 'FRACTAL3D.HOW_IT_WORKS.ITEM_3', 'FRACTAL3D.HOW_IT_WORKS.ITEM_4', 'FRACTAL3D.HOW_IT_WORKS.ITEM_5']},
sources: SOURCE_PATHS.FRACTAL3D
}; };
fractalConfig: RenderConfig = { fractalConfig: RenderConfig = {
@@ -63,8 +69,16 @@ export class Fractal3dComponent {
private oldType = 0; private oldType = 0;
public currentFractalType = 0; public currentFractalType = 0;
private readonly fpsCounter = new FpsCounter();
readonly backend = signal('–');
readonly metrics = computed<Metric[]>(() => [
{label: 'METRICS.FPS', value: this.fpsCounter.fps()},
{label: 'METRICS.BACKEND', value: this.backend()},
{label: 'METRICS.RAY_STEPS', value: MAX_RAY_STEPS}
]);
onRender: RenderCallback = (material: ShaderMaterial, camera: Camera) => { onRender: RenderCallback = (material: ShaderMaterial, camera: Camera) => {
this.fpsCounter.tick();
this.time += 0.005; this.time += 0.005;
if (this.oldType != this.currentFractalType && camera instanceof ArcRotateCamera) { if (this.oldType != this.currentFractalType && camera instanceof ArcRotateCamera) {
@@ -78,6 +92,10 @@ export class Fractal3dComponent {
}; };
onSceneReady(event: SceneEventData): void {
this.backend.set(event.gpuTier === 'webgpu' ? 'WebGPU' : 'WebGL');
}
onFractalTypeChange(type: number): void { onFractalTypeChange(type: number): void {
this.currentFractalType = type; this.currentFractalType = type;
} }
@@ -1,4 +1,6 @@
export const MANDELBULB_VERTEX = /* glsl */` export const MAX_RAY_STEPS = 128;
export const MANDELBULB_VERTEX = /* glsl */`
precision highp float; precision highp float;
attribute vec3 position; attribute vec3 position;
attribute vec2 uv; attribute vec2 uv;
@@ -151,7 +153,7 @@ export const MANDELBULB_FRAGMENT = /* glsl */`
float t = max(tEnter, 0.0); float t = max(tEnter, 0.0);
float tMax = tExit; float tMax = tExit;
for (int i = 0; i < 128; i++) { for (int i = 0; i < ${MAX_RAY_STEPS}; i++) {
vec3 pos = ro + t * rd; vec3 pos = ro + t * rd;
float d = map(pos); float d = map(pos);
@@ -19,6 +19,20 @@
} }
} }
<h4 class="m-0 mt-3 mb-1">{{ 'ALGORITHM.HOW_IT_WORKS' | translate }}</h4>
<p class="my-2">{{ info.howItWorks.intro | translate }}</p>
<ul>
@for (item of info.howItWorks.items; track item) {
<li>{{ item | translate }}</li>
}
</ul>
<p class="my-2">
<strong class="mr-2">{{ 'ALGORITHM.SOURCE' | translate }}:</strong>
@for (path of info.sources; track path) {
<a [href]="sourceUrl(path)" target="_blank" rel="noopener noreferrer" class="mr-3">{{ fileName(path) }}</a>
}
</p>
@if (info.disclaimer !== '') @if (info.disclaimer !== '')
{ {
<p class="my-2"> <p class="my-2">
@@ -4,6 +4,13 @@
disclaimer: string; disclaimer: string;
disclaimerBottom: string; disclaimerBottom: string;
disclaimerListEntry: string[]; disclaimerListEntry: string[];
howItWorks: HowItWorks;
sources: readonly string[];
}
export interface HowItWorks {
intro: string;
items: string[];
} }
export interface AlgorithmEntry { export interface AlgorithmEntry {
@@ -0,0 +1,43 @@
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {provideZonelessChangeDetection} from '@angular/core';
import {provideTranslateService} from '@ngx-translate/core';
import {Information} from './information';
import {AlgorithmInformation} from './information.models';
import {UrlConstants} from '../../../constants/UrlConstants';
describe('Information', () => {
let fixture: ComponentFixture<Information>;
const info: AlgorithmInformation = {
title: 'T',
entries: [],
disclaimer: '',
disclaimerBottom: '',
disclaimerListEntry: [],
howItWorks: {intro: 'INTRO_KEY', items: ['ITEM_A', 'ITEM_B']},
sources: ['src/app/a/one.ts', 'src/app/b/two.ts']
};
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideZonelessChangeDetection(), provideTranslateService({lang: 'en', fallbackLang: 'en'})]
});
fixture = TestBed.createComponent(Information);
fixture.componentRef.setInput('algorithmInformation', info);
fixture.detectChanges();
});
it('renders one list entry per how-it-works item', () => {
const items = fixture.nativeElement.querySelectorAll('ul li');
expect(items.length).toBe(2);
});
it('renders one source link per path with the Gitea base URL and the file name', () => {
const links: HTMLAnchorElement[] = Array.from(fixture.nativeElement.querySelectorAll('a'));
expect(links.map(link => link.getAttribute('href'))).toEqual([
UrlConstants.PLAYGROUND_SOURCE_BASE + 'src/app/a/one.ts',
UrlConstants.PLAYGROUND_SOURCE_BASE + 'src/app/b/two.ts'
]);
expect(links.map(link => link.textContent?.trim())).toEqual(['one.ts', 'two.ts']);
});
});
@@ -1,6 +1,7 @@
import {Component, input} from '@angular/core'; import {Component, input} from '@angular/core';
import {TranslatePipe} from "@ngx-translate/core"; import {TranslatePipe} from "@ngx-translate/core";
import {AlgorithmInformation} from './information.models'; import {AlgorithmInformation} from './information.models';
import {UrlConstants} from '../../../constants/UrlConstants';
@Component({ @Component({
selector: 'app-information', selector: 'app-information',
@@ -13,4 +14,12 @@ import {AlgorithmInformation} from './information.models';
export class Information { export class Information {
readonly algorithmInformation = input.required<AlgorithmInformation>(); readonly algorithmInformation = input.required<AlgorithmInformation>();
protected sourceUrl(path: string): string {
return UrlConstants.PLAYGROUND_SOURCE_BASE + path;
}
protected fileName(path: string): string {
return path.slice(path.lastIndexOf('/') + 1);
}
} }
+4
View File
@@ -0,0 +1,4 @@
// No Node types in tsconfig.spec: they would change the type of setTimeout in the whole app.
declare module 'node:fs' {
export function existsSync(path: string): boolean;
}
@@ -0,0 +1,14 @@
import {existsSync} from 'node:fs';
import {SOURCE_PATHS} from './source-paths';
// Paths are relative to the repo root, which is the working directory of `ng test`.
describe('SOURCE_PATHS', () => {
it('every linked source file exists in the repo', () => {
const missing = Object.values(SOURCE_PATHS).flat().filter(path => !existsSync(path));
expect(missing).toEqual([]);
});
it('detects a path that does not exist', () => {
expect(existsSync('src/app/does-not-exist.ts')).toBe(false);
});
});
@@ -0,0 +1,13 @@
/** Repo-relative paths of the files that hold the algorithm logic. Checked by source-paths.spec.ts. */
export const SOURCE_PATHS = {
PATHFINDING: ['src/app/pages/algorithms/pathfinding/service/pathfinding.service.ts', 'src/app/pages/algorithms/pathfinding/pathfinding-animator.ts'],
LABYRINTH: ['src/app/pages/algorithms/pathfinding/labyrinth/service/maze-generator.service.ts'],
SORTING: ['src/app/pages/algorithms/sorting/service/sorting.service.ts'],
GOL: ['src/app/pages/algorithms/conway-gol/service/conway-gol.service.ts'],
FOUR_COLOR: ['src/app/pages/algorithms/four-color/service/four-color.service.ts'],
FOURIER: ['src/app/pages/algorithms/fourier/service/fourier.service.ts'],
FRACTAL: ['src/app/pages/algorithms/fractal/fractal.shader.ts', 'src/app/pages/algorithms/fractal/fractal-view.ts'],
FRACTAL3D: ['src/app/pages/algorithms/fractal3d/fractal3d.shader.ts'],
PENDULUM: ['src/app/pages/algorithms/pendulum/pendulum.shader.ts', 'src/app/pages/algorithms/pendulum/strategies/pendulum-gpu.strategy.ts', 'src/app/pages/algorithms/pendulum/strategies/pendulum-cpu-physics.ts'],
CLOTH: ['src/app/pages/algorithms/cloth/cloth.shader.ts', 'src/app/pages/algorithms/cloth/strategies/cloth-gpu.strategy.ts', 'src/app/pages/algorithms/cloth/strategies/cloth-cpu-physics.ts'],
} as const satisfies Record<string, readonly string[]>;
@@ -21,10 +21,7 @@
<span><span class="legend-swatch bg-sky-300"></span> {{ 'PATHFINDING.VISITED' | translate }}</span> <span><span class="legend-swatch bg-sky-300"></span> {{ 'PATHFINDING.VISITED' | translate }}</span>
<span><span class="legend-swatch bg-[gold]"></span> {{ 'PATHFINDING.PATH' | translate }}</span> <span><span class="legend-swatch bg-[gold]"></span> {{ 'PATHFINDING.PATH' | translate }}</span>
</div> </div>
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]"> <app-metrics [metrics]="metrics()"/>
<p>{{ 'PATHFINDING.PATH_LENGTH' | translate }}: {{ pathLength() }}</p>
<p>{{ 'PATHFINDING.EXECUTION_TIME' | translate }}: {{ executionTime() | number:'1.2-2' }} ms</p>
</div>
</div> </div>
<app-generic-grid <app-generic-grid
[gridRows]="gridRows" [gridRows]="gridRows"
@@ -1,13 +1,14 @@
import {AfterViewInit, Component, inject, signal, ViewChild} from '@angular/core'; import {AfterViewInit, Component, computed, inject, signal, ViewChild} from '@angular/core';
import {Information} from '../../information/information'; import {Information} from '../../information/information';
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card'; import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
import {TranslatePipe} from '@ngx-translate/core'; import {TranslatePipe} from '@ngx-translate/core';
import {GenericGridComponent, GridPos} from '../../../../shared/components/generic-grid/generic-grid'; import {GenericGridComponent, GridPos} from '../../../../shared/components/generic-grid/generic-grid';
import {AlgorithmInformation} from '../../information/information.models'; import {AlgorithmInformation} from '../../information/information.models';
import {SOURCE_PATHS} from '../../information/source-paths';
import {UrlConstants} from '../../../../constants/UrlConstants'; import {UrlConstants} from '../../../../constants/UrlConstants';
import {createPathfindingNode, Node, PathfindingAlgorithm} from '../pathfinding.models'; import {createPathfindingNode, Node, PathfindingAlgorithm} from '../pathfinding.models';
import {MatButton} from '@angular/material/button'; import {MatButton} from '@angular/material/button';
import {DecimalPipe} from '@angular/common'; import {Metric, MetricsComponent} from '../../../../shared/components/metrics/metrics';
import {PathfindingService} from '../service/pathfinding.service'; import {PathfindingService} from '../service/pathfinding.service';
import {PathfindingAnimator} from '../pathfinding-animator'; import {PathfindingAnimator} from '../pathfinding-animator';
import {MazeGeneratorService} from './service/maze-generator.service'; import {MazeGeneratorService} from './service/maze-generator.service';
@@ -23,7 +24,7 @@ import {MazeGeneratorService} from './service/maze-generator.service';
TranslatePipe, TranslatePipe,
GenericGridComponent, GenericGridComponent,
MatButton, MatButton,
DecimalPipe MetricsComponent
], ],
templateUrl: './labyrinth.component.html', templateUrl: './labyrinth.component.html',
styleUrl: './labyrinth.component.scss', styleUrl: './labyrinth.component.scss',
@@ -54,7 +55,9 @@ export class LabyrinthComponent implements AfterViewInit {
], ],
disclaimer: 'LABYRINTH.EXPLANATION.DISCLAIMER', disclaimer: 'LABYRINTH.EXPLANATION.DISCLAIMER',
disclaimerBottom: '', disclaimerBottom: '',
disclaimerListEntry: ['LABYRINTH.EXPLANATION.DISCLAIMER_1', 'LABYRINTH.EXPLANATION.DISCLAIMER_2', 'LABYRINTH.EXPLANATION.DISCLAIMER_3', 'LABYRINTH.EXPLANATION.DISCLAIMER_4'] disclaimerListEntry: ['LABYRINTH.EXPLANATION.DISCLAIMER_1', 'LABYRINTH.EXPLANATION.DISCLAIMER_2', 'LABYRINTH.EXPLANATION.DISCLAIMER_3', 'LABYRINTH.EXPLANATION.DISCLAIMER_4'],
howItWorks: {intro: 'LABYRINTH.HOW_IT_WORKS.INTRO', items: ['LABYRINTH.HOW_IT_WORKS.ITEM_1', 'LABYRINTH.HOW_IT_WORKS.ITEM_2', 'LABYRINTH.HOW_IT_WORKS.ITEM_3', 'LABYRINTH.HOW_IT_WORKS.ITEM_4']},
sources: SOURCE_PATHS.LABYRINTH
}; };
@ViewChild(GenericGridComponent) genericGridComponent!: GenericGridComponent; @ViewChild(GenericGridComponent) genericGridComponent!: GenericGridComponent;
@@ -65,8 +68,15 @@ export class LabyrinthComponent implements AfterViewInit {
animationSpeed = 3; animationSpeed = 3;
mazeAnimationSpeed = 1; mazeAnimationSpeed = 1;
readonly pathLength = signal("0"); readonly pathLength = signal("0");
readonly visitedCount = signal(0);
readonly executionTime = signal(0); readonly executionTime = signal(0);
readonly metrics = computed<Metric[]>(() => [
{label: 'PATHFINDING.VISITED_NODES', value: this.visitedCount()},
{label: 'PATHFINDING.PATH_LENGTH', value: this.pathLength()},
{label: 'PATHFINDING.EXECUTION_TIME', value: this.executionTime(), unit: 'ms'}
]);
private readonly animator = new PathfindingAnimator( private readonly animator = new PathfindingAnimator(
node => this.genericGridComponent?.drawNode(node), node => this.genericGridComponent?.drawNode(node),
() => this.animationSpeed () => this.animationSpeed
@@ -116,6 +126,7 @@ export class LabyrinthComponent implements AfterViewInit {
const endTime = performance.now(); const endTime = performance.now();
this.pathLength.set(PathfindingAnimator.pathLengthLabel(result)); this.pathLength.set(PathfindingAnimator.pathLengthLabel(result));
this.visitedCount.set(result.visitedNodesInOrder.length);
this.executionTime.set(endTime - startTime); this.executionTime.set(endTime - startTime);
this.animator.animate(result); this.animator.animate(result);
@@ -60,17 +60,15 @@
<span><span class="legend-swatch bg-[gold]"></span> {{ 'PATHFINDING.PATH' | translate }}</span> <span><span class="legend-swatch bg-[gold]"></span> {{ 'PATHFINDING.PATH' | translate }}</span>
</div> </div>
@if (!compareMode()) { @if (!compareMode()) {
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]"> <app-metrics [metrics]="metrics()"/>
<p>{{ 'PATHFINDING.PATH_LENGTH' | translate }}: {{ pathLength() }}</p>
<p>{{ 'PATHFINDING.EXECUTION_TIME' | translate }}: {{ executionTime() | number:'1.2-2' }} ms</p>
</div>
} }
</div> </div>
<div class="flex flex-col lg:flex-row gap-4"> <div class="flex flex-col lg:flex-row gap-4">
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
@if (compareMode()) { @if (compareMode()) {
<p class="text-[0.9em] mb-2">A* · {{ 'PATHFINDING.VISITED_NODES' | translate }}: {{ astarStats()?.visitedCount }} · {{ 'PATHFINDING.PATH_LENGTH' | translate }}: {{ astarStats()?.pathLength }}</p> <p class="m-0 font-semibold">A*</p>
<app-metrics [metrics]="astarMetrics()"/>
} }
<app-generic-grid <app-generic-grid
#primaryGrid #primaryGrid
@@ -85,7 +83,8 @@
></app-generic-grid> ></app-generic-grid>
</div> </div>
<div class="flex-1 min-w-0" [class.hidden]="!compareMode()"> <div class="flex-1 min-w-0" [class.hidden]="!compareMode()">
<p class="text-[0.9em] mb-2">Dijkstra · {{ 'PATHFINDING.VISITED_NODES' | translate }}: {{ dijkstraStats()?.visitedCount }} · {{ 'PATHFINDING.PATH_LENGTH' | translate }}: {{ dijkstraStats()?.pathLength }}</p> <p class="m-0 font-semibold">Dijkstra</p>
<app-metrics [metrics]="dijkstraMetrics()"/>
<app-generic-grid <app-generic-grid
#compareGrid #compareGrid
[minGridSize]="MIN_GRID_SIZE" [minGridSize]="MIN_GRID_SIZE"
@@ -1,4 +1,4 @@
import {afterNextRender, AfterViewInit, Component, inject, Injector, signal, ViewChild} from '@angular/core'; import {afterNextRender, AfterViewInit, Component, computed, inject, Injector, signal, ViewChild} from '@angular/core';
import {CommonModule} from '@angular/common'; import {CommonModule} from '@angular/common';
import {FormsModule} from '@angular/forms'; import {FormsModule} from '@angular/forms';
@@ -18,7 +18,9 @@ import {UrlConstants} from '../../../constants/UrlConstants';
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card'; import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
import {Information} from '../information/information'; import {Information} from '../information/information';
import {AlgorithmInformation} from '../information/information.models'; import {AlgorithmInformation} from '../information/information.models';
import {SOURCE_PATHS} from '../information/source-paths';
import {GenericGridComponent, GridPos} from '../../../shared/components/generic-grid/generic-grid'; import {GenericGridComponent, GridPos} from '../../../shared/components/generic-grid/generic-grid';
import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics';
enum NodeType { enum NodeType {
Start = 'start', Start = 'start',
@@ -42,7 +44,8 @@ enum NodeType {
MatCardTitle, MatCardTitle,
MatCardContent, MatCardContent,
Information, Information,
GenericGridComponent GenericGridComponent,
MetricsComponent
], ],
templateUrl: './pathfinding.component.html', templateUrl: './pathfinding.component.html',
}) })
@@ -74,7 +77,9 @@ export class PathfindingComponent implements AfterViewInit {
], ],
disclaimer: 'PATHFINDING.EXPLANATION.DISCLAIMER', disclaimer: 'PATHFINDING.EXPLANATION.DISCLAIMER',
disclaimerBottom: '', disclaimerBottom: '',
disclaimerListEntry: [] disclaimerListEntry: [],
howItWorks: {intro: 'PATHFINDING.HOW_IT_WORKS.INTRO', items: ['PATHFINDING.HOW_IT_WORKS.ITEM_1', 'PATHFINDING.HOW_IT_WORKS.ITEM_2', 'PATHFINDING.HOW_IT_WORKS.ITEM_3', 'PATHFINDING.HOW_IT_WORKS.ITEM_4', 'PATHFINDING.HOW_IT_WORKS.ITEM_5']},
sources: SOURCE_PATHS.PATHFINDING
}; };
readonly gridRows = signal(DEFAULT_GRID_ROWS); readonly gridRows = signal(DEFAULT_GRID_ROWS);
@@ -88,13 +93,20 @@ export class PathfindingComponent implements AfterViewInit {
private shouldAddWall = true; // Moved here private shouldAddWall = true; // Moved here
animationSpeed = 3; animationSpeed = 3;
readonly pathLength = signal("0"); readonly runStats = signal<PathfindingRunStats | null>(null);
readonly executionTime = signal(0); readonly executionTime = signal(0);
readonly compareMode = signal(false); readonly compareMode = signal(false);
readonly astarStats = signal<PathfindingRunStats | null>(null); readonly astarStats = signal<PathfindingRunStats | null>(null);
readonly dijkstraStats = signal<PathfindingRunStats | null>(null); readonly dijkstraStats = signal<PathfindingRunStats | null>(null);
readonly metrics = computed<Metric[]>(() => [
...this.runMetrics(this.runStats()),
{label: 'PATHFINDING.EXECUTION_TIME', value: this.executionTime(), unit: 'ms'}
]);
readonly astarMetrics = computed(() => this.runMetrics(this.astarStats()));
readonly dijkstraMetrics = computed(() => this.runMetrics(this.dijkstraStats()));
private readonly animator = new PathfindingAnimator( private readonly animator = new PathfindingAnimator(
node => this.genericGridComponent?.drawNode(node), node => this.genericGridComponent?.drawNode(node),
() => this.animationSpeed () => this.animationSpeed
@@ -248,7 +260,7 @@ export class PathfindingComponent implements AfterViewInit {
const result = this.pathfindingService.findPath(algorithm, this.grid, this.startNode!, this.endNode!); const result = this.pathfindingService.findPath(algorithm, this.grid, this.startNode!, this.endNode!);
const endTime = performance.now(); const endTime = performance.now();
this.pathLength.set(PathfindingAnimator.pathLengthLabel(result)); this.runStats.set(this.toRunStats(result));
this.executionTime.set(endTime - startTime); this.executionTime.set(endTime - startTime);
this.animator.animate(result); this.animator.animate(result);
@@ -315,6 +327,13 @@ export class PathfindingComponent implements AfterViewInit {
this.compareAnimator.stop(); this.compareAnimator.stop();
} }
private runMetrics(stats: PathfindingRunStats | null): Metric[] {
return [
{label: 'PATHFINDING.VISITED_NODES', value: stats?.visitedCount ?? 0},
{label: 'PATHFINDING.PATH_LENGTH', value: stats?.pathLength ?? '0'}
];
}
private toRunStats(result: PathfindingResult): PathfindingRunStats { private toRunStats(result: PathfindingResult): PathfindingRunStats {
return {visitedCount: result.visitedNodesInOrder.length, pathLength: PathfindingAnimator.pathLengthLabel(result)}; return {visitedCount: result.visitedNodesInOrder.length, pathLength: PathfindingAnimator.pathLengthLabel(result)};
} }
@@ -62,6 +62,7 @@
<span><span class="legend-swatch bg-green-600"></span> M2</span> <span><span class="legend-swatch bg-green-600"></span> M2</span>
</div> </div>
</div> </div>
<app-metrics [metrics]="metrics()"/>
<app-babylon-canvas <app-babylon-canvas
[config]="renderConfig" [config]="renderConfig"
(sceneReady)="onSceneReady($event)" (sceneReady)="onSceneReady($event)"
@@ -1,4 +1,4 @@
import {afterNextRender, Component, signal} from '@angular/core'; import {afterNextRender, Component, computed, signal} from '@angular/core';
import {BabylonCanvas, RenderConfig, SceneEventData} from '../../../shared/components/render-canvas/babylon-canvas.component'; import {BabylonCanvas, RenderConfig, SceneEventData} from '../../../shared/components/render-canvas/babylon-canvas.component';
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card'; import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
@@ -9,10 +9,13 @@ import {TranslatePipe} from '@ngx-translate/core';
import {MatButton} from '@angular/material/button'; import {MatButton} from '@angular/material/button';
import {Information} from '../information/information'; import {Information} from '../information/information';
import {AlgorithmInformation} from '../information/information.models'; import {AlgorithmInformation} from '../information/information.models';
import {SOURCE_PATHS} from '../information/source-paths';
import {UrlConstants} from '../../../constants/UrlConstants'; import {UrlConstants} from '../../../constants/UrlConstants';
import {PendulumSimulationStrategy} from './strategies/pendulum-simulation.strategy'; import {PendulumSimulationStrategy} from './strategies/pendulum-simulation.strategy';
import {PendulumGpuStrategy} from './strategies/pendulum-gpu.strategy'; import {PendulumGpuStrategy} from './strategies/pendulum-gpu.strategy';
import {PendulumCpuStrategy} from './strategies/pendulum-cpu.strategy'; import {PendulumCpuStrategy} from './strategies/pendulum-cpu.strategy';
import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics';
import {FpsCounter} from '../../../shared/fps-counter';
@Component({ @Component({
selector: 'app-pendulum', selector: 'app-pendulum',
@@ -27,6 +30,7 @@ import {PendulumCpuStrategy} from './strategies/pendulum-cpu.strategy';
TranslatePipe, TranslatePipe,
MatButton, MatButton,
Information, Information,
MetricsComponent,
], ],
templateUrl: './pendulum.component.html', templateUrl: './pendulum.component.html',
styleUrl: './pendulum.component.scss', styleUrl: './pendulum.component.scss',
@@ -51,7 +55,9 @@ class PendulumComponent {
], ],
disclaimer: 'PENDULUM.EXPLANATION.DISCLAIMER', disclaimer: 'PENDULUM.EXPLANATION.DISCLAIMER',
disclaimerBottom: 'PENDULUM.EXPLANATION.DISCLAIMER_BOTTOM', disclaimerBottom: 'PENDULUM.EXPLANATION.DISCLAIMER_BOTTOM',
disclaimerListEntry: ['PENDULUM.EXPLANATION.DISCLAIMER_1', 'PENDULUM.EXPLANATION.DISCLAIMER_2', 'PENDULUM.EXPLANATION.DISCLAIMER_3', 'PENDULUM.EXPLANATION.DISCLAIMER_4'] disclaimerListEntry: ['PENDULUM.EXPLANATION.DISCLAIMER_1', 'PENDULUM.EXPLANATION.DISCLAIMER_2', 'PENDULUM.EXPLANATION.DISCLAIMER_3', 'PENDULUM.EXPLANATION.DISCLAIMER_4'],
howItWorks: {intro: 'PENDULUM.HOW_IT_WORKS.INTRO', items: ['PENDULUM.HOW_IT_WORKS.ITEM_1', 'PENDULUM.HOW_IT_WORKS.ITEM_2', 'PENDULUM.HOW_IT_WORKS.ITEM_3', 'PENDULUM.HOW_IT_WORKS.ITEM_4']},
sources: SOURCE_PATHS.PENDULUM
}; };
renderConfig: RenderConfig = { renderConfig: RenderConfig = {
@@ -124,6 +130,14 @@ class PendulumComponent {
impulseM2: 0.0, impulseM2: 0.0,
}; };
private readonly fpsCounter = new FpsCounter();
readonly backend = signal('–');
readonly metrics = computed<Metric[]>(() => [
{label: 'METRICS.FPS', value: this.fpsCounter.fps()},
{label: 'METRICS.BACKEND', value: this.backend()},
{label: 'METRICS.TIME_STEP', value: this.loop.dt, fractionDigits: 3}
]);
private currentSceneData: SceneEventData | null = null; private currentSceneData: SceneEventData | null = null;
private strategy: PendulumSimulationStrategy | null = null; private strategy: PendulumSimulationStrategy | null = null;
@@ -147,6 +161,7 @@ class PendulumComponent {
? new PendulumGpuStrategy() ? new PendulumGpuStrategy()
: new PendulumCpuStrategy(); : new PendulumCpuStrategy();
this.backend.set(gpuTier === 'webgpu' ? 'WebGPU (Compute)' : 'WebGL + CPU');
this.strategy.init(scene, engine); this.strategy.init(scene, engine);
this.startParamUpdateLoop(scene); this.startParamUpdateLoop(scene);
} }
@@ -154,6 +169,7 @@ class PendulumComponent {
private startParamUpdateLoop(scene: any): void { private startParamUpdateLoop(scene: any): void {
scene.onAfterRenderObservable.clear(); scene.onAfterRenderObservable.clear();
scene.onAfterRenderObservable.add(() => { scene.onAfterRenderObservable.add(() => {
this.fpsCounter.tick();
this.loop.time += this.loop.dt; this.loop.time += this.loop.dt;
if (this.strategy) { if (this.strategy) {
@@ -54,4 +54,37 @@ describe('SortingService', () => {
expect(input.map((item) => item.value)).toEqual(unsorted); expect(input.map((item) => item.value)).toEqual(unsorted);
}); });
} }
describe('counters', () => {
const last = (snapshots: SortSnapshot[]) => snapshots[snapshots.length - 1];
it('bubble sort counts every comparison and two writes per swap', () => {
const final = last(service.bubbleSort(toSortData([3, 2, 1])));
expect(final.comparisons).toBe(3);
expect(final.writes).toBe(6);
});
it('bubble sort writes nothing on sorted input', () => {
expect(last(service.bubbleSort(toSortData([1, 2, 3]))).writes).toBe(0);
});
it('counters restart with every run', () => {
service.bubbleSort(toSortData([3, 2, 1]));
expect(last(service.bubbleSort(toSortData([1, 2, 3]))).comparisons).toBe(3);
});
for (const algorithm of ['bubbleSort', 'cocktailSort', 'quickSort', 'heapSort', 'timSort'] as const) {
it(`${algorithm} counts comparisons and never lets a counter decrease`, () => {
const snapshots = service[algorithm](toSortData(unsorted));
expect(last(snapshots).comparisons).toBeGreaterThan(0);
for (let i = 1; i < snapshots.length; i++) {
expect(snapshots[i].comparisons).toBeGreaterThanOrEqual(snapshots[i - 1].comparisons);
expect(snapshots[i].writes).toBeGreaterThanOrEqual(snapshots[i - 1].writes);
}
});
}
});
}); });
@@ -6,16 +6,27 @@ import {SortData, SortSnapshot} from '../sorting.models';
}) })
export class SortingService { export class SortingService {
private comparisons = 0;
private writes = 0;
private startRun(array: SortData[]): SortData[] {
this.comparisons = 0;
this.writes = 0;
return array.map(item => ({ ...item }));
}
private createSnapshot(array: SortData[]): SortSnapshot { private createSnapshot(array: SortData[]): SortSnapshot {
return { return {
array: array.map(item => ({ ...item })) array: array.map(item => ({ ...item })),
comparisons: this.comparisons,
writes: this.writes
}; };
} }
// --- BUBBLE SORT --- // --- BUBBLE SORT ---
bubbleSort(array: SortData[]): SortSnapshot[] { bubbleSort(array: SortData[]): SortSnapshot[] {
const snapshots: SortSnapshot[] = []; const snapshots: SortSnapshot[] = [];
const arr = array.map(item => ({ ...item })); const arr = this.startRun(array);
const n = arr.length; const n = arr.length;
snapshots.push(this.createSnapshot(arr)); snapshots.push(this.createSnapshot(arr));
@@ -26,10 +37,9 @@ export class SortingService {
arr[j + 1].state = 'comparing'; arr[j + 1].state = 'comparing';
snapshots.push(this.createSnapshot(arr)); snapshots.push(this.createSnapshot(arr));
this.comparisons++;
if (arr[j].value > arr[j + 1].value) { if (arr[j].value > arr[j + 1].value) {
const temp = arr[j].value; this.swap(arr, j, j + 1);
arr[j].value = arr[j + 1].value;
arr[j + 1].value = temp;
snapshots.push(this.createSnapshot(arr)); snapshots.push(this.createSnapshot(arr));
} }
@@ -51,7 +61,7 @@ export class SortingService {
cocktailSort(array: SortData[]): SortSnapshot[] { cocktailSort(array: SortData[]): SortSnapshot[] {
const snapshots: SortSnapshot[] = []; const snapshots: SortSnapshot[] = [];
const arr = array.map(item => ({ ...item })); const arr = this.startRun(array);
snapshots.push(this.createSnapshot(arr)); snapshots.push(this.createSnapshot(arr));
let start = -1; let start = -1;
@@ -91,10 +101,9 @@ export class SortingService {
secondValue.state = 'comparing'; secondValue.state = 'comparing';
snapshots.push(this.createSnapshot(arr)); snapshots.push(this.createSnapshot(arr));
this.comparisons++;
if (firstValue.value > secondValue.value) { if (firstValue.value > secondValue.value) {
const temp = firstValue.value; this.swap(arr, i, i + 1);
firstValue.value = secondValue.value;
secondValue.value = temp;
changed = true; changed = true;
} }
firstValue.state = 'unsorted'; firstValue.state = 'unsorted';
@@ -106,7 +115,7 @@ export class SortingService {
// --- QUICK SORT --- // --- QUICK SORT ---
quickSort(array: SortData[]): SortSnapshot[] { quickSort(array: SortData[]): SortSnapshot[] {
const snapshots: SortSnapshot[] = []; const snapshots: SortSnapshot[] = [];
const arr = array.map(item => ({ ...item })); const arr = this.startRun(array);
snapshots.push(this.createSnapshot(arr)); snapshots.push(this.createSnapshot(arr));
this.quickSortHelper(arr, 0, arr.length - 1, snapshots); this.quickSortHelper(arr, 0, arr.length - 1, snapshots);
@@ -140,6 +149,7 @@ export class SortingService {
arr[j].state = 'comparing'; arr[j].state = 'comparing';
snapshots.push(this.createSnapshot(arr)); snapshots.push(this.createSnapshot(arr));
this.comparisons++;
if (arr[j].value < pivot.value) { if (arr[j].value < pivot.value) {
i++; i++;
this.swap(arr, i, j); this.swap(arr, i, j);
@@ -159,7 +169,7 @@ export class SortingService {
// --- HEAP SORT --- // --- HEAP SORT ---
heapSort(array: SortData[]): SortSnapshot[] { heapSort(array: SortData[]): SortSnapshot[] {
const snapshots: SortSnapshot[] = []; const snapshots: SortSnapshot[] = [];
const arr = array.map(item => ({ ...item })); const arr = this.startRun(array);
const n = arr.length; const n = arr.length;
snapshots.push(this.createSnapshot(arr)); snapshots.push(this.createSnapshot(arr));
@@ -197,6 +207,7 @@ export class SortingService {
arr[largest].state = 'comparing'; arr[largest].state = 'comparing';
snapshots.push(this.createSnapshot(arr)); snapshots.push(this.createSnapshot(arr));
this.comparisons++;
if (arr[l].value > arr[largest].value) { if (arr[l].value > arr[largest].value) {
largest = l; largest = l;
} }
@@ -209,6 +220,7 @@ export class SortingService {
arr[largest].state = 'comparing'; arr[largest].state = 'comparing';
snapshots.push(this.createSnapshot(arr)); snapshots.push(this.createSnapshot(arr));
this.comparisons++;
if (arr[r].value > arr[largest].value) { if (arr[r].value > arr[largest].value) {
largest = r; largest = r;
} }
@@ -227,7 +239,7 @@ export class SortingService {
// --- TIM SORT --- // --- TIM SORT ---
timSort(array: SortData[]): SortSnapshot[] { timSort(array: SortData[]): SortSnapshot[] {
const snapshots: SortSnapshot[] = []; const snapshots: SortSnapshot[] = [];
const arr = array.map(item => ({ ...item })); const arr = this.startRun(array);
const n = arr.length; const n = arr.length;
const RUN = 32; const RUN = 32;
@@ -263,15 +275,21 @@ export class SortingService {
snapshots.push(this.createSnapshot(arr)); snapshots.push(this.createSnapshot(arr));
let j = i - 1; let j = i - 1;
while (j >= left && arr[j].value > tempValue) { while (j >= left) {
this.comparisons++;
if (arr[j].value <= tempValue) {
break;
}
arr[j].state = 'comparing'; arr[j].state = 'comparing';
arr[j + 1].value = arr[j].value; arr[j + 1].value = arr[j].value;
this.writes++;
arr[j].state = 'unsorted'; arr[j].state = 'unsorted';
snapshots.push(this.createSnapshot(arr)); snapshots.push(this.createSnapshot(arr));
j--; j--;
} }
arr[j + 1].value = tempValue; arr[j + 1].value = tempValue;
this.writes++;
arr[i].state = 'unsorted'; arr[i].state = 'unsorted';
snapshots.push(this.createSnapshot(arr)); snapshots.push(this.createSnapshot(arr));
} }
@@ -295,6 +313,7 @@ export class SortingService {
arr[rightSourceIdx].state = 'unsorted'; arr[rightSourceIdx].state = 'unsorted';
this.comparisons++;
if (leftPart[i].value <= rightPart[j].value) { if (leftPart[i].value <= rightPart[j].value) {
arr[k].value = leftPart[i].value; arr[k].value = leftPart[i].value;
i++; i++;
@@ -302,6 +321,7 @@ export class SortingService {
arr[k].value = rightPart[j].value; arr[k].value = rightPart[j].value;
j++; j++;
} }
this.writes++;
arr[k].state = 'unsorted'; arr[k].state = 'unsorted';
k++; k++;
@@ -310,12 +330,14 @@ export class SortingService {
while (i < leftPart.length) { while (i < leftPart.length) {
arr[k].value = leftPart[i].value; arr[k].value = leftPart[i].value;
this.writes++;
i++; i++;
k++; k++;
} }
while (j < rightPart.length) { while (j < rightPart.length) {
arr[k].value = rightPart[j].value; arr[k].value = rightPart[j].value;
this.writes++;
j++; j++;
k++; k++;
} }
@@ -325,5 +347,6 @@ export class SortingService {
const temp = arr[i].value; const temp = arr[i].value;
arr[i].value = arr[j].value; arr[i].value = arr[j].value;
arr[j].value = temp; arr[j].value = temp;
this.writes += 2;
} }
} }
@@ -43,9 +43,7 @@
</button> </button>
</div> </div>
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]"> <app-metrics [metrics]="metrics()"/>
<p>{{ 'SORTING.EXECUTION_TIME' | translate }}: {{ executionTime() }} ms</p>
</div>
<div class="flex items-end h-[clamp(200px,40vh,400px)] border-b border-app-fg mb-[clamp(10px,3vw,20px)] gap-px bg-card-bg"> <div class="flex items-end h-[clamp(200px,40vh,400px)] border-b border-app-fg mb-[clamp(10px,3vw,20px)] gap-px bg-card-bg">
@for (item of sortArray(); track $index) { @for (item of sortArray(); track $index) {
<div <div
@@ -68,4 +68,19 @@ describe('SortingComponent', () => {
expect(heights()).toEqual(initial); expect(heights()).toEqual(initial);
expect(bars().some(bar => bar.nativeElement.classList.contains('bg-[#ffeb3b]'))).toBe(false); expect(bars().some(bar => bar.nativeElement.classList.contains('bg-[#ffeb3b]'))).toBe(false);
}); });
it('shows the counters of the last snapshot and zeroes them on reset', () => {
const component = fixture.componentInstance;
component.startSorting();
vi.runAllTimers();
expect(component.comparisons()).toBeGreaterThan(0);
expect(component.writes()).toBeGreaterThan(0);
component.resetSorting();
expect(component.comparisons()).toBe(0);
expect(component.writes()).toBe(0);
});
}); });
@@ -1,4 +1,4 @@
import {Component, inject, OnInit, signal} from '@angular/core'; import {Component, computed, inject, OnInit, signal} from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import {MatCardModule} from "@angular/material/card"; import {MatCardModule} from "@angular/material/card";
import {MatFormFieldModule} from "@angular/material/form-field"; import {MatFormFieldModule} from "@angular/material/form-field";
@@ -13,10 +13,12 @@ import { FormsModule } from '@angular/forms';
import {MatInput} from '@angular/material/input'; import {MatInput} from '@angular/material/input';
import {UrlConstants} from '../../../constants/UrlConstants'; import {UrlConstants} from '../../../constants/UrlConstants';
import {AlgorithmInformation} from '../information/information.models'; import {AlgorithmInformation} from '../information/information.models';
import {SOURCE_PATHS} from '../information/source-paths';
import {Information} from '../information/information'; import {Information} from '../information/information';
import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics';
@Component({ @Component({
selector: 'app-sorting', selector: 'app-sorting',
imports: [CommonModule, MatCardModule, MatFormFieldModule, MatSelectModule, MatButtonModule, MatIconModule, TranslatePipe, FormsModule, MatInput, Information], imports: [CommonModule, MatCardModule, MatFormFieldModule, MatSelectModule, MatButtonModule, MatIconModule, TranslatePipe, FormsModule, MatInput, Information, MetricsComponent],
templateUrl: './sorting.component.html', templateUrl: './sorting.component.html',
styleUrl: './sorting.component.scss' styleUrl: './sorting.component.scss'
}) })
@@ -63,7 +65,9 @@ export class SortingComponent implements OnInit {
'SORTING.EXPLANATION.DISCLAIMER_1', 'SORTING.EXPLANATION.DISCLAIMER_1',
'SORTING.EXPLANATION.DISCLAIMER_2', 'SORTING.EXPLANATION.DISCLAIMER_2',
'SORTING.EXPLANATION.DISCLAIMER_3' 'SORTING.EXPLANATION.DISCLAIMER_3'
] ],
howItWorks: {intro: 'SORTING.HOW_IT_WORKS.INTRO', items: ['SORTING.HOW_IT_WORKS.ITEM_1', 'SORTING.HOW_IT_WORKS.ITEM_2', 'SORTING.HOW_IT_WORKS.ITEM_3', 'SORTING.HOW_IT_WORKS.ITEM_4', 'SORTING.HOW_IT_WORKS.ITEM_5']},
sources: SOURCE_PATHS.SORTING
}; };
private timeoutIds: number[] = []; private timeoutIds: number[] = [];
@@ -74,6 +78,13 @@ export class SortingComponent implements OnInit {
animationSpeed = 100; // Milliseconds per step animationSpeed = 100; // Milliseconds per step
readonly selectedAlgorithm = signal(this.algoInformation.entries[0].name); readonly selectedAlgorithm = signal(this.algoInformation.entries[0].name);
readonly executionTime = signal(0); readonly executionTime = signal(0);
readonly comparisons = signal(0);
readonly writes = signal(0);
readonly metrics = computed<Metric[]>(() => [
{label: 'METRICS.COMPARISONS', value: this.comparisons()},
{label: 'METRICS.WRITES', value: this.writes()},
{label: 'SORTING.EXECUTION_TIME', value: this.executionTime(), unit: 'ms'}
]);
readonly isSoundEnabled = signal(false); readonly isSoundEnabled = signal(false);
ngOnInit(): void { ngOnInit(): void {
@@ -151,6 +162,8 @@ export class SortingComponent implements OnInit {
snapshots.forEach((snapshot, index) => { snapshots.forEach((snapshot, index) => {
const id = setTimeout(() => { const id = setTimeout(() => {
this.sortArray.set(snapshot.array); this.sortArray.set(snapshot.array);
this.comparisons.set(snapshot.comparisons);
this.writes.set(snapshot.writes);
if (this.isSoundEnabled()) { if (this.isSoundEnabled()) {
// Play a tone for each comparing bar (max 2 at once to avoid noise) // Play a tone for each comparing bar (max 2 at once to avoid noise)
@@ -180,5 +193,7 @@ export class SortingComponent implements OnInit {
resetSorting(): void { resetSorting(): void {
this.stopAnimations(); this.stopAnimations();
this.resetSortState(); this.resetSortState();
this.comparisons.set(0);
this.writes.set(0);
} }
} }
@@ -5,4 +5,6 @@ export interface SortData {
export interface SortSnapshot { export interface SortSnapshot {
array: SortData[]; array: SortData[];
comparisons: number;
writes: number;
} }
@@ -0,0 +1,5 @@
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
@for (metric of metrics(); track metric.label) {
<span>{{ metric.label | translate }}: <strong>{{ format(metric) }}</strong>@if (metric.unit) { {{ metric.unit }}}</span>
}
</div>
@@ -0,0 +1,46 @@
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {provideZonelessChangeDetection} from '@angular/core';
import {provideTranslateService} from '@ngx-translate/core';
import {LanguageService} from '../../../service/language.service';
import {Metric, MetricsComponent} from './metrics';
describe('MetricsComponent', () => {
let fixture: ComponentFixture<MetricsComponent>;
function render(metrics: Metric[]): string {
fixture.componentRef.setInput('metrics', metrics);
fixture.detectChanges();
return fixture.nativeElement.textContent.replace(/\s+/g, ' ').trim();
}
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideZonelessChangeDetection(), provideTranslateService({lang: 'en', fallbackLang: 'en'})]
});
fixture = TestBed.createComponent(MetricsComponent);
});
it('renders label, value and unit', () => {
expect(render([{label: 'METRICS.FPS', value: 60, unit: 'ms'}])).toBe('METRICS.FPS: 60 ms');
});
it('formats thousands per language', () => {
const language = TestBed.inject(LanguageService);
language.lang.set('en');
expect(render([{label: 'A', value: 39402}])).toContain('39,402');
language.lang.set('de');
expect(render([{label: 'A', value: 39402}])).toContain('39.402');
});
it('formats small decimals per language with the requested fraction digits', () => {
TestBed.inject(LanguageService).lang.set('de');
expect(render([{label: 'A', value: 0.015, fractionDigits: 3}])).toContain('0,015');
});
it('shows text values as they are', () => {
expect(render([{label: 'A', value: 'WebGPU'}])).toContain('WebGPU');
});
});
@@ -0,0 +1,28 @@
import {Component, inject, input} from '@angular/core';
import {TranslatePipe} from '@ngx-translate/core';
import {LanguageService} from '../../../service/language.service';
export interface Metric {
/** i18n key */
label: string;
value: number | string;
unit?: string;
/** Maximum fraction digits for numbers, default 2. */
fractionDigits?: number;
}
@Component({
selector: 'app-metrics',
imports: [TranslatePipe],
templateUrl: './metrics.html'
})
export class MetricsComponent {
private readonly language = inject(LanguageService);
readonly metrics = input.required<Metric[]>();
protected format(metric: Metric): string {
const {value, fractionDigits = 2} = metric;
return typeof value === 'number' ? value.toLocaleString(this.language.lang(), {maximumFractionDigits: fractionDigits}) : value;
}
}
+30
View File
@@ -0,0 +1,30 @@
import {FpsCounter} from './fps-counter';
describe('FpsCounter', () => {
const FRAME_MS = 1000 / 60;
it('publishes 60 after the first 500 ms window and not before', () => {
const counter = new FpsCounter();
const at = (frame: number) => 1 + FRAME_MS * frame;
for (let frame = 0; at(frame) - 1 < 500; frame++) {
counter.tick(at(frame));
}
expect(counter.fps()).toBe(0);
counter.tick(at(30)); // 30 ticks since the window opened, 500 ms elapsed
expect(counter.fps()).toBe(60);
});
it('reset sets the rate back to 0', () => {
const counter = new FpsCounter();
for (let i = 0; i <= 61; i++) {
counter.tick(1 + FRAME_MS * i);
}
expect(counter.fps()).toBeGreaterThan(0);
counter.reset();
expect(counter.fps()).toBe(0);
});
});
+30
View File
@@ -0,0 +1,30 @@
import {signal} from '@angular/core';
/** Counts ticks and publishes a rate a few times per second, so the display never costs frame time. */
export class FpsCounter {
readonly fps = signal(0);
private ticks = 0;
private windowStart = 0;
constructor(private readonly intervalMs = 500) {}
tick(now: number = performance.now()): void {
if (this.windowStart === 0) {
this.windowStart = now;
return;
}
this.ticks++;
const elapsed = now - this.windowStart;
if (elapsed >= this.intervalMs) {
this.fps.set(Math.round(this.ticks * 1000 / elapsed));
this.ticks = 0;
this.windowStart = now;
}
}
reset(): void {
this.ticks = 0;
this.windowStart = 0;
this.fps.set(0);
}
}
+110 -10
View File
@@ -499,6 +499,14 @@
}, },
"ALERT": { "ALERT": {
"START_END_NODES": "Bitte wählen Sie einen Start- und Endknoten aus, bevor Sie den Algorithmus starten." "START_END_NODES": "Bitte wählen Sie einen Start- und Endknoten aus, bevor Sie den Algorithmus starten."
},
"HOW_IT_WORKS": {
"INTRO": "Das Gitter ist ein zweidimensionales Array aus Knoten. Jeder Knoten speichert seine Distanz vom Start, seinen Vorgänger und für A* die Heuristik h und die Summe f = g + h. Beide Algorithmen laufen vorab komplett durch und liefern die Liste der besuchten Knoten und den Pfad. Die Animation spielt diese Liste danach Knoten für Knoten mit setTimeout ab.",
"ITEM_1": "Dijkstra: alle Knoten liegen in einer Liste. Dijkstra sortiert in jedem Schritt die ganze Liste nach Distanz (O(n² log n)) und entnimmt den nächsten Knoten. Ein Heap wäre schneller; bei höchstens 22.500 Knoten reicht es hier. Hat der nächste Knoten die Distanz unendlich, ist das Ziel nicht erreichbar.",
"ITEM_2": "A*: gleiche Struktur, aber nur die offene Liste wird sortiert, nach f = g + h; bei gleichem f gewinnt das kleinere h. Heuristik ist die Manhattan-Distanz. Sie ist zulässig, weil jeder Schritt 1 kostet und es nur vier Richtungen gibt. Darum bleibt der Pfad optimal.",
"ITEM_3": "Der Pfad entsteht am Ende aus der Vorgänger-Kette, vom Ziel zurück zum Start.",
"ITEM_4": "Vergleich: Start, Ziel und Wände werden in ein zweites Gitter kopiert. A* läuft auf dem ersten, Dijkstra auf dem zweiten, beide Animationen laufen gleichzeitig. Die Zahl der besuchten Knoten zeigt den Unterschied.",
"ITEM_5": "Rechenzeit ist die Zeit der Suche ohne Animation. Speicher: ein Knotenobjekt pro Zelle, maximal 150 × 150 = 22.500 Knoten."
} }
}, },
"SORTING": { "SORTING": {
@@ -523,6 +531,14 @@
"DISCLAIMER_2": "Average Case: Der statistische Normalfall.", "DISCLAIMER_2": "Average Case: Der statistische Normalfall.",
"DISCLAIMER_3": "Worst Case: Die Daten sind maximal ungünstig angeordnet (hier bricht Quick Sort ohne Optimierung ein, während Heap Sort stabil bleibt).", "DISCLAIMER_3": "Worst Case: Die Daten sind maximal ungünstig angeordnet (hier bricht Quick Sort ohne Optimierung ein, während Heap Sort stabil bleibt).",
"DISCLAIMER_4": "Zusätzlich gibt es fast immer einen Time-Space Trade-off (Zeit-Speicher-Kompromiss): Algorithmen, die extrem schnell sind (wie Merge Sort), benötigen oft viel zusätzlichen Arbeitsspeicher. Algorithmen, die direkt im vorhandenen Speicher arbeiten (wie Heap Sort), sparen Platz, sind aber manchmal komplexer oder minimal langsamer. Es gibt also keine \"One-Size-Fits-All\"-Lösung." "DISCLAIMER_4": "Zusätzlich gibt es fast immer einen Time-Space Trade-off (Zeit-Speicher-Kompromiss): Algorithmen, die extrem schnell sind (wie Merge Sort), benötigen oft viel zusätzlichen Arbeitsspeicher. Algorithmen, die direkt im vorhandenen Speicher arbeiten (wie Heap Sort), sparen Platz, sind aber manchmal komplexer oder minimal langsamer. Es gibt also keine \"One-Size-Fits-All\"-Lösung."
},
"HOW_IT_WORKS": {
"INTRO": "Jeder Algorithmus läuft zuerst komplett durch und legt bei jedem Vergleich und jedem Tausch eine Kopie des Arrays (Snapshot) in eine Liste. Die Animation spielt die Snapshots danach mit 100 ms pro Schritt ab. Die Rechenzeit ist die Zeit für den Durchlauf samt Kopien, nicht die Animation. Vergleiche und Schreibzugriffe zählen während der Animation hoch.",
"ITEM_1": "Bubble Sort und Cocktail Sort: O(n²) Vergleiche, in-place. Cocktail Sort wechselt pro Durchlauf die Richtung und stoppt, sobald ein Durchlauf nichts mehr tauscht.",
"ITEM_2": "Quick Sort: Lomuto-Partition mit dem letzten Element als Pivot, rekursiv. Durchschnitt O(n log n), schlechtester Fall O(n²), zum Beispiel bei schon sortierten Daten.",
"ITEM_3": "Heap Sort: baut einen Max-Heap im Array auf (heapify von der Mitte nach vorn) und tauscht die Wurzel ans Ende. Immer O(n log n), kein Zusatzspeicher.",
"ITEM_4": "Tim Sort (vereinfacht): Insertion Sort auf festen Blöcken von 32 Elementen, dann Bottom-up-Merge mit Hilfsarrays. Ohne Run-Erkennung und Galloping wie im Original. Bei 200 Balken sind das 7 Blöcke und 3 Merge-Ebenen.",
"ITEM_5": "Speicher: die Snapshot-Liste ist der größte Posten, O(Schritte · n). Bubble Sort mit 200 Balken erzeugt bis zu etwa 40.000 Snapshots mit je 200 Einträgen."
} }
}, },
"GOL": { "GOL": {
@@ -545,6 +561,13 @@
"DISCLAIMER_2": "Eine tote Zelle „wird geboren“ (lebt in der Folgegeneration), wenn sie genau drei lebende Nachbarn hat.", "DISCLAIMER_2": "Eine tote Zelle „wird geboren“ (lebt in der Folgegeneration), wenn sie genau drei lebende Nachbarn hat.",
"DISCLAIMER_3": "Eine lebende Zelle „stirbt“ (ist in der Folgegeneration tot), wenn sie weniger als zwei (Vereinsamung) oder mehr als drei (Übervölkerung) lebende Nachbarn hat.", "DISCLAIMER_3": "Eine lebende Zelle „stirbt“ (ist in der Folgegeneration tot), wenn sie weniger als zwei (Vereinsamung) oder mehr als drei (Übervölkerung) lebende Nachbarn hat.",
"DISCLAIMER_4": " Eine tote Zelle bleibt tot, wenn sie nicht genau drei lebende Nachbarn hat." "DISCLAIMER_4": " Eine tote Zelle bleibt tot, wenn sie nicht genau drei lebende Nachbarn hat."
},
"HOW_IT_WORKS": {
"INTRO": "Das Spielfeld sind zwei Gitter aus Zellen mit einem alive-Flag: ein Lese- und ein Schreibgitter (Double Buffering). Jede Generation liest nur aus dem einen und schreibt nur in das andere, danach werden beide getauscht. So beeinflusst eine schon geänderte Zelle ihre Nachbarn nicht in derselben Generation.",
"ITEM_1": "Pro Zelle werden die acht Nachbarn gezählt (Moore-Nachbarschaft). Der Rand wird abgeschnitten, nicht umgebrochen: Randzellen haben weniger Nachbarn, das Feld ist kein Torus.",
"ITEM_2": "Laufzeit pro Generation O(Zeilen · Spalten · 8). Bei 200 × 200 sind das 40.000 Zellen und 320.000 Nachbar-Prüfungen.",
"ITEM_3": "Die Schleife ist eine async-Schleife: rechnen, zeichnen, dann warten, bis die eingestellte Zeit pro Generation voll ist. Ändert sich keine Zelle mehr, stoppt das Spiel von selbst.",
"ITEM_4": "Gezeichnet wird das ganze Gitter auf ein Canvas: eine Füll-Operation für den Hintergrund plus eine pro lebender Zelle."
} }
}, },
"LABYRINTH": { "LABYRINTH": {
@@ -560,6 +583,13 @@
"DISCLAIMER_2": "Erreichbarkeit: Da es ein Spannbaum ist, wird garantiert jede Zelle des Gitters Teil des Labyrinths, es gibt keine isolierten Bereiche.", "DISCLAIMER_2": "Erreichbarkeit: Da es ein Spannbaum ist, wird garantiert jede Zelle des Gitters Teil des Labyrinths, es gibt keine isolierten Bereiche.",
"DISCLAIMER_3": "Zufälligkeit: Durch die Gewichtung der Kanten mit Zufallswerten entstehen bei jedem Durchlauf völlig neue, einzigartige Strukturen.", "DISCLAIMER_3": "Zufälligkeit: Durch die Gewichtung der Kanten mit Zufallswerten entstehen bei jedem Durchlauf völlig neue, einzigartige Strukturen.",
"DISCLAIMER_4": "Anwendung: Solche Labyrinthe sind die perfekte Testumgebung für Pfadfindungsalgorithmen wie Dijkstra oder A*." "DISCLAIMER_4": "Anwendung: Solche Labyrinthe sind die perfekte Testumgebung für Pfadfindungsalgorithmen wie Dijkstra oder A*."
},
"HOW_IT_WORKS": {
"INTRO": "Das Gitter hat 101 × 101 Zellen. Zellen mit geraden Koordinaten sind Räume (51 × 51 = 2.601), die Zellen dazwischen sind Wände. Beide Algorithmen berechnen zuerst die Reihenfolge, in der Wände geöffnet werden; die Animation öffnet sie danach eine nach der anderen. Das Ergebnis ist ein Spannbaum: jeder Raum ist erreichbar, und zwischen zwei Räumen gibt es genau einen Weg.",
"ITEM_1": "Prim (randomisiert): startet in einem zufälligen Raum und hält eine Frontier-Liste mit Wänden zu noch nicht besuchten Nachbarräumen. Pro Schritt wird eine zufällige Wand gewählt (Tausch mit dem letzten Element, dann pop: O(1)) und geöffnet, wenn der Raum dahinter noch unbesucht ist. Statt Kantengewichten entscheidet der Zufall.",
"ITEM_2": "Kruskal (randomisiert): alle 5.100 Wände zwischen zwei Räumen werden gesammelt und mit Fisher-Yates gemischt. Jeder Raum trägt eine Mengen-ID. Eine Wand wird geöffnet, wenn die Räume verschiedene IDs haben; danach bekommen alle Räume der einen Menge die ID der anderen. Das ist ein Durchlauf über das Gitter pro Vereinigung, einfacher als Union-Find und bei 2.601 Räumen schnell genug.",
"ITEM_3": "Start und Ziel liegen in zwei zufälligen Ecken. Dijkstra und A* laufen danach auf demselben Gitter wie auf der Wegfindungs-Seite.",
"ITEM_4": "Speicher: ein Knotenobjekt pro Zelle (10.201) plus die Liste der zu öffnenden Zellen."
} }
}, },
"FRACTAL": { "FRACTAL": {
@@ -571,7 +601,7 @@
"EXPLANATION": { "EXPLANATION": {
"TITLE": "Mathematische Kunst", "TITLE": "Mathematische Kunst",
"MANDELBROT_EXPLANATION": "basiert auf der iterativen Formel 'z_{n+1} = z_n^2 + c'. Sie prüft für jeden Punkt in der komplexen Ebene, ob die Zahlenfolge stabil bleibt oder ins Unendliche entkommt. Vorteil: Gilt als 'Apfelmännchen' und Mutter der Fraktale. Sie bietet eine unendliche Vielfalt an selbstähnlichen Strukturen, in die man ewig hineinzoomen kann.", "MANDELBROT_EXPLANATION": "basiert auf der iterativen Formel 'z_{n+1} = z_n^2 + c'. Sie prüft für jeden Punkt in der komplexen Ebene, ob die Zahlenfolge stabil bleibt oder ins Unendliche entkommt. Vorteil: Gilt als 'Apfelmännchen' und Mutter der Fraktale. Sie bietet eine unendliche Vielfalt an selbstähnlichen Strukturen, in die man ewig hineinzoomen kann.",
"JULIA_EXPLANATION": "nutzt dieselbe Formel wie Mandelbrot, fixiert jedoch den Parameter 'c' und variiert den Startwert. Je nach Wahl von 'c' entstehen filigrane, wolkenartige Gebilde oder zusammenhanglose 'Staubwolken'. Vorteil: Ermöglicht eine enorme ästiehetische Varianz, da jede Koordinate der Mandelbrot-Menge ein völlig eigenes, einzigartiges Julia-Fraktal erzeugt.", "JULIA_EXPLANATION": "nutzt dieselbe Formel wie Mandelbrot, fixiert jedoch den Parameter 'c' und variiert den Startwert. Je nach Wahl von 'c' entstehen filigrane, wolkenartige Gebilde oder zusammenhanglose 'Staubwolken'. Vorteil: Ermöglicht eine enorme ästhetische Varianz, da jede Koordinate der Mandelbrot-Menge ein völlig eigenes, einzigartiges Julia-Fraktal erzeugt.",
"NEWTON_EXPLANATION": "entsteht durch die Visualisierung des Newton-Verfahrens zur Nullstellen-Suche einer komplexen Funktion. Jeder Pixel wird danach eingefärbt, zu welcher Nullstelle der Algorithmus konvergiert. Vorteil: Erzeugt faszinierende, sternförmige Symmetrien und komplexe Grenzen, an denen sich die Einzugsgebiete der Nullstellen auf chaotische Weise treffen.", "NEWTON_EXPLANATION": "entsteht durch die Visualisierung des Newton-Verfahrens zur Nullstellen-Suche einer komplexen Funktion. Jeder Pixel wird danach eingefärbt, zu welcher Nullstelle der Algorithmus konvergiert. Vorteil: Erzeugt faszinierende, sternförmige Symmetrien und komplexe Grenzen, an denen sich die Einzugsgebiete der Nullstellen auf chaotische Weise treffen.",
"BURNING_SHIP_EXPLANATION": "ist eine variation des Mandelbrots, bei der vor jedem Iterationsschritt der Absolutbetrag der Real- und Imaginärteile genommen wird: '(|Re(z)| + i|Im(z)|)^2 + c'. Vorteil: Erzeugt eine markante, asymmetrische Struktur, die einem brennenden Schiff mit Segeln ähnelt. Das Fraktal wirkt düsterer und 'mechanischer' als die klassischen Mengen.", "BURNING_SHIP_EXPLANATION": "ist eine variation des Mandelbrots, bei der vor jedem Iterationsschritt der Absolutbetrag der Real- und Imaginärteile genommen wird: '(|Re(z)| + i|Im(z)|)^2 + c'. Vorteil: Erzeugt eine markante, asymmetrische Struktur, die einem brennenden Schiff mit Segeln ähnelt. Das Fraktal wirkt düsterer und 'mechanischer' als die klassischen Mengen.",
"DISCLAIMER": "Alle diese Fraktale basieren auf dem Prinzip der Iteration und dem Chaos-Effekt. Das bedeutet für deine Visualisierung:", "DISCLAIMER": "Alle diese Fraktale basieren auf dem Prinzip der Iteration und dem Chaos-Effekt. Das bedeutet für deine Visualisierung:",
@@ -580,6 +610,14 @@
"DISCLAIMER_3": "Komplexe Zahlen: Die Berechnung findet nicht in einem normalen Koordinatensystem statt, sondern in der komplexen Ebene mit realen und imaginären Anteilen.", "DISCLAIMER_3": "Komplexe Zahlen: Die Berechnung findet nicht in einem normalen Koordinatensystem statt, sondern in der komplexen Ebene mit realen und imaginären Anteilen.",
"DISCLAIMER_4": "Rechenintensität: Da für jeden Pixel hunderte Berechnungen durchgeführt werden, sind Fraktale ein klassischer Benchmark für die Performance von Grafikprozessoren (GPUs).", "DISCLAIMER_4": "Rechenintensität: Da für jeden Pixel hunderte Berechnungen durchgeführt werden, sind Fraktale ein klassischer Benchmark für die Performance von Grafikprozessoren (GPUs).",
"DISCLAIMER_BOTTOM": "Grafikkarten rechnen standardmäßig mit 32-Bit Fließkommazahlen (float). Diese haben nur etwa 7 Stellen Genauigkeit. Bei sehr hohem Zoom (> 100.000) ist der Unterschied zwischen zwei Pixeln so winzig, dass die Grafikkarte ihn nicht mehr darstellen kann. Sie berechnet für 10 Pixel nebeneinander exakt denselben Wert -> Du siehst Blöcke oder Treppenstufen." "DISCLAIMER_BOTTOM": "Grafikkarten rechnen standardmäßig mit 32-Bit Fließkommazahlen (float). Diese haben nur etwa 7 Stellen Genauigkeit. Bei sehr hohem Zoom (> 100.000) ist der Unterschied zwischen zwei Pixeln so winzig, dass die Grafikkarte ihn nicht mehr darstellen kann. Sie berechnet für 10 Pixel nebeneinander exakt denselben Wert -> Du siehst Blöcke oder Treppenstufen."
},
"HOW_IT_WORKS": {
"INTRO": "Das Bild ist ein Fragment-Shader (GLSL) auf einem bildschirmfüllenden Rechteck in Babylon.js. Jeder Pixel rechnet unabhängig seine Folge: für Mandelbrot, Julia und Burning Ship z → z² + c, bis |z|² > 4 oder die maximale Iterationszahl erreicht ist (Fluchtzeit-Algorithmus). Newton iteriert z → z − (z³ − 1) / (3z²) und färbt nach der Nullstelle, gegen die der Pixel konvergiert.",
"ITEM_1": "Die CPU schickt pro Frame nur Uniforms: Mittelpunkt, Zoom, maximale Iterationen, Algorithmus, Farbschema und die Julia-Konstante. Die GPU rechnet alle Pixel parallel; Aufwand O(Pixel · Iterationen).",
"ITEM_2": "Die maximale Iterationszahl wächst mit dem Zoom: 100 + 200 · log10(Zoom), also bis etwa 1.060 beim maximalen Zoom 64.000.",
"ITEM_3": "Zoom per Mausrad hält den Punkt unter dem Cursor fest: sein Weltpunkt wird vor und nach dem Zoom gleich gesetzt und der Mittelpunkt entsprechend verschoben.",
"ITEM_4": "Die Farbe kommt aus dem Verhältnis Iterationen / Maximum; Punkte, die nie entkommen, bleiben schwarz.",
"ITEM_5": "Unter WebGPU übersetzt Babylon.js denselben GLSL-Shader nach WGSL; es gibt keinen eigenen Compute-Pfad."
} }
}, },
"FRACTAL3D": { "FRACTAL3D": {
@@ -597,7 +635,15 @@
"DISCLAIMER_1": "Keine Polygone: Es gibt keine Dreiecke oder Gitter. Die Form wird rein mathematisch für jeden Pixel in Echtzeit berechnet.", "DISCLAIMER_1": "Keine Polygone: Es gibt keine Dreiecke oder Gitter. Die Form wird rein mathematisch für jeden Pixel in Echtzeit berechnet.",
"DISCLAIMER_2": "Distance Estimation: Der Algorithmus 'tastet' sich mit Lichtstrahlen voran, indem er berechnet, wie weit das nächste Objekt entfernt ist, ohne es sofort zu berühren.", "DISCLAIMER_2": "Distance Estimation: Der Algorithmus 'tastet' sich mit Lichtstrahlen voran, indem er berechnet, wie weit das nächste Objekt entfernt ist, ohne es sofort zu berühren.",
"DISCLAIMER_3": "Unendliche Details: Da die Oberfläche mathematisch definiert ist, verpixelt sie nicht beim Zoom – es erscheinen immer neue Strukturen.", "DISCLAIMER_3": "Unendliche Details: Da die Oberfläche mathematisch definiert ist, verpixelt sie nicht beim Zoom – es erscheinen immer neue Strukturen.",
"DISCLAIMER_4": "Licht & Schatten: Um die Tiefe sichtbar zu machen, werden Lichtreflexionen und Schatten (Ambient Occlusion) basierend auf der Krümmung der Formel simuliert." "DISCLAIMER_4": "Licht: Die Tiefe entsteht aus der Oberflächennormale: Flächen, die zur Kamera zeigen, sind heller, plus ein fester Anteil Umgebungslicht. Schatten und Ambient Occlusion werden nicht berechnet."
},
"HOW_IT_WORKS": {
"INTRO": "Es gibt keine Geometrie. Ein Fragment-Shader schickt pro Pixel einen Strahl von der Kamera in die Szene und tastet ihn mit Sphere Tracing (Raymarching) ab: ein Distance Estimator sagt, wie weit die Oberfläche mindestens entfernt ist, und der Strahl springt um diesen Betrag (mal 0,8 als Sicherheitsfaktor) weiter, maximal 128 Schritte. Vorher begrenzt ein Kugeltest (Radius 6) den Strahl auf den relevanten Bereich.",
"ITEM_1": "Mandelbulb: 8 Iterationen in Kugelkoordinaten mit Potenz 8 (z → z⁸ + c). Die Distanz ist 0,5 · ln(r) · r / dr, wobei dr die mitgeführte Ableitung ist.",
"ITEM_2": "Mandelbox: 15 Iterationen aus Box-Fold (Spiegeln an ±1), Sphere-Fold (Skalieren nach Radius) und Skalierung 2,8.",
"ITEM_3": "Julia-Bulb: wie Mandelbulb, aber mit fester Konstante c, die sich mit der Zeit leicht bewegt. Darum bewegt sich die Form.",
"ITEM_4": "Normalen kommen aus vier zusätzlichen Distanzabfragen. Beleuchtung: Licht aus Kamerarichtung plus 40 % Umgebungslicht, keine Schatten, kein Ambient Occlusion. Farbe: Orbit Trap (kleinster Radius während der Iteration) auf eine Cosinus-Palette.",
"ITEM_5": "Aufwand pro Frame: Pixel × Strahlschritte × Iterationen. Bei Full HD und Mandelbox im schlimmsten Fall rund 4 Milliarden Iterationsschritte. Das läuft als Fragment-Shader nur auf der GPU; einen CPU-Pfad gibt es nicht."
} }
}, },
"PENDULUM": { "PENDULUM": {
@@ -615,12 +661,19 @@
"EXPLANATION": { "EXPLANATION": {
"TITLE": "Chaostheorie: Das Doppelpendel", "TITLE": "Chaostheorie: Das Doppelpendel",
"EXPLANATION": "Das Doppelpendel ist eines der bekanntesten und faszinierendsten Beispiele der Physik für ein dynamisches System, das 'deterministisches Chaos' erzeugt. Es besteht schlicht aus einem einfachen Pendel, an dessen unterem Ende ein zweites Pendel befestigt ist. Obwohl die zugrundeliegenden Bewegungsgesetze der klassischen Mechanik streng mathematisch definiert sind, ist das Verhalten des Doppelpendels auf lange Sicht absolut unvorhersehbar. Es gilt in der Physik als das klassische Vorzeigeobjekt für den sogenannten Schmetterlingseffekt.", "EXPLANATION": "Das Doppelpendel ist eines der bekanntesten und faszinierendsten Beispiele der Physik für ein dynamisches System, das 'deterministisches Chaos' erzeugt. Es besteht schlicht aus einem einfachen Pendel, an dessen unterem Ende ein zweites Pendel befestigt ist. Obwohl die zugrundeliegenden Bewegungsgesetze der klassischen Mechanik streng mathematisch definiert sind, ist das Verhalten des Doppelpendels auf lange Sicht absolut unvorhersehbar. Es gilt in der Physik als das klassische Vorzeigeobjekt für den sogenannten Schmetterlingseffekt.",
"DISCLAIMER": "Diese WebGPU-Simulation berechnet die Bewegungs- und Beschleunigungsgleichungen des Pendels 60-mal pro Sekunde in Echtzeit. Dabei gelten folgende Besonderheiten:", "DISCLAIMER": "Die Simulation rechnet die Bewegungsgleichungen des Pendels einmal pro gerendertem Frame mit festem Zeitschritt dt = 0,015, auf der GPU (WebGPU) oder als Fallback auf der CPU. Dabei gelten folgende Besonderheiten:",
"DISCLAIMER_1": "Extreme Sensitivität: Winzigste Änderungen in den Startbedingungen (z.B. ein Tausendstel Grad Abweichung im Startwinkel oder bei der Masse) führen schon nach kurzer Zeit zu einer völlig anderen, chaotischen Flugbahn.", "DISCLAIMER_1": "Extreme Sensitivität: Winzigste Änderungen in den Startbedingungen (z.B. ein Tausendstel Grad Abweichung im Startwinkel oder bei der Masse) führen schon nach kurzer Zeit zu einer völlig anderen, chaotischen Flugbahn.",
"DISCLAIMER_2": "Deterministisches Chaos: Die Bewegung wirkt zwar völlig wild und zufällig, ist es aber nicht. Startest du die Simulation mit exakt denselben Werten neu, wird das Pendel zu 100 % denselben Weg fliegen.", "DISCLAIMER_2": "Deterministisches Chaos: Die Bewegung wirkt zwar völlig wild und zufällig, ist es aber nicht. Startest du die Simulation mit exakt denselben Werten neu, wird das Pendel zu 100 % denselben Weg fliegen.",
"DISCLAIMER_3": "Numerische Integration: Da Computer Zeit nicht stufenlos, sondern in winzigen Schritten (dt) berechnen, entstehen bei jedem Frame winzige mathematische Rundungsfehler. Diese summieren sich auf und beeinflussen das Chaos zusätzlich.", "DISCLAIMER_3": "Numerische Integration: Da Computer Zeit nicht stufenlos, sondern in winzigen Schritten (dt) berechnen, entstehen bei jedem Frame winzige mathematische Rundungsfehler. Diese summieren sich auf und beeinflussen das Chaos zusätzlich.",
"DISCLAIMER_4": "Energieerhaltung & Reibung: In einem perfekten physikalischen System ohne Widerstand würde das Pendel ewig weiterschwingen. Für eine natürliche Optik nutzt der Algorithmus einen künstlichen Dämpfungsfaktor, der Luftreibung simuliert und das System irgendwann beruhigt.", "DISCLAIMER_4": "Energieerhaltung & Reibung: In einem perfekten physikalischen System ohne Widerstand würde das Pendel ewig weiterschwingen. Für eine natürliche Optik nutzt der Algorithmus einen künstlichen Dämpfungsfaktor, der Luftreibung simuliert und das System irgendwann beruhigt.",
"DISCLAIMER_BOTTOM": "HINWEIS: Wenn zuviele Impulse in das System gegeben werden, wird die Simulation instabil. Dann hängt das Pendel nur noch runter und es muss neu gestartet werden." "DISCLAIMER_BOTTOM": "HINWEIS: Wenn zuviele Impulse in das System gegeben werden, wird die Simulation instabil. Dann hängt das Pendel nur noch runter und es muss neu gestartet werden."
},
"HOW_IT_WORKS": {
"INTRO": "Die Bewegung folgt den Bewegungsgleichungen des Doppelpendels (Lagrange-Formulierung, Formeln wie im Wikipedia-Artikel). Pro gerendertem Frame wird ein Schritt mit festem Zeitschritt dt = 0,015 gerechnet: erst die Winkelbeschleunigungen a1 und a2, dann die Winkelgeschwindigkeiten (mal Dämpfung, plus Impuls), dann die Winkel (semi-implizites Euler-Verfahren).",
"ITEM_1": "WebGPU: ein Compute-Shader mit einem einzigen Thread rechnet den Physikschritt; der Zustand (θ1, θ2, v1, v2) bleibt in einem Storage Buffer auf der GPU. Ein zweiter Compute-Shader (64 Threads pro Workgroup, einer pro Pixel) zeichnet Stangen, Massen und Spur direkt in einen Pixel-Buffer; die Spur verblasst pro Frame um den Faktor Spurlänge. Ein Fragment-Shader zeigt den Buffer an.",
"ITEM_2": "Ohne WebGPU: dieselbe Physik in TypeScript auf der CPU. Die Spur entsteht per Ping-Pong zwischen zwei Render-Target-Texturen: ein GLSL-Shader liest das alte Bild, dämpft es und zeichnet die beiden Massen neu; ein zweiter Shader zeigt Spur und Stangen an.",
"ITEM_3": "Pixel-Buffer-Kodierung: Intensität 0 bis 1 für Spur 1, +2 für Spur 2, +10 und +20 für die Stangen. So reicht ein Float pro Pixel.",
"ITEM_4": "Der Zeitschritt ist fest und nicht an die Bildrate gekoppelt: bei 120 Hz läuft das Pendel doppelt so schnell wie bei 60 Hz. Das Euler-Verfahren erhält die Energie nicht exakt; große Impulse können die Simulation instabil machen (siehe Hinweis)."
} }
}, },
"CLOTH": { "CLOTH": {
@@ -640,13 +693,21 @@
"DATA_STRUCTURES_EXPLANATION_TITLE": "Datenstrukturen", "DATA_STRUCTURES_EXPLANATION_TITLE": "Datenstrukturen",
"CLOTH_SIMULATION_EXPLANATION": "Stoffsimulationen modellieren Textilien meist als ein Gitter aus Massepunkten (Vertices), die durch unsichtbare Verbindungen zusammengehalten werden. Ziel ist es, physikalische Einflüsse wie Schwerkraft, Wind und Kollisionen in Echtzeit darzustellen, ohne dass das Material zerreißt oder sich unnatürlich wie Gummi dehnt.", "CLOTH_SIMULATION_EXPLANATION": "Stoffsimulationen modellieren Textilien meist als ein Gitter aus Massepunkten (Vertices), die durch unsichtbare Verbindungen zusammengehalten werden. Ziel ist es, physikalische Einflüsse wie Schwerkraft, Wind und Kollisionen in Echtzeit darzustellen, ohne dass das Material zerreißt oder sich unnatürlich wie Gummi dehnt.",
"XPBD_EXPLANATION": "XPBD (Extended Position-Based Dynamics) ist ein moderner Algorithmus, der statt Beschleunigungen direkt die Positionen der Punkte manipuliert, um Abstandsbedingungen (Constraints) zu erfüllen. Das 'Extended' bedeutet, dass echte physikalische Steifigkeit unabhängig von der Framerate simuliert wird. Vorteil: Absolut stabil, explodiert nicht und topologische Änderungen (wie das Zerschneiden von Stoff) sind trivial. Nachteil: Es ist ein iteratives Näherungsverfahren und physikalisch minimal weniger akkurat als komplexe Matrix-Löser.", "XPBD_EXPLANATION": "XPBD (Extended Position-Based Dynamics) ist ein moderner Algorithmus, der statt Beschleunigungen direkt die Positionen der Punkte manipuliert, um Abstandsbedingungen (Constraints) zu erfüllen. Das 'Extended' bedeutet, dass echte physikalische Steifigkeit unabhängig von der Framerate simuliert wird. Vorteil: Absolut stabil, explodiert nicht und topologische Änderungen (wie das Zerschneiden von Stoff) sind trivial. Nachteil: Es ist ein iteratives Näherungsverfahren und physikalisch minimal weniger akkurat als komplexe Matrix-Löser.",
"GPU_PARALLELIZATION_EXPLANATION": "Um zehntausende Punkte parallel auf der Grafikkarte zu berechnen, muss man 'Race Conditions' verhindern – also dass zwei Rechenkerne gleichzeitig denselben Knotenpunkt verschieben. Die Lösung nennt sich 'Independent Sets' (oder Graph Coloring): Die Verbindungen werden in isolierte Gruppen (z. B. 4 Phasen bei einem Gitter) unterteilt, in denen sich kein einziger Punkt überschneidet. So kann die GPU jede Gruppe blind und mit maximaler Geschwindigkeit abarbeiten.", "GPU_PARALLELIZATION_EXPLANATION": "Um zehntausende Punkte parallel auf der Grafikkarte zu berechnen, muss man 'Race Conditions' verhindern – also dass zwei Rechenkerne gleichzeitig denselben Knotenpunkt verschieben. Die Lösung nennt sich 'Independent Sets' (oder Graph Coloring): Die Verbindungen werden in isolierte Gruppen (hier 8 Phasen: 2 horizontal, 2 vertikal, 4 diagonal) unterteilt, in denen sich kein einziger Punkt überschneidet. So kann die GPU jede Gruppe blind und mit maximaler Geschwindigkeit abarbeiten.",
"DATA_STRUCTURES_EXPLANATION": "Für maximale GPU-Performance müssen Daten speicherfreundlich ausgerichtet werden (16-Byte-Alignment). Anstatt viele einzelne Variablen zu nutzen, packt man Informationen clever in 4er-Blöcke (vec4). Ein Vertex speichert so z. B. [X, Y, Z, Inverse_Masse]. Hat ein Punkt die inverse Masse 0.0, wird er vom Algorithmus ignoriert und schwebt unbeweglich in der Luft – ein eleganter Trick für Aufhängungen ohne extra Wenn-Dann-Abfragen.", "DATA_STRUCTURES_EXPLANATION": "Für maximale GPU-Performance müssen Daten speicherfreundlich ausgerichtet werden (16-Byte-Alignment). Anstatt viele einzelne Variablen zu nutzen, packt man Informationen clever in 4er-Blöcke (vec4). Ein Vertex speichert so z. B. [X, Y, Z, Inverse_Masse]. Hat ein Punkt die inverse Masse 0.0, wird er vom Algorithmus ignoriert und schwebt unbeweglich in der Luft – ein eleganter Trick für Aufhängungen ohne extra Wenn-Dann-Abfragen.",
"DISCLAIMER": "XPBD vs. Masse-Feder-Systeme: In der physikalischen Simulation gibt es grundlegende Architektur-Unterschiede beim Lösen der Gleichungen:", "DISCLAIMER": "XPBD vs. Masse-Feder-Systeme: In der physikalischen Simulation gibt es grundlegende Architektur-Unterschiede beim Lösen der Gleichungen:",
"DISCLAIMER_1": "Klassische Masse-Feder-Systeme: Hier werden Kräfte (Hookesches Gesetz) berechnet, die zu Beschleunigungen und schließlich zu neuen Positionen führen. Es gibt zwei Wege, diese mathematisch in die Zukunft zu rechnen (Integration):", "DISCLAIMER_1": "Klassische Masse-Feder-Systeme: Hier werden Kräfte (Hookesches Gesetz) berechnet, die zu Beschleunigungen und schließlich zu neuen Positionen führen. Es gibt zwei Wege, diese mathematisch in die Zukunft zu rechnen (Integration):",
"DISCLAIMER_2": "Explizite Löser (z.B. Forward Euler): Sie berechnen den nächsten Schritt stur aus dem aktuellen Zustand. Sie sind leicht zu programmieren, aber bei steifen Stoffen extrem instabil. Die Kräfte schaukeln sich auf und die simulation 'explodiert', sofern man keine winzigen, sehr leistungsfressenden Zeitschritte wählt.", "DISCLAIMER_2": "Explizite Löser (z.B. Forward Euler): Sie berechnen den nächsten Schritt stur aus dem aktuellen Zustand. Sie sind leicht zu programmieren, aber bei steifen Stoffen extrem instabil. Die Kräfte schaukeln sich auf und die simulation 'explodiert', sofern man keine winzigen, sehr leistungsfressenden Zeitschritte wählt.",
"DISCLAIMER_3": "Implizite Löser (z.B. Backward Euler): Sie berechnen den nächsten Schritt basierend auf dem zukünftigen Zustand. Das ist mathematisch enorm stabil, erfordert aber das Lösen riesiger globaler Matrix-Gleichungssysteme in jedem Frame. Dies ist auf der GPU schwerer zu parallelisieren und bricht zusammen, wenn sich die Struktur ändert (z. B. durch Zerschneiden des Stoffs).", "DISCLAIMER_3": "Implizite Löser (z.B. Backward Euler): Sie berechnen den nächsten Schritt basierend auf dem zukünftigen Zustand. Das ist mathematisch enorm stabil, erfordert aber das Lösen riesiger globaler Matrix-Gleichungssysteme in jedem Frame. Dies ist auf der GPU schwerer zu parallelisieren und bricht zusammen, wenn sich die Struktur ändert (z. B. durch Zerschneiden des Stoffs).",
"DISCLAIMER_4": "Der XPBD-Kompromiss: XPBD umgeht dieses komplexe Matrix-Problem völlig, indem es als lokaler Löser arbeitet. Es kombiniert die unbedingte Stabilität eines impliziten Lösers mit der enormen Geschwindigkeit, Parallelisierbarkeit und dynamischen Anpassungsfähigkeit eines expliziten Systems." "DISCLAIMER_4": "Der XPBD-Kompromiss: XPBD umgeht dieses komplexe Matrix-Problem völlig, indem es als lokaler Löser arbeitet. Es kombiniert die unbedingte Stabilität eines impliziten Lösers mit der enormen Geschwindigkeit, Parallelisierbarkeit und dynamischen Anpassungsfähigkeit eines expliziten Systems."
},
"HOW_IT_WORKS": {
"INTRO": "Der Stoff ist ein Gitter aus 100 × 100 = 10.000 Partikeln. Jedes Partikel ist ein vec4 [x, y, z, 1/Masse]; die obere Reihe hat inverse Masse 0 und hängt fest. 39.402 Abstandsbedingungen (Constraints) halten die Partikel zusammen: horizontal, vertikal und zwei Diagonalen gegen Scherung. Pro Frame: Integration (Schwerkraft, Wind, Positionsvorhersage), dann 15 Substeps, in denen jede Bedingung einmal gelöst wird (XPBD), zuletzt die Geschwindigkeit aus der Positionsänderung.",
"ITEM_1": "XPBD: pro Bedingung wird die Abweichung C = |pA − pB| − Ruhelänge berechnet und beide Partikel um λ = −C / (wA + wB + α) entlang der Verbindung verschoben, gewichtet mit der inversen Masse. α = Compliance / dt² macht die Steifigkeit unabhängig vom Zeitschritt. Der Regler Dehnung skaliert die Ruhelänge.",
"ITEM_2": "GPU (WebGPU): drei Compute-Shader (Integrate, Solve, Velocity) mit 64 Threads pro Workgroup. Alle Daten bleiben in Storage Buffers auf der GPU; der Vertex-Shader liest die Positionen direkt aus dem Buffer. Nichts geht zurück zur CPU.",
"ITEM_3": "Graph Coloring: zwei Threads dürfen nie dasselbe Partikel schreiben. Darum sind die Bedingungen in 8 Phasen sortiert (gerade und ungerade Spalten, gerade und ungerade Zeilen, je zwei pro Diagonale). In einer Phase hat kein Partikel zwei Bedingungen, also läuft jede Phase ohne Locks. Pro Frame sind das 15 · 8 = 120 Solver-Dispatches (dazu je einer für Integrate und Velocity).",
"ITEM_4": "Ohne WebGPU: dieselben drei Schritte in TypeScript auf der CPU mit identischem Speicherlayout (Float32Array). Die Positionen werden pro Frame in den Vertex-Buffer des Meshes kopiert. 39.402 · 15 = 591.030 Bedingungs-Lösungen pro Frame sind für eine CPU viel, die Bildrate fällt deutlich.",
"ITEM_5": "Wind: feste Richtung (5, 0, 15) plus ein Sinus-Flattern aus Position und Zeit. Der Flatter-Anteil gibt zusätzlich leichten Auftrieb (y: Flattern · 2)."
} }
}, },
"GPU": { "GPU": {
@@ -657,7 +718,7 @@
"TITLE": "Algorithmen", "TITLE": "Algorithmen",
"PATHFINDING": { "PATHFINDING": {
"TITLE": "Wegfindung", "TITLE": "Wegfindung",
"DESCRIPTION": "Vergleich von Dijkstra vs. A*." "DESCRIPTION": "Dijkstra und A* auf einem Gitter, einzeln oder im direkten Vergleich."
}, },
"SORTING": { "SORTING": {
"TITLE": "Sortierung", "TITLE": "Sortierung",
@@ -669,7 +730,7 @@
}, },
"LABYRINTH": { "LABYRINTH": {
"TITLE": "Labyrinth-Erzeugung", "TITLE": "Labyrinth-Erzeugung",
"DESCRIPTION": "Visualisierung verschiedener Laybrinth-Erzeugungs-Algorithmen." "DESCRIPTION": "Labyrinth-Erzeugung mit Prim und Kruskal, danach Wegfindung darin."
}, },
"FOUR_COLOR": { "FOUR_COLOR": {
"TITLE": "Vier-Farben-Satz", "TITLE": "Vier-Farben-Satz",
@@ -689,15 +750,17 @@
}, },
"PENDULUM": { "PENDULUM": {
"TITLE": "Doppel-Pendel", "TITLE": "Doppel-Pendel",
"DESCRIPTION": "Visualisierung einer chaotischen Doppel-Pendel-Simulation mit WebGPU." "DESCRIPTION": "Chaotisches Doppelpendel, Physik im WebGPU-Compute-Shader (Fallback WebGL + CPU)."
}, },
"CLOTH": { "CLOTH": {
"TITLE": "Stoffsimulation", "TITLE": "Stoffsimulation",
"DESCRIPTION": "Simulation on Stoff mit WebGPU." "DESCRIPTION": "Stoffsimulation mit XPBD auf der GPU (WebGPU, Fallback WebGL + CPU)."
}, },
"NOTE": "HINWEIS", "NOTE": "HINWEIS",
"GRID_HEIGHT": "Höhe", "GRID_HEIGHT": "Höhe",
"GRID_WIDTH": "Beite" "GRID_WIDTH": "Breite",
"HOW_IT_WORKS": "So funktioniert es",
"SOURCE": "Quellcode"
}, },
"FOUR_COLOR": { "FOUR_COLOR": {
"TITLE": "Vier-Farben-Satz", "TITLE": "Vier-Farben-Satz",
@@ -727,7 +790,15 @@
"DISCLAIMER_BOTTOM": "Zum einfärben in die Zelle klicken. Durch erneutes klicken ändert sich die Farbe." "DISCLAIMER_BOTTOM": "Zum einfärben in die Zelle klicken. Durch erneutes klicken ändert sich die Farbe."
}, },
"ALERT": { "ALERT": {
"NO_SOLUTION": "Keine Lösung gefunden (das sollte bei einer planaren Karte nicht passieren!)." "NO_SOLUTION": "Keine Lösung gefunden (das sollte bei einer planaren Karte nicht passieren!).",
"LIMIT_REACHED": "Keine Lösung innerhalb von {{limit}} Versuchen gefunden. Bitte eine neue Karte erzeugen."
},
"HOW_IT_WORKS": {
"INTRO": "Die Karte entsteht auf einem Gitter: pro 30 Zellen wird ein Keim an eine zufällige freie Position gesetzt. Alle Keime wachsen gleichzeitig per Breitensuche (eine gemeinsame Warteschlange), bis jede Zelle zu einer Region gehört. Zwei Regionen sind Nachbarn, wenn zwei ihrer Zellen eine Kante teilen.",
"ITEM_1": "Datenstruktur: eine Liste von Regionen, jede mit einem Set ihrer Nachbar-IDs (Adjazenzliste). Das Gitter speichert pro Zelle nur Regions-ID und Farbe.",
"ITEM_2": "Lösen: Backtracking. Der Löser nimmt immer die am stärksten eingeschränkte Region zuerst (DSATUR): die mit den meisten verschiedenen Farben bei ihren Nachbarn, bei Gleichstand die mit den meisten Nachbarn. Für sie werden die Farben 1 bis 4 probiert; passt keine, geht der Löser einen Schritt zurück. Versuche zählen jede Farbzuweisung, auch die später zurückgenommenen.",
"ITEM_3": "Die Standardkarte (25 × 25, 20 Regionen) löst er in unter 1 ms. Meist braucht er genau einen Versuch pro Region. Damit die Seite nie einfriert, gibt er nach 100.000 Versuchen auf (ungefähr 1 von 3.000 Karten in der größten Stufe) und meldet das; dann hilft eine neue Karte.",
"ITEM_4": "Prüfen: alle Regionen durchgehen und jede Farbe mit den Nachbarn vergleichen, O(Regionen + Grenzen)."
} }
}, },
"FOURIER": { "FOURIER": {
@@ -749,6 +820,14 @@
"DISCLAIMER_2": "Jeder Punkt ist eine komplexe Zahl x + iy. Eine direkte diskrete Fourier-Transformation liefert einen Kreis pro Frequenz.", "DISCLAIMER_2": "Jeder Punkt ist eine komplexe Zahl x + iy. Eine direkte diskrete Fourier-Transformation liefert einen Kreis pro Frequenz.",
"DISCLAIMER_3": "Die Kreise werden nach Radius sortiert. Der Regler behält nur die größten N Kreise.", "DISCLAIMER_3": "Die Kreise werden nach Radius sortiert. Der Regler behält nur die größten N Kreise.",
"DISCLAIMER_BOTTOM": "Weniger Kreise geben eine glattere Näherung. Mit allen Kreisen läuft die Kurve exakt durch alle 256 Punkte." "DISCLAIMER_BOTTOM": "Weniger Kreise geben eine glattere Näherung. Mit allen Kreisen läuft die Kurve exakt durch alle 256 Punkte."
},
"HOW_IT_WORKS": {
"INTRO": "Die gezeichnete Form wird als geschlossenes Polygon behandelt und auf 256 Punkte mit gleichem Bogenabstand neu abgetastet. Jeder Punkt ist eine komplexe Zahl x + i·y. Eine direkte diskrete Fourier-Transformation (zwei verschachtelte Schleifen, O(N²) = 65.536 Schritte) liefert pro Frequenz einen Kreis mit Radius, Startwinkel und Drehzahl. Die Kreise werden nach Radius sortiert.",
"ITEM_1": "Frequenzen laufen von 0 bis N−1; alles über N/2 zählt als negative Frequenz, damit Kreise auch rückwärts drehen.",
"ITEM_2": "Pro Frame werden die Kreismittelpunkte addiert (O(Kreise)), die Spitze des letzten Kreises kommt an die Spur. Nach einer vollen Periode wird die Spur gelöscht.",
"ITEM_3": "Gezeichnet wird auf Canvas 2D mit requestAnimationFrame außerhalb der Angular-Zone. dt ist die Zeit zwischen zwei Frames, darum ist die Geschwindigkeit unabhängig von der Bildrate.",
"ITEM_4": "Vorlagen: Herz und Stern sind Formeln, das Quadrat vier Eckpunkte, der Hund ist eine Punktliste, die mit drei Chaikin-Durchläufen (Corner Cutting) geglättet wird.",
"ITEM_5": "Weniger Kreise wirken wie ein Tiefpass: nur die größten Radien bleiben, die Form wird weicher."
} }
}, },
"STOPWATCH": { "STOPWATCH": {
@@ -807,5 +886,26 @@
"TITLE": "Seite nicht gefunden – Andreas Dahm", "TITLE": "Seite nicht gefunden – Andreas Dahm",
"DESCRIPTION": "Die angeforderte Seite konnte nicht gefunden werden." "DESCRIPTION": "Die angeforderte Seite konnte nicht gefunden werden."
} }
},
"METRICS": {
"FPS": "Bilder/s",
"BACKEND": "Backend",
"STEP_TIME": "Rechenzeit pro Generation",
"GENERATION": "Generation",
"GENERATIONS_PER_SECOND": "Generationen/s",
"COMPARISONS": "Vergleiche",
"WRITES": "Schreibzugriffe",
"REGIONS": "Regionen",
"BORDERS": "Grenzen",
"ATTEMPTS": "Farbversuche (Backtracking)",
"SAMPLES": "Abtastpunkte",
"ZOOM": "Zoom",
"MAX_ITERATIONS": "Max. Iterationen",
"RAY_STEPS": "Max. Strahlschritte",
"TIME_STEP": "Zeitschritt dt",
"PARTICLES": "Partikel",
"CONSTRAINTS": "Constraints",
"PHASES": "Phasen",
"SUBSTEPS": "Substeps pro Frame"
} }
} }
+109 -9
View File
@@ -499,6 +499,14 @@
}, },
"ALERT": { "ALERT": {
"START_END_NODES": "Please select a start and end node before running the algorithm." "START_END_NODES": "Please select a start and end node before running the algorithm."
},
"HOW_IT_WORKS": {
"INTRO": "The grid is a two-dimensional array of nodes. Each node stores its distance from the start, its predecessor and, for A*, the heuristic h and the sum f = g + h. Both algorithms run to the end first and return the list of visited nodes and the path. The animation then replays that list node by node with setTimeout.",
"ITEM_1": "Dijkstra: all nodes sit in one list. Dijkstra sorts the whole list by distance in every step (O(n² log n)) and takes the next node. A heap would be faster; with at most 22,500 nodes this is enough here. If the next node has distance infinity, the goal cannot be reached.",
"ITEM_2": "A*: same structure, but only the open list is sorted, by f = g + h; on equal f the smaller h wins. The heuristic is the Manhattan distance. It is admissible because each step costs 1 and there are only four directions, so the path stays optimal.",
"ITEM_3": "The path is read at the end from the predecessor chain, from the goal back to the start.",
"ITEM_4": "Compare mode: start, goal and walls are copied into a second grid. A* runs on the first, Dijkstra on the second, both animations run at the same time. The number of visited nodes shows the difference.",
"ITEM_5": "Execution time is the search only, without the animation. Memory: one node object per cell, at most 150 × 150 = 22,500 nodes."
} }
}, },
"SORTING": { "SORTING": {
@@ -523,6 +531,14 @@
"DISCLAIMER_2": "Average Case: The statistical norm.", "DISCLAIMER_2": "Average Case: The statistical norm.",
"DISCLAIMER_3": "Worst Case: The data is arranged in the most unfavorable way possible (Quick Sort performs poorly here without optimization, while Heap Sort remains stable).", "DISCLAIMER_3": "Worst Case: The data is arranged in the most unfavorable way possible (Quick Sort performs poorly here without optimization, while Heap Sort remains stable).",
"DISCLAIMER_4": "Additionally, there is almost always a Time-Space Trade-off: Algorithms that are extremely fast (like Merge Sort) often require a lot of additional working memory. Algorithms that work directly in existing memory (like Heap Sort) save space but are sometimes more complex or slightly slower. Thus, there is no \"one-size-fits-all\" solution." "DISCLAIMER_4": "Additionally, there is almost always a Time-Space Trade-off: Algorithms that are extremely fast (like Merge Sort) often require a lot of additional working memory. Algorithms that work directly in existing memory (like Heap Sort) save space but are sometimes more complex or slightly slower. Thus, there is no \"one-size-fits-all\" solution."
},
"HOW_IT_WORKS": {
"INTRO": "Each algorithm runs to the end first and stores a copy of the array (snapshot) in a list at every comparison and every swap. The animation then replays the snapshots at 100 ms per step. Execution time is the run including the copies, not the animation. Comparisons and writes count up during the animation.",
"ITEM_1": "Bubble sort and cocktail sort: O(n²) comparisons, in place. Cocktail sort changes direction every pass and stops as soon as a pass swaps nothing.",
"ITEM_2": "Quick sort: Lomuto partition with the last element as pivot, recursive. Average O(n log n), worst case O(n²), for example on already sorted data.",
"ITEM_3": "Heap sort: builds a max heap inside the array (heapify from the middle to the front) and swaps the root to the end. Always O(n log n), no extra memory.",
"ITEM_4": "Tim sort (simplified): insertion sort on fixed blocks of 32 elements, then bottom-up merge with helper arrays. No run detection and no galloping like the original. With 200 bars that is 7 blocks and 3 merge levels.",
"ITEM_5": "Memory: the snapshot list is the biggest item, O(steps · n). Bubble sort with 200 bars produces up to about 40,000 snapshots of 200 entries each."
} }
}, },
"GOL": { "GOL": {
@@ -545,6 +561,13 @@
"DISCLAIMER_2": "A dead cell is “born” (is alive in the next generation) if it has exactly three living neighbors.", "DISCLAIMER_2": "A dead cell is “born” (is alive in the next generation) if it has exactly three living neighbors.",
"DISCLAIMER_3": "A living cell “dies” (is dead in the next generation) if it has fewer than two (underpopulation) or more than three (overpopulation) living neighbors.", "DISCLAIMER_3": "A living cell “dies” (is dead in the next generation) if it has fewer than two (underpopulation) or more than three (overpopulation) living neighbors.",
"DISCLAIMER_4": "A dead cell remains dead if it does not have exactly three living neighbors." "DISCLAIMER_4": "A dead cell remains dead if it does not have exactly three living neighbors."
},
"HOW_IT_WORKS": {
"INTRO": "The board is two grids of cells with an alive flag: a read grid and a write grid (double buffering). Each generation reads only from one and writes only to the other, then both are swapped. So a cell that already changed does not affect its neighbors in the same generation.",
"ITEM_1": "For each cell the eight neighbors are counted (Moore neighborhood). The border is cut off, not wrapped: border cells have fewer neighbors, the board is not a torus.",
"ITEM_2": "Time per generation is O(rows · columns · 8). At 200 × 200 that is 40,000 cells and 320,000 neighbor checks.",
"ITEM_3": "The loop is an async loop: compute, draw, then wait until the configured time per generation has passed. If no cell changes any more, the game stops by itself.",
"ITEM_4": "The whole grid is drawn onto a canvas: one fill operation for the background plus one per living cell."
} }
}, },
"LABYRINTH": { "LABYRINTH": {
@@ -560,6 +583,13 @@
"DISCLAIMER_2": "Reachability: Since it is a spanning tree, every cell in the grid is guaranteed to be part of the labyrinth; there are no isolated areas.", "DISCLAIMER_2": "Reachability: Since it is a spanning tree, every cell in the grid is guaranteed to be part of the labyrinth; there are no isolated areas.",
"DISCLAIMER_3": "Randomness: By weighting the edges with random values, each run produces completely new, unique structures.", "DISCLAIMER_3": "Randomness: By weighting the edges with random values, each run produces completely new, unique structures.",
"DISCLAIMER_4": "Application: Such labyrinths are the perfect test environment for pathfinding algorithms such as Dijkstra or A*." "DISCLAIMER_4": "Application: Such labyrinths are the perfect test environment for pathfinding algorithms such as Dijkstra or A*."
},
"HOW_IT_WORKS": {
"INTRO": "The grid has 101 × 101 cells. Cells with even coordinates are rooms (51 × 51 = 2,601), the cells between them are walls. Both algorithms first compute the order in which walls are opened; the animation then opens them one by one. The result is a spanning tree: every room is reachable and there is exactly one way between two rooms.",
"ITEM_1": "Prim (randomized): starts in a random room and keeps a frontier list of walls to rooms not yet visited. Each step picks a random wall (swap with the last element, then pop: O(1)) and opens it if the room behind it is still unvisited. Randomness replaces edge weights.",
"ITEM_2": "Kruskal (randomized): all 5,100 walls between two rooms are collected and shuffled with Fisher-Yates. Each room carries a set id. A wall is opened when the rooms have different ids; then all rooms of one set get the id of the other. That is one pass over the grid per union, simpler than union-find and fast enough for 2,601 rooms.",
"ITEM_3": "Start and goal sit in two random corners. Dijkstra and A* then run on the same grid as on the pathfinding page.",
"ITEM_4": "Memory: one node object per cell (10,201) plus the list of cells to open."
} }
}, },
"FRACTAL": { "FRACTAL": {
@@ -580,6 +610,14 @@
"DISCLAIMER_3": "Complex Numbers: Calculations don't happen in a standard coordinate system, but in the complex plane using real and imaginary components.", "DISCLAIMER_3": "Complex Numbers: Calculations don't happen in a standard coordinate system, but in the complex plane using real and imaginary components.",
"DISCLAIMER_4": "Computational Load: Since hundreds of calculations are performed for every single pixel, fractals are a classic benchmark for GPU and processor performance.", "DISCLAIMER_4": "Computational Load: Since hundreds of calculations are performed for every single pixel, fractals are a classic benchmark for GPU and processor performance.",
"DISCLAIMER_BOTTOM": "Graphics cards calculate with 32-bit floating point numbers (float) by default. These only have about 7 digits of accuracy. At very high zoom levels (> 100.000), the difference between two pixels is so tiny that the graphics card can no longer display it. It calculates exactly the same value for 10 pixels next to each other -> you see blocks or stair steps." "DISCLAIMER_BOTTOM": "Graphics cards calculate with 32-bit floating point numbers (float) by default. These only have about 7 digits of accuracy. At very high zoom levels (> 100.000), the difference between two pixels is so tiny that the graphics card can no longer display it. It calculates exactly the same value for 10 pixels next to each other -> you see blocks or stair steps."
},
"HOW_IT_WORKS": {
"INTRO": "The image is a fragment shader (GLSL) on a full-screen quad in Babylon.js. Each pixel computes its own sequence: for Mandelbrot, Julia and Burning Ship z → z² + c until |z|² > 4 or the maximum iteration count is reached (escape-time algorithm). Newton iterates z → z − (z³ − 1) / (3z²) and colors by the root the pixel converges to.",
"ITEM_1": "The CPU sends only uniforms per frame: center, zoom, maximum iterations, algorithm, color scheme and the Julia constant. The GPU computes all pixels in parallel; cost O(pixels · iterations).",
"ITEM_2": "The maximum iteration count grows with the zoom: 100 + 200 · log10(zoom), so up to about 1,060 at the maximum zoom of 64,000.",
"ITEM_3": "Zooming with the mouse wheel keeps the point under the cursor fixed: its world point is set equal before and after the zoom and the center is moved accordingly.",
"ITEM_4": "The color comes from the ratio iterations / maximum; points that never escape stay black.",
"ITEM_5": "Under WebGPU Babylon.js translates the same GLSL shader to WGSL; there is no separate compute path."
} }
}, },
"FRACTAL3D": { "FRACTAL3D": {
@@ -597,7 +635,15 @@
"DISCLAIMER_1": "No Polygons: There are no triangles or meshes. The shape is calculated mathematically for every pixel in real-time.", "DISCLAIMER_1": "No Polygons: There are no triangles or meshes. The shape is calculated mathematically for every pixel in real-time.",
"DISCLAIMER_2": "Distance Estimation: The algorithm 'marches' light rays forward by calculating the safe distance to the nearest object without hitting it immediately.", "DISCLAIMER_2": "Distance Estimation: The algorithm 'marches' light rays forward by calculating the safe distance to the nearest object without hitting it immediately.",
"DISCLAIMER_3": "Infinite Detail: Since the surface is mathematically defined, it never pixelates when zooming in – new structures always emerge.", "DISCLAIMER_3": "Infinite Detail: Since the surface is mathematically defined, it never pixelates when zooming in – new structures always emerge.",
"DISCLAIMER_4": "Light & Shadow: To visualize depth, light reflections and shadows (Ambient Occlusion) are simulated based on the curvature of the formula." "DISCLAIMER_4": "Light: depth comes from the surface normal: faces that point at the camera are brighter, plus a fixed ambient term. Shadows and ambient occlusion are not computed."
},
"HOW_IT_WORKS": {
"INTRO": "There is no geometry. A fragment shader sends one ray per pixel from the camera into the scene and walks it with sphere tracing (raymarching): a distance estimator says how far the surface is at least, and the ray jumps by that amount (times 0.8 as a safety factor), at most 128 steps. Before that, a sphere test (radius 6) limits the ray to the relevant range.",
"ITEM_1": "Mandelbulb: 8 iterations in spherical coordinates with power 8 (z → z⁸ + c). The distance is 0.5 · ln(r) · r / dr, where dr is the running derivative.",
"ITEM_2": "Mandelbox: 15 iterations of box fold (mirror at ±1), sphere fold (scale by radius) and scale 2.8.",
"ITEM_3": "Julia bulb: like Mandelbulb, but with a fixed constant c that moves slightly over time, so the shape moves.",
"ITEM_4": "Normals come from four extra distance queries. Lighting: light from the camera direction plus 40 % ambient, no shadows, no ambient occlusion. Color: orbit trap (smallest radius during iteration) mapped to a cosine palette.",
"ITEM_5": "Cost per frame: pixels × ray steps × iterations. At full HD with the Mandelbox that is about 4 billion iteration steps in the worst case. It runs as a fragment shader on the GPU only; there is no CPU path."
} }
}, },
"PENDULUM": { "PENDULUM": {
@@ -615,12 +661,19 @@
"EXPLANATION": { "EXPLANATION": {
"TITLE": "Chaos Theory: The Double Pendulum", "TITLE": "Chaos Theory: The Double Pendulum",
"EXPLANATION": "The double pendulum is one of physics' most famous and fascinating examples of a dynamic system that generates 'deterministic chaos'. It simply consists of a standard pendulum with a second pendulum attached to its lower end. Although the underlying laws of classical mechanics are strictly mathematically defined, the long-term behavior of the double pendulum is absolutely unpredictable. In physics, it is considered the classic showcase object for the so-called butterfly effect.", "EXPLANATION": "The double pendulum is one of physics' most famous and fascinating examples of a dynamic system that generates 'deterministic chaos'. It simply consists of a standard pendulum with a second pendulum attached to its lower end. Although the underlying laws of classical mechanics are strictly mathematically defined, the long-term behavior of the double pendulum is absolutely unpredictable. In physics, it is considered the classic showcase object for the so-called butterfly effect.",
"DISCLAIMER": "This WebGPU simulation calculates the motion and acceleration equations of the pendulum 60 times per second in real-time. The following characteristics apply:", "DISCLAIMER": "The simulation computes the pendulum's equations of motion once per rendered frame with a fixed time step dt = 0.015, on the GPU (WebGPU) or on the CPU as fallback. The following applies:",
"DISCLAIMER_1": "Extreme Sensitivity: The tiniest changes in the initial conditions (e.g., a thousandth of a degree deviation in the starting angle or mass) lead to a completely different, chaotic trajectory after just a short time.", "DISCLAIMER_1": "Extreme Sensitivity: The tiniest changes in the initial conditions (e.g., a thousandth of a degree deviation in the starting angle or mass) lead to a completely different, chaotic trajectory after just a short time.",
"DISCLAIMER_2": "Deterministic Chaos: The movement may look completely wild and random, but it isn't. If you restart the simulation with the exact same values, the pendulum will follow 100% the same path.", "DISCLAIMER_2": "Deterministic Chaos: The movement may look completely wild and random, but it isn't. If you restart the simulation with the exact same values, the pendulum will follow 100% the same path.",
"DISCLAIMER_3": "Numerical Integration: Since computers do not calculate time continuously but in tiny steps (dt), minute mathematical rounding errors occur in every frame. These add up over time and further influence the chaos.", "DISCLAIMER_3": "Numerical Integration: Since computers do not calculate time continuously but in tiny steps (dt), minute mathematical rounding errors occur in every frame. These add up over time and further influence the chaos.",
"DISCLAIMER_4": "Energy Conservation & Friction: In a perfect physical system without resistance, the pendulum would swing forever. For a natural look, the algorithm uses an artificial damping factor that simulates air friction and eventually brings the system to a halt.", "DISCLAIMER_4": "Energy Conservation & Friction: In a perfect physical system without resistance, the pendulum would swing forever. For a natural look, the algorithm uses an artificial damping factor that simulates air friction and eventually brings the system to a halt.",
"DISCLAIMER_BOTTOM": "NOTE: If too many impulses are fed into the system, the simulation becomes unstable. The pendulum will then just hang down and the simulation will have to be restarted." "DISCLAIMER_BOTTOM": "NOTE: If too many impulses are fed into the system, the simulation becomes unstable. The pendulum will then just hang down and the simulation will have to be restarted."
},
"HOW_IT_WORKS": {
"INTRO": "The motion follows the equations of motion of the double pendulum (Lagrangian form, formulas as in the Wikipedia article). Each rendered frame computes one step with a fixed time step dt = 0.015: first the angular accelerations a1 and a2, then the angular velocities (times damping, plus impulse), then the angles (semi-implicit Euler).",
"ITEM_1": "WebGPU: a compute shader with a single thread computes the physics step; the state (θ1, θ2, v1, v2) stays in a storage buffer on the GPU. A second compute shader (64 threads per workgroup, one per pixel) draws rods, masses and trail directly into a pixel buffer; the trail fades every frame by the trail length factor. A fragment shader displays the buffer.",
"ITEM_2": "Without WebGPU: the same physics in TypeScript on the CPU. The trail comes from ping-pong between two render target textures: a GLSL shader reads the old image, fades it and draws the two masses again; a second shader displays trail and rods.",
"ITEM_3": "Pixel buffer encoding: intensity 0 to 1 for trail 1, +2 for trail 2, +10 and +20 for the rods. So one float per pixel is enough.",
"ITEM_4": "The time step is fixed and not tied to the frame rate: at 120 Hz the pendulum runs twice as fast as at 60 Hz. Euler does not conserve energy exactly; big impulses can make the simulation unstable (see note)."
} }
}, },
"CLOTH": { "CLOTH": {
@@ -640,13 +693,21 @@
"DATA_STRUCTURES_EXPLANATION_TITLE": "Data Structures", "DATA_STRUCTURES_EXPLANATION_TITLE": "Data Structures",
"CLOTH_SIMULATION_EXPLANATION": "Cloth simulations usually model textiles as a grid of mass points (vertices) held together by invisible connections. The goal is to represent physical influences like gravity, wind, and collisions in real time without the material tearing or stretching unnaturally like rubber.", "CLOTH_SIMULATION_EXPLANATION": "Cloth simulations usually model textiles as a grid of mass points (vertices) held together by invisible connections. The goal is to represent physical influences like gravity, wind, and collisions in real time without the material tearing or stretching unnaturally like rubber.",
"XPBD_EXPLANATION": "XPBD (Extended Position-Based Dynamics) is a modern algorithm that manipulates point positions directly to satisfy distance conditions (constraints) instead of calculating accelerations. The 'Extended' means that true physical stiffness is simulated independently of the framerate. Advantage: Absolutely stable, does not explode, and topological changes (like cutting cloth) are trivial. Disadvantage: It is an iterative approximation method and slightly less physically accurate than complex matrix solvers.", "XPBD_EXPLANATION": "XPBD (Extended Position-Based Dynamics) is a modern algorithm that manipulates point positions directly to satisfy distance conditions (constraints) instead of calculating accelerations. The 'Extended' means that true physical stiffness is simulated independently of the framerate. Advantage: Absolutely stable, does not explode, and topological changes (like cutting cloth) are trivial. Disadvantage: It is an iterative approximation method and slightly less physically accurate than complex matrix solvers.",
"GPU_PARALLELIZATION_EXPLANATION": "To calculate tens of thousands of points in parallel on the graphics card, one must prevent 'race conditions' – i.e., two processing cores shifting the same node at the exact same time. The solution is called 'Independent Sets' (or Graph Coloring): The connections are divided into isolated groups (e.g., 4 phases for a 2D grid) in which not a single point overlaps. This allows the GPU to process each group blindly and at maximum speed.", "GPU_PARALLELIZATION_EXPLANATION": "To calculate tens of thousands of points in parallel on the graphics card, one must prevent 'race conditions' – i.e., two processing cores shifting the same node at the exact same time. The solution is called 'Independent Sets' (or Graph Coloring): The connections are divided into isolated groups (here 8 phases: 2 horizontal, 2 vertical, 4 diagonal) in which not a single point overlaps. This allows the GPU to process each group blindly and at maximum speed.",
"DATA_STRUCTURES_EXPLANATION": "For maximum GPU performance, data must be memory-aligned (16-byte alignment). Instead of using many individual variables, information is cleverly packed into blocks of four (vec4). A vertex stores, for example, [X, Y, Z, Inverse_Mass]. If a point has an inverse mass of 0.0, the algorithm ignores it, and it floats motionlessly in the air – an elegant trick for pinning cloth without extra if/then statements.", "DATA_STRUCTURES_EXPLANATION": "For maximum GPU performance, data must be memory-aligned (16-byte alignment). Instead of using many individual variables, information is cleverly packed into blocks of four (vec4). A vertex stores, for example, [X, Y, Z, Inverse_Mass]. If a point has an inverse mass of 0.0, the algorithm ignores it, and it floats motionlessly in the air – an elegant trick for pinning cloth without extra if/then statements.",
"DISCLAIMER": "XPBD vs. Mass-Spring Systems: In physical simulations, there are fundamental architectural differences when solving equations:", "DISCLAIMER": "XPBD vs. Mass-Spring Systems: In physical simulations, there are fundamental architectural differences when solving equations:",
"DISCLAIMER_1": "Classical Mass-Spring Systems: Here, forces (Hooke's Law) are calculated, leading to accelerations and ultimately new positions. There are two ways to mathematically project these into the future (integration):", "DISCLAIMER_1": "Classical Mass-Spring Systems: Here, forces (Hooke's Law) are calculated, leading to accelerations and ultimately new positions. There are two ways to mathematically project these into the future (integration):",
"DISCLAIMER_2": "Explicit Solvers (e.g., Forward Euler): These rigidly calculate the next step solely from the current state. They are easy to program but extremely unstable for stiff cloths. Forces can escalate and the simulation 'explodes' unless tiny, very performance-heavy time steps are chosen.", "DISCLAIMER_2": "Explicit Solvers (e.g., Forward Euler): These rigidly calculate the next step solely from the current state. They are easy to program but extremely unstable for stiff cloths. Forces can escalate and the simulation 'explodes' unless tiny, very performance-heavy time steps are chosen.",
"DISCLAIMER_3": "Implicit Solvers (e.g., Backward Euler): These calculate the next step based on the future state. This is mathematically highly stable but requires solving massive global matrix equation systems in every frame. This is harder to parallelize on the GPU and breaks down if the structure changes (e.g., when the cloth is cut).", "DISCLAIMER_3": "Implicit Solvers (e.g., Backward Euler): These calculate the next step based on the future state. This is mathematically highly stable but requires solving massive global matrix equation systems in every frame. This is harder to parallelize on the GPU and breaks down if the structure changes (e.g., when the cloth is cut).",
"DISCLAIMER_4": "The XPBD Compromise: XPBD completely bypasses this complex matrix problem by acting as a local solver. It combines the absolute stability of an implicit solver with the enormous speed, parallelizability, and dynamic adaptability of an explicit system." "DISCLAIMER_4": "The XPBD Compromise: XPBD completely bypasses this complex matrix problem by acting as a local solver. It combines the absolute stability of an implicit solver with the enormous speed, parallelizability, and dynamic adaptability of an explicit system."
},
"HOW_IT_WORKS": {
"INTRO": "The cloth is a grid of 100 × 100 = 10,000 particles. Each particle is a vec4 [x, y, z, 1/mass]; the top row has inverse mass 0 and is pinned. 39,402 distance constraints hold the particles together: horizontal, vertical and two diagonals against shearing. Per frame: integration (gravity, wind, position prediction), then 15 substeps in which every constraint is solved once (XPBD), finally the velocity from the position change.",
"ITEM_1": "XPBD: per constraint the error C = |pA − pB| − rest length is computed and both particles are moved by λ = −C / (wA + wB + α) along the connection, weighted by inverse mass. α = compliance / dt² makes the stiffness independent of the time step. The elongation slider scales the rest length.",
"ITEM_2": "GPU (WebGPU): three compute shaders (integrate, solve, velocity) with 64 threads per workgroup. All data stays in storage buffers on the GPU; the vertex shader reads the positions straight from the buffer. Nothing goes back to the CPU.",
"ITEM_3": "Graph coloring: two threads must never write the same particle. So the constraints are sorted into 8 phases (even and odd columns, even and odd rows, two per diagonal). Within one phase no particle has two constraints, so each phase runs without locks. That is 15 · 8 = 120 solver dispatches per frame (plus one each for integrate and velocity).",
"ITEM_4": "Without WebGPU: the same three steps in TypeScript on the CPU with the identical memory layout (Float32Array). The positions are copied into the mesh's vertex buffer every frame. 39,402 · 15 = 591,030 constraint solves per frame is a lot for a CPU; the frame rate drops clearly.",
"ITEM_5": "Wind: fixed direction (5, 0, 15) plus a sine flutter from position and time. The flutter part also adds a slight lift (y: flutter · 2)."
} }
}, },
"GPU": { "GPU": {
@@ -657,7 +718,7 @@
"TITLE": "Algorithms", "TITLE": "Algorithms",
"PATHFINDING": { "PATHFINDING": {
"TITLE": "Pathfinding", "TITLE": "Pathfinding",
"DESCRIPTION": "Comparing of Dijkstra vs. A*." "DESCRIPTION": "Dijkstra and A* on a grid, alone or side by side."
}, },
"SORTING": { "SORTING": {
"TITLE": "Sorting", "TITLE": "Sorting",
@@ -669,7 +730,7 @@
}, },
"LABYRINTH": { "LABYRINTH": {
"TITLE": "Maze Generation", "TITLE": "Maze Generation",
"DESCRIPTION": "Visualizing various maze generation algorithms." "DESCRIPTION": "Maze generation with Prim and Kruskal, then pathfinding inside it."
}, },
"FOUR_COLOR": { "FOUR_COLOR": {
"TITLE": "Four Color Theorem", "TITLE": "Four Color Theorem",
@@ -689,15 +750,17 @@
}, },
"PENDULUM": { "PENDULUM": {
"TITLE": "Double pendulum", "TITLE": "Double pendulum",
"DESCRIPTION": "Visualisation of a chaotic double pendulum simulation with WebGPU." "DESCRIPTION": "Chaotic double pendulum, physics in a WebGPU compute shader (fallback WebGL + CPU)."
}, },
"CLOTH": { "CLOTH": {
"TITLE": "Cloth simulation", "TITLE": "Cloth simulation",
"DESCRIPTION": "Simulation of cloth with WebGPU." "DESCRIPTION": "Cloth simulation with XPBD on the GPU (WebGPU, fallback WebGL + CPU)."
}, },
"NOTE": "Note", "NOTE": "Note",
"GRID_HEIGHT": "Height", "GRID_HEIGHT": "Height",
"GRID_WIDTH": "Width" "GRID_WIDTH": "Width",
"HOW_IT_WORKS": "How it works",
"SOURCE": "Source code"
}, },
"FOUR_COLOR": { "FOUR_COLOR": {
"TITLE": "Four Color Theorem", "TITLE": "Four Color Theorem",
@@ -727,7 +790,15 @@
"DISCLAIMER_BOTTOM": "Click to color the region. Click again to change the color." "DISCLAIMER_BOTTOM": "Click to color the region. Click again to change the color."
}, },
"ALERT": { "ALERT": {
"NO_SOLUTION": "No solution found (this should not happen for a planar map!)." "NO_SOLUTION": "No solution found (this should not happen for a planar map!).",
"LIMIT_REACHED": "No solution found within {{limit}} attempts. Please generate a new map."
},
"HOW_IT_WORKS": {
"INTRO": "The map is built on a grid: one seed per 30 cells is placed at a random free position. All seeds grow at the same time by breadth-first search (one shared queue) until every cell belongs to a region. Two regions are neighbors when two of their cells share an edge.",
"ITEM_1": "Data structure: a list of regions, each with a set of its neighbor ids (adjacency list). The grid stores only region id and color per cell.",
"ITEM_2": "Solving: backtracking. The solver always takes the most constrained region first (DSATUR): the one whose neighbors already use the most distinct colors, on a tie the one with the most neighbors. For it the colors 1 to 4 are tried; if none fits, the solver steps back. Attempts count every color assignment, including the ones undone later.",
"ITEM_3": "The default map (25 × 25, 20 regions) solves in under 1 ms. Usually it needs exactly one attempt per region. So the page never freezes, the solver gives up after 100,000 attempts (about 1 in 3,000 maps at the largest size) and says so; then generate a new map.",
"ITEM_4": "Checking: walk all regions and compare each color with its neighbors, O(regions + borders)."
} }
}, },
"FOURIER": { "FOURIER": {
@@ -749,6 +820,14 @@
"DISCLAIMER_2": "Each point is a complex number x + iy. A direct discrete Fourier transform gives one circle per frequency.", "DISCLAIMER_2": "Each point is a complex number x + iy. A direct discrete Fourier transform gives one circle per frequency.",
"DISCLAIMER_3": "The circles are sorted by radius. The slider keeps only the biggest N circles.", "DISCLAIMER_3": "The circles are sorted by radius. The slider keeps only the biggest N circles.",
"DISCLAIMER_BOTTOM": "Fewer circles give a smoother approximation. With all circles the curve passes exactly through all 256 points." "DISCLAIMER_BOTTOM": "Fewer circles give a smoother approximation. With all circles the curve passes exactly through all 256 points."
},
"HOW_IT_WORKS": {
"INTRO": "The drawn shape is treated as a closed polygon and resampled to 256 points with equal arc-length spacing. Each point is a complex number x + i·y. A direct discrete Fourier transform (two nested loops, O(N²) = 65,536 steps) gives one circle per frequency with radius, start angle and rotation speed. The circles are sorted by radius.",
"ITEM_1": "Frequencies run from 0 to N−1; everything above N/2 counts as a negative frequency so circles can also turn backwards.",
"ITEM_2": "Each frame adds up the circle centers (O(circles)); the tip of the last circle goes onto the trace. After one full period the trace is cleared.",
"ITEM_3": "Drawing uses Canvas 2D with requestAnimationFrame outside the Angular zone. dt is the time between two frames, so the speed does not depend on the frame rate.",
"ITEM_4": "Presets: heart and star are formulas, the square is four corner points, the dog is a point list smoothed with three Chaikin passes (corner cutting).",
"ITEM_5": "Fewer circles act like a low-pass filter: only the largest radii stay, the shape gets smoother."
} }
}, },
"STOPWATCH": { "STOPWATCH": {
@@ -807,5 +886,26 @@
"TITLE": "Page not found – Andreas Dahm", "TITLE": "Page not found – Andreas Dahm",
"DESCRIPTION": "The requested page could not be found." "DESCRIPTION": "The requested page could not be found."
} }
},
"METRICS": {
"FPS": "Frames/s",
"BACKEND": "Backend",
"STEP_TIME": "Compute time per generation",
"GENERATION": "Generation",
"GENERATIONS_PER_SECOND": "Generations/s",
"COMPARISONS": "Comparisons",
"WRITES": "Writes",
"REGIONS": "Regions",
"BORDERS": "Borders",
"ATTEMPTS": "Color attempts (backtracking)",
"SAMPLES": "Sample points",
"ZOOM": "Zoom",
"MAX_ITERATIONS": "Max. iterations",
"RAY_STEPS": "Max. ray steps",
"TIME_STEP": "Time step dt",
"PARTICLES": "Particles",
"CONSTRAINTS": "Constraints",
"PHASES": "Phases",
"SUBSTEPS": "Substeps per frame"
} }
} }