Added timer component
This commit is contained in:
@@ -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.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.STOPWATCH.PATH, loadComponent: () => import('./pages/stopwatch/stopwatch.component').then(m => m.StopwatchComponent) },
|
||||
];
|
||||
|
||||
|
||||
@@ -64,5 +64,10 @@
|
||||
PATH: 'imprint',
|
||||
LINK: '/imprint',
|
||||
};
|
||||
|
||||
static readonly STOPWATCH = {
|
||||
PATH: 'stopwatch',
|
||||
LINK: '/stopwatch',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
[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"
|
||||
[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"
|
||||
[routerLink]="RouterConstants.IMPRINT.LINK" routerLinkActive="active" mat-button>{{ 'TOPBAR.IMPRINT' | translate }}</a>
|
||||
</nav>
|
||||
@@ -34,6 +36,9 @@
|
||||
<button mat-menu-item [routerLink]="RouterConstants.ALGORITHMS.LINK">
|
||||
{{ 'TOPBAR.ALGORITHMS' | translate }}
|
||||
</button>
|
||||
<button mat-menu-item [routerLink]="RouterConstants.STOPWATCH.LINK">
|
||||
{{ 'TOPBAR.STOPWATCH' | translate }}
|
||||
</button>
|
||||
<button mat-menu-item [routerLink]="RouterConstants.IMPRINT.LINK">
|
||||
{{ 'TOPBAR.IMPRINT' | translate }}
|
||||
</button>
|
||||
|
||||
75
src/app/pages/stopwatch/stopwatch.component.html
Normal file
75
src/app/pages/stopwatch/stopwatch.component.html
Normal 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>
|
||||
186
src/app/pages/stopwatch/stopwatch.component.ts
Normal file
186
src/app/pages/stopwatch/stopwatch.component.ts
Normal 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');
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
"IMPRINT": "Impressum",
|
||||
"PROJECTS": "Projekte",
|
||||
"ALGORITHMS": "Algorithmen",
|
||||
"STOPWATCH": "Stoppuhr",
|
||||
"SETTINGS": "Einstellungen",
|
||||
"LANGUAGE": "Sprache",
|
||||
"APPEARANCE": "Darstellung"
|
||||
@@ -588,5 +589,16 @@
|
||||
"ALERT": {
|
||||
"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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"IMPRINT": "Impressum",
|
||||
"PROJECTS": "Projects",
|
||||
"ALGORITHMS": "Algorithms",
|
||||
"STOPWATCH": "Stopwatch",
|
||||
"SETTINGS": "Settings",
|
||||
"LANGUAGE": "Language",
|
||||
"APPEARANCE": "Appearance"
|
||||
@@ -587,5 +588,16 @@
|
||||
"ALERT": {
|
||||
"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."
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user