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);
}
}