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.
This commit is contained in:
lobo committed 2026-10-21 14:35:15 +02:00
1 parent 4b19245c6c
commit 833e1ec8dc
39 files changed
+543 -79

No files matched your search

@@ -23,6 +23,7 @@
</mat-slider>
</div>
</div>
<app-metrics [metrics]="metrics()"/>
<app-babylon-canvas
[config]="renderConfig"
(sceneReady)="onSceneReady($event)"
@@ -1,11 +1,11 @@
import {Component, signal} from '@angular/core';
import {Component, computed, signal} from '@angular/core';
import {FormsModule} from '@angular/forms';
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
import {MatSliderModule} from '@angular/material/slider';
import {TranslatePipe} from '@ngx-translate/core';
import {BabylonCanvas, RenderConfig, SceneEventData} from '../../../shared/components/render-canvas/babylon-canvas.component';
import {MatButton} from '@angular/material/button';
import {ClothConfig} from './cloth.model';
import {CLOTH_SUBSTEPS, ClothConfig, ClothStats} from './cloth.model';
import {Information} from '../information/information';
import {AlgorithmInformation} from '../information/information.models';
import {SOURCE_PATHS} from '../information/source-paths';
@@ -13,6 +13,8 @@ import {UrlConstants} from '../../../constants/UrlConstants';
import {ClothSimulationStrategy} from './strategies/cloth-simulation.strategy';
import {ClothGpuStrategy} from './strategies/cloth-gpu.strategy';
import {ClothCpuStrategy} from './strategies/cloth-cpu.strategy';
import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics';
import {FpsCounter} from '../../../shared/fps-counter';
@Component({
selector: 'app-cloth',
@@ -26,7 +28,8 @@ import {ClothCpuStrategy} from './strategies/cloth-cpu.strategy';
MatButton,
MatSliderModule,
FormsModule,
Information
Information,
MetricsComponent
],
templateUrl: './cloth.component.html',
styleUrl: './cloth.component.scss',
@@ -41,6 +44,19 @@ export class ClothComponent {
public stiffness: number = 80;
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 = {
mode: '3D',
initialViewSize: 20
@@ -110,7 +126,7 @@ export class ClothComponent {
}
const {engine, scene, gpuTier} = this.currentSceneData;
const config = this.getClothConfig();
const config = this.clothConfig;
if (this.strategy) {
this.strategy.dispose();
@@ -120,13 +136,16 @@ export class ClothComponent {
? new ClothGpuStrategy()
: new ClothCpuStrategy();
this.backend.set(gpuTier === 'webgpu' ? 'WebGPU (Compute)' : 'WebGL + CPU');
this.strategy.init(scene, engine, config);
this.stats.set(this.strategy.getStats());
this.startParamUpdateLoop(scene, engine);
}
private startParamUpdateLoop(scene: any, engine: any): void {
scene.onAfterRenderObservable.clear();
scene.onAfterRenderObservable.add(() => {
this.fpsCounter.tick();
this.simulationTime += engine.getDeltaTime() / 1000.0;
if (this.strategy) {
@@ -7,6 +7,21 @@ export interface ClothConfig {
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 {
positions: Float32Array;
prevPositions: Float32Array;
@@ -1,5 +1,5 @@
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 {ClothPhysicsParams, integratePositions, solveConstraints, updateVelocities} from './cloth-cpu-physics';
import {ClothSimulationParams, ClothSimulationStrategy} from './cloth-simulation.strategy';
@@ -13,6 +13,7 @@ export class ClothCpuStrategy implements ClothSimulationStrategy {
private prevPositions!: Float32Array;
private velocities!: Float32Array;
private constraintPhases!: Float32Array[];
private stats: ClothStats = {constraints: 0, phases: 0};
private physicsParams: ClothPhysicsParams = {
dt: 0.016,
@@ -36,11 +37,16 @@ export class ClothCpuStrategy implements ClothSimulationStrategy {
this.prevPositions = clothData.prevPositions;
this.velocities = clothData.velocities;
this.constraintPhases = clothData.constraints.map(c => new Float32Array(c));
this.stats = clothStatsOf(clothData.constraints);
this.setupRenderMesh(scene, config);
this.startRenderLoop(scene, config);
}
getStats(): ClothStats {
return this.stats;
}
updateParams(params: ClothSimulationParams): void {
const windX = params.isWindActive ? 5.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 {
const substeps = 15;
scene.onBeforeRenderObservable.clear();
scene.onBeforeRenderObservable.add(() => {
if (!this.clothMesh) {
@@ -201,7 +205,7 @@ export class ClothCpuStrategy implements ClothSimulationStrategy {
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) {
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 {ClothConfig, ClothData} from '../cloth.model';
import {CLOTH_SUBSTEPS, ClothConfig, ClothData, ClothStats, clothStatsOf} from '../cloth.model';
import {
CLOTH_FRAGMENT_SHADER_WGSL,
CLOTH_INTEGRATE_COMPUTE_WGSL,
@@ -32,6 +32,7 @@ export class ClothGpuStrategy implements ClothSimulationStrategy {
private dispatchXConstraints: number[] = [];
private dispatchXVertices = 0;
private numVertices = 0;
private stats: ClothStats = {constraints: 0, phases: 0};
init(scene: Scene, engine: WebGPUEngine | Engine, config: ClothConfig): void {
this.scene = scene;
@@ -39,6 +40,7 @@ export class ClothGpuStrategy implements ClothSimulationStrategy {
const gpuEngine = engine as WebGPUEngine;
const clothData = this.generateClothData(config);
this.stats = clothStatsOf(clothData.constraints);
this.buffers = this.createStorageBuffers(gpuEngine, clothData);
this.pipelines = this.setupComputePipelines(gpuEngine, this.buffers);
this.setupRenderMesh(scene, config, this.buffers.positions);
@@ -46,6 +48,10 @@ export class ClothGpuStrategy implements ClothSimulationStrategy {
this.startRenderLoop(scene);
}
getStats(): ClothStats {
return this.stats;
}
updateParams(params: ClothSimulationParams): void {
if (!this.buffers) {
return;
@@ -251,8 +257,6 @@ export class ClothGpuStrategy implements ClothSimulationStrategy {
}
private startRenderLoop(scene: Scene): void {
const substeps = 15;
scene.onBeforeRenderObservable.clear();
scene.onBeforeRenderObservable.add(() => {
if (!this.buffers || !this.pipelines) {
@@ -263,7 +267,7 @@ export class ClothGpuStrategy implements ClothSimulationStrategy {
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++) {
this.pipelines.solvers[phase].dispatch(this.dispatchXConstraints[phase], 1, 1);
}
@@ -1,5 +1,5 @@
import {Engine, GroundMesh, Scene, WebGPUEngine} from '@babylonjs/core';
import {ClothConfig} from '../cloth.model';
import {ClothConfig, ClothStats} from '../cloth.model';
export interface ClothSimulationParams {
stiffness: number;
@@ -13,5 +13,6 @@ export interface ClothSimulationStrategy {
init(scene: Scene, engine: WebGPUEngine | Engine, config: ClothConfig): void;
updateParams(params: ClothSimulationParams): void;
getMesh(): GroundMesh | null;
getStats(): ClothStats;
dispose(): void;
}
@@ -33,8 +33,8 @@
<mat-icon>play_arrow</mat-icon> {{ 'GOL.START' | translate }}
</button>
}
<p>{{ 'SORTING.EXECUTION_TIME' | translate }}: {{ executionTime() }} ms</p>
</div>
<app-metrics [metrics]="metrics()"/>
<div class="flex gap-3 items-center flex-wrap">
<mat-form-field appearance="outline" class="w-[150px]">
<mat-label>{{ 'ALGORITHM.GRID_HEIGHT' | translate }}</mat-label>
@@ -65,4 +65,16 @@ describe('ConwayGolComponent', () => {
expect(component['gridCols']()).toBeNull();
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,4 +1,4 @@
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 {TranslatePipe} from "@ngx-translate/core";
import {UrlConstants} from '../../../constants/UrlConstants';
@@ -12,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 {GenericGridComponent, GridPos} from '../../../shared/components/generic-grid/generic-grid';
import {ConwayGolService} from './service/conway-gol.service';
import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics';
import {FpsCounter} from '../../../shared/fps-counter';
@Component({
selector: 'app-conway-gol',
@@ -29,7 +31,8 @@ import {ConwayGolService} from './service/conway-gol.service';
MatLabel,
ReactiveFormsModule,
FormsModule,
GenericGridComponent
GenericGridComponent,
MetricsComponent
],
templateUrl: './conway-gol.component.html',
})
@@ -62,6 +65,13 @@ export class ConwayGolComponent implements AfterViewInit {
readGrid: Node[][] = [];
writeGrid: Node[][] = [];
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;
readonly gameStarted = signal(false);
@@ -143,6 +153,8 @@ export class ConwayGolComponent implements AfterViewInit {
initializeConwayGrid = (grid: Node[][]): void => {
this.gameStarted.set(false);
this.generation.set(0);
this.generationRate.reset();
this.readGrid = grid;
this.golService.applyScenario(grid, this.currentScenario);
this.writeGrid = structuredClone(this.readGrid);
@@ -161,6 +173,8 @@ export class ConwayGolComponent implements AfterViewInit {
const changed = this.golService.nextGeneration(this.readGrid, this.writeGrid);
this.swapGrids();
this.generation.update(count => count + 1);
this.generationRate.tick();
const endTime = performance.now();
const elapsed = Number.parseFloat((endTime - startTime).toFixed(4));
this.executionTime.set(elapsed);
@@ -171,6 +185,7 @@ export class ConwayGolComponent implements AfterViewInit {
await this.delay(delta);
}
this.executionTime.set(0);
this.generationRate.reset();
}
private swapGrids() {
@@ -47,6 +47,8 @@
<span><span class="legend-swatch bg-[#FFEB3B]"></span> {{ 'FOUR_COLOR.COLOR_4' | translate }}</span>
</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"
[ngClass]="{
'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 {FormsModule} from '@angular/forms';
import {MatButtonModule} from '@angular/material/button';
@@ -15,6 +15,7 @@ import {Information} from '../information/information';
import {GridPos} from '../../../shared/components/generic-grid/generic-grid';
import {FourColorService} from './service/four-color.service';
import {UrlConstants} from '../../../constants/UrlConstants';
import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics';
@Component({
selector: 'app-four-color',
@@ -27,7 +28,8 @@ import {UrlConstants} from '../../../constants/UrlConstants';
MatFormFieldModule,
MatInputModule,
TranslatePipe,
Information
Information,
MetricsComponent
],
templateUrl: './four-color.component.html',
styleUrl: './four-color.component.scss'
@@ -66,7 +68,16 @@ export class FourColorComponent implements AfterViewInit {
readonly gridRows = signal(DEFAULT_GRID_ROWS);
readonly gridCols = signal(DEFAULT_GRID_COLS);
map: FourColorMap = {grid: [], regions: []};
executionTime = 0;
readonly executionTime = signal(0);
readonly attempts = signal(0);
readonly regionCount = signal(0);
readonly borderCount = signal(0);
readonly metrics = computed<Metric[]>(() => [
{label: 'METRICS.REGIONS', value: this.regionCount()},
{label: 'METRICS.BORDERS', value: this.borderCount()},
{label: 'METRICS.ATTEMPTS', value: this.attempts()},
{label: 'FOUR_COLOR.EXECUTION_TIME', value: this.executionTime(), unit: 'ms'}
]);
readonly solutionStatus = signal<SolutionStatus>('INCOMPLETE');
@ViewChild('fourColorCanvas') canvasRef!: ElementRef<HTMLCanvasElement>;
@@ -91,6 +102,10 @@ export class FourColorComponent implements AfterViewInit {
initializeGrid(): void {
this.map = this.fourColorService.createMap(this.gridRows(), this.gridCols());
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.executionTime.set(0);
this.resizeCanvas();
this.drawGrid();
}
@@ -215,11 +230,12 @@ export class FourColorComponent implements AfterViewInit {
const startTime = performance.now();
this.resetColors();
const success = this.fourColorService.solve(this.map);
const {solved, attempts} = this.fourColorService.solve(this.map);
const endTime = performance.now();
this.executionTime = endTime - startTime;
this.executionTime.set(endTime - startTime);
this.attempts.set(attempts);
if (success) {
if (solved) {
this.solutionStatus.set(this.fourColorService.evaluate(this.map.regions));
this.drawGrid();
} else {
@@ -80,7 +80,7 @@ describe('FourColorService', () => {
it('colors K4 with four distinct colors and mirrors them to the grid', () => {
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);
map.regions.forEach((r, i) => expect(map.grid[0][i].color).toBe(r.color));
@@ -89,20 +89,38 @@ describe('FourColorService', () => {
it('fails on K5 and leaves every color at 0', () => {
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.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('solves a generated map', () => {
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');
});
});
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', () => {
it('is INCOMPLETE when nothing is colored', () => {
expect(service.evaluate(complete(3))).toBe('INCOMPLETE');
@@ -3,11 +3,18 @@ import {CELLS_PER_REGION, COLOR_COUNT, FourColorMap, FourColorNode, Region, Solu
import {GridPos} from '../../../../shared/components/generic-grid/generic-grid';
import {SharedFunctions} from '../../../../shared/SharedFunctions';
export interface SolveResult {
solved: boolean;
attempts: number;
}
@Injectable({
providedIn: 'root'
})
export class FourColorService {
private attempts = 0;
createMap(rows: number, cols: number): FourColorMap {
const grid: FourColorNode[][] = [];
for (let r = 0; r < rows; r++) {
@@ -39,10 +46,15 @@ export class FourColorService {
this.applyRegionColors(map);
}
solve(map: FourColorMap): boolean {
solve(map: FourColorMap): SolveResult {
this.attempts = 0;
const solved = this.assignColors(map.regions, 0);
this.applyRegionColors(map);
return solved;
return {solved, attempts: this.attempts};
}
countBorders(regions: Region[]): number {
return regions.reduce((sum, region) => sum + region.neighbors.size, 0) / 2;
}
evaluate(regions: Region[]): SolutionStatus {
@@ -137,6 +149,7 @@ export class FourColorService {
const region = regions[regionIndex];
for (let color = 1; color <= COLOR_COUNT; color++) {
if (this.isColorValid(regions, region, color)) {
this.attempts++;
region.color = color;
if (this.assignColors(regions, regionIndex + 1)) {
return true;
@@ -40,6 +40,8 @@
</span>
</div>
<app-metrics [metrics]="metrics()"/>
<div class="w-full max-w-[800px] mx-auto">
<canvas #fourierCanvas
class="touch-none cursor-crosshair max-w-full"
@@ -12,6 +12,8 @@ import {SOURCE_PATHS} from '../information/source-paths';
import {UrlConstants} from '../../../constants/UrlConstants';
import {ThemeService} from '../../../service/theme.service';
import {FourierService} from './service/fourier.service';
import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics';
import {FpsCounter} from '../../../shared/fps-counter';
import {
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';
@@ -28,7 +30,8 @@ import {
MatButton,
MatIcon,
MatSliderModule,
FormsModule
FormsModule,
MetricsComponent
],
templateUrl: './fourier.component.html'
})
@@ -74,6 +77,11 @@ export class FourierComponent implements AfterViewInit, OnDestroy {
readonly showCircles = signal(true);
readonly maxCircles = computed(() => Math.max(this.terms().length, 1));
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 MIN_SPEED = MIN_SPEED;
@@ -163,6 +171,7 @@ export class FourierComponent implements AfterViewInit, OnDestroy {
}
private readonly frame = (now: number): void => {
this.fpsCounter.tick(now);
const dt = this.lastFrameTime ? (now - this.lastFrameTime) / 1000 : 0;
this.lastFrameTime = now;
const positions = this.advance(dt);
@@ -40,6 +40,7 @@
</div>
</div>
<app-metrics [metrics]="metrics()"/>
<app-babylon-canvas
[config]="renderConfig"
[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 {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
import {TranslatePipe} from '@ngx-translate/core';
@@ -16,6 +16,8 @@ import {MatButton} from '@angular/material/button';
import {MatIcon} from '@angular/material/icon';
import {NgxSliderModule, Options} from '@angular-slider/ngx-slider';
import {clampZoom, initialView, iterationsForZoom, zoomAroundPoint} from './fractal-view';
import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics';
import {FpsCounter} from '../../../shared/fps-counter';
@Component({
selector: 'app-fractal',
@@ -34,7 +36,8 @@ import {clampZoom, initialView, iterationsForZoom, zoomAroundPoint} from './frac
BabylonCanvas,
MatButton,
MatIcon,
NgxSliderModule
NgxSliderModule,
MetricsComponent
],
templateUrl: './fractal.component.html',
styleUrl: './fractal.component.scss',
@@ -113,16 +116,26 @@ export class FractalComponent implements OnInit {
zoom = 0;
offsetX = 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;
juliaImag = 0.27015;
// --- Render Callback ---
onRender: RenderCallback = (material: ShaderMaterial) => {
this.fpsCounter.tick();
material.setVector2("center", new Vector2(this.offsetX, this.offsetY));
material.setFloat("zoom", this.zoom);
material.setInt("maxIterations", this.maxIterations);
material.setInt("maxIterations", this.maxIterations());
material.setInt("algorithm", this.selectedAlgorithm);
material.setInt("colorScheme", this.selectedColorScheme);
material.setVector2("juliaC", new Vector2(this.juliaReal, this.juliaImag));
@@ -153,10 +166,12 @@ export class FractalComponent implements OnInit {
onReset(): void {
({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 {
this.backend.set(event.gpuTier === 'webgpu' ? 'WebGPU' : 'WebGL');
event.scene.onPointerObservable.add((pointerInfo) => {
switch (pointerInfo.type) {
@@ -240,11 +255,11 @@ export class FractalComponent implements OnInit {
newZoom
));
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 {
this.zoom = clampZoom(newValue, this.minZoomValue, this.maxZoomValue);
this.maxIterations = iterationsForZoom(this.zoom);
this.maxIterations.set(iterationsForZoom(this.zoom));
}
}
@@ -11,9 +11,11 @@
<button matButton="filled" (click)="onFractalTypeChange(2)">{{ 'FRACTAL3D.JULIA' | translate }}</button>
</div>
</div>
<app-metrics [metrics]="metrics()"/>
<app-babylon-canvas
[config]="fractalConfig"
[renderCallback]="onRender"
(sceneReady)="onSceneReady($event)"
/>
</mat-card-content>
</mat-card>
@@ -1,6 +1,6 @@
import {Component} from '@angular/core';
import {Component, computed, signal} from '@angular/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 {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
import {TranslatePipe} from '@ngx-translate/core';
@@ -8,7 +8,9 @@ import {AlgorithmInformation} from '../information/information.models';
import {SOURCE_PATHS} from '../information/source-paths';
import {UrlConstants} from '../../../constants/UrlConstants';
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({
selector: 'app-fractal3d',
@@ -20,7 +22,8 @@ import {BabylonCanvas, RenderCallback, RenderConfig} from '../../../shared/compo
MatCardTitle,
TranslatePipe,
MatButton,
BabylonCanvas
BabylonCanvas,
MetricsComponent
],
templateUrl: './fractal3d.component.html',
styleUrl: './fractal3d.component.scss',
@@ -66,8 +69,16 @@ export class Fractal3dComponent {
private oldType = 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) => {
this.fpsCounter.tick();
this.time += 0.005;
if (this.oldType != this.currentFractalType && camera instanceof ArcRotateCamera) {
@@ -81,6 +92,10 @@ export class Fractal3dComponent {
};
onSceneReady(event: SceneEventData): void {
this.backend.set(event.gpuTier === 'webgpu' ? 'WebGPU' : 'WebGL');
}
onFractalTypeChange(type: number): void {
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;
attribute vec3 position;
attribute vec2 uv;
@@ -151,7 +153,7 @@ export const MANDELBULB_FRAGMENT = /* glsl */`
float t = max(tEnter, 0.0);
float tMax = tExit;
for (int i = 0; i < 128; i++) {
for (int i = 0; i < ${MAX_RAY_STEPS}; i++) {
vec3 pos = ro + t * rd;
float d = map(pos);
@@ -21,10 +21,7 @@
<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>
</div>
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
<p>{{ 'PATHFINDING.PATH_LENGTH' | translate }}: {{ pathLength() }}</p>
<p>{{ 'PATHFINDING.EXECUTION_TIME' | translate }}: {{ executionTime() | number:'1.2-2' }} ms</p>
</div>
<app-metrics [metrics]="metrics()"/>
</div>
<app-generic-grid
[gridRows]="gridRows"
@@ -1,4 +1,4 @@
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 {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
import {TranslatePipe} from '@ngx-translate/core';
@@ -8,7 +8,7 @@ import {SOURCE_PATHS} from '../../information/source-paths';
import {UrlConstants} from '../../../../constants/UrlConstants';
import {createPathfindingNode, Node, PathfindingAlgorithm} from '../pathfinding.models';
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 {PathfindingAnimator} from '../pathfinding-animator';
import {MazeGeneratorService} from './service/maze-generator.service';
@@ -24,7 +24,7 @@ import {MazeGeneratorService} from './service/maze-generator.service';
TranslatePipe,
GenericGridComponent,
MatButton,
DecimalPipe
MetricsComponent
],
templateUrl: './labyrinth.component.html',
styleUrl: './labyrinth.component.scss',
@@ -68,8 +68,15 @@ export class LabyrinthComponent implements AfterViewInit {
animationSpeed = 3;
mazeAnimationSpeed = 1;
readonly pathLength = signal("0");
readonly visitedCount = 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(
node => this.genericGridComponent?.drawNode(node),
() => this.animationSpeed
@@ -119,6 +126,7 @@ export class LabyrinthComponent implements AfterViewInit {
const endTime = performance.now();
this.pathLength.set(PathfindingAnimator.pathLengthLabel(result));
this.visitedCount.set(result.visitedNodesInOrder.length);
this.executionTime.set(endTime - startTime);
this.animator.animate(result);
@@ -60,17 +60,15 @@
<span><span class="legend-swatch bg-[gold]"></span> {{ 'PATHFINDING.PATH' | translate }}</span>
</div>
@if (!compareMode()) {
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
<p>{{ 'PATHFINDING.PATH_LENGTH' | translate }}: {{ pathLength() }}</p>
<p>{{ 'PATHFINDING.EXECUTION_TIME' | translate }}: {{ executionTime() | number:'1.2-2' }} ms</p>
</div>
<app-metrics [metrics]="metrics()"/>
}
</div>
<div class="flex flex-col lg:flex-row gap-4">
<div class="flex-1 min-w-0">
@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
#primaryGrid
@@ -85,7 +83,8 @@
></app-generic-grid>
</div>
<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
#compareGrid
[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 {FormsModule} from '@angular/forms';
@@ -20,6 +20,7 @@ import {Information} from '../information/information';
import {AlgorithmInformation} from '../information/information.models';
import {SOURCE_PATHS} from '../information/source-paths';
import {GenericGridComponent, GridPos} from '../../../shared/components/generic-grid/generic-grid';
import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics';
enum NodeType {
Start = 'start',
@@ -43,7 +44,8 @@ enum NodeType {
MatCardTitle,
MatCardContent,
Information,
GenericGridComponent
GenericGridComponent,
MetricsComponent
],
templateUrl: './pathfinding.component.html',
})
@@ -91,13 +93,20 @@ export class PathfindingComponent implements AfterViewInit {
private shouldAddWall = true; // Moved here
animationSpeed = 3;
readonly pathLength = signal("0");
readonly runStats = signal<PathfindingRunStats | null>(null);
readonly executionTime = signal(0);
readonly compareMode = signal(false);
readonly astarStats = 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(
node => this.genericGridComponent?.drawNode(node),
() => this.animationSpeed
@@ -251,7 +260,7 @@ export class PathfindingComponent implements AfterViewInit {
const result = this.pathfindingService.findPath(algorithm, this.grid, this.startNode!, this.endNode!);
const endTime = performance.now();
this.pathLength.set(PathfindingAnimator.pathLengthLabel(result));
this.runStats.set(this.toRunStats(result));
this.executionTime.set(endTime - startTime);
this.animator.animate(result);
@@ -318,6 +327,13 @@ export class PathfindingComponent implements AfterViewInit {
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 {
return {visitedCount: result.visitedNodesInOrder.length, pathLength: PathfindingAnimator.pathLengthLabel(result)};
}
@@ -62,6 +62,7 @@
<span><span class="legend-swatch bg-green-600"></span> M2</span>
</div>
</div>
<app-metrics [metrics]="metrics()"/>
<app-babylon-canvas
[config]="renderConfig"
(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 {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
@@ -14,6 +14,8 @@ import {UrlConstants} from '../../../constants/UrlConstants';
import {PendulumSimulationStrategy} from './strategies/pendulum-simulation.strategy';
import {PendulumGpuStrategy} from './strategies/pendulum-gpu.strategy';
import {PendulumCpuStrategy} from './strategies/pendulum-cpu.strategy';
import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics';
import {FpsCounter} from '../../../shared/fps-counter';
@Component({
selector: 'app-pendulum',
@@ -28,6 +30,7 @@ import {PendulumCpuStrategy} from './strategies/pendulum-cpu.strategy';
TranslatePipe,
MatButton,
Information,
MetricsComponent,
],
templateUrl: './pendulum.component.html',
styleUrl: './pendulum.component.scss',
@@ -127,6 +130,14 @@ class PendulumComponent {
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 strategy: PendulumSimulationStrategy | null = null;
@@ -150,6 +161,7 @@ class PendulumComponent {
? new PendulumGpuStrategy()
: new PendulumCpuStrategy();
this.backend.set(gpuTier === 'webgpu' ? 'WebGPU (Compute)' : 'WebGL + CPU');
this.strategy.init(scene, engine);
this.startParamUpdateLoop(scene);
}
@@ -157,6 +169,7 @@ class PendulumComponent {
private startParamUpdateLoop(scene: any): void {
scene.onAfterRenderObservable.clear();
scene.onAfterRenderObservable.add(() => {
this.fpsCounter.tick();
this.loop.time += this.loop.dt;
if (this.strategy) {
@@ -54,4 +54,37 @@ describe('SortingService', () => {
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 {
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 {
return {
array: array.map(item => ({ ...item }))
array: array.map(item => ({ ...item })),
comparisons: this.comparisons,
writes: this.writes
};
}
// --- BUBBLE SORT ---
bubbleSort(array: SortData[]): SortSnapshot[] {
const snapshots: SortSnapshot[] = [];
const arr = array.map(item => ({ ...item }));
const arr = this.startRun(array);
const n = arr.length;
snapshots.push(this.createSnapshot(arr));
@@ -26,10 +37,9 @@ export class SortingService {
arr[j + 1].state = 'comparing';
snapshots.push(this.createSnapshot(arr));
this.comparisons++;
if (arr[j].value > arr[j + 1].value) {
const temp = arr[j].value;
arr[j].value = arr[j + 1].value;
arr[j + 1].value = temp;
this.swap(arr, j, j + 1);
snapshots.push(this.createSnapshot(arr));
}
@@ -51,7 +61,7 @@ export class SortingService {
cocktailSort(array: SortData[]): SortSnapshot[] {
const snapshots: SortSnapshot[] = [];
const arr = array.map(item => ({ ...item }));
const arr = this.startRun(array);
snapshots.push(this.createSnapshot(arr));
let start = -1;
@@ -91,10 +101,9 @@ export class SortingService {
secondValue.state = 'comparing';
snapshots.push(this.createSnapshot(arr));
this.comparisons++;
if (firstValue.value > secondValue.value) {
const temp = firstValue.value;
firstValue.value = secondValue.value;
secondValue.value = temp;
this.swap(arr, i, i + 1);
changed = true;
}
firstValue.state = 'unsorted';
@@ -106,7 +115,7 @@ export class SortingService {
// --- QUICK SORT ---
quickSort(array: SortData[]): SortSnapshot[] {
const snapshots: SortSnapshot[] = [];
const arr = array.map(item => ({ ...item }));
const arr = this.startRun(array);
snapshots.push(this.createSnapshot(arr));
this.quickSortHelper(arr, 0, arr.length - 1, snapshots);
@@ -140,6 +149,7 @@ export class SortingService {
arr[j].state = 'comparing';
snapshots.push(this.createSnapshot(arr));
this.comparisons++;
if (arr[j].value < pivot.value) {
i++;
this.swap(arr, i, j);
@@ -159,7 +169,7 @@ export class SortingService {
// --- HEAP SORT ---
heapSort(array: SortData[]): SortSnapshot[] {
const snapshots: SortSnapshot[] = [];
const arr = array.map(item => ({ ...item }));
const arr = this.startRun(array);
const n = arr.length;
snapshots.push(this.createSnapshot(arr));
@@ -197,6 +207,7 @@ export class SortingService {
arr[largest].state = 'comparing';
snapshots.push(this.createSnapshot(arr));
this.comparisons++;
if (arr[l].value > arr[largest].value) {
largest = l;
}
@@ -209,6 +220,7 @@ export class SortingService {
arr[largest].state = 'comparing';
snapshots.push(this.createSnapshot(arr));
this.comparisons++;
if (arr[r].value > arr[largest].value) {
largest = r;
}
@@ -227,7 +239,7 @@ export class SortingService {
// --- TIM SORT ---
timSort(array: SortData[]): SortSnapshot[] {
const snapshots: SortSnapshot[] = [];
const arr = array.map(item => ({ ...item }));
const arr = this.startRun(array);
const n = arr.length;
const RUN = 32;
@@ -263,15 +275,21 @@ export class SortingService {
snapshots.push(this.createSnapshot(arr));
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 + 1].value = arr[j].value;
this.writes++;
arr[j].state = 'unsorted';
snapshots.push(this.createSnapshot(arr));
j--;
}
arr[j + 1].value = tempValue;
this.writes++;
arr[i].state = 'unsorted';
snapshots.push(this.createSnapshot(arr));
}
@@ -295,6 +313,7 @@ export class SortingService {
arr[rightSourceIdx].state = 'unsorted';
this.comparisons++;
if (leftPart[i].value <= rightPart[j].value) {
arr[k].value = leftPart[i].value;
i++;
@@ -302,6 +321,7 @@ export class SortingService {
arr[k].value = rightPart[j].value;
j++;
}
this.writes++;
arr[k].state = 'unsorted';
k++;
@@ -310,12 +330,14 @@ export class SortingService {
while (i < leftPart.length) {
arr[k].value = leftPart[i].value;
this.writes++;
i++;
k++;
}
while (j < rightPart.length) {
arr[k].value = rightPart[j].value;
this.writes++;
j++;
k++;
}
@@ -325,5 +347,6 @@ export class SortingService {
const temp = arr[i].value;
arr[i].value = arr[j].value;
arr[j].value = temp;
this.writes += 2;
}
}
@@ -43,9 +43,7 @@
</button>
</div>
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
<p>{{ 'SORTING.EXECUTION_TIME' | translate }}: {{ executionTime() }} ms</p>
</div>
<app-metrics [metrics]="metrics()"/>
<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) {
<div
@@ -68,4 +68,19 @@ describe('SortingComponent', () => {
expect(heights()).toEqual(initial);
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 {MatCardModule} from "@angular/material/card";
import {MatFormFieldModule} from "@angular/material/form-field";
@@ -15,9 +15,10 @@ import {UrlConstants} from '../../../constants/UrlConstants';
import {AlgorithmInformation} from '../information/information.models';
import {SOURCE_PATHS} from '../information/source-paths';
import {Information} from '../information/information';
import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics';
@Component({
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',
styleUrl: './sorting.component.scss'
})
@@ -77,6 +78,13 @@ export class SortingComponent implements OnInit {
animationSpeed = 100; // Milliseconds per step
readonly selectedAlgorithm = signal(this.algoInformation.entries[0].name);
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);
ngOnInit(): void {
@@ -154,6 +162,8 @@ export class SortingComponent implements OnInit {
snapshots.forEach((snapshot, index) => {
const id = setTimeout(() => {
this.sortArray.set(snapshot.array);
this.comparisons.set(snapshot.comparisons);
this.writes.set(snapshot.writes);
if (this.isSoundEnabled()) {
// Play a tone for each comparing bar (max 2 at once to avoid noise)
@@ -183,5 +193,7 @@ export class SortingComponent implements OnInit {
resetSorting(): void {
this.stopAnimations();
this.resetSortState();
this.comparisons.set(0);
this.writes.set(0);
}
}
@@ -5,4 +5,6 @@ export interface SortData {
export interface SortSnapshot {
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);
}
}
+22 -1
View File
@@ -533,7 +533,7 @@
"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.",
"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.",
@@ -885,5 +885,26 @@
"TITLE": "Seite nicht gefunden – Andreas Dahm",
"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"
}
}
+22 -1
View File
@@ -533,7 +533,7 @@
"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.",
"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.",
@@ -885,5 +885,26 @@
"TITLE": "Page not found – Andreas Dahm",
"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"
}
}