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:
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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user