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>
|
</mat-slider>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<app-metrics [metrics]="metrics()"/>
|
||||||
<app-babylon-canvas
|
<app-babylon-canvas
|
||||||
[config]="renderConfig"
|
[config]="renderConfig"
|
||||||
(sceneReady)="onSceneReady($event)"
|
(sceneReady)="onSceneReady($event)"
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import {Component, signal} from '@angular/core';
|
import {Component, computed, signal} from '@angular/core';
|
||||||
import {FormsModule} from '@angular/forms';
|
import {FormsModule} from '@angular/forms';
|
||||||
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
|
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
|
||||||
import {MatSliderModule} from '@angular/material/slider';
|
import {MatSliderModule} from '@angular/material/slider';
|
||||||
import {TranslatePipe} from '@ngx-translate/core';
|
import {TranslatePipe} from '@ngx-translate/core';
|
||||||
import {BabylonCanvas, RenderConfig, SceneEventData} from '../../../shared/components/render-canvas/babylon-canvas.component';
|
import {BabylonCanvas, RenderConfig, SceneEventData} from '../../../shared/components/render-canvas/babylon-canvas.component';
|
||||||
import {MatButton} from '@angular/material/button';
|
import {MatButton} from '@angular/material/button';
|
||||||
import {ClothConfig} from './cloth.model';
|
import {CLOTH_SUBSTEPS, ClothConfig, ClothStats} from './cloth.model';
|
||||||
import {Information} from '../information/information';
|
import {Information} from '../information/information';
|
||||||
import {AlgorithmInformation} from '../information/information.models';
|
import {AlgorithmInformation} from '../information/information.models';
|
||||||
import {SOURCE_PATHS} from '../information/source-paths';
|
import {SOURCE_PATHS} from '../information/source-paths';
|
||||||
@@ -13,6 +13,8 @@ import {UrlConstants} from '../../../constants/UrlConstants';
|
|||||||
import {ClothSimulationStrategy} from './strategies/cloth-simulation.strategy';
|
import {ClothSimulationStrategy} from './strategies/cloth-simulation.strategy';
|
||||||
import {ClothGpuStrategy} from './strategies/cloth-gpu.strategy';
|
import {ClothGpuStrategy} from './strategies/cloth-gpu.strategy';
|
||||||
import {ClothCpuStrategy} from './strategies/cloth-cpu.strategy';
|
import {ClothCpuStrategy} from './strategies/cloth-cpu.strategy';
|
||||||
|
import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics';
|
||||||
|
import {FpsCounter} from '../../../shared/fps-counter';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-cloth',
|
selector: 'app-cloth',
|
||||||
@@ -26,7 +28,8 @@ import {ClothCpuStrategy} from './strategies/cloth-cpu.strategy';
|
|||||||
MatButton,
|
MatButton,
|
||||||
MatSliderModule,
|
MatSliderModule,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
Information
|
Information,
|
||||||
|
MetricsComponent
|
||||||
],
|
],
|
||||||
templateUrl: './cloth.component.html',
|
templateUrl: './cloth.component.html',
|
||||||
styleUrl: './cloth.component.scss',
|
styleUrl: './cloth.component.scss',
|
||||||
@@ -41,6 +44,19 @@ export class ClothComponent {
|
|||||||
public stiffness: number = 80;
|
public stiffness: number = 80;
|
||||||
public readonly elongation = signal(1.0);
|
public readonly elongation = signal(1.0);
|
||||||
|
|
||||||
|
private readonly fpsCounter = new FpsCounter();
|
||||||
|
private readonly clothConfig = this.getClothConfig();
|
||||||
|
readonly backend = signal('–');
|
||||||
|
readonly stats = signal<ClothStats>({constraints: 0, phases: 0});
|
||||||
|
readonly metrics = computed<Metric[]>(() => [
|
||||||
|
{label: 'METRICS.FPS', value: this.fpsCounter.fps()},
|
||||||
|
{label: 'METRICS.BACKEND', value: this.backend()},
|
||||||
|
{label: 'METRICS.PARTICLES', value: this.clothConfig.numVertices},
|
||||||
|
{label: 'METRICS.CONSTRAINTS', value: this.stats().constraints},
|
||||||
|
{label: 'METRICS.PHASES', value: this.stats().phases},
|
||||||
|
{label: 'METRICS.SUBSTEPS', value: CLOTH_SUBSTEPS}
|
||||||
|
]);
|
||||||
|
|
||||||
public renderConfig: RenderConfig = {
|
public renderConfig: RenderConfig = {
|
||||||
mode: '3D',
|
mode: '3D',
|
||||||
initialViewSize: 20
|
initialViewSize: 20
|
||||||
@@ -110,7 +126,7 @@ export class ClothComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const {engine, scene, gpuTier} = this.currentSceneData;
|
const {engine, scene, gpuTier} = this.currentSceneData;
|
||||||
const config = this.getClothConfig();
|
const config = this.clothConfig;
|
||||||
|
|
||||||
if (this.strategy) {
|
if (this.strategy) {
|
||||||
this.strategy.dispose();
|
this.strategy.dispose();
|
||||||
@@ -120,13 +136,16 @@ export class ClothComponent {
|
|||||||
? new ClothGpuStrategy()
|
? new ClothGpuStrategy()
|
||||||
: new ClothCpuStrategy();
|
: new ClothCpuStrategy();
|
||||||
|
|
||||||
|
this.backend.set(gpuTier === 'webgpu' ? 'WebGPU (Compute)' : 'WebGL + CPU');
|
||||||
this.strategy.init(scene, engine, config);
|
this.strategy.init(scene, engine, config);
|
||||||
|
this.stats.set(this.strategy.getStats());
|
||||||
this.startParamUpdateLoop(scene, engine);
|
this.startParamUpdateLoop(scene, engine);
|
||||||
}
|
}
|
||||||
|
|
||||||
private startParamUpdateLoop(scene: any, engine: any): void {
|
private startParamUpdateLoop(scene: any, engine: any): void {
|
||||||
scene.onAfterRenderObservable.clear();
|
scene.onAfterRenderObservable.clear();
|
||||||
scene.onAfterRenderObservable.add(() => {
|
scene.onAfterRenderObservable.add(() => {
|
||||||
|
this.fpsCounter.tick();
|
||||||
this.simulationTime += engine.getDeltaTime() / 1000.0;
|
this.simulationTime += engine.getDeltaTime() / 1000.0;
|
||||||
|
|
||||||
if (this.strategy) {
|
if (this.strategy) {
|
||||||
|
|||||||
@@ -7,6 +7,21 @@ export interface ClothConfig {
|
|||||||
particleInvMass: number;
|
particleInvMass: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const CLOTH_SUBSTEPS = 15;
|
||||||
|
|
||||||
|
export interface ClothStats {
|
||||||
|
constraints: number;
|
||||||
|
phases: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Each constraint takes 4 numbers in its phase array. */
|
||||||
|
export function clothStatsOf(constraintPhases: number[][]): ClothStats {
|
||||||
|
return {
|
||||||
|
constraints: constraintPhases.reduce((sum, phase) => sum + phase.length / 4, 0),
|
||||||
|
phases: constraintPhases.length
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface ClothData {
|
export interface ClothData {
|
||||||
positions: Float32Array;
|
positions: Float32Array;
|
||||||
prevPositions: Float32Array;
|
prevPositions: Float32Array;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {ArcRotateCamera, Engine, GroundMesh, MeshBuilder, Scene, ShaderMaterial, VertexBuffer, WebGPUEngine} from '@babylonjs/core';
|
import {ArcRotateCamera, Engine, GroundMesh, MeshBuilder, Scene, ShaderMaterial, VertexBuffer, WebGPUEngine} from '@babylonjs/core';
|
||||||
import {ClothConfig, ClothData} from '../cloth.model';
|
import {CLOTH_SUBSTEPS, ClothConfig, ClothData, ClothStats, clothStatsOf} from '../cloth.model';
|
||||||
import {CLOTH_FRAGMENT_SHADER_GLSL, CLOTH_VERTEX_SHADER_GLSL} from '../cloth-glsl.shader';
|
import {CLOTH_FRAGMENT_SHADER_GLSL, CLOTH_VERTEX_SHADER_GLSL} from '../cloth-glsl.shader';
|
||||||
import {ClothPhysicsParams, integratePositions, solveConstraints, updateVelocities} from './cloth-cpu-physics';
|
import {ClothPhysicsParams, integratePositions, solveConstraints, updateVelocities} from './cloth-cpu-physics';
|
||||||
import {ClothSimulationParams, ClothSimulationStrategy} from './cloth-simulation.strategy';
|
import {ClothSimulationParams, ClothSimulationStrategy} from './cloth-simulation.strategy';
|
||||||
@@ -13,6 +13,7 @@ export class ClothCpuStrategy implements ClothSimulationStrategy {
|
|||||||
private prevPositions!: Float32Array;
|
private prevPositions!: Float32Array;
|
||||||
private velocities!: Float32Array;
|
private velocities!: Float32Array;
|
||||||
private constraintPhases!: Float32Array[];
|
private constraintPhases!: Float32Array[];
|
||||||
|
private stats: ClothStats = {constraints: 0, phases: 0};
|
||||||
|
|
||||||
private physicsParams: ClothPhysicsParams = {
|
private physicsParams: ClothPhysicsParams = {
|
||||||
dt: 0.016,
|
dt: 0.016,
|
||||||
@@ -36,11 +37,16 @@ export class ClothCpuStrategy implements ClothSimulationStrategy {
|
|||||||
this.prevPositions = clothData.prevPositions;
|
this.prevPositions = clothData.prevPositions;
|
||||||
this.velocities = clothData.velocities;
|
this.velocities = clothData.velocities;
|
||||||
this.constraintPhases = clothData.constraints.map(c => new Float32Array(c));
|
this.constraintPhases = clothData.constraints.map(c => new Float32Array(c));
|
||||||
|
this.stats = clothStatsOf(clothData.constraints);
|
||||||
|
|
||||||
this.setupRenderMesh(scene, config);
|
this.setupRenderMesh(scene, config);
|
||||||
this.startRenderLoop(scene, config);
|
this.startRenderLoop(scene, config);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getStats(): ClothStats {
|
||||||
|
return this.stats;
|
||||||
|
}
|
||||||
|
|
||||||
updateParams(params: ClothSimulationParams): void {
|
updateParams(params: ClothSimulationParams): void {
|
||||||
const windX = params.isWindActive ? 5.0 : 0.0;
|
const windX = params.isWindActive ? 5.0 : 0.0;
|
||||||
const windZ = params.isWindActive ? 15.0 : 0.0;
|
const windZ = params.isWindActive ? 15.0 : 0.0;
|
||||||
@@ -191,8 +197,6 @@ export class ClothCpuStrategy implements ClothSimulationStrategy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private startRenderLoop(scene: Scene, config: ClothConfig): void {
|
private startRenderLoop(scene: Scene, config: ClothConfig): void {
|
||||||
const substeps = 15;
|
|
||||||
|
|
||||||
scene.onBeforeRenderObservable.clear();
|
scene.onBeforeRenderObservable.clear();
|
||||||
scene.onBeforeRenderObservable.add(() => {
|
scene.onBeforeRenderObservable.add(() => {
|
||||||
if (!this.clothMesh) {
|
if (!this.clothMesh) {
|
||||||
@@ -201,7 +205,7 @@ export class ClothCpuStrategy implements ClothSimulationStrategy {
|
|||||||
|
|
||||||
integratePositions(this.positions, this.prevPositions, this.velocities, this.physicsParams);
|
integratePositions(this.positions, this.prevPositions, this.velocities, this.physicsParams);
|
||||||
|
|
||||||
for (let i = 0; i < substeps; i++) {
|
for (let i = 0; i < CLOTH_SUBSTEPS; i++) {
|
||||||
for (const phase of this.constraintPhases) {
|
for (const phase of this.constraintPhases) {
|
||||||
solveConstraints(this.positions, phase, this.physicsParams);
|
solveConstraints(this.positions, phase, this.physicsParams);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {ArcRotateCamera, ComputeShader, Engine, GroundMesh, MeshBuilder, Scene, ShaderLanguage, ShaderMaterial, StorageBuffer, WebGPUEngine} from '@babylonjs/core';
|
import {ArcRotateCamera, ComputeShader, Engine, GroundMesh, MeshBuilder, Scene, ShaderLanguage, ShaderMaterial, StorageBuffer, WebGPUEngine} from '@babylonjs/core';
|
||||||
import {ClothConfig, ClothData} from '../cloth.model';
|
import {CLOTH_SUBSTEPS, ClothConfig, ClothData, ClothStats, clothStatsOf} from '../cloth.model';
|
||||||
import {
|
import {
|
||||||
CLOTH_FRAGMENT_SHADER_WGSL,
|
CLOTH_FRAGMENT_SHADER_WGSL,
|
||||||
CLOTH_INTEGRATE_COMPUTE_WGSL,
|
CLOTH_INTEGRATE_COMPUTE_WGSL,
|
||||||
@@ -32,6 +32,7 @@ export class ClothGpuStrategy implements ClothSimulationStrategy {
|
|||||||
private dispatchXConstraints: number[] = [];
|
private dispatchXConstraints: number[] = [];
|
||||||
private dispatchXVertices = 0;
|
private dispatchXVertices = 0;
|
||||||
private numVertices = 0;
|
private numVertices = 0;
|
||||||
|
private stats: ClothStats = {constraints: 0, phases: 0};
|
||||||
|
|
||||||
init(scene: Scene, engine: WebGPUEngine | Engine, config: ClothConfig): void {
|
init(scene: Scene, engine: WebGPUEngine | Engine, config: ClothConfig): void {
|
||||||
this.scene = scene;
|
this.scene = scene;
|
||||||
@@ -39,6 +40,7 @@ export class ClothGpuStrategy implements ClothSimulationStrategy {
|
|||||||
const gpuEngine = engine as WebGPUEngine;
|
const gpuEngine = engine as WebGPUEngine;
|
||||||
|
|
||||||
const clothData = this.generateClothData(config);
|
const clothData = this.generateClothData(config);
|
||||||
|
this.stats = clothStatsOf(clothData.constraints);
|
||||||
this.buffers = this.createStorageBuffers(gpuEngine, clothData);
|
this.buffers = this.createStorageBuffers(gpuEngine, clothData);
|
||||||
this.pipelines = this.setupComputePipelines(gpuEngine, this.buffers);
|
this.pipelines = this.setupComputePipelines(gpuEngine, this.buffers);
|
||||||
this.setupRenderMesh(scene, config, this.buffers.positions);
|
this.setupRenderMesh(scene, config, this.buffers.positions);
|
||||||
@@ -46,6 +48,10 @@ export class ClothGpuStrategy implements ClothSimulationStrategy {
|
|||||||
this.startRenderLoop(scene);
|
this.startRenderLoop(scene);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getStats(): ClothStats {
|
||||||
|
return this.stats;
|
||||||
|
}
|
||||||
|
|
||||||
updateParams(params: ClothSimulationParams): void {
|
updateParams(params: ClothSimulationParams): void {
|
||||||
if (!this.buffers) {
|
if (!this.buffers) {
|
||||||
return;
|
return;
|
||||||
@@ -251,8 +257,6 @@ export class ClothGpuStrategy implements ClothSimulationStrategy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private startRenderLoop(scene: Scene): void {
|
private startRenderLoop(scene: Scene): void {
|
||||||
const substeps = 15;
|
|
||||||
|
|
||||||
scene.onBeforeRenderObservable.clear();
|
scene.onBeforeRenderObservable.clear();
|
||||||
scene.onBeforeRenderObservable.add(() => {
|
scene.onBeforeRenderObservable.add(() => {
|
||||||
if (!this.buffers || !this.pipelines) {
|
if (!this.buffers || !this.pipelines) {
|
||||||
@@ -263,7 +267,7 @@ export class ClothGpuStrategy implements ClothSimulationStrategy {
|
|||||||
|
|
||||||
this.pipelines.integrate.dispatch(this.dispatchXVertices, 1, 1);
|
this.pipelines.integrate.dispatch(this.dispatchXVertices, 1, 1);
|
||||||
|
|
||||||
for (let i = 0; i < substeps; i++) {
|
for (let i = 0; i < CLOTH_SUBSTEPS; i++) {
|
||||||
for (let phase = 0; phase < this.pipelines.solvers.length; phase++) {
|
for (let phase = 0; phase < this.pipelines.solvers.length; phase++) {
|
||||||
this.pipelines.solvers[phase].dispatch(this.dispatchXConstraints[phase], 1, 1);
|
this.pipelines.solvers[phase].dispatch(this.dispatchXConstraints[phase], 1, 1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {Engine, GroundMesh, Scene, WebGPUEngine} from '@babylonjs/core';
|
import {Engine, GroundMesh, Scene, WebGPUEngine} from '@babylonjs/core';
|
||||||
import {ClothConfig} from '../cloth.model';
|
import {ClothConfig, ClothStats} from '../cloth.model';
|
||||||
|
|
||||||
export interface ClothSimulationParams {
|
export interface ClothSimulationParams {
|
||||||
stiffness: number;
|
stiffness: number;
|
||||||
@@ -13,5 +13,6 @@ export interface ClothSimulationStrategy {
|
|||||||
init(scene: Scene, engine: WebGPUEngine | Engine, config: ClothConfig): void;
|
init(scene: Scene, engine: WebGPUEngine | Engine, config: ClothConfig): void;
|
||||||
updateParams(params: ClothSimulationParams): void;
|
updateParams(params: ClothSimulationParams): void;
|
||||||
getMesh(): GroundMesh | null;
|
getMesh(): GroundMesh | null;
|
||||||
|
getStats(): ClothStats;
|
||||||
dispose(): void;
|
dispose(): void;
|
||||||
}
|
}
|
||||||
@@ -33,8 +33,8 @@
|
|||||||
<mat-icon>play_arrow</mat-icon> {{ 'GOL.START' | translate }}
|
<mat-icon>play_arrow</mat-icon> {{ 'GOL.START' | translate }}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
<p>{{ 'SORTING.EXECUTION_TIME' | translate }}: {{ executionTime() }} ms</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<app-metrics [metrics]="metrics()"/>
|
||||||
<div class="flex gap-3 items-center flex-wrap">
|
<div class="flex gap-3 items-center flex-wrap">
|
||||||
<mat-form-field appearance="outline" class="w-[150px]">
|
<mat-form-field appearance="outline" class="w-[150px]">
|
||||||
<mat-label>{{ 'ALGORITHM.GRID_HEIGHT' | translate }}</mat-label>
|
<mat-label>{{ 'ALGORITHM.GRID_HEIGHT' | translate }}</mat-label>
|
||||||
|
|||||||
@@ -65,4 +65,16 @@ describe('ConwayGolComponent', () => {
|
|||||||
expect(component['gridCols']()).toBeNull();
|
expect(component['gridCols']()).toBeNull();
|
||||||
expect(component.readGrid[0].length).toBe(20);
|
expect(component.readGrid[0].length).toBe(20);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('counts generations while running and restarts at 0 on generate', () => {
|
||||||
|
const run = component.startGame();
|
||||||
|
component.pauseGame();
|
||||||
|
|
||||||
|
expect(component.generation()).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
component.generate(component.currentScenario);
|
||||||
|
|
||||||
|
expect(component.generation()).toBe(0);
|
||||||
|
return run;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,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 {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from "@angular/material/card";
|
||||||
import {TranslatePipe} from "@ngx-translate/core";
|
import {TranslatePipe} from "@ngx-translate/core";
|
||||||
import {UrlConstants} from '../../../constants/UrlConstants';
|
import {UrlConstants} from '../../../constants/UrlConstants';
|
||||||
@@ -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 {DEFAULT_GRID_COLS, DEFAULT_GRID_ROWS, DEFAULT_TIME_PER_GENERATION, MAX_GRID_PX, MAX_GRID_SIZE, MAX_TIME_PER_GENERATION, MIN_GRID_SIZE, MIN_TIME_PER_GENERATION, Node, Scenario} from './conway-gol.models';
|
||||||
import {GenericGridComponent, GridPos} from '../../../shared/components/generic-grid/generic-grid';
|
import {GenericGridComponent, GridPos} from '../../../shared/components/generic-grid/generic-grid';
|
||||||
import {ConwayGolService} from './service/conway-gol.service';
|
import {ConwayGolService} from './service/conway-gol.service';
|
||||||
|
import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics';
|
||||||
|
import {FpsCounter} from '../../../shared/fps-counter';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-conway-gol',
|
selector: 'app-conway-gol',
|
||||||
@@ -29,7 +31,8 @@ import {ConwayGolService} from './service/conway-gol.service';
|
|||||||
MatLabel,
|
MatLabel,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
GenericGridComponent
|
GenericGridComponent,
|
||||||
|
MetricsComponent
|
||||||
],
|
],
|
||||||
templateUrl: './conway-gol.component.html',
|
templateUrl: './conway-gol.component.html',
|
||||||
})
|
})
|
||||||
@@ -62,6 +65,13 @@ export class ConwayGolComponent implements AfterViewInit {
|
|||||||
readGrid: Node[][] = [];
|
readGrid: Node[][] = [];
|
||||||
writeGrid: Node[][] = [];
|
writeGrid: Node[][] = [];
|
||||||
readonly executionTime = signal(0);
|
readonly executionTime = signal(0);
|
||||||
|
readonly generation = signal(0);
|
||||||
|
private readonly generationRate = new FpsCounter();
|
||||||
|
readonly metrics = computed<Metric[]>(() => [
|
||||||
|
{label: 'METRICS.GENERATION', value: this.generation()},
|
||||||
|
{label: 'METRICS.GENERATIONS_PER_SECOND', value: this.generationRate.fps()},
|
||||||
|
{label: 'METRICS.STEP_TIME', value: this.executionTime(), unit: 'ms'}
|
||||||
|
]);
|
||||||
currentScenario: Scenario = Scenario.SIMPLE;
|
currentScenario: Scenario = Scenario.SIMPLE;
|
||||||
readonly gameStarted = signal(false);
|
readonly gameStarted = signal(false);
|
||||||
|
|
||||||
@@ -143,6 +153,8 @@ export class ConwayGolComponent implements AfterViewInit {
|
|||||||
|
|
||||||
initializeConwayGrid = (grid: Node[][]): void => {
|
initializeConwayGrid = (grid: Node[][]): void => {
|
||||||
this.gameStarted.set(false);
|
this.gameStarted.set(false);
|
||||||
|
this.generation.set(0);
|
||||||
|
this.generationRate.reset();
|
||||||
this.readGrid = grid;
|
this.readGrid = grid;
|
||||||
this.golService.applyScenario(grid, this.currentScenario);
|
this.golService.applyScenario(grid, this.currentScenario);
|
||||||
this.writeGrid = structuredClone(this.readGrid);
|
this.writeGrid = structuredClone(this.readGrid);
|
||||||
@@ -161,6 +173,8 @@ export class ConwayGolComponent implements AfterViewInit {
|
|||||||
const changed = this.golService.nextGeneration(this.readGrid, this.writeGrid);
|
const changed = this.golService.nextGeneration(this.readGrid, this.writeGrid);
|
||||||
|
|
||||||
this.swapGrids();
|
this.swapGrids();
|
||||||
|
this.generation.update(count => count + 1);
|
||||||
|
this.generationRate.tick();
|
||||||
const endTime = performance.now();
|
const endTime = performance.now();
|
||||||
const elapsed = Number.parseFloat((endTime - startTime).toFixed(4));
|
const elapsed = Number.parseFloat((endTime - startTime).toFixed(4));
|
||||||
this.executionTime.set(elapsed);
|
this.executionTime.set(elapsed);
|
||||||
@@ -171,6 +185,7 @@ export class ConwayGolComponent implements AfterViewInit {
|
|||||||
await this.delay(delta);
|
await this.delay(delta);
|
||||||
}
|
}
|
||||||
this.executionTime.set(0);
|
this.executionTime.set(0);
|
||||||
|
this.generationRate.reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
private swapGrids() {
|
private swapGrids() {
|
||||||
|
|||||||
@@ -47,6 +47,8 @@
|
|||||||
<span><span class="legend-swatch bg-[#FFEB3B]"></span> {{ 'FOUR_COLOR.COLOR_4' | translate }}</span>
|
<span><span class="legend-swatch bg-[#FFEB3B]"></span> {{ 'FOUR_COLOR.COLOR_4' | translate }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<app-metrics [metrics]="metrics()"/>
|
||||||
|
|
||||||
<div class="mt-5 flex gap-2.5 py-2.5 px-4 rounded-sm border-l-[5px] font-medium min-w-[300px] items-center"
|
<div class="mt-5 flex gap-2.5 py-2.5 px-4 rounded-sm border-l-[5px] font-medium min-w-[300px] items-center"
|
||||||
[ngClass]="{
|
[ngClass]="{
|
||||||
'border-l-gray-500 bg-app-bg': solutionStatus() === 'INCOMPLETE',
|
'border-l-gray-500 bg-app-bg': solutionStatus() === 'INCOMPLETE',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {AfterViewInit, Component, ElementRef, inject, PLATFORM_ID, signal, ViewChild} from '@angular/core';
|
import {AfterViewInit, Component, computed, ElementRef, inject, PLATFORM_ID, signal, ViewChild} from '@angular/core';
|
||||||
import {CommonModule, isPlatformBrowser} from '@angular/common';
|
import {CommonModule, isPlatformBrowser} from '@angular/common';
|
||||||
import {FormsModule} from '@angular/forms';
|
import {FormsModule} from '@angular/forms';
|
||||||
import {MatButtonModule} from '@angular/material/button';
|
import {MatButtonModule} from '@angular/material/button';
|
||||||
@@ -15,6 +15,7 @@ import {Information} from '../information/information';
|
|||||||
import {GridPos} from '../../../shared/components/generic-grid/generic-grid';
|
import {GridPos} from '../../../shared/components/generic-grid/generic-grid';
|
||||||
import {FourColorService} from './service/four-color.service';
|
import {FourColorService} from './service/four-color.service';
|
||||||
import {UrlConstants} from '../../../constants/UrlConstants';
|
import {UrlConstants} from '../../../constants/UrlConstants';
|
||||||
|
import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-four-color',
|
selector: 'app-four-color',
|
||||||
@@ -27,7 +28,8 @@ import {UrlConstants} from '../../../constants/UrlConstants';
|
|||||||
MatFormFieldModule,
|
MatFormFieldModule,
|
||||||
MatInputModule,
|
MatInputModule,
|
||||||
TranslatePipe,
|
TranslatePipe,
|
||||||
Information
|
Information,
|
||||||
|
MetricsComponent
|
||||||
],
|
],
|
||||||
templateUrl: './four-color.component.html',
|
templateUrl: './four-color.component.html',
|
||||||
styleUrl: './four-color.component.scss'
|
styleUrl: './four-color.component.scss'
|
||||||
@@ -66,7 +68,16 @@ export class FourColorComponent implements AfterViewInit {
|
|||||||
readonly gridRows = signal(DEFAULT_GRID_ROWS);
|
readonly gridRows = signal(DEFAULT_GRID_ROWS);
|
||||||
readonly gridCols = signal(DEFAULT_GRID_COLS);
|
readonly gridCols = signal(DEFAULT_GRID_COLS);
|
||||||
map: FourColorMap = {grid: [], regions: []};
|
map: FourColorMap = {grid: [], regions: []};
|
||||||
executionTime = 0;
|
readonly executionTime = signal(0);
|
||||||
|
readonly attempts = signal(0);
|
||||||
|
readonly 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');
|
readonly solutionStatus = signal<SolutionStatus>('INCOMPLETE');
|
||||||
|
|
||||||
@ViewChild('fourColorCanvas') canvasRef!: ElementRef<HTMLCanvasElement>;
|
@ViewChild('fourColorCanvas') canvasRef!: ElementRef<HTMLCanvasElement>;
|
||||||
@@ -91,6 +102,10 @@ export class FourColorComponent implements AfterViewInit {
|
|||||||
initializeGrid(): void {
|
initializeGrid(): void {
|
||||||
this.map = this.fourColorService.createMap(this.gridRows(), this.gridCols());
|
this.map = this.fourColorService.createMap(this.gridRows(), this.gridCols());
|
||||||
this.solutionStatus.set('INCOMPLETE');
|
this.solutionStatus.set('INCOMPLETE');
|
||||||
|
this.regionCount.set(this.map.regions.length);
|
||||||
|
this.borderCount.set(this.fourColorService.countBorders(this.map.regions));
|
||||||
|
this.attempts.set(0);
|
||||||
|
this.executionTime.set(0);
|
||||||
this.resizeCanvas();
|
this.resizeCanvas();
|
||||||
this.drawGrid();
|
this.drawGrid();
|
||||||
}
|
}
|
||||||
@@ -215,11 +230,12 @@ export class FourColorComponent implements AfterViewInit {
|
|||||||
const startTime = performance.now();
|
const startTime = performance.now();
|
||||||
this.resetColors();
|
this.resetColors();
|
||||||
|
|
||||||
const success = this.fourColorService.solve(this.map);
|
const {solved, attempts} = this.fourColorService.solve(this.map);
|
||||||
const endTime = performance.now();
|
const endTime = performance.now();
|
||||||
this.executionTime = endTime - startTime;
|
this.executionTime.set(endTime - startTime);
|
||||||
|
this.attempts.set(attempts);
|
||||||
|
|
||||||
if (success) {
|
if (solved) {
|
||||||
this.solutionStatus.set(this.fourColorService.evaluate(this.map.regions));
|
this.solutionStatus.set(this.fourColorService.evaluate(this.map.regions));
|
||||||
this.drawGrid();
|
this.drawGrid();
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ describe('FourColorService', () => {
|
|||||||
it('colors K4 with four distinct colors and mirrors them to the grid', () => {
|
it('colors K4 with four distinct colors and mirrors them to the grid', () => {
|
||||||
const map = handBuiltMap(complete(4));
|
const map = handBuiltMap(complete(4));
|
||||||
|
|
||||||
expect(service.solve(map)).toBe(true);
|
expect(service.solve(map).solved).toBe(true);
|
||||||
|
|
||||||
expect(new Set(map.regions.map(r => r.color)).size).toBe(4);
|
expect(new Set(map.regions.map(r => r.color)).size).toBe(4);
|
||||||
map.regions.forEach((r, i) => expect(map.grid[0][i].color).toBe(r.color));
|
map.regions.forEach((r, i) => expect(map.grid[0][i].color).toBe(r.color));
|
||||||
@@ -89,20 +89,38 @@ describe('FourColorService', () => {
|
|||||||
it('fails on K5 and leaves every color at 0', () => {
|
it('fails on K5 and leaves every color at 0', () => {
|
||||||
const map = handBuiltMap(complete(5));
|
const map = handBuiltMap(complete(5));
|
||||||
|
|
||||||
expect(service.solve(map)).toBe(false);
|
expect(service.solve(map).solved).toBe(false);
|
||||||
|
|
||||||
expect(map.regions.every(r => r.color === 0)).toBe(true);
|
expect(map.regions.every(r => r.color === 0)).toBe(true);
|
||||||
expect(map.grid.flat().every(node => node.color === 0)).toBe(true);
|
expect(map.grid.flat().every(node => node.color === 0)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('counts one attempt per region on a chain of three regions', () => {
|
||||||
|
const map = handBuiltMap([region(0, 0, [1]), region(1, 0, [0, 2]), region(2, 0, [1])]);
|
||||||
|
|
||||||
|
expect(service.solve(map).attempts).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts undone assignments as attempts when the map is unsolvable', () => {
|
||||||
|
const map = handBuiltMap(complete(5));
|
||||||
|
|
||||||
|
expect(service.solve(map).attempts).toBeGreaterThan(5);
|
||||||
|
});
|
||||||
|
|
||||||
it('solves a generated map', () => {
|
it('solves a generated map', () => {
|
||||||
const map = service.createMap(25, 25);
|
const map = service.createMap(25, 25);
|
||||||
|
|
||||||
expect(service.solve(map)).toBe(true);
|
expect(service.solve(map).solved).toBe(true);
|
||||||
expect(service.evaluate(map.regions)).toBe('SOLVED');
|
expect(service.evaluate(map.regions)).toBe('SOLVED');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('countBorders counts every shared border once', () => {
|
||||||
|
const chain = [region(0, 0, [1]), region(1, 0, [0, 2]), region(2, 0, [1])];
|
||||||
|
|
||||||
|
expect(service.countBorders(chain)).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
describe('evaluate', () => {
|
describe('evaluate', () => {
|
||||||
it('is INCOMPLETE when nothing is colored', () => {
|
it('is INCOMPLETE when nothing is colored', () => {
|
||||||
expect(service.evaluate(complete(3))).toBe('INCOMPLETE');
|
expect(service.evaluate(complete(3))).toBe('INCOMPLETE');
|
||||||
|
|||||||
@@ -3,11 +3,18 @@ import {CELLS_PER_REGION, COLOR_COUNT, FourColorMap, FourColorNode, Region, Solu
|
|||||||
import {GridPos} from '../../../../shared/components/generic-grid/generic-grid';
|
import {GridPos} from '../../../../shared/components/generic-grid/generic-grid';
|
||||||
import {SharedFunctions} from '../../../../shared/SharedFunctions';
|
import {SharedFunctions} from '../../../../shared/SharedFunctions';
|
||||||
|
|
||||||
|
export interface SolveResult {
|
||||||
|
solved: boolean;
|
||||||
|
attempts: number;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
})
|
})
|
||||||
export class FourColorService {
|
export class FourColorService {
|
||||||
|
|
||||||
|
private attempts = 0;
|
||||||
|
|
||||||
createMap(rows: number, cols: number): FourColorMap {
|
createMap(rows: number, cols: number): FourColorMap {
|
||||||
const grid: FourColorNode[][] = [];
|
const grid: FourColorNode[][] = [];
|
||||||
for (let r = 0; r < rows; r++) {
|
for (let r = 0; r < rows; r++) {
|
||||||
@@ -39,10 +46,15 @@ export class FourColorService {
|
|||||||
this.applyRegionColors(map);
|
this.applyRegionColors(map);
|
||||||
}
|
}
|
||||||
|
|
||||||
solve(map: FourColorMap): boolean {
|
solve(map: FourColorMap): SolveResult {
|
||||||
|
this.attempts = 0;
|
||||||
const solved = this.assignColors(map.regions, 0);
|
const solved = this.assignColors(map.regions, 0);
|
||||||
this.applyRegionColors(map);
|
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 {
|
evaluate(regions: Region[]): SolutionStatus {
|
||||||
@@ -137,6 +149,7 @@ export class FourColorService {
|
|||||||
const region = regions[regionIndex];
|
const region = regions[regionIndex];
|
||||||
for (let color = 1; color <= COLOR_COUNT; color++) {
|
for (let color = 1; color <= COLOR_COUNT; color++) {
|
||||||
if (this.isColorValid(regions, region, color)) {
|
if (this.isColorValid(regions, region, color)) {
|
||||||
|
this.attempts++;
|
||||||
region.color = color;
|
region.color = color;
|
||||||
if (this.assignColors(regions, regionIndex + 1)) {
|
if (this.assignColors(regions, regionIndex + 1)) {
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -40,6 +40,8 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<app-metrics [metrics]="metrics()"/>
|
||||||
|
|
||||||
<div class="w-full max-w-[800px] mx-auto">
|
<div class="w-full max-w-[800px] mx-auto">
|
||||||
<canvas #fourierCanvas
|
<canvas #fourierCanvas
|
||||||
class="touch-none cursor-crosshair max-w-full"
|
class="touch-none cursor-crosshair max-w-full"
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {SOURCE_PATHS} from '../information/source-paths';
|
|||||||
import {UrlConstants} from '../../../constants/UrlConstants';
|
import {UrlConstants} from '../../../constants/UrlConstants';
|
||||||
import {ThemeService} from '../../../service/theme.service';
|
import {ThemeService} from '../../../service/theme.service';
|
||||||
import {FourierService} from './service/fourier.service';
|
import {FourierService} from './service/fourier.service';
|
||||||
|
import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics';
|
||||||
|
import {FpsCounter} from '../../../shared/fps-counter';
|
||||||
import {
|
import {
|
||||||
DEFAULT_SPEED, EpicycleColors, EpicycleTerm, MAX_CANVAS_PX, MAX_SPEED, MIN_DRAW_LENGTH, MIN_DRAW_POINTS, MIN_SPEED, Point, Preset, SAMPLE_COUNT, THEME_COLORS
|
DEFAULT_SPEED, EpicycleColors, EpicycleTerm, MAX_CANVAS_PX, MAX_SPEED, MIN_DRAW_LENGTH, MIN_DRAW_POINTS, MIN_SPEED, Point, Preset, SAMPLE_COUNT, THEME_COLORS
|
||||||
} from './fourier.models';
|
} from './fourier.models';
|
||||||
@@ -28,7 +30,8 @@ import {
|
|||||||
MatButton,
|
MatButton,
|
||||||
MatIcon,
|
MatIcon,
|
||||||
MatSliderModule,
|
MatSliderModule,
|
||||||
FormsModule
|
FormsModule,
|
||||||
|
MetricsComponent
|
||||||
],
|
],
|
||||||
templateUrl: './fourier.component.html'
|
templateUrl: './fourier.component.html'
|
||||||
})
|
})
|
||||||
@@ -74,6 +77,11 @@ export class FourierComponent implements AfterViewInit, OnDestroy {
|
|||||||
readonly showCircles = signal(true);
|
readonly showCircles = signal(true);
|
||||||
readonly maxCircles = computed(() => Math.max(this.terms().length, 1));
|
readonly maxCircles = computed(() => Math.max(this.terms().length, 1));
|
||||||
readonly usedTerms = computed(() => this.terms().slice(0, this.circleCount()));
|
readonly usedTerms = computed(() => this.terms().slice(0, this.circleCount()));
|
||||||
|
private readonly fpsCounter = new FpsCounter();
|
||||||
|
readonly metrics = computed<Metric[]>(() => [
|
||||||
|
{label: 'METRICS.FPS', value: this.fpsCounter.fps()},
|
||||||
|
{label: 'METRICS.SAMPLES', value: SAMPLE_COUNT}
|
||||||
|
]);
|
||||||
|
|
||||||
protected readonly Preset = Preset;
|
protected readonly Preset = Preset;
|
||||||
protected readonly MIN_SPEED = MIN_SPEED;
|
protected readonly MIN_SPEED = MIN_SPEED;
|
||||||
@@ -163,6 +171,7 @@ export class FourierComponent implements AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private readonly frame = (now: number): void => {
|
private readonly frame = (now: number): void => {
|
||||||
|
this.fpsCounter.tick(now);
|
||||||
const dt = this.lastFrameTime ? (now - this.lastFrameTime) / 1000 : 0;
|
const dt = this.lastFrameTime ? (now - this.lastFrameTime) / 1000 : 0;
|
||||||
this.lastFrameTime = now;
|
this.lastFrameTime = now;
|
||||||
const positions = this.advance(dt);
|
const positions = this.advance(dt);
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<app-metrics [metrics]="metrics()"/>
|
||||||
<app-babylon-canvas
|
<app-babylon-canvas
|
||||||
[config]="renderConfig"
|
[config]="renderConfig"
|
||||||
[renderCallback]="onRender"
|
[renderCallback]="onRender"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {afterNextRender, Component, OnInit, signal} from '@angular/core';
|
import {afterNextRender, Component, computed, OnInit, signal} from '@angular/core';
|
||||||
import {Information} from '../information/information';
|
import {Information} from '../information/information';
|
||||||
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
|
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
|
||||||
import {TranslatePipe} from '@ngx-translate/core';
|
import {TranslatePipe} from '@ngx-translate/core';
|
||||||
@@ -16,6 +16,8 @@ import {MatButton} from '@angular/material/button';
|
|||||||
import {MatIcon} from '@angular/material/icon';
|
import {MatIcon} from '@angular/material/icon';
|
||||||
import {NgxSliderModule, Options} from '@angular-slider/ngx-slider';
|
import {NgxSliderModule, Options} from '@angular-slider/ngx-slider';
|
||||||
import {clampZoom, initialView, iterationsForZoom, zoomAroundPoint} from './fractal-view';
|
import {clampZoom, initialView, iterationsForZoom, zoomAroundPoint} from './fractal-view';
|
||||||
|
import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics';
|
||||||
|
import {FpsCounter} from '../../../shared/fps-counter';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-fractal',
|
selector: 'app-fractal',
|
||||||
@@ -34,7 +36,8 @@ import {clampZoom, initialView, iterationsForZoom, zoomAroundPoint} from './frac
|
|||||||
BabylonCanvas,
|
BabylonCanvas,
|
||||||
MatButton,
|
MatButton,
|
||||||
MatIcon,
|
MatIcon,
|
||||||
NgxSliderModule
|
NgxSliderModule,
|
||||||
|
MetricsComponent
|
||||||
],
|
],
|
||||||
templateUrl: './fractal.component.html',
|
templateUrl: './fractal.component.html',
|
||||||
styleUrl: './fractal.component.scss',
|
styleUrl: './fractal.component.scss',
|
||||||
@@ -113,16 +116,26 @@ export class FractalComponent implements OnInit {
|
|||||||
zoom = 0;
|
zoom = 0;
|
||||||
offsetX = 0;
|
offsetX = 0;
|
||||||
offsetY = 0;
|
offsetY = 0;
|
||||||
maxIterations = 0;
|
readonly maxIterations = signal(100);
|
||||||
|
|
||||||
|
private readonly fpsCounter = new FpsCounter();
|
||||||
|
readonly backend = signal('–');
|
||||||
|
readonly metrics = computed<Metric[]>(() => [
|
||||||
|
{label: 'METRICS.FPS', value: this.fpsCounter.fps()},
|
||||||
|
{label: 'METRICS.BACKEND', value: this.backend()},
|
||||||
|
{label: 'METRICS.ZOOM', value: this.sliderValue(), unit: '×'},
|
||||||
|
{label: 'METRICS.MAX_ITERATIONS', value: this.maxIterations()}
|
||||||
|
]);
|
||||||
|
|
||||||
juliaReal = -0.7;
|
juliaReal = -0.7;
|
||||||
juliaImag = 0.27015;
|
juliaImag = 0.27015;
|
||||||
|
|
||||||
// --- Render Callback ---
|
// --- Render Callback ---
|
||||||
onRender: RenderCallback = (material: ShaderMaterial) => {
|
onRender: RenderCallback = (material: ShaderMaterial) => {
|
||||||
|
this.fpsCounter.tick();
|
||||||
material.setVector2("center", new Vector2(this.offsetX, this.offsetY));
|
material.setVector2("center", new Vector2(this.offsetX, this.offsetY));
|
||||||
material.setFloat("zoom", this.zoom);
|
material.setFloat("zoom", this.zoom);
|
||||||
material.setInt("maxIterations", this.maxIterations);
|
material.setInt("maxIterations", this.maxIterations());
|
||||||
material.setInt("algorithm", this.selectedAlgorithm);
|
material.setInt("algorithm", this.selectedAlgorithm);
|
||||||
material.setInt("colorScheme", this.selectedColorScheme);
|
material.setInt("colorScheme", this.selectedColorScheme);
|
||||||
material.setVector2("juliaC", new Vector2(this.juliaReal, this.juliaImag));
|
material.setVector2("juliaC", new Vector2(this.juliaReal, this.juliaImag));
|
||||||
@@ -153,10 +166,12 @@ export class FractalComponent implements OnInit {
|
|||||||
|
|
||||||
onReset(): void {
|
onReset(): void {
|
||||||
({zoom: this.zoom, offsetX: this.offsetX, offsetY: this.offsetY} = initialView(this.selectedAlgorithm));
|
({zoom: this.zoom, offsetX: this.offsetX, offsetY: this.offsetY} = initialView(this.selectedAlgorithm));
|
||||||
this.maxIterations = 100;
|
this.maxIterations.set(100);
|
||||||
|
this.sliderValue.set(this.zoom);
|
||||||
}
|
}
|
||||||
|
|
||||||
onSceneReady(event: SceneEventData): void {
|
onSceneReady(event: SceneEventData): void {
|
||||||
|
this.backend.set(event.gpuTier === 'webgpu' ? 'WebGPU' : 'WebGL');
|
||||||
event.scene.onPointerObservable.add((pointerInfo) => {
|
event.scene.onPointerObservable.add((pointerInfo) => {
|
||||||
switch (pointerInfo.type) {
|
switch (pointerInfo.type) {
|
||||||
|
|
||||||
@@ -240,11 +255,11 @@ export class FractalComponent implements OnInit {
|
|||||||
newZoom
|
newZoom
|
||||||
));
|
));
|
||||||
this.sliderValue.set(this.zoom);
|
this.sliderValue.set(this.zoom);
|
||||||
this.maxIterations = Math.min(iterationsForZoom(this.zoom), 3000);
|
this.maxIterations.set(Math.min(iterationsForZoom(this.zoom), 3000));
|
||||||
}
|
}
|
||||||
|
|
||||||
onSliderChange(newValue: number): void {
|
onSliderChange(newValue: number): void {
|
||||||
this.zoom = clampZoom(newValue, this.minZoomValue, this.maxZoomValue);
|
this.zoom = clampZoom(newValue, this.minZoomValue, this.maxZoomValue);
|
||||||
this.maxIterations = iterationsForZoom(this.zoom);
|
this.maxIterations.set(iterationsForZoom(this.zoom));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -11,9 +11,11 @@
|
|||||||
<button matButton="filled" (click)="onFractalTypeChange(2)">{{ 'FRACTAL3D.JULIA' | translate }}</button>
|
<button matButton="filled" (click)="onFractalTypeChange(2)">{{ 'FRACTAL3D.JULIA' | translate }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<app-metrics [metrics]="metrics()"/>
|
||||||
<app-babylon-canvas
|
<app-babylon-canvas
|
||||||
[config]="fractalConfig"
|
[config]="fractalConfig"
|
||||||
[renderCallback]="onRender"
|
[renderCallback]="onRender"
|
||||||
|
(sceneReady)="onSceneReady($event)"
|
||||||
/>
|
/>
|
||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import {Component} from '@angular/core';
|
import {Component, computed, signal} from '@angular/core';
|
||||||
import {ArcRotateCamera, Camera, ShaderMaterial} from '@babylonjs/core';
|
import {ArcRotateCamera, Camera, ShaderMaterial} from '@babylonjs/core';
|
||||||
import {MANDELBULB_FRAGMENT, MANDELBULB_VERTEX} from './fractal3d.shader';
|
import {MANDELBULB_FRAGMENT, MANDELBULB_VERTEX, MAX_RAY_STEPS} from './fractal3d.shader';
|
||||||
import {Information} from '../information/information';
|
import {Information} from '../information/information';
|
||||||
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
|
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
|
||||||
import {TranslatePipe} from '@ngx-translate/core';
|
import {TranslatePipe} from '@ngx-translate/core';
|
||||||
@@ -8,7 +8,9 @@ import {AlgorithmInformation} from '../information/information.models';
|
|||||||
import {SOURCE_PATHS} from '../information/source-paths';
|
import {SOURCE_PATHS} from '../information/source-paths';
|
||||||
import {UrlConstants} from '../../../constants/UrlConstants';
|
import {UrlConstants} from '../../../constants/UrlConstants';
|
||||||
import {MatButton} from '@angular/material/button';
|
import {MatButton} from '@angular/material/button';
|
||||||
import {BabylonCanvas, RenderCallback, RenderConfig} from '../../../shared/components/render-canvas/babylon-canvas.component';
|
import {BabylonCanvas, RenderCallback, RenderConfig, SceneEventData} from '../../../shared/components/render-canvas/babylon-canvas.component';
|
||||||
|
import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics';
|
||||||
|
import {FpsCounter} from '../../../shared/fps-counter';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-fractal3d',
|
selector: 'app-fractal3d',
|
||||||
@@ -20,7 +22,8 @@ import {BabylonCanvas, RenderCallback, RenderConfig} from '../../../shared/compo
|
|||||||
MatCardTitle,
|
MatCardTitle,
|
||||||
TranslatePipe,
|
TranslatePipe,
|
||||||
MatButton,
|
MatButton,
|
||||||
BabylonCanvas
|
BabylonCanvas,
|
||||||
|
MetricsComponent
|
||||||
],
|
],
|
||||||
templateUrl: './fractal3d.component.html',
|
templateUrl: './fractal3d.component.html',
|
||||||
styleUrl: './fractal3d.component.scss',
|
styleUrl: './fractal3d.component.scss',
|
||||||
@@ -66,8 +69,16 @@ export class Fractal3dComponent {
|
|||||||
private oldType = 0;
|
private oldType = 0;
|
||||||
public currentFractalType = 0;
|
public currentFractalType = 0;
|
||||||
|
|
||||||
|
private readonly fpsCounter = new FpsCounter();
|
||||||
|
readonly backend = signal('–');
|
||||||
|
readonly metrics = computed<Metric[]>(() => [
|
||||||
|
{label: 'METRICS.FPS', value: this.fpsCounter.fps()},
|
||||||
|
{label: 'METRICS.BACKEND', value: this.backend()},
|
||||||
|
{label: 'METRICS.RAY_STEPS', value: MAX_RAY_STEPS}
|
||||||
|
]);
|
||||||
|
|
||||||
onRender: RenderCallback = (material: ShaderMaterial, camera: Camera) => {
|
onRender: RenderCallback = (material: ShaderMaterial, camera: Camera) => {
|
||||||
|
this.fpsCounter.tick();
|
||||||
this.time += 0.005;
|
this.time += 0.005;
|
||||||
|
|
||||||
if (this.oldType != this.currentFractalType && camera instanceof ArcRotateCamera) {
|
if (this.oldType != this.currentFractalType && camera instanceof ArcRotateCamera) {
|
||||||
@@ -81,6 +92,10 @@ export class Fractal3dComponent {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
onSceneReady(event: SceneEventData): void {
|
||||||
|
this.backend.set(event.gpuTier === 'webgpu' ? 'WebGPU' : 'WebGL');
|
||||||
|
}
|
||||||
|
|
||||||
onFractalTypeChange(type: number): void {
|
onFractalTypeChange(type: number): void {
|
||||||
this.currentFractalType = type;
|
this.currentFractalType = type;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
export const MANDELBULB_VERTEX = /* glsl */`
|
export const MAX_RAY_STEPS = 128;
|
||||||
|
|
||||||
|
export const MANDELBULB_VERTEX = /* glsl */`
|
||||||
precision highp float;
|
precision highp float;
|
||||||
attribute vec3 position;
|
attribute vec3 position;
|
||||||
attribute vec2 uv;
|
attribute vec2 uv;
|
||||||
@@ -151,7 +153,7 @@ export const MANDELBULB_FRAGMENT = /* glsl */`
|
|||||||
float t = max(tEnter, 0.0);
|
float t = max(tEnter, 0.0);
|
||||||
float tMax = tExit;
|
float tMax = tExit;
|
||||||
|
|
||||||
for (int i = 0; i < 128; i++) {
|
for (int i = 0; i < ${MAX_RAY_STEPS}; i++) {
|
||||||
vec3 pos = ro + t * rd;
|
vec3 pos = ro + t * rd;
|
||||||
float d = map(pos);
|
float d = map(pos);
|
||||||
|
|
||||||
|
|||||||
@@ -21,10 +21,7 @@
|
|||||||
<span><span class="legend-swatch bg-sky-300"></span> {{ 'PATHFINDING.VISITED' | translate }}</span>
|
<span><span class="legend-swatch bg-sky-300"></span> {{ 'PATHFINDING.VISITED' | translate }}</span>
|
||||||
<span><span class="legend-swatch bg-[gold]"></span> {{ 'PATHFINDING.PATH' | translate }}</span>
|
<span><span class="legend-swatch bg-[gold]"></span> {{ 'PATHFINDING.PATH' | translate }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
|
<app-metrics [metrics]="metrics()"/>
|
||||||
<p>{{ 'PATHFINDING.PATH_LENGTH' | translate }}: {{ pathLength() }}</p>
|
|
||||||
<p>{{ 'PATHFINDING.EXECUTION_TIME' | translate }}: {{ executionTime() | number:'1.2-2' }} ms</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<app-generic-grid
|
<app-generic-grid
|
||||||
[gridRows]="gridRows"
|
[gridRows]="gridRows"
|
||||||
|
|||||||
@@ -1,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 {Information} from '../../information/information';
|
||||||
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
|
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
|
||||||
import {TranslatePipe} from '@ngx-translate/core';
|
import {TranslatePipe} from '@ngx-translate/core';
|
||||||
@@ -8,7 +8,7 @@ import {SOURCE_PATHS} from '../../information/source-paths';
|
|||||||
import {UrlConstants} from '../../../../constants/UrlConstants';
|
import {UrlConstants} from '../../../../constants/UrlConstants';
|
||||||
import {createPathfindingNode, Node, PathfindingAlgorithm} from '../pathfinding.models';
|
import {createPathfindingNode, Node, PathfindingAlgorithm} from '../pathfinding.models';
|
||||||
import {MatButton} from '@angular/material/button';
|
import {MatButton} from '@angular/material/button';
|
||||||
import {DecimalPipe} from '@angular/common';
|
import {Metric, MetricsComponent} from '../../../../shared/components/metrics/metrics';
|
||||||
import {PathfindingService} from '../service/pathfinding.service';
|
import {PathfindingService} from '../service/pathfinding.service';
|
||||||
import {PathfindingAnimator} from '../pathfinding-animator';
|
import {PathfindingAnimator} from '../pathfinding-animator';
|
||||||
import {MazeGeneratorService} from './service/maze-generator.service';
|
import {MazeGeneratorService} from './service/maze-generator.service';
|
||||||
@@ -24,7 +24,7 @@ import {MazeGeneratorService} from './service/maze-generator.service';
|
|||||||
TranslatePipe,
|
TranslatePipe,
|
||||||
GenericGridComponent,
|
GenericGridComponent,
|
||||||
MatButton,
|
MatButton,
|
||||||
DecimalPipe
|
MetricsComponent
|
||||||
],
|
],
|
||||||
templateUrl: './labyrinth.component.html',
|
templateUrl: './labyrinth.component.html',
|
||||||
styleUrl: './labyrinth.component.scss',
|
styleUrl: './labyrinth.component.scss',
|
||||||
@@ -68,8 +68,15 @@ export class LabyrinthComponent implements AfterViewInit {
|
|||||||
animationSpeed = 3;
|
animationSpeed = 3;
|
||||||
mazeAnimationSpeed = 1;
|
mazeAnimationSpeed = 1;
|
||||||
readonly pathLength = signal("0");
|
readonly pathLength = signal("0");
|
||||||
|
readonly visitedCount = signal(0);
|
||||||
readonly executionTime = signal(0);
|
readonly executionTime = signal(0);
|
||||||
|
|
||||||
|
readonly metrics = computed<Metric[]>(() => [
|
||||||
|
{label: 'PATHFINDING.VISITED_NODES', value: this.visitedCount()},
|
||||||
|
{label: 'PATHFINDING.PATH_LENGTH', value: this.pathLength()},
|
||||||
|
{label: 'PATHFINDING.EXECUTION_TIME', value: this.executionTime(), unit: 'ms'}
|
||||||
|
]);
|
||||||
|
|
||||||
private readonly animator = new PathfindingAnimator(
|
private readonly animator = new PathfindingAnimator(
|
||||||
node => this.genericGridComponent?.drawNode(node),
|
node => this.genericGridComponent?.drawNode(node),
|
||||||
() => this.animationSpeed
|
() => this.animationSpeed
|
||||||
@@ -119,6 +126,7 @@ export class LabyrinthComponent implements AfterViewInit {
|
|||||||
const endTime = performance.now();
|
const endTime = performance.now();
|
||||||
|
|
||||||
this.pathLength.set(PathfindingAnimator.pathLengthLabel(result));
|
this.pathLength.set(PathfindingAnimator.pathLengthLabel(result));
|
||||||
|
this.visitedCount.set(result.visitedNodesInOrder.length);
|
||||||
this.executionTime.set(endTime - startTime);
|
this.executionTime.set(endTime - startTime);
|
||||||
|
|
||||||
this.animator.animate(result);
|
this.animator.animate(result);
|
||||||
|
|||||||
@@ -60,17 +60,15 @@
|
|||||||
<span><span class="legend-swatch bg-[gold]"></span> {{ 'PATHFINDING.PATH' | translate }}</span>
|
<span><span class="legend-swatch bg-[gold]"></span> {{ 'PATHFINDING.PATH' | translate }}</span>
|
||||||
</div>
|
</div>
|
||||||
@if (!compareMode()) {
|
@if (!compareMode()) {
|
||||||
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
|
<app-metrics [metrics]="metrics()"/>
|
||||||
<p>{{ 'PATHFINDING.PATH_LENGTH' | translate }}: {{ pathLength() }}</p>
|
|
||||||
<p>{{ 'PATHFINDING.EXECUTION_TIME' | translate }}: {{ executionTime() | number:'1.2-2' }} ms</p>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col lg:flex-row gap-4">
|
<div class="flex flex-col lg:flex-row gap-4">
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
@if (compareMode()) {
|
@if (compareMode()) {
|
||||||
<p class="text-[0.9em] mb-2">A* · {{ 'PATHFINDING.VISITED_NODES' | translate }}: {{ astarStats()?.visitedCount }} · {{ 'PATHFINDING.PATH_LENGTH' | translate }}: {{ astarStats()?.pathLength }}</p>
|
<p class="m-0 font-semibold">A*</p>
|
||||||
|
<app-metrics [metrics]="astarMetrics()"/>
|
||||||
}
|
}
|
||||||
<app-generic-grid
|
<app-generic-grid
|
||||||
#primaryGrid
|
#primaryGrid
|
||||||
@@ -85,7 +83,8 @@
|
|||||||
></app-generic-grid>
|
></app-generic-grid>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 min-w-0" [class.hidden]="!compareMode()">
|
<div class="flex-1 min-w-0" [class.hidden]="!compareMode()">
|
||||||
<p class="text-[0.9em] mb-2">Dijkstra · {{ 'PATHFINDING.VISITED_NODES' | translate }}: {{ dijkstraStats()?.visitedCount }} · {{ 'PATHFINDING.PATH_LENGTH' | translate }}: {{ dijkstraStats()?.pathLength }}</p>
|
<p class="m-0 font-semibold">Dijkstra</p>
|
||||||
|
<app-metrics [metrics]="dijkstraMetrics()"/>
|
||||||
<app-generic-grid
|
<app-generic-grid
|
||||||
#compareGrid
|
#compareGrid
|
||||||
[minGridSize]="MIN_GRID_SIZE"
|
[minGridSize]="MIN_GRID_SIZE"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {afterNextRender, AfterViewInit, Component, inject, Injector, signal, ViewChild} from '@angular/core';
|
import {afterNextRender, AfterViewInit, Component, computed, inject, Injector, signal, ViewChild} from '@angular/core';
|
||||||
import {CommonModule} from '@angular/common';
|
import {CommonModule} from '@angular/common';
|
||||||
import {FormsModule} from '@angular/forms';
|
import {FormsModule} from '@angular/forms';
|
||||||
|
|
||||||
@@ -20,6 +20,7 @@ import {Information} from '../information/information';
|
|||||||
import {AlgorithmInformation} from '../information/information.models';
|
import {AlgorithmInformation} from '../information/information.models';
|
||||||
import {SOURCE_PATHS} from '../information/source-paths';
|
import {SOURCE_PATHS} from '../information/source-paths';
|
||||||
import {GenericGridComponent, GridPos} from '../../../shared/components/generic-grid/generic-grid';
|
import {GenericGridComponent, GridPos} from '../../../shared/components/generic-grid/generic-grid';
|
||||||
|
import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics';
|
||||||
|
|
||||||
enum NodeType {
|
enum NodeType {
|
||||||
Start = 'start',
|
Start = 'start',
|
||||||
@@ -43,7 +44,8 @@ enum NodeType {
|
|||||||
MatCardTitle,
|
MatCardTitle,
|
||||||
MatCardContent,
|
MatCardContent,
|
||||||
Information,
|
Information,
|
||||||
GenericGridComponent
|
GenericGridComponent,
|
||||||
|
MetricsComponent
|
||||||
],
|
],
|
||||||
templateUrl: './pathfinding.component.html',
|
templateUrl: './pathfinding.component.html',
|
||||||
})
|
})
|
||||||
@@ -91,13 +93,20 @@ export class PathfindingComponent implements AfterViewInit {
|
|||||||
private shouldAddWall = true; // Moved here
|
private shouldAddWall = true; // Moved here
|
||||||
|
|
||||||
animationSpeed = 3;
|
animationSpeed = 3;
|
||||||
readonly pathLength = signal("0");
|
readonly runStats = signal<PathfindingRunStats | null>(null);
|
||||||
readonly executionTime = signal(0);
|
readonly executionTime = signal(0);
|
||||||
|
|
||||||
readonly compareMode = signal(false);
|
readonly compareMode = signal(false);
|
||||||
readonly astarStats = signal<PathfindingRunStats | null>(null);
|
readonly astarStats = signal<PathfindingRunStats | null>(null);
|
||||||
readonly dijkstraStats = signal<PathfindingRunStats | null>(null);
|
readonly dijkstraStats = signal<PathfindingRunStats | null>(null);
|
||||||
|
|
||||||
|
readonly metrics = computed<Metric[]>(() => [
|
||||||
|
...this.runMetrics(this.runStats()),
|
||||||
|
{label: 'PATHFINDING.EXECUTION_TIME', value: this.executionTime(), unit: 'ms'}
|
||||||
|
]);
|
||||||
|
readonly astarMetrics = computed(() => this.runMetrics(this.astarStats()));
|
||||||
|
readonly dijkstraMetrics = computed(() => this.runMetrics(this.dijkstraStats()));
|
||||||
|
|
||||||
private readonly animator = new PathfindingAnimator(
|
private readonly animator = new PathfindingAnimator(
|
||||||
node => this.genericGridComponent?.drawNode(node),
|
node => this.genericGridComponent?.drawNode(node),
|
||||||
() => this.animationSpeed
|
() => this.animationSpeed
|
||||||
@@ -251,7 +260,7 @@ export class PathfindingComponent implements AfterViewInit {
|
|||||||
const result = this.pathfindingService.findPath(algorithm, this.grid, this.startNode!, this.endNode!);
|
const result = this.pathfindingService.findPath(algorithm, this.grid, this.startNode!, this.endNode!);
|
||||||
const endTime = performance.now();
|
const endTime = performance.now();
|
||||||
|
|
||||||
this.pathLength.set(PathfindingAnimator.pathLengthLabel(result));
|
this.runStats.set(this.toRunStats(result));
|
||||||
this.executionTime.set(endTime - startTime);
|
this.executionTime.set(endTime - startTime);
|
||||||
|
|
||||||
this.animator.animate(result);
|
this.animator.animate(result);
|
||||||
@@ -318,6 +327,13 @@ export class PathfindingComponent implements AfterViewInit {
|
|||||||
this.compareAnimator.stop();
|
this.compareAnimator.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private runMetrics(stats: PathfindingRunStats | null): Metric[] {
|
||||||
|
return [
|
||||||
|
{label: 'PATHFINDING.VISITED_NODES', value: stats?.visitedCount ?? 0},
|
||||||
|
{label: 'PATHFINDING.PATH_LENGTH', value: stats?.pathLength ?? '0'}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
private toRunStats(result: PathfindingResult): PathfindingRunStats {
|
private toRunStats(result: PathfindingResult): PathfindingRunStats {
|
||||||
return {visitedCount: result.visitedNodesInOrder.length, pathLength: PathfindingAnimator.pathLengthLabel(result)};
|
return {visitedCount: result.visitedNodesInOrder.length, pathLength: PathfindingAnimator.pathLengthLabel(result)};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,7 @@
|
|||||||
<span><span class="legend-swatch bg-green-600"></span> M2</span>
|
<span><span class="legend-swatch bg-green-600"></span> M2</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<app-metrics [metrics]="metrics()"/>
|
||||||
<app-babylon-canvas
|
<app-babylon-canvas
|
||||||
[config]="renderConfig"
|
[config]="renderConfig"
|
||||||
(sceneReady)="onSceneReady($event)"
|
(sceneReady)="onSceneReady($event)"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {afterNextRender, Component, signal} from '@angular/core';
|
import {afterNextRender, Component, computed, signal} from '@angular/core';
|
||||||
import {BabylonCanvas, RenderConfig, SceneEventData} from '../../../shared/components/render-canvas/babylon-canvas.component';
|
import {BabylonCanvas, RenderConfig, SceneEventData} from '../../../shared/components/render-canvas/babylon-canvas.component';
|
||||||
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
|
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
|
||||||
|
|
||||||
@@ -14,6 +14,8 @@ import {UrlConstants} from '../../../constants/UrlConstants';
|
|||||||
import {PendulumSimulationStrategy} from './strategies/pendulum-simulation.strategy';
|
import {PendulumSimulationStrategy} from './strategies/pendulum-simulation.strategy';
|
||||||
import {PendulumGpuStrategy} from './strategies/pendulum-gpu.strategy';
|
import {PendulumGpuStrategy} from './strategies/pendulum-gpu.strategy';
|
||||||
import {PendulumCpuStrategy} from './strategies/pendulum-cpu.strategy';
|
import {PendulumCpuStrategy} from './strategies/pendulum-cpu.strategy';
|
||||||
|
import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics';
|
||||||
|
import {FpsCounter} from '../../../shared/fps-counter';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-pendulum',
|
selector: 'app-pendulum',
|
||||||
@@ -28,6 +30,7 @@ import {PendulumCpuStrategy} from './strategies/pendulum-cpu.strategy';
|
|||||||
TranslatePipe,
|
TranslatePipe,
|
||||||
MatButton,
|
MatButton,
|
||||||
Information,
|
Information,
|
||||||
|
MetricsComponent,
|
||||||
],
|
],
|
||||||
templateUrl: './pendulum.component.html',
|
templateUrl: './pendulum.component.html',
|
||||||
styleUrl: './pendulum.component.scss',
|
styleUrl: './pendulum.component.scss',
|
||||||
@@ -127,6 +130,14 @@ class PendulumComponent {
|
|||||||
impulseM2: 0.0,
|
impulseM2: 0.0,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private readonly fpsCounter = new FpsCounter();
|
||||||
|
readonly backend = signal('–');
|
||||||
|
readonly metrics = computed<Metric[]>(() => [
|
||||||
|
{label: 'METRICS.FPS', value: this.fpsCounter.fps()},
|
||||||
|
{label: 'METRICS.BACKEND', value: this.backend()},
|
||||||
|
{label: 'METRICS.TIME_STEP', value: this.loop.dt, fractionDigits: 3}
|
||||||
|
]);
|
||||||
|
|
||||||
private currentSceneData: SceneEventData | null = null;
|
private currentSceneData: SceneEventData | null = null;
|
||||||
private strategy: PendulumSimulationStrategy | null = null;
|
private strategy: PendulumSimulationStrategy | null = null;
|
||||||
|
|
||||||
@@ -150,6 +161,7 @@ class PendulumComponent {
|
|||||||
? new PendulumGpuStrategy()
|
? new PendulumGpuStrategy()
|
||||||
: new PendulumCpuStrategy();
|
: new PendulumCpuStrategy();
|
||||||
|
|
||||||
|
this.backend.set(gpuTier === 'webgpu' ? 'WebGPU (Compute)' : 'WebGL + CPU');
|
||||||
this.strategy.init(scene, engine);
|
this.strategy.init(scene, engine);
|
||||||
this.startParamUpdateLoop(scene);
|
this.startParamUpdateLoop(scene);
|
||||||
}
|
}
|
||||||
@@ -157,6 +169,7 @@ class PendulumComponent {
|
|||||||
private startParamUpdateLoop(scene: any): void {
|
private startParamUpdateLoop(scene: any): void {
|
||||||
scene.onAfterRenderObservable.clear();
|
scene.onAfterRenderObservable.clear();
|
||||||
scene.onAfterRenderObservable.add(() => {
|
scene.onAfterRenderObservable.add(() => {
|
||||||
|
this.fpsCounter.tick();
|
||||||
this.loop.time += this.loop.dt;
|
this.loop.time += this.loop.dt;
|
||||||
|
|
||||||
if (this.strategy) {
|
if (this.strategy) {
|
||||||
|
|||||||
@@ -54,4 +54,37 @@ describe('SortingService', () => {
|
|||||||
expect(input.map((item) => item.value)).toEqual(unsorted);
|
expect(input.map((item) => item.value)).toEqual(unsorted);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describe('counters', () => {
|
||||||
|
const last = (snapshots: SortSnapshot[]) => snapshots[snapshots.length - 1];
|
||||||
|
|
||||||
|
it('bubble sort counts every comparison and two writes per swap', () => {
|
||||||
|
const final = last(service.bubbleSort(toSortData([3, 2, 1])));
|
||||||
|
|
||||||
|
expect(final.comparisons).toBe(3);
|
||||||
|
expect(final.writes).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bubble sort writes nothing on sorted input', () => {
|
||||||
|
expect(last(service.bubbleSort(toSortData([1, 2, 3]))).writes).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counters restart with every run', () => {
|
||||||
|
service.bubbleSort(toSortData([3, 2, 1]));
|
||||||
|
|
||||||
|
expect(last(service.bubbleSort(toSortData([1, 2, 3]))).comparisons).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const algorithm of ['bubbleSort', 'cocktailSort', 'quickSort', 'heapSort', 'timSort'] as const) {
|
||||||
|
it(`${algorithm} counts comparisons and never lets a counter decrease`, () => {
|
||||||
|
const snapshots = service[algorithm](toSortData(unsorted));
|
||||||
|
|
||||||
|
expect(last(snapshots).comparisons).toBeGreaterThan(0);
|
||||||
|
for (let i = 1; i < snapshots.length; i++) {
|
||||||
|
expect(snapshots[i].comparisons).toBeGreaterThanOrEqual(snapshots[i - 1].comparisons);
|
||||||
|
expect(snapshots[i].writes).toBeGreaterThanOrEqual(snapshots[i - 1].writes);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -6,16 +6,27 @@ import {SortData, SortSnapshot} from '../sorting.models';
|
|||||||
})
|
})
|
||||||
export class SortingService {
|
export class SortingService {
|
||||||
|
|
||||||
|
private comparisons = 0;
|
||||||
|
private writes = 0;
|
||||||
|
|
||||||
|
private startRun(array: SortData[]): SortData[] {
|
||||||
|
this.comparisons = 0;
|
||||||
|
this.writes = 0;
|
||||||
|
return array.map(item => ({ ...item }));
|
||||||
|
}
|
||||||
|
|
||||||
private createSnapshot(array: SortData[]): SortSnapshot {
|
private createSnapshot(array: SortData[]): SortSnapshot {
|
||||||
return {
|
return {
|
||||||
array: array.map(item => ({ ...item }))
|
array: array.map(item => ({ ...item })),
|
||||||
|
comparisons: this.comparisons,
|
||||||
|
writes: this.writes
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- BUBBLE SORT ---
|
// --- BUBBLE SORT ---
|
||||||
bubbleSort(array: SortData[]): SortSnapshot[] {
|
bubbleSort(array: SortData[]): SortSnapshot[] {
|
||||||
const snapshots: SortSnapshot[] = [];
|
const snapshots: SortSnapshot[] = [];
|
||||||
const arr = array.map(item => ({ ...item }));
|
const arr = this.startRun(array);
|
||||||
const n = arr.length;
|
const n = arr.length;
|
||||||
|
|
||||||
snapshots.push(this.createSnapshot(arr));
|
snapshots.push(this.createSnapshot(arr));
|
||||||
@@ -26,10 +37,9 @@ export class SortingService {
|
|||||||
arr[j + 1].state = 'comparing';
|
arr[j + 1].state = 'comparing';
|
||||||
snapshots.push(this.createSnapshot(arr));
|
snapshots.push(this.createSnapshot(arr));
|
||||||
|
|
||||||
|
this.comparisons++;
|
||||||
if (arr[j].value > arr[j + 1].value) {
|
if (arr[j].value > arr[j + 1].value) {
|
||||||
const temp = arr[j].value;
|
this.swap(arr, j, j + 1);
|
||||||
arr[j].value = arr[j + 1].value;
|
|
||||||
arr[j + 1].value = temp;
|
|
||||||
|
|
||||||
snapshots.push(this.createSnapshot(arr));
|
snapshots.push(this.createSnapshot(arr));
|
||||||
}
|
}
|
||||||
@@ -51,7 +61,7 @@ export class SortingService {
|
|||||||
|
|
||||||
cocktailSort(array: SortData[]): SortSnapshot[] {
|
cocktailSort(array: SortData[]): SortSnapshot[] {
|
||||||
const snapshots: SortSnapshot[] = [];
|
const snapshots: SortSnapshot[] = [];
|
||||||
const arr = array.map(item => ({ ...item }));
|
const arr = this.startRun(array);
|
||||||
snapshots.push(this.createSnapshot(arr));
|
snapshots.push(this.createSnapshot(arr));
|
||||||
|
|
||||||
let start = -1;
|
let start = -1;
|
||||||
@@ -91,10 +101,9 @@ export class SortingService {
|
|||||||
secondValue.state = 'comparing';
|
secondValue.state = 'comparing';
|
||||||
snapshots.push(this.createSnapshot(arr));
|
snapshots.push(this.createSnapshot(arr));
|
||||||
|
|
||||||
|
this.comparisons++;
|
||||||
if (firstValue.value > secondValue.value) {
|
if (firstValue.value > secondValue.value) {
|
||||||
const temp = firstValue.value;
|
this.swap(arr, i, i + 1);
|
||||||
firstValue.value = secondValue.value;
|
|
||||||
secondValue.value = temp;
|
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
firstValue.state = 'unsorted';
|
firstValue.state = 'unsorted';
|
||||||
@@ -106,7 +115,7 @@ export class SortingService {
|
|||||||
// --- QUICK SORT ---
|
// --- QUICK SORT ---
|
||||||
quickSort(array: SortData[]): SortSnapshot[] {
|
quickSort(array: SortData[]): SortSnapshot[] {
|
||||||
const snapshots: SortSnapshot[] = [];
|
const snapshots: SortSnapshot[] = [];
|
||||||
const arr = array.map(item => ({ ...item }));
|
const arr = this.startRun(array);
|
||||||
snapshots.push(this.createSnapshot(arr));
|
snapshots.push(this.createSnapshot(arr));
|
||||||
|
|
||||||
this.quickSortHelper(arr, 0, arr.length - 1, snapshots);
|
this.quickSortHelper(arr, 0, arr.length - 1, snapshots);
|
||||||
@@ -140,6 +149,7 @@ export class SortingService {
|
|||||||
arr[j].state = 'comparing';
|
arr[j].state = 'comparing';
|
||||||
snapshots.push(this.createSnapshot(arr));
|
snapshots.push(this.createSnapshot(arr));
|
||||||
|
|
||||||
|
this.comparisons++;
|
||||||
if (arr[j].value < pivot.value) {
|
if (arr[j].value < pivot.value) {
|
||||||
i++;
|
i++;
|
||||||
this.swap(arr, i, j);
|
this.swap(arr, i, j);
|
||||||
@@ -159,7 +169,7 @@ export class SortingService {
|
|||||||
// --- HEAP SORT ---
|
// --- HEAP SORT ---
|
||||||
heapSort(array: SortData[]): SortSnapshot[] {
|
heapSort(array: SortData[]): SortSnapshot[] {
|
||||||
const snapshots: SortSnapshot[] = [];
|
const snapshots: SortSnapshot[] = [];
|
||||||
const arr = array.map(item => ({ ...item }));
|
const arr = this.startRun(array);
|
||||||
const n = arr.length;
|
const n = arr.length;
|
||||||
|
|
||||||
snapshots.push(this.createSnapshot(arr));
|
snapshots.push(this.createSnapshot(arr));
|
||||||
@@ -197,6 +207,7 @@ export class SortingService {
|
|||||||
arr[largest].state = 'comparing';
|
arr[largest].state = 'comparing';
|
||||||
snapshots.push(this.createSnapshot(arr));
|
snapshots.push(this.createSnapshot(arr));
|
||||||
|
|
||||||
|
this.comparisons++;
|
||||||
if (arr[l].value > arr[largest].value) {
|
if (arr[l].value > arr[largest].value) {
|
||||||
largest = l;
|
largest = l;
|
||||||
}
|
}
|
||||||
@@ -209,6 +220,7 @@ export class SortingService {
|
|||||||
arr[largest].state = 'comparing';
|
arr[largest].state = 'comparing';
|
||||||
snapshots.push(this.createSnapshot(arr));
|
snapshots.push(this.createSnapshot(arr));
|
||||||
|
|
||||||
|
this.comparisons++;
|
||||||
if (arr[r].value > arr[largest].value) {
|
if (arr[r].value > arr[largest].value) {
|
||||||
largest = r;
|
largest = r;
|
||||||
}
|
}
|
||||||
@@ -227,7 +239,7 @@ export class SortingService {
|
|||||||
// --- TIM SORT ---
|
// --- TIM SORT ---
|
||||||
timSort(array: SortData[]): SortSnapshot[] {
|
timSort(array: SortData[]): SortSnapshot[] {
|
||||||
const snapshots: SortSnapshot[] = [];
|
const snapshots: SortSnapshot[] = [];
|
||||||
const arr = array.map(item => ({ ...item }));
|
const arr = this.startRun(array);
|
||||||
const n = arr.length;
|
const n = arr.length;
|
||||||
const RUN = 32;
|
const RUN = 32;
|
||||||
|
|
||||||
@@ -263,15 +275,21 @@ export class SortingService {
|
|||||||
snapshots.push(this.createSnapshot(arr));
|
snapshots.push(this.createSnapshot(arr));
|
||||||
|
|
||||||
let j = i - 1;
|
let j = i - 1;
|
||||||
while (j >= left && arr[j].value > tempValue) {
|
while (j >= left) {
|
||||||
|
this.comparisons++;
|
||||||
|
if (arr[j].value <= tempValue) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
arr[j].state = 'comparing';
|
arr[j].state = 'comparing';
|
||||||
arr[j + 1].value = arr[j].value;
|
arr[j + 1].value = arr[j].value;
|
||||||
|
this.writes++;
|
||||||
arr[j].state = 'unsorted';
|
arr[j].state = 'unsorted';
|
||||||
snapshots.push(this.createSnapshot(arr));
|
snapshots.push(this.createSnapshot(arr));
|
||||||
j--;
|
j--;
|
||||||
}
|
}
|
||||||
|
|
||||||
arr[j + 1].value = tempValue;
|
arr[j + 1].value = tempValue;
|
||||||
|
this.writes++;
|
||||||
arr[i].state = 'unsorted';
|
arr[i].state = 'unsorted';
|
||||||
snapshots.push(this.createSnapshot(arr));
|
snapshots.push(this.createSnapshot(arr));
|
||||||
}
|
}
|
||||||
@@ -295,6 +313,7 @@ export class SortingService {
|
|||||||
|
|
||||||
arr[rightSourceIdx].state = 'unsorted';
|
arr[rightSourceIdx].state = 'unsorted';
|
||||||
|
|
||||||
|
this.comparisons++;
|
||||||
if (leftPart[i].value <= rightPart[j].value) {
|
if (leftPart[i].value <= rightPart[j].value) {
|
||||||
arr[k].value = leftPart[i].value;
|
arr[k].value = leftPart[i].value;
|
||||||
i++;
|
i++;
|
||||||
@@ -302,6 +321,7 @@ export class SortingService {
|
|||||||
arr[k].value = rightPart[j].value;
|
arr[k].value = rightPart[j].value;
|
||||||
j++;
|
j++;
|
||||||
}
|
}
|
||||||
|
this.writes++;
|
||||||
|
|
||||||
arr[k].state = 'unsorted';
|
arr[k].state = 'unsorted';
|
||||||
k++;
|
k++;
|
||||||
@@ -310,12 +330,14 @@ export class SortingService {
|
|||||||
|
|
||||||
while (i < leftPart.length) {
|
while (i < leftPart.length) {
|
||||||
arr[k].value = leftPart[i].value;
|
arr[k].value = leftPart[i].value;
|
||||||
|
this.writes++;
|
||||||
i++;
|
i++;
|
||||||
k++;
|
k++;
|
||||||
}
|
}
|
||||||
|
|
||||||
while (j < rightPart.length) {
|
while (j < rightPart.length) {
|
||||||
arr[k].value = rightPart[j].value;
|
arr[k].value = rightPart[j].value;
|
||||||
|
this.writes++;
|
||||||
j++;
|
j++;
|
||||||
k++;
|
k++;
|
||||||
}
|
}
|
||||||
@@ -325,5 +347,6 @@ export class SortingService {
|
|||||||
const temp = arr[i].value;
|
const temp = arr[i].value;
|
||||||
arr[i].value = arr[j].value;
|
arr[i].value = arr[j].value;
|
||||||
arr[j].value = temp;
|
arr[j].value = temp;
|
||||||
|
this.writes += 2;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -43,9 +43,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
|
<app-metrics [metrics]="metrics()"/>
|
||||||
<p>{{ 'SORTING.EXECUTION_TIME' | translate }}: {{ executionTime() }} ms</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-end h-[clamp(200px,40vh,400px)] border-b border-app-fg mb-[clamp(10px,3vw,20px)] gap-px bg-card-bg">
|
<div class="flex items-end h-[clamp(200px,40vh,400px)] border-b border-app-fg mb-[clamp(10px,3vw,20px)] gap-px bg-card-bg">
|
||||||
@for (item of sortArray(); track $index) {
|
@for (item of sortArray(); track $index) {
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -68,4 +68,19 @@ describe('SortingComponent', () => {
|
|||||||
expect(heights()).toEqual(initial);
|
expect(heights()).toEqual(initial);
|
||||||
expect(bars().some(bar => bar.nativeElement.classList.contains('bg-[#ffeb3b]'))).toBe(false);
|
expect(bars().some(bar => bar.nativeElement.classList.contains('bg-[#ffeb3b]'))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows the counters of the last snapshot and zeroes them on reset', () => {
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
|
||||||
|
component.startSorting();
|
||||||
|
vi.runAllTimers();
|
||||||
|
|
||||||
|
expect(component.comparisons()).toBeGreaterThan(0);
|
||||||
|
expect(component.writes()).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
component.resetSorting();
|
||||||
|
|
||||||
|
expect(component.comparisons()).toBe(0);
|
||||||
|
expect(component.writes()).toBe(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import {Component, inject, OnInit, signal} from '@angular/core';
|
import {Component, computed, inject, OnInit, signal} from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import {MatCardModule} from "@angular/material/card";
|
import {MatCardModule} from "@angular/material/card";
|
||||||
import {MatFormFieldModule} from "@angular/material/form-field";
|
import {MatFormFieldModule} from "@angular/material/form-field";
|
||||||
@@ -15,9 +15,10 @@ import {UrlConstants} from '../../../constants/UrlConstants';
|
|||||||
import {AlgorithmInformation} from '../information/information.models';
|
import {AlgorithmInformation} from '../information/information.models';
|
||||||
import {SOURCE_PATHS} from '../information/source-paths';
|
import {SOURCE_PATHS} from '../information/source-paths';
|
||||||
import {Information} from '../information/information';
|
import {Information} from '../information/information';
|
||||||
|
import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics';
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-sorting',
|
selector: 'app-sorting',
|
||||||
imports: [CommonModule, MatCardModule, MatFormFieldModule, MatSelectModule, MatButtonModule, MatIconModule, TranslatePipe, FormsModule, MatInput, Information],
|
imports: [CommonModule, MatCardModule, MatFormFieldModule, MatSelectModule, MatButtonModule, MatIconModule, TranslatePipe, FormsModule, MatInput, Information, MetricsComponent],
|
||||||
templateUrl: './sorting.component.html',
|
templateUrl: './sorting.component.html',
|
||||||
styleUrl: './sorting.component.scss'
|
styleUrl: './sorting.component.scss'
|
||||||
})
|
})
|
||||||
@@ -77,6 +78,13 @@ export class SortingComponent implements OnInit {
|
|||||||
animationSpeed = 100; // Milliseconds per step
|
animationSpeed = 100; // Milliseconds per step
|
||||||
readonly selectedAlgorithm = signal(this.algoInformation.entries[0].name);
|
readonly selectedAlgorithm = signal(this.algoInformation.entries[0].name);
|
||||||
readonly executionTime = signal(0);
|
readonly executionTime = signal(0);
|
||||||
|
readonly comparisons = signal(0);
|
||||||
|
readonly writes = signal(0);
|
||||||
|
readonly metrics = computed<Metric[]>(() => [
|
||||||
|
{label: 'METRICS.COMPARISONS', value: this.comparisons()},
|
||||||
|
{label: 'METRICS.WRITES', value: this.writes()},
|
||||||
|
{label: 'SORTING.EXECUTION_TIME', value: this.executionTime(), unit: 'ms'}
|
||||||
|
]);
|
||||||
readonly isSoundEnabled = signal(false);
|
readonly isSoundEnabled = signal(false);
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
@@ -154,6 +162,8 @@ export class SortingComponent implements OnInit {
|
|||||||
snapshots.forEach((snapshot, index) => {
|
snapshots.forEach((snapshot, index) => {
|
||||||
const id = setTimeout(() => {
|
const id = setTimeout(() => {
|
||||||
this.sortArray.set(snapshot.array);
|
this.sortArray.set(snapshot.array);
|
||||||
|
this.comparisons.set(snapshot.comparisons);
|
||||||
|
this.writes.set(snapshot.writes);
|
||||||
|
|
||||||
if (this.isSoundEnabled()) {
|
if (this.isSoundEnabled()) {
|
||||||
// Play a tone for each comparing bar (max 2 at once to avoid noise)
|
// Play a tone for each comparing bar (max 2 at once to avoid noise)
|
||||||
@@ -183,5 +193,7 @@ export class SortingComponent implements OnInit {
|
|||||||
resetSorting(): void {
|
resetSorting(): void {
|
||||||
this.stopAnimations();
|
this.stopAnimations();
|
||||||
this.resetSortState();
|
this.resetSortState();
|
||||||
|
this.comparisons.set(0);
|
||||||
|
this.writes.set(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,4 +5,6 @@ export interface SortData {
|
|||||||
|
|
||||||
export interface SortSnapshot {
|
export interface SortSnapshot {
|
||||||
array: SortData[];
|
array: SortData[];
|
||||||
|
comparisons: number;
|
||||||
|
writes: number;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
|
||||||
|
@for (metric of metrics(); track metric.label) {
|
||||||
|
<span>{{ metric.label | translate }}: <strong>{{ format(metric) }}</strong>@if (metric.unit) { {{ metric.unit }}}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import {ComponentFixture, TestBed} from '@angular/core/testing';
|
||||||
|
import {provideZonelessChangeDetection} from '@angular/core';
|
||||||
|
import {provideTranslateService} from '@ngx-translate/core';
|
||||||
|
import {LanguageService} from '../../../service/language.service';
|
||||||
|
import {Metric, MetricsComponent} from './metrics';
|
||||||
|
|
||||||
|
describe('MetricsComponent', () => {
|
||||||
|
let fixture: ComponentFixture<MetricsComponent>;
|
||||||
|
|
||||||
|
function render(metrics: Metric[]): string {
|
||||||
|
fixture.componentRef.setInput('metrics', metrics);
|
||||||
|
fixture.detectChanges();
|
||||||
|
return fixture.nativeElement.textContent.replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [provideZonelessChangeDetection(), provideTranslateService({lang: 'en', fallbackLang: 'en'})]
|
||||||
|
});
|
||||||
|
fixture = TestBed.createComponent(MetricsComponent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders label, value and unit', () => {
|
||||||
|
expect(render([{label: 'METRICS.FPS', value: 60, unit: 'ms'}])).toBe('METRICS.FPS: 60 ms');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats thousands per language', () => {
|
||||||
|
const language = TestBed.inject(LanguageService);
|
||||||
|
|
||||||
|
language.lang.set('en');
|
||||||
|
expect(render([{label: 'A', value: 39402}])).toContain('39,402');
|
||||||
|
|
||||||
|
language.lang.set('de');
|
||||||
|
expect(render([{label: 'A', value: 39402}])).toContain('39.402');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats small decimals per language with the requested fraction digits', () => {
|
||||||
|
TestBed.inject(LanguageService).lang.set('de');
|
||||||
|
|
||||||
|
expect(render([{label: 'A', value: 0.015, fractionDigits: 3}])).toContain('0,015');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows text values as they are', () => {
|
||||||
|
expect(render([{label: 'A', value: 'WebGPU'}])).toContain('WebGPU');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import {Component, inject, input} from '@angular/core';
|
||||||
|
import {TranslatePipe} from '@ngx-translate/core';
|
||||||
|
import {LanguageService} from '../../../service/language.service';
|
||||||
|
|
||||||
|
export interface Metric {
|
||||||
|
/** i18n key */
|
||||||
|
label: string;
|
||||||
|
value: number | string;
|
||||||
|
unit?: string;
|
||||||
|
/** Maximum fraction digits for numbers, default 2. */
|
||||||
|
fractionDigits?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-metrics',
|
||||||
|
imports: [TranslatePipe],
|
||||||
|
templateUrl: './metrics.html'
|
||||||
|
})
|
||||||
|
export class MetricsComponent {
|
||||||
|
private readonly language = inject(LanguageService);
|
||||||
|
|
||||||
|
readonly metrics = input.required<Metric[]>();
|
||||||
|
|
||||||
|
protected format(metric: Metric): string {
|
||||||
|
const {value, fractionDigits = 2} = metric;
|
||||||
|
return typeof value === 'number' ? value.toLocaleString(this.language.lang(), {maximumFractionDigits: fractionDigits}) : value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
-1
@@ -533,7 +533,7 @@
|
|||||||
"DISCLAIMER_4": "Zusätzlich gibt es fast immer einen Time-Space Trade-off (Zeit-Speicher-Kompromiss): Algorithmen, die extrem schnell sind (wie Merge Sort), benötigen oft viel zusätzlichen Arbeitsspeicher. Algorithmen, die direkt im vorhandenen Speicher arbeiten (wie Heap Sort), sparen Platz, sind aber manchmal komplexer oder minimal langsamer. Es gibt also keine \"One-Size-Fits-All\"-Lösung."
|
"DISCLAIMER_4": "Zusätzlich gibt es fast immer einen Time-Space Trade-off (Zeit-Speicher-Kompromiss): Algorithmen, die extrem schnell sind (wie Merge Sort), benötigen oft viel zusätzlichen Arbeitsspeicher. Algorithmen, die direkt im vorhandenen Speicher arbeiten (wie Heap Sort), sparen Platz, sind aber manchmal komplexer oder minimal langsamer. Es gibt also keine \"One-Size-Fits-All\"-Lösung."
|
||||||
},
|
},
|
||||||
"HOW_IT_WORKS": {
|
"HOW_IT_WORKS": {
|
||||||
"INTRO": "Jeder Algorithmus läuft zuerst komplett durch und legt bei jedem Vergleich und jedem Tausch eine Kopie des Arrays (Snapshot) in eine Liste. Die Animation spielt die Snapshots danach mit 100 ms pro Schritt ab. Die Rechenzeit ist die Zeit für den Durchlauf samt Kopien, nicht die Animation.",
|
"INTRO": "Jeder Algorithmus läuft zuerst komplett durch und legt bei jedem Vergleich und jedem Tausch eine Kopie des Arrays (Snapshot) in eine Liste. Die Animation spielt die Snapshots danach mit 100 ms pro Schritt ab. Die Rechenzeit ist die Zeit für den Durchlauf samt Kopien, nicht die Animation. Vergleiche und Schreibzugriffe zählen während der Animation hoch.",
|
||||||
"ITEM_1": "Bubble Sort und Cocktail Sort: O(n²) Vergleiche, in-place. Cocktail Sort wechselt pro Durchlauf die Richtung und stoppt, sobald ein Durchlauf nichts mehr tauscht.",
|
"ITEM_1": "Bubble Sort und Cocktail Sort: O(n²) Vergleiche, in-place. Cocktail Sort wechselt pro Durchlauf die Richtung und stoppt, sobald ein Durchlauf nichts mehr tauscht.",
|
||||||
"ITEM_2": "Quick Sort: Lomuto-Partition mit dem letzten Element als Pivot, rekursiv. Durchschnitt O(n log n), schlechtester Fall O(n²), zum Beispiel bei schon sortierten Daten.",
|
"ITEM_2": "Quick Sort: Lomuto-Partition mit dem letzten Element als Pivot, rekursiv. Durchschnitt O(n log n), schlechtester Fall O(n²), zum Beispiel bei schon sortierten Daten.",
|
||||||
"ITEM_3": "Heap Sort: baut einen Max-Heap im Array auf (heapify von der Mitte nach vorn) und tauscht die Wurzel ans Ende. Immer O(n log n), kein Zusatzspeicher.",
|
"ITEM_3": "Heap Sort: baut einen Max-Heap im Array auf (heapify von der Mitte nach vorn) und tauscht die Wurzel ans Ende. Immer O(n log n), kein Zusatzspeicher.",
|
||||||
@@ -885,5 +885,26 @@
|
|||||||
"TITLE": "Seite nicht gefunden – Andreas Dahm",
|
"TITLE": "Seite nicht gefunden – Andreas Dahm",
|
||||||
"DESCRIPTION": "Die angeforderte Seite konnte nicht gefunden werden."
|
"DESCRIPTION": "Die angeforderte Seite konnte nicht gefunden werden."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"METRICS": {
|
||||||
|
"FPS": "Bilder/s",
|
||||||
|
"BACKEND": "Backend",
|
||||||
|
"STEP_TIME": "Rechenzeit pro Generation",
|
||||||
|
"GENERATION": "Generation",
|
||||||
|
"GENERATIONS_PER_SECOND": "Generationen/s",
|
||||||
|
"COMPARISONS": "Vergleiche",
|
||||||
|
"WRITES": "Schreibzugriffe",
|
||||||
|
"REGIONS": "Regionen",
|
||||||
|
"BORDERS": "Grenzen",
|
||||||
|
"ATTEMPTS": "Farbversuche (Backtracking)",
|
||||||
|
"SAMPLES": "Abtastpunkte",
|
||||||
|
"ZOOM": "Zoom",
|
||||||
|
"MAX_ITERATIONS": "Max. Iterationen",
|
||||||
|
"RAY_STEPS": "Max. Strahlschritte",
|
||||||
|
"TIME_STEP": "Zeitschritt dt",
|
||||||
|
"PARTICLES": "Partikel",
|
||||||
|
"CONSTRAINTS": "Constraints",
|
||||||
|
"PHASES": "Phasen",
|
||||||
|
"SUBSTEPS": "Substeps pro Frame"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+22
-1
@@ -533,7 +533,7 @@
|
|||||||
"DISCLAIMER_4": "Additionally, there is almost always a Time-Space Trade-off: Algorithms that are extremely fast (like Merge Sort) often require a lot of additional working memory. Algorithms that work directly in existing memory (like Heap Sort) save space but are sometimes more complex or slightly slower. Thus, there is no \"one-size-fits-all\" solution."
|
"DISCLAIMER_4": "Additionally, there is almost always a Time-Space Trade-off: Algorithms that are extremely fast (like Merge Sort) often require a lot of additional working memory. Algorithms that work directly in existing memory (like Heap Sort) save space but are sometimes more complex or slightly slower. Thus, there is no \"one-size-fits-all\" solution."
|
||||||
},
|
},
|
||||||
"HOW_IT_WORKS": {
|
"HOW_IT_WORKS": {
|
||||||
"INTRO": "Each algorithm runs to the end first and stores a copy of the array (snapshot) in a list at every comparison and every swap. The animation then replays the snapshots at 100 ms per step. Execution time is the run including the copies, not the animation.",
|
"INTRO": "Each algorithm runs to the end first and stores a copy of the array (snapshot) in a list at every comparison and every swap. The animation then replays the snapshots at 100 ms per step. Execution time is the run including the copies, not the animation. Comparisons and writes count up during the animation.",
|
||||||
"ITEM_1": "Bubble sort and cocktail sort: O(n²) comparisons, in place. Cocktail sort changes direction every pass and stops as soon as a pass swaps nothing.",
|
"ITEM_1": "Bubble sort and cocktail sort: O(n²) comparisons, in place. Cocktail sort changes direction every pass and stops as soon as a pass swaps nothing.",
|
||||||
"ITEM_2": "Quick sort: Lomuto partition with the last element as pivot, recursive. Average O(n log n), worst case O(n²), for example on already sorted data.",
|
"ITEM_2": "Quick sort: Lomuto partition with the last element as pivot, recursive. Average O(n log n), worst case O(n²), for example on already sorted data.",
|
||||||
"ITEM_3": "Heap sort: builds a max heap inside the array (heapify from the middle to the front) and swaps the root to the end. Always O(n log n), no extra memory.",
|
"ITEM_3": "Heap sort: builds a max heap inside the array (heapify from the middle to the front) and swaps the root to the end. Always O(n log n), no extra memory.",
|
||||||
@@ -885,5 +885,26 @@
|
|||||||
"TITLE": "Page not found – Andreas Dahm",
|
"TITLE": "Page not found – Andreas Dahm",
|
||||||
"DESCRIPTION": "The requested page could not be found."
|
"DESCRIPTION": "The requested page could not be found."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"METRICS": {
|
||||||
|
"FPS": "Frames/s",
|
||||||
|
"BACKEND": "Backend",
|
||||||
|
"STEP_TIME": "Compute time per generation",
|
||||||
|
"GENERATION": "Generation",
|
||||||
|
"GENERATIONS_PER_SECOND": "Generations/s",
|
||||||
|
"COMPARISONS": "Comparisons",
|
||||||
|
"WRITES": "Writes",
|
||||||
|
"REGIONS": "Regions",
|
||||||
|
"BORDERS": "Borders",
|
||||||
|
"ATTEMPTS": "Color attempts (backtracking)",
|
||||||
|
"SAMPLES": "Sample points",
|
||||||
|
"ZOOM": "Zoom",
|
||||||
|
"MAX_ITERATIONS": "Max. iterations",
|
||||||
|
"RAY_STEPS": "Max. ray steps",
|
||||||
|
"TIME_STEP": "Time step dt",
|
||||||
|
"PARTICLES": "Particles",
|
||||||
|
"CONSTRAINTS": "Constraints",
|
||||||
|
"PHASES": "Phases",
|
||||||
|
"SUBSTEPS": "Substeps per frame"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user