From 39b90a0b810ba501397f10ee80a4e36ae9a018ae Mon Sep 17 00:00:00 2001 From: Andreas Dahm Date: Wed, 6 May 2026 10:46:57 +0200 Subject: [PATCH] Added timer component --- src/app/app.routes.ts | 1 + src/app/constants/RouterConstants.ts | 5 + src/app/layout/topbar/topbar.component.html | 5 + .../pages/stopwatch/stopwatch.component.html | 75 +++++++ .../pages/stopwatch/stopwatch.component.ts | 186 ++++++++++++++++++ src/assets/i18n/de.json | 12 ++ src/assets/i18n/en.json | 12 ++ 7 files changed, 296 insertions(+) create mode 100644 src/app/pages/stopwatch/stopwatch.component.html create mode 100644 src/app/pages/stopwatch/stopwatch.component.ts diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index acc5163..7e6cb94 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -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) }, ]; diff --git a/src/app/constants/RouterConstants.ts b/src/app/constants/RouterConstants.ts index cbec636..920a003 100644 --- a/src/app/constants/RouterConstants.ts +++ b/src/app/constants/RouterConstants.ts @@ -64,5 +64,10 @@ PATH: 'imprint', LINK: '/imprint', }; + + static readonly STOPWATCH = { + PATH: 'stopwatch', + LINK: '/stopwatch', + }; } diff --git a/src/app/layout/topbar/topbar.component.html b/src/app/layout/topbar/topbar.component.html index b7377a9..317d8d6 100644 --- a/src/app/layout/topbar/topbar.component.html +++ b/src/app/layout/topbar/topbar.component.html @@ -12,6 +12,8 @@ [routerLink]="RouterConstants.PROJECTS.LINK" routerLinkActive="active" mat-button>{{ 'TOPBAR.PROJECTS' | translate }} {{ 'TOPBAR.ALGORITHMS' | translate }} + {{ 'TOPBAR.STOPWATCH' | translate }} {{ 'TOPBAR.IMPRINT' | translate }} @@ -34,6 +36,9 @@ + diff --git a/src/app/pages/stopwatch/stopwatch.component.html b/src/app/pages/stopwatch/stopwatch.component.html new file mode 100644 index 0000000..304319e --- /dev/null +++ b/src/app/pages/stopwatch/stopwatch.component.html @@ -0,0 +1,75 @@ + + + {{ 'STOPWATCH.TITLE' | translate }} + + +
+
+ {{ display() }} +
+ +
+ + + + +
+ +
+ + {{ 'STOPWATCH.INTERVAL_ENABLED' | translate }} + + +
+ + {{ 'STOPWATCH.INTERVAL_MINUTES' | translate }} + + + + + {{ 'STOPWATCH.INTERVAL_SECONDS' | translate }} + + +
+ +

+ {{ 'STOPWATCH.INTERVAL_HINT' | translate }} +

+
+
+
+
diff --git a/src/app/pages/stopwatch/stopwatch.component.ts b/src/app/pages/stopwatch/stopwatch.component.ts new file mode 100644 index 0000000..40eb517 --- /dev/null +++ b/src/app/pages/stopwatch/stopwatch.component.ts @@ -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('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'); +} diff --git a/src/assets/i18n/de.json b/src/assets/i18n/de.json index aaf99d3..b3b8f8c 100644 --- a/src/assets/i18n/de.json +++ b/src/assets/i18n/de.json @@ -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." } } diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 3d5bc46..24c9d5a 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -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." } }