- @for (item of sortArray; track $index) {
+ @for (item of sortArray(); track $index) {
{
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
+
+ await TestBed.configureTestingModule({
+ imports: [SortingComponent],
+ providers: [
+ provideZonelessChangeDetection(),
+ provideNoopAnimations(),
+ provideTranslateService({ lang: 'en', fallbackLang: 'en' }),
+ {
+ provide: SortingAudioService,
+ useValue: { initOnUserGesture: vi.fn(), playTone: vi.fn(), playSortedSweep: vi.fn() },
+ },
+ ],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(SortingComponent);
+ fixture.detectChanges();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ function bars() {
+ return fixture.debugElement.queryAll(By.css('[data-testid="sort-bar"]'));
+ }
+
+ function heights(): number[] {
+ return bars().map(bar => Number.parseFloat((bar.nativeElement as HTMLElement).style.height));
+ }
+
+ it('shows every bar as sorted once the animation has run', () => {
+ fixture.componentInstance.startSorting();
+ vi.runAllTimers();
+ fixture.detectChanges();
+
+ expect(bars()).toHaveLength(50);
+ expect(bars().every(bar => bar.nativeElement.classList.contains('bg-[#4caf50]'))).toBe(true);
+ const sorted = heights();
+ expect(sorted).toEqual([...sorted].sort((a, b) => a - b));
+ });
+
+ it('restores the unsorted array when reset during the animation', () => {
+ const initial = heights();
+
+ fixture.componentInstance.startSorting();
+ vi.advanceTimersByTime(300);
+ fixture.componentInstance.resetSorting();
+ fixture.detectChanges();
+
+ expect(heights()).toEqual(initial);
+ expect(bars().some(bar => bar.nativeElement.classList.contains('bg-[#ffeb3b]'))).toBe(false);
+ });
+});
diff --git a/src/app/pages/algorithms/sorting/sorting.component.ts b/src/app/pages/algorithms/sorting/sorting.component.ts
index d71ded6..24d13d6 100644
--- a/src/app/pages/algorithms/sorting/sorting.component.ts
+++ b/src/app/pages/algorithms/sorting/sorting.component.ts
@@ -1,4 +1,4 @@
-import {ChangeDetectorRef, Component, inject, OnInit, ChangeDetectionStrategy} from '@angular/core';
+import {Component, inject, OnInit, signal} from '@angular/core';
import { CommonModule } from '@angular/common';
import {MatCardModule} from "@angular/material/card";
import {MatFormFieldModule} from "@angular/material/form-field";
@@ -18,14 +18,12 @@ import {Information} from '../information/information';
selector: 'app-sorting',
imports: [CommonModule, MatCardModule, MatFormFieldModule, MatSelectModule, MatButtonModule, MatIconModule, TranslatePipe, FormsModule, MatInput, Information],
templateUrl: './sorting.component.html',
- changeDetection: ChangeDetectionStrategy.Eager,
styleUrl: './sorting.component.scss'
})
export class SortingComponent implements OnInit {
private readonly sortingService: SortingService = inject(SortingService);
private readonly audioService: SortingAudioService = inject(SortingAudioService);
- private readonly cdr: ChangeDetectorRef = inject(ChangeDetectorRef);
readonly MAX_ARRAY_SIZE: number = 200;
readonly MIN_ARRAY_SIZE: number = 20;
@@ -69,14 +67,14 @@ export class SortingComponent implements OnInit {
};
private timeoutIds: number[] = [];
- sortArray: SortData[] = [];
+ readonly sortArray = signal([]);
unsortedArrayCopy: SortData[] = [];
- arraySize = 50;
+ readonly arraySize = signal(50);
maxArrayValue = 100;
animationSpeed = 100; // Milliseconds per step
- selectedAlgorithm: string = this.algoInformation.entries[0].name;
- executionTime = 0;
- isSoundEnabled = false;
+ readonly selectedAlgorithm = signal(this.algoInformation.entries[0].name);
+ readonly executionTime = signal(0);
+ readonly isSoundEnabled = signal(false);
ngOnInit(): void {
this.generateNewArray();
@@ -84,8 +82,9 @@ export class SortingComponent implements OnInit {
newArraySizeSet()
{
- this.arraySize = Math.min(Math.max(this.arraySize, this.MIN_ARRAY_SIZE), this.MAX_ARRAY_SIZE);
- if (this.arraySize == this.sortArray.length)
+ const clamped = Math.min(Math.max(this.arraySize(), this.MIN_ARRAY_SIZE), this.MAX_ARRAY_SIZE);
+ this.arraySize.set(clamped);
+ if (clamped == this.sortArray().length)
{
return;
}
@@ -94,35 +93,27 @@ export class SortingComponent implements OnInit {
generateNewArray(): void {
this.resetSorting();
- this.executionTime = 0;
- this.unsortedArrayCopy = [];
- this.sortArray = [];
+ this.executionTime.set(0);
+ const unsorted: SortData[] = [];
- for (let i = 0; i < this.arraySize; i++) {
+ for (let i = 0; i < this.arraySize(); i++) {
const randomValue = Math.floor(Math.random() * this.maxArrayValue) + 1;
- this.sortArray.push({
- value: randomValue,
- state: 'unsorted'
- });
-
- this.unsortedArrayCopy.push({
+ unsorted.push({
value: randomValue,
state: 'unsorted'
});
}
+
+ this.unsortedArrayCopy = unsorted;
+ this.resetSortState();
}
private resetSortState() {
- for (let i = 0; i < this.sortArray.length; i++) {
- const element = this.sortArray[i];
- const unsortedElement = this.unsortedArrayCopy[i];
- element.value = unsortedElement.value;
- element.state = 'unsorted';
- }
+ this.sortArray.set(this.unsortedArrayCopy.map(item => ({...item})));
}
toggleSound(): void {
- this.isSoundEnabled = !this.isSoundEnabled;
+ this.isSoundEnabled.update(v => !v);
}
async startSorting(): Promise {
@@ -132,26 +123,26 @@ export class SortingComponent implements OnInit {
const startTime = performance.now();
let snapshots: SortSnapshot[] = [];
- switch (this.selectedAlgorithm) {
+ switch (this.selectedAlgorithm()) {
case 'Bubble Sort':
- snapshots = this.sortingService.bubbleSort(this.sortArray);
+ snapshots = this.sortingService.bubbleSort(this.sortArray());
break;
case 'Quick Sort':
- snapshots = this.sortingService.quickSort(this.sortArray);
+ snapshots = this.sortingService.quickSort(this.sortArray());
break;
case 'Heap Sort':
- snapshots = this.sortingService.heapSort(this.sortArray);
+ snapshots = this.sortingService.heapSort(this.sortArray());
break;
case 'Cocktail Sort':
- snapshots = this.sortingService.cocktailSort(this.sortArray);
+ snapshots = this.sortingService.cocktailSort(this.sortArray());
break;
case 'Tim Sort':
- snapshots = this.sortingService.timSort(this.sortArray);
+ snapshots = this.sortingService.timSort(this.sortArray());
break;
}
const endTime = performance.now();
- this.executionTime = Number.parseFloat((endTime - startTime).toFixed(4));
+ this.executionTime.set(Number.parseFloat((endTime - startTime).toFixed(4)));
this.animateSorting(snapshots);
}
@@ -159,14 +150,9 @@ export class SortingComponent implements OnInit {
private animateSorting(snapshots: SortSnapshot[]): void {
snapshots.forEach((snapshot, index) => {
const id = setTimeout(() => {
- for (let i = 0; i < this.sortArray.length; i++) {
- if (snapshot.array[i]) {
- this.sortArray[i].value = snapshot.array[i].value;
- this.sortArray[i].state = snapshot.array[i].state;
- }
- }
+ this.sortArray.set(snapshot.array);
- if (this.isSoundEnabled) {
+ if (this.isSoundEnabled()) {
// Play a tone for each comparing bar (max 2 at once to avoid noise)
snapshot.array
.filter(item => item.state === 'comparing')
@@ -174,13 +160,10 @@ export class SortingComponent implements OnInit {
.forEach(item => this.audioService.playTone(item.value, this.maxArrayValue, this.animationSpeed));
}
- this.cdr.detectChanges();
-
if (index === snapshots.length - 1) {
- this.sortArray.forEach(item => item.state = 'sorted');
- this.cdr.detectChanges();
- if (this.isSoundEnabled) {
- this.audioService.playSortedSweep(this.sortArray.map(item => item.value), this.maxArrayValue);
+ this.sortArray.set(snapshot.array.map(item => ({...item, state: 'sorted'})));
+ if (this.isSoundEnabled()) {
+ this.audioService.playSortedSweep(this.sortArray().map(item => item.value), this.maxArrayValue);
}
}
}, index * this.animationSpeed);
diff --git a/src/app/pages/imprint/imprint.component.ts b/src/app/pages/imprint/imprint.component.ts
index 4f35f47..c1579c7 100644
--- a/src/app/pages/imprint/imprint.component.ts
+++ b/src/app/pages/imprint/imprint.component.ts
@@ -1,4 +1,4 @@
-import { Component, ChangeDetectionStrategy } from '@angular/core';
+import { Component } from '@angular/core';
import {MatCard} from '@angular/material/card';
import {TranslatePipe} from '@ngx-translate/core';
import {SharedFunctions} from '../../shared/SharedFunctions';
@@ -10,7 +10,6 @@ import {SharedFunctions} from '../../shared/SharedFunctions';
TranslatePipe
],
templateUrl: './imprint.component.html',
- changeDetection: ChangeDetectionStrategy.Eager,
styleUrl: './imprint.component.scss',
})
export class ImprintComponent {
diff --git a/src/app/pages/not-found/not-found.component.ts b/src/app/pages/not-found/not-found.component.ts
index bca38af..193475e 100644
--- a/src/app/pages/not-found/not-found.component.ts
+++ b/src/app/pages/not-found/not-found.component.ts
@@ -1,4 +1,4 @@
-import { Component, ChangeDetectionStrategy } from '@angular/core';
+import { Component } from '@angular/core';
import { RouterLink } from '@angular/router';
import { MatCard } from '@angular/material/card';
import { MatButton } from '@angular/material/button';
@@ -8,7 +8,6 @@ import { RouterConstants } from '../../constants/RouterConstants';
@Component({
selector: 'app-not-found',
imports: [RouterLink, MatCard, MatButton, TranslatePipe],
- changeDetection: ChangeDetectionStrategy.Eager,
templateUrl: './not-found.component.html',
})
export class NotFoundComponent {
diff --git a/src/app/pages/projects/dialog/project-dialog.component.ts b/src/app/pages/projects/dialog/project-dialog.component.ts
index c7c47c9..f79ca06 100644
--- a/src/app/pages/projects/dialog/project-dialog.component.ts
+++ b/src/app/pages/projects/dialog/project-dialog.component.ts
@@ -1,4 +1,4 @@
-import {AfterViewInit, Component, inject, CUSTOM_ELEMENTS_SCHEMA, ViewChild, ElementRef, ChangeDetectionStrategy} from '@angular/core';
+import {AfterViewInit, Component, inject, CUSTOM_ELEMENTS_SCHEMA, ViewChild, ElementRef} from '@angular/core';
import {
MAT_DIALOG_DATA,
MatDialogActions,
@@ -30,7 +30,6 @@ register();
MatButton,
MatDialogClose
],
- changeDetection: ChangeDetectionStrategy.Eager,
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class ProjectDialogComponent implements AfterViewInit {
diff --git a/src/app/pages/projects/projects.component.ts b/src/app/pages/projects/projects.component.ts
index 17bc785..0a81a6e 100644
--- a/src/app/pages/projects/projects.component.ts
+++ b/src/app/pages/projects/projects.component.ts
@@ -1,4 +1,4 @@
-import { Component, computed, inject, CUSTOM_ELEMENTS_SCHEMA, OnDestroy, OnInit, ChangeDetectionStrategy } from '@angular/core';
+import { Component, computed, inject, CUSTOM_ELEMENTS_SCHEMA, OnDestroy, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { Subscription } from "rxjs";
import { MatCardModule } from "@angular/material/card";
@@ -43,7 +43,6 @@ export interface Projects {
],
templateUrl: './projects.component.html',
styleUrl: './projects.component.scss',
- changeDetection: ChangeDetectionStrategy.Eager,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class ProjectsComponent implements OnInit, OnDestroy {
diff --git a/src/app/pages/stopwatch/stopwatch.component.ts b/src/app/pages/stopwatch/stopwatch.component.ts
index 7b1fd46..e3d3a38 100644
--- a/src/app/pages/stopwatch/stopwatch.component.ts
+++ b/src/app/pages/stopwatch/stopwatch.component.ts
@@ -1,4 +1,4 @@
-import {Component, computed, effect, inject, OnDestroy, signal, WritableSignal, ChangeDetectionStrategy} from '@angular/core';
+import {Component, computed, effect, inject, OnDestroy, signal, WritableSignal} from '@angular/core';
import {FormsModule} from '@angular/forms';
import {MatButtonModule} from '@angular/material/button';
import {MatCardModule} from '@angular/material/card';
@@ -30,7 +30,6 @@ const SECOND_INTERVAL_FREQUENCY_HZ = 587;
MatCheckboxModule,
TranslatePipe
],
- changeDetection: ChangeDetectionStrategy.Eager,
templateUrl: './stopwatch.component.html'
})
export class StopwatchComponent implements OnDestroy {
diff --git a/src/app/shared/components/generic-grid/generic-grid.ts b/src/app/shared/components/generic-grid/generic-grid.ts
index 2aedb47..77c3c86 100644
--- a/src/app/shared/components/generic-grid/generic-grid.ts
+++ b/src/app/shared/components/generic-grid/generic-grid.ts
@@ -1,4 +1,4 @@
-import {AfterViewInit, Component, ElementRef, EventEmitter, Input, Output, ViewChild, ChangeDetectionStrategy} from '@angular/core';
+import {AfterViewInit, Component, ElementRef, EventEmitter, Input, Output, ViewChild} from '@angular/core';
import {CommonModule} from '@angular/common';
export interface GridPos { row: number; col: number }
@@ -7,7 +7,6 @@ export interface GridPos { row: number; col: number }
selector: 'app-generic-grid',
imports: [CommonModule],
templateUrl: './generic-grid.html',
- changeDetection: ChangeDetectionStrategy.Eager,
styleUrl: './generic-grid.scss',
})
export class GenericGridComponent implements AfterViewInit {
diff --git a/src/app/shared/components/particles-background/particles-background.component.ts b/src/app/shared/components/particles-background/particles-background.component.ts
index 957c85c..8a76159 100644
--- a/src/app/shared/components/particles-background/particles-background.component.ts
+++ b/src/app/shared/components/particles-background/particles-background.component.ts
@@ -1,11 +1,10 @@
-import {AfterViewInit, Component, ElementRef, HostListener, inject, NgZone, OnDestroy, PLATFORM_ID, ViewChild, ChangeDetectionStrategy} from '@angular/core';
+import {AfterViewInit, Component, ElementRef, HostListener, inject, NgZone, OnDestroy, PLATFORM_ID, ViewChild} from '@angular/core';
import {isPlatformBrowser} from '@angular/common';
@Component({
selector: 'app-particles-background',
imports: [],
templateUrl: './particles-background.component.html',
- changeDetection: ChangeDetectionStrategy.Eager,
styleUrl: './particles-background.component.scss',
})
export class ParticleBackgroundComponent implements AfterViewInit, OnDestroy {
diff --git a/src/app/shared/components/render-canvas/babylon-canvas.component.ts b/src/app/shared/components/render-canvas/babylon-canvas.component.ts
index aaf8cab..362b330 100644
--- a/src/app/shared/components/render-canvas/babylon-canvas.component.ts
+++ b/src/app/shared/components/render-canvas/babylon-canvas.component.ts
@@ -1,4 +1,4 @@
-import {AfterViewInit, Component, ElementRef, EventEmitter, inject, Input, NgZone, OnDestroy, Output, ViewChild, ChangeDetectionStrategy} from '@angular/core';
+import {AfterViewInit, Component, ElementRef, EventEmitter, inject, input, NgZone, OnDestroy, Output, ViewChild} from '@angular/core';
import {MatSnackBar} from '@angular/material/snack-bar';
import {TranslateService} from '@ngx-translate/core';
import {ArcRotateCamera, Camera, Engine, MeshBuilder, Scene, ShaderLanguage, ShaderMaterial, Vector2, Vector3, WebGPUEngine} from '@babylonjs/core';
@@ -26,7 +26,6 @@ export interface SceneEventData {
selector: 'app-babylon-canvas',
imports: [],
templateUrl: './babylon-canvas.component.html',
- changeDetection: ChangeDetectionStrategy.Eager,
styleUrl: './babylon-canvas.component.scss',
})
export class BabylonCanvas implements AfterViewInit, OnDestroy {
@@ -37,8 +36,8 @@ export class BabylonCanvas implements AfterViewInit, OnDestroy {
@ViewChild('renderCanvas', { static: true }) canvasRef!: ElementRef;
- @Input({ required: true }) config!: RenderConfig;
- @Input() renderCallback?: RenderCallback;
+ readonly config = input.required();
+ readonly renderCallback = input();
@Output() sceneReady = new EventEmitter();
@Output() sceneResized = new EventEmitter();
@@ -131,7 +130,7 @@ export class BabylonCanvas implements AfterViewInit, OnDestroy {
}
private setupCamera(canvas: HTMLCanvasElement) {
- if (this.config.mode === '3D') {
+ if (this.config().mode === '3D') {
this.setup3dCamera(canvas);
return;
}
@@ -144,7 +143,7 @@ export class BabylonCanvas implements AfterViewInit, OnDestroy {
cam.mode = Camera.ORTHOGRAPHIC_CAMERA;
const aspect = canvas.width / canvas.height;
- const viewSize = this.config?.initialViewSize ?? 10;
+ const viewSize = this.config().initialViewSize ?? 10;
cam.orthoLeft = -viewSize * aspect / 2;
cam.orthoRight = viewSize * aspect / 2;
cam.orthoTop = viewSize / 2;
@@ -161,17 +160,18 @@ export class BabylonCanvas implements AfterViewInit, OnDestroy {
cam.maxZ = 100;
cam.lowerRadiusLimit = 1.5;
cam.upperRadiusLimit = 20;
- cam.radius = this.config?.initialViewSize ?? 1;
+ cam.radius = this.config().initialViewSize ?? 1;
cam.attachControl(canvas, true);
this.camera = cam;
}
private createFullScreenRect() {
- if (!this.config.vertexShader || !this.config.fragmentShader) {
+ const config = this.config();
+ if (!config.vertexShader || !config.fragmentShader) {
return;
}
const plane = MeshBuilder.CreatePlane("plane", {size: 100}, this.scene);
- if (this.config.mode === '3D') {
+ if (config.mode === '3D') {
plane.parent = this.camera;
plane.position.z = 1;
} else {
@@ -183,7 +183,8 @@ export class BabylonCanvas implements AfterViewInit, OnDestroy {
}
private createShaderMaterial() {
- if (!this.config.vertexShader || !this.config.fragmentShader || !this.config.uniformNames) {
+ const config = this.config();
+ if (!config.vertexShader || !config.fragmentShader || !config.uniformNames) {
return;
}
@@ -191,14 +192,14 @@ export class BabylonCanvas implements AfterViewInit, OnDestroy {
"shaderMaterial",
this.scene,
{
- vertexSource: this.config.vertexShader,
- fragmentSource: this.config.fragmentShader
+ vertexSource: config.vertexShader,
+ fragmentSource: config.fragmentShader
},
{
attributes: ["position", "uv"],
- uniforms: ["resolution", "cameraPosition", ...this.config.uniformNames],
- uniformBuffers: this.config.uniformBufferNames ?? [],
- shaderLanguage: this.config.shaderLanguage ?? ShaderLanguage.GLSL
+ uniforms: ["resolution", "cameraPosition", ...config.uniformNames],
+ uniformBuffers: config.uniformBufferNames ?? [],
+ shaderLanguage: config.shaderLanguage ?? ShaderLanguage.GLSL
}
);
this.shaderMaterial.disableDepthWrite = true;
@@ -209,8 +210,9 @@ export class BabylonCanvas implements AfterViewInit, OnDestroy {
this.engine.runRenderLoop(() => {
// callback call to call specific uniforms
- if (this.renderCallback) {
- this.renderCallback(this.shaderMaterial, this.camera, canvas, this.scene);
+ const callback = this.renderCallback();
+ if (callback) {
+ callback(this.shaderMaterial, this.camera, canvas, this.scene);
}
// default uniforms which maybe each scene has
@@ -228,8 +230,9 @@ export class BabylonCanvas implements AfterViewInit, OnDestroy {
this.engine.resize();
}
- if (this.config.mode === '2D' && this.camera instanceof ArcRotateCamera) {
- const viewSize = this.config?.initialViewSize ?? 10;
+ const config = this.config();
+ if (config.mode === '2D' && this.camera instanceof ArcRotateCamera) {
+ const viewSize = config.initialViewSize ?? 10;
this.camera.orthoLeft = -viewSize / 2;
this.camera.orthoRight = viewSize / 2;
this.camera.orthoTop = viewSize / 2;