Angular 22 defaults to OnPush, so Eager is removed. Template state is now signals, so sorting needs no detectChanges. Game of Life execution time now updates live. Remove the unused ImageDialogComponent and turn the OnPush lint rule back on.
324 lines
9.6 KiB
TypeScript
324 lines
9.6 KiB
TypeScript
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';
|
|
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 {Title} from '@angular/platform-browser';
|
|
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,
|
|
imports: [
|
|
FormsModule,
|
|
MatButtonModule,
|
|
MatCardModule,
|
|
MatFormFieldModule,
|
|
MatIconModule,
|
|
MatInputModule,
|
|
MatCheckboxModule,
|
|
TranslatePipe
|
|
],
|
|
templateUrl: './stopwatch.component.html'
|
|
})
|
|
export class StopwatchComponent implements OnDestroy {
|
|
readonly state = signal<StopwatchState>('idle');
|
|
readonly elapsedMs = signal(0);
|
|
|
|
readonly intervalEnabled = signal(false);
|
|
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;
|
|
private previousElapsedMs = 0;
|
|
private audioContext: AudioContext | null = null;
|
|
private flashTimeoutId: number | null = null;
|
|
private readonly titleService = inject(Title);
|
|
private readonly originalTitle = this.titleService.getTitle();
|
|
|
|
constructor() {
|
|
effect(() => {
|
|
const state = this.state();
|
|
const elapsed = this.elapsedMs();
|
|
this.updateDocumentTitle(state, elapsed);
|
|
});
|
|
}
|
|
|
|
start(): void {
|
|
if (this.state() === 'running') {
|
|
return;
|
|
}
|
|
this.ensureAudioContext();
|
|
this.resumeAudioContext();
|
|
this.startTimestamp = performance.now();
|
|
this.previousElapsedMs = this.accumulatedMs;
|
|
this.state.set('running');
|
|
this.startTicking();
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
/** 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) {
|
|
clearTimeout(this.flashTimeoutId);
|
|
this.flashTimeoutId = null;
|
|
}
|
|
if (this.audioContext) {
|
|
this.audioContext.close().catch(() => undefined);
|
|
this.audioContext = null;
|
|
}
|
|
this.titleService.setTitle(this.originalTitle);
|
|
}
|
|
|
|
private startTicking(): void {
|
|
this.cancelTick();
|
|
this.tick();
|
|
// setInterval keeps firing in background tabs (throttled to ~1s) so beeps still play when unfocused.
|
|
this.intervalId = window.setInterval(this.tick, 50);
|
|
}
|
|
|
|
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);
|
|
};
|
|
|
|
private cancelTick(): void {
|
|
if (this.intervalId !== null) {
|
|
clearInterval(this.intervalId);
|
|
this.intervalId = null;
|
|
}
|
|
}
|
|
|
|
private updateDocumentTitle(state: StopwatchState, elapsed: number): void {
|
|
if (state === 'idle') {
|
|
this.titleService.setTitle(this.originalTitle);
|
|
return;
|
|
}
|
|
const prefix = state === 'running' ? '▶' : '⏸';
|
|
this.titleService.setTitle(`${prefix} ${formatElapsedShort(elapsed)} · ${this.originalTitle}`);
|
|
}
|
|
|
|
private checkIntervalCrossing(prevMs: number, currMs: number): void {
|
|
if (!this.intervalEnabled()) {
|
|
return;
|
|
}
|
|
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 slot = slotOfBeep(currentBeeps, secondMs);
|
|
this.beep(slot === 'first' ? FIRST_INTERVAL_FREQUENCY_HZ : SECOND_INTERVAL_FREQUENCY_HZ);
|
|
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 resumeAudioContext(): void {
|
|
// Browsers suspend AudioContext until a user gesture; resume on start so beeps can play in background tabs.
|
|
if (this.audioContext && this.audioContext.state === 'suspended') {
|
|
this.audioContext.resume().catch(() => undefined);
|
|
}
|
|
}
|
|
|
|
private beep(frequencyHz: number): void {
|
|
if (!this.audioContext) {
|
|
return;
|
|
}
|
|
const ctx = this.audioContext;
|
|
const oscillator = ctx.createOscillator();
|
|
const gain = ctx.createGain();
|
|
|
|
oscillator.type = 'sine';
|
|
oscillator.frequency.value = frequencyHz;
|
|
|
|
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 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);
|
|
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 formatElapsedShort(totalMs: number): string {
|
|
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);
|
|
|
|
if (hours > 0) {
|
|
return `${pad(hours, 2)}:${pad(minutes, 2)}:${pad(seconds, 2)}`;
|
|
}
|
|
return `${pad(minutes, 2)}:${pad(seconds, 2)}`;
|
|
}
|
|
|
|
function pad(value: number, length: number): string {
|
|
return value.toString().padStart(length, '0');
|
|
}
|