Upgraded dependencies and extended stopwatch
This commit is contained in:
1 parent
3f1093e3c8
commit
f543a5e2f3
31 files changed
+2829
-2551
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import {Component, computed, effect, inject, OnDestroy, signal} 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';
|
||||
@@ -7,10 +7,16 @@ import {MatIconModule} from '@angular/material/icon';
|
||||
import {MatInputModule} from '@angular/material/input';
|
||||
import {MatCheckboxModule} from '@angular/material/checkbox';
|
||||
import {Title} from '@angular/platform-browser';
|
||||
import {TranslateModule} from '@ngx-translate/core';
|
||||
import {TranslatePipe} from '@ngx-translate/core';
|
||||
|
||||
type StopwatchState = 'idle' | 'running' | 'paused';
|
||||
|
||||
/** Which of the two configured intervals a beep belongs to. */
|
||||
type IntervalSlot = 'first' | 'second';
|
||||
|
||||
const FIRST_INTERVAL_FREQUENCY_HZ = 880;
|
||||
const SECOND_INTERVAL_FREQUENCY_HZ = 587;
|
||||
|
||||
@Component({
|
||||
selector: 'app-stopwatch',
|
||||
standalone: true,
|
||||
@@ -22,7 +28,7 @@ type StopwatchState = 'idle' | 'running' | 'paused';
|
||||
MatIconModule,
|
||||
MatInputModule,
|
||||
MatCheckboxModule,
|
||||
TranslateModule
|
||||
TranslatePipe
|
||||
],
|
||||
templateUrl: './stopwatch.component.html'
|
||||
})
|
||||
@@ -31,13 +37,34 @@ export class StopwatchComponent implements OnDestroy {
|
||||
readonly elapsedMs = signal(0);
|
||||
|
||||
readonly intervalEnabled = signal(false);
|
||||
readonly intervalMinutes = signal(2);
|
||||
readonly intervalSeconds = signal(0);
|
||||
readonly firstIntervalMinutes = signal(1);
|
||||
readonly firstIntervalSeconds = signal(0);
|
||||
|
||||
readonly secondIntervalEnabled = signal(false);
|
||||
readonly secondIntervalMinutes = signal(2);
|
||||
readonly secondIntervalSeconds = signal(0);
|
||||
|
||||
readonly flashing = signal(false);
|
||||
|
||||
readonly display = computed(() => formatElapsed(this.elapsedMs()));
|
||||
|
||||
readonly firstIntervalMs = computed(
|
||||
() => toMilliseconds(this.firstIntervalMinutes(), this.firstIntervalSeconds())
|
||||
);
|
||||
|
||||
readonly secondIntervalMs = computed(() => {
|
||||
if (!this.secondIntervalEnabled()) {
|
||||
return 0;
|
||||
}
|
||||
return toMilliseconds(this.secondIntervalMinutes(), this.secondIntervalSeconds());
|
||||
});
|
||||
|
||||
/** Absolute times of the upcoming beeps, used as a preview of the alternating schedule. */
|
||||
readonly schedulePreview = computed(() => {
|
||||
const beepTimes = collectBeepTimes(this.firstIntervalMs(), this.secondIntervalMs(), 4);
|
||||
return beepTimes.map(formatElapsedShort).join(' · ');
|
||||
});
|
||||
|
||||
private intervalId: number | null = null;
|
||||
private startTimestamp = 0;
|
||||
private accumulatedMs = 0;
|
||||
@@ -88,6 +115,11 @@ export class StopwatchComponent implements OnDestroy {
|
||||
this.stop();
|
||||
}
|
||||
|
||||
/** Number inputs can emit empty strings or negative values, so sanitize before storing. */
|
||||
updateIntervalValue(target: WritableSignal<number>, value: unknown): void {
|
||||
target.set(toNonNegativeInteger(value));
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.cancelTick();
|
||||
if (this.flashTimeoutId !== null) {
|
||||
@@ -140,16 +172,18 @@ export class StopwatchComponent implements OnDestroy {
|
||||
if (!this.intervalEnabled()) {
|
||||
return;
|
||||
}
|
||||
const intervalMs = this.intervalMinutes() * 60_000 + this.intervalSeconds() * 1_000;
|
||||
if (intervalMs <= 0) {
|
||||
const firstMs = this.firstIntervalMs();
|
||||
const secondMs = this.secondIntervalMs();
|
||||
|
||||
const previousBeeps = countBeeps(prevMs, firstMs, secondMs);
|
||||
const currentBeeps = countBeeps(currMs, firstMs, secondMs);
|
||||
if (currentBeeps <= previousBeeps) {
|
||||
return;
|
||||
}
|
||||
const prevTick = Math.floor(prevMs / intervalMs);
|
||||
const currTick = Math.floor(currMs / intervalMs);
|
||||
if (currTick > prevTick) {
|
||||
this.beep();
|
||||
this.triggerFlash();
|
||||
}
|
||||
|
||||
const slot = slotOfBeep(currentBeeps, secondMs);
|
||||
this.beep(slot === 'first' ? FIRST_INTERVAL_FREQUENCY_HZ : SECOND_INTERVAL_FREQUENCY_HZ);
|
||||
this.triggerFlash();
|
||||
}
|
||||
|
||||
private triggerFlash(): void {
|
||||
@@ -180,7 +214,7 @@ export class StopwatchComponent implements OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
private beep(): void {
|
||||
private beep(frequencyHz: number): void {
|
||||
if (!this.audioContext) {
|
||||
return;
|
||||
}
|
||||
@@ -189,7 +223,7 @@ export class StopwatchComponent implements OnDestroy {
|
||||
const gain = ctx.createGain();
|
||||
|
||||
oscillator.type = 'sine';
|
||||
oscillator.frequency.value = 880;
|
||||
oscillator.frequency.value = frequencyHz;
|
||||
|
||||
const now = ctx.currentTime;
|
||||
const duration = 0.18;
|
||||
@@ -204,6 +238,62 @@ export class StopwatchComponent implements OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
function toMilliseconds(minutes: number, seconds: number): number {
|
||||
return minutes * 60_000 + seconds * 1_000;
|
||||
}
|
||||
|
||||
function toNonNegativeInteger(value: unknown): number {
|
||||
const parsed = Math.floor(Number(value));
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
return 0;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* The two intervals alternate: the first beep lands after `firstMs`, the next one
|
||||
* `secondMs` later, then `firstMs` again. With first = 1min and second = 2min the
|
||||
* beeps therefore fall on 1, 3, 4, 6, 7 ... minutes.
|
||||
*/
|
||||
export function countBeeps(elapsedMs: number, firstMs: number, secondMs: number): number {
|
||||
if (firstMs <= 0) {
|
||||
return 0;
|
||||
}
|
||||
if (secondMs <= 0) {
|
||||
return Math.floor(elapsedMs / firstMs);
|
||||
}
|
||||
|
||||
const cycleMs = firstMs + secondMs;
|
||||
const completedCycles = Math.floor(elapsedMs / cycleMs);
|
||||
const remainderMs = elapsedMs % cycleMs;
|
||||
const firstBeepOfCycleReached = remainderMs >= firstMs ? 1 : 0;
|
||||
|
||||
return completedCycles * 2 + firstBeepOfCycleReached;
|
||||
}
|
||||
|
||||
/** Beeps alternate, so odd beep numbers end the first interval and even ones the second. */
|
||||
export function slotOfBeep(beepNumber: number, secondMs: number): IntervalSlot {
|
||||
if (secondMs <= 0) {
|
||||
return 'first';
|
||||
}
|
||||
return beepNumber % 2 === 1 ? 'first' : 'second';
|
||||
}
|
||||
|
||||
export function collectBeepTimes(firstMs: number, secondMs: number, count: number): number[] {
|
||||
if (firstMs <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const times: number[] = [];
|
||||
let current = 0;
|
||||
for (let beepNumber = 1; beepNumber <= count; beepNumber++) {
|
||||
const isFirstSlot = slotOfBeep(beepNumber, secondMs) === 'first';
|
||||
current += isFirstSlot ? firstMs : secondMs;
|
||||
times.push(current);
|
||||
}
|
||||
return times;
|
||||
}
|
||||
|
||||
function formatElapsed(totalMs: number): string {
|
||||
const ms = Math.floor(totalMs % 1000);
|
||||
const totalSeconds = Math.floor(totalMs / 1000);
|
||||
|
||||
Reference in new issue
Block a user