Compare commits

...

2 Commits

Author SHA1 Message Date
e20aa11927 Merge pull request 'Added timer component' (#35) from feat/online-timer into main
All checks were successful
Build, Test & Push Frontend / quality-check (push) Successful in 1m28s
Build, Test & Push Frontend / docker (push) Successful in 1m2s
Reviewed-on: #35
2026-05-06 10:47:20 +02:00
Andreas Dahm
39b90a0b81 Added timer component
All checks were successful
Build, Test & Push Frontend / quality-check (pull_request) Successful in 1m30s
Build, Test & Push Frontend / docker (pull_request) Has been skipped
2026-05-06 10:46:57 +02:00
7 changed files with 296 additions and 0 deletions

View File

@@ -16,5 +16,6 @@ export const routes: Routes = [
{ path: RouterConstants.PENDULUM.PATH, loadComponent: () => import('./pages/algorithms/pendulum/pendulum.component').then(m => m.default) }, { path: RouterConstants.PENDULUM.PATH, loadComponent: () => import('./pages/algorithms/pendulum/pendulum.component').then(m => m.default) },
{ path: RouterConstants.CLOTH.PATH, loadComponent: () => import('./pages/algorithms/cloth/cloth.component').then(m => m.ClothComponent) }, { path: RouterConstants.CLOTH.PATH, loadComponent: () => import('./pages/algorithms/cloth/cloth.component').then(m => m.ClothComponent) },
{ path: RouterConstants.FOUR_COLOR.PATH, loadComponent: () => import('./pages/algorithms/four-color/four-color.component').then(m => m.FourColorComponent) }, { path: RouterConstants.FOUR_COLOR.PATH, loadComponent: () => import('./pages/algorithms/four-color/four-color.component').then(m => m.FourColorComponent) },
{ path: RouterConstants.STOPWATCH.PATH, loadComponent: () => import('./pages/stopwatch/stopwatch.component').then(m => m.StopwatchComponent) },
]; ];

View File

@@ -64,5 +64,10 @@
PATH: 'imprint', PATH: 'imprint',
LINK: '/imprint', LINK: '/imprint',
}; };
static readonly STOPWATCH = {
PATH: 'stopwatch',
LINK: '/stopwatch',
};
} }

View File

@@ -12,6 +12,8 @@
[routerLink]="RouterConstants.PROJECTS.LINK" routerLinkActive="active" mat-button>{{ 'TOPBAR.PROJECTS' | translate }}</a> [routerLink]="RouterConstants.PROJECTS.LINK" routerLinkActive="active" mat-button>{{ 'TOPBAR.PROJECTS' | translate }}</a>
<a class="opacity-70 transition-opacity duration-150 hover:opacity-100 relative after:content-[''] after:absolute after:bottom-1 after:left-2.5 after:right-2.5 after:h-0.5 after:bg-current after:rounded-sm after:scale-x-0 after:transition-transform [&.active]:opacity-100 [&.active]:after:scale-x-100" <a class="opacity-70 transition-opacity duration-150 hover:opacity-100 relative after:content-[''] after:absolute after:bottom-1 after:left-2.5 after:right-2.5 after:h-0.5 after:bg-current after:rounded-sm after:scale-x-0 after:transition-transform [&.active]:opacity-100 [&.active]:after:scale-x-100"
[routerLink]="RouterConstants.ALGORITHMS.LINK" routerLinkActive="active" mat-button>{{ 'TOPBAR.ALGORITHMS' | translate }}</a> [routerLink]="RouterConstants.ALGORITHMS.LINK" routerLinkActive="active" mat-button>{{ 'TOPBAR.ALGORITHMS' | translate }}</a>
<a class="opacity-70 transition-opacity duration-150 hover:opacity-100 relative after:content-[''] after:absolute after:bottom-1 after:left-2.5 after:right-2.5 after:h-0.5 after:bg-current after:rounded-sm after:scale-x-0 after:transition-transform [&.active]:opacity-100 [&.active]:after:scale-x-100"
[routerLink]="RouterConstants.STOPWATCH.LINK" routerLinkActive="active" mat-button>{{ 'TOPBAR.STOPWATCH' | translate }}</a>
<a class="opacity-70 transition-opacity duration-150 hover:opacity-100 relative after:content-[''] after:absolute after:bottom-1 after:left-2.5 after:right-2.5 after:h-0.5 after:bg-current after:rounded-sm after:scale-x-0 after:transition-transform [&.active]:opacity-100 [&.active]:after:scale-x-100" <a class="opacity-70 transition-opacity duration-150 hover:opacity-100 relative after:content-[''] after:absolute after:bottom-1 after:left-2.5 after:right-2.5 after:h-0.5 after:bg-current after:rounded-sm after:scale-x-0 after:transition-transform [&.active]:opacity-100 [&.active]:after:scale-x-100"
[routerLink]="RouterConstants.IMPRINT.LINK" routerLinkActive="active" mat-button>{{ 'TOPBAR.IMPRINT' | translate }}</a> [routerLink]="RouterConstants.IMPRINT.LINK" routerLinkActive="active" mat-button>{{ 'TOPBAR.IMPRINT' | translate }}</a>
</nav> </nav>
@@ -34,6 +36,9 @@
<button mat-menu-item [routerLink]="RouterConstants.ALGORITHMS.LINK"> <button mat-menu-item [routerLink]="RouterConstants.ALGORITHMS.LINK">
{{ 'TOPBAR.ALGORITHMS' | translate }} {{ 'TOPBAR.ALGORITHMS' | translate }}
</button> </button>
<button mat-menu-item [routerLink]="RouterConstants.STOPWATCH.LINK">
{{ 'TOPBAR.STOPWATCH' | translate }}
</button>
<button mat-menu-item [routerLink]="RouterConstants.IMPRINT.LINK"> <button mat-menu-item [routerLink]="RouterConstants.IMPRINT.LINK">
{{ 'TOPBAR.IMPRINT' | translate }} {{ 'TOPBAR.IMPRINT' | translate }}
</button> </button>

View File

@@ -0,0 +1,75 @@
<mat-card class="w-full max-w-[1920px] p-5">
<mat-card-header>
<mat-card-title>{{ 'STOPWATCH.TITLE' | translate }}</mat-card-title>
</mat-card-header>
<mat-card-content>
<div class="flex flex-col items-center gap-6 mt-4">
<div
class="font-mono tabular-nums tracking-wider text-[clamp(2.5rem,8vw,5rem)] font-semibold leading-none rounded-2xl px-6 py-3 ring-2 ring-transparent transition-all duration-300 ease-out"
[class]="flashing()
? 'scale-110 !text-[var(--mat-sys-primary)] !ring-[var(--mat-sys-primary)] shadow-[0_0_40px_var(--mat-sys-primary)]'
: ''"
>
{{ display() }}
</div>
<div class="flex flex-wrap gap-3 justify-center">
<button mat-flat-button color="primary" (click)="start()" [disabled]="state() === 'running'">
<mat-icon>play_arrow</mat-icon>
{{ 'STOPWATCH.START' | translate }}
</button>
<button mat-flat-button color="accent" (click)="pause()" [disabled]="state() !== 'running'">
<mat-icon>pause</mat-icon>
{{ 'STOPWATCH.PAUSE' | translate }}
</button>
<button mat-stroked-button (click)="stop()" [disabled]="state() === 'idle' && elapsedMs() === 0">
<mat-icon>stop</mat-icon>
{{ 'STOPWATCH.STOP' | translate }}
</button>
<button mat-stroked-button (click)="reset()" [disabled]="elapsedMs() === 0 && state() === 'idle'">
<mat-icon>restart_alt</mat-icon>
{{ 'STOPWATCH.RESET' | translate }}
</button>
</div>
<div class="flex flex-col gap-3 items-center w-full max-w-[520px]">
<mat-checkbox [checked]="intervalEnabled()" (change)="intervalEnabled.set($event.checked)">
{{ 'STOPWATCH.INTERVAL_ENABLED' | translate }}
</mat-checkbox>
<div class="flex flex-wrap gap-3 items-center justify-center">
<mat-form-field appearance="outline" class="w-[160px]">
<mat-label>{{ 'STOPWATCH.INTERVAL_MINUTES' | translate }}</mat-label>
<input
matInput
type="number"
min="0"
step="1"
[disabled]="!intervalEnabled()"
[ngModel]="intervalMinutes()"
(ngModelChange)="intervalMinutes.set($event)"
/>
</mat-form-field>
<mat-form-field appearance="outline" class="w-[160px]">
<mat-label>{{ 'STOPWATCH.INTERVAL_SECONDS' | translate }}</mat-label>
<input
matInput
type="number"
min="0"
max="59"
step="1"
[disabled]="!intervalEnabled()"
[ngModel]="intervalSeconds()"
(ngModelChange)="intervalSeconds.set($event)"
/>
</mat-form-field>
</div>
<p class="m-0 opacity-70 text-sm text-center">
{{ 'STOPWATCH.INTERVAL_HINT' | translate }}
</p>
</div>
</div>
</mat-card-content>
</mat-card>

View File

@@ -0,0 +1,186 @@
import {Component, computed, OnDestroy, signal} from '@angular/core';
import {FormsModule} from '@angular/forms';
import {MatButtonModule} from '@angular/material/button';
import {MatCardModule} from '@angular/material/card';
import {MatFormFieldModule} from '@angular/material/form-field';
import {MatIconModule} from '@angular/material/icon';
import {MatInputModule} from '@angular/material/input';
import {MatCheckboxModule} from '@angular/material/checkbox';
import {TranslateModule} from '@ngx-translate/core';
type StopwatchState = 'idle' | 'running' | 'paused';
@Component({
selector: 'app-stopwatch',
standalone: true,
imports: [
FormsModule,
MatButtonModule,
MatCardModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatCheckboxModule,
TranslateModule
],
templateUrl: './stopwatch.component.html'
})
export class StopwatchComponent implements OnDestroy {
readonly state = signal<StopwatchState>('idle');
readonly elapsedMs = signal(0);
readonly intervalEnabled = signal(false);
readonly intervalMinutes = signal(2);
readonly intervalSeconds = signal(0);
readonly flashing = signal(false);
readonly display = computed(() => formatElapsed(this.elapsedMs()));
private rafId: number | null = null;
private startTimestamp = 0;
private accumulatedMs = 0;
private previousElapsedMs = 0;
private audioContext: AudioContext | null = null;
private flashTimeoutId: number | null = null;
start(): void {
if (this.state() === 'running') {
return;
}
this.ensureAudioContext();
this.startTimestamp = performance.now();
this.previousElapsedMs = this.accumulatedMs;
this.state.set('running');
this.tick();
}
pause(): void {
if (this.state() !== 'running') {
return;
}
this.accumulatedMs = this.elapsedMs();
this.cancelTick();
this.state.set('paused');
}
stop(): void {
this.cancelTick();
this.accumulatedMs = 0;
this.previousElapsedMs = 0;
this.elapsedMs.set(0);
this.state.set('idle');
}
reset(): void {
this.stop();
}
ngOnDestroy(): void {
this.cancelTick();
if (this.flashTimeoutId !== null) {
clearTimeout(this.flashTimeoutId);
this.flashTimeoutId = null;
}
if (this.audioContext) {
this.audioContext.close().catch(() => undefined);
this.audioContext = null;
}
}
private tick = (): void => {
if (this.state() !== 'running') {
return;
}
const now = performance.now();
const newElapsed = this.accumulatedMs + (now - this.startTimestamp);
this.checkIntervalCrossing(this.previousElapsedMs, newElapsed);
this.previousElapsedMs = newElapsed;
this.elapsedMs.set(newElapsed);
this.rafId = requestAnimationFrame(this.tick);
};
private cancelTick(): void {
if (this.rafId !== null) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
}
private checkIntervalCrossing(prevMs: number, currMs: number): void {
if (!this.intervalEnabled()) {
return;
}
const intervalMs = this.intervalMinutes() * 60_000 + this.intervalSeconds() * 1_000;
if (intervalMs <= 0) {
return;
}
const prevTick = Math.floor(prevMs / intervalMs);
const currTick = Math.floor(currMs / intervalMs);
if (currTick > prevTick) {
this.beep();
this.triggerFlash();
}
}
private triggerFlash(): void {
if (this.flashTimeoutId !== null) {
clearTimeout(this.flashTimeoutId);
}
this.flashing.set(true);
this.flashTimeoutId = window.setTimeout(() => {
this.flashing.set(false);
this.flashTimeoutId = null;
}, 450);
}
private ensureAudioContext(): void {
if (this.audioContext) {
return;
}
const Ctor = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (Ctor) {
this.audioContext = new Ctor();
}
}
private beep(): void {
if (!this.audioContext) {
return;
}
const ctx = this.audioContext;
const oscillator = ctx.createOscillator();
const gain = ctx.createGain();
oscillator.type = 'sine';
oscillator.frequency.value = 880;
const now = ctx.currentTime;
const duration = 0.18;
gain.gain.setValueAtTime(0, now);
gain.gain.linearRampToValueAtTime(0.25, now + 0.01);
gain.gain.linearRampToValueAtTime(0, now + duration);
oscillator.connect(gain);
gain.connect(ctx.destination);
oscillator.start(now);
oscillator.stop(now + duration);
}
}
function formatElapsed(totalMs: number): string {
const ms = Math.floor(totalMs % 1000);
const totalSeconds = Math.floor(totalMs / 1000);
const seconds = totalSeconds % 60;
const totalMinutes = Math.floor(totalSeconds / 60);
const minutes = totalMinutes % 60;
const hours = Math.floor(totalMinutes / 60);
return `${pad(hours, 2)}:${pad(minutes, 2)}:${pad(seconds, 2)}.${pad(ms, 3)}`;
}
function pad(value: number, length: number): string {
return value.toString().padStart(length, '0');
}

View File

@@ -8,6 +8,7 @@
"IMPRINT": "Impressum", "IMPRINT": "Impressum",
"PROJECTS": "Projekte", "PROJECTS": "Projekte",
"ALGORITHMS": "Algorithmen", "ALGORITHMS": "Algorithmen",
"STOPWATCH": "Stoppuhr",
"SETTINGS": "Einstellungen", "SETTINGS": "Einstellungen",
"LANGUAGE": "Sprache", "LANGUAGE": "Sprache",
"APPEARANCE": "Darstellung" "APPEARANCE": "Darstellung"
@@ -588,5 +589,16 @@
"ALERT": { "ALERT": {
"NO_SOLUTION": "Keine Lösung gefunden (das sollte bei einer planaren Karte nicht passieren!)." "NO_SOLUTION": "Keine Lösung gefunden (das sollte bei einer planaren Karte nicht passieren!)."
} }
},
"STOPWATCH": {
"TITLE": "Stoppuhr",
"START": "Start",
"PAUSE": "Pause",
"STOP": "Stopp",
"RESET": "Zurücksetzen",
"INTERVAL_ENABLED": "Signalton bei Intervall",
"INTERVAL_MINUTES": "Minuten",
"INTERVAL_SECONDS": "Sekunden",
"INTERVAL_HINT": "Spielt einen kurzen Ton ab, sobald die verstrichene Zeit ein Vielfaches des Intervalls überschreitet."
} }
} }

View File

@@ -8,6 +8,7 @@
"IMPRINT": "Impressum", "IMPRINT": "Impressum",
"PROJECTS": "Projects", "PROJECTS": "Projects",
"ALGORITHMS": "Algorithms", "ALGORITHMS": "Algorithms",
"STOPWATCH": "Stopwatch",
"SETTINGS": "Settings", "SETTINGS": "Settings",
"LANGUAGE": "Language", "LANGUAGE": "Language",
"APPEARANCE": "Appearance" "APPEARANCE": "Appearance"
@@ -587,5 +588,16 @@
"ALERT": { "ALERT": {
"NO_SOLUTION": "No solution found (this should not happen for a planar map!)." "NO_SOLUTION": "No solution found (this should not happen for a planar map!)."
} }
},
"STOPWATCH": {
"TITLE": "Stopwatch",
"START": "Start",
"PAUSE": "Pause",
"STOP": "Stop",
"RESET": "Reset",
"INTERVAL_ENABLED": "Beep at interval",
"INTERVAL_MINUTES": "Minutes",
"INTERVAL_SECONDS": "Seconds",
"INTERVAL_HINT": "Plays a short tone whenever the elapsed time crosses a multiple of the interval."
} }
} }