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
@@ -124,7 +124,7 @@
|
||||
@if (entry.externalLink) {
|
||||
<div class="mt-[0.1rem] opacity-85">
|
||||
<a class="inline-flex items-center gap-[0.35rem] leading-none" href="{{entry.externalLink}}" target="_blank" rel="noopener noreferrer">
|
||||
<mat-icon class="!text-[18px] !w-[18px] !h-[18px]">open_in_new</mat-icon>
|
||||
<mat-icon class="text-[18px]! w-[18px]! h-[18px]!">open_in_new</mat-icon>
|
||||
{{ (entry.key + '.LINK_EXTERNAL') | translate }}
|
||||
</a>
|
||||
</div>
|
||||
@@ -132,7 +132,7 @@
|
||||
<div class="mt-[0.1rem] opacity-85">
|
||||
<a class="inline-flex items-center gap-[0.35rem] leading-none" [routerLink]="['/projects']" [queryParams]="{ project: entry.identifier }"
|
||||
rel="noopener noreferrer">
|
||||
<mat-icon class="!text-[18px] !w-[18px] !h-[18px]">link</mat-icon>
|
||||
<mat-icon class="text-[18px]! w-[18px]! h-[18px]!">link</mat-icon>
|
||||
{{ (entry.key + '.LINK_INTERNAL') | translate }}
|
||||
</a>
|
||||
</div>
|
||||
@@ -169,7 +169,7 @@
|
||||
|
||||
@if(entry.key !== educationKeys.at(educationKeys.length-1)?.key)
|
||||
{
|
||||
<mat-divider class="!my-2"></mat-divider>
|
||||
<mat-divider class="my-2!"></mat-divider>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDividerModule } from '@angular/material/divider';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import {UrlConstants} from '../../constants/UrlConstants';
|
||||
import {AssetsConstants} from '../../constants/AssetsConstants';
|
||||
import {RouterLink} from '@angular/router';
|
||||
@@ -21,7 +21,7 @@ import {SharedFunctions} from '../../shared/SharedFunctions';
|
||||
MatIconModule,
|
||||
MatButtonModule,
|
||||
MatDividerModule,
|
||||
TranslateModule,
|
||||
TranslatePipe,
|
||||
RouterLink
|
||||
],
|
||||
templateUrl: './about.component.html',
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
@for (category of categories; track category.id) {
|
||||
<mat-card class="card-gradient-bar transition-transform duration-200 ease-in-out flex flex-col cursor-pointer hover:-translate-y-1 hover:shadow-[0_8px_24px_rgba(0,0,0,0.12)]" [routerLink]="[category.routerLink]">
|
||||
<mat-card-content>
|
||||
<div class="flex items-center text-[var(--mat-sys-primary)] mb-4">
|
||||
<mat-icon class="!text-[26px] !w-[26px] !h-[26px]">{{ category.icon }}</mat-icon>
|
||||
<div class="flex items-center text-(--mat-sys-primary) mb-4">
|
||||
<mat-icon class="text-[26px]! w-[26px]! h-[26px]!">{{ category.icon }}</mat-icon>
|
||||
</div>
|
||||
<h3 class="text-[1.05rem] font-semibold mb-2 m-0">{{ category.title | translate }}</h3>
|
||||
<p class="m-0 opacity-75 text-sm leading-relaxed">{{ category.description | translate }}</p>
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
<span><span class="legend-swatch bg-[#FFEB3B]"></span> {{ 'FOUR_COLOR.COLOR_4' | translate }}</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 flex gap-2.5 py-2.5 px-4 rounded border-l-[5px] font-medium min-w-[300px] items-center"
|
||||
<div class="mt-5 flex gap-2.5 py-2.5 px-4 rounded-sm border-l-[5px] font-medium min-w-[300px] items-center"
|
||||
[ngClass]="{
|
||||
'border-l-gray-500 bg-app-bg': solutionStatus === 'INCOMPLETE',
|
||||
'border-l-green-500 bg-green-50 text-green-800': solutionStatus === 'SOLVED',
|
||||
|
||||
@@ -5,7 +5,7 @@ import {MatButtonModule} from '@angular/material/button';
|
||||
import {MatCardModule} from '@angular/material/card';
|
||||
import {MatFormFieldModule} from '@angular/material/form-field';
|
||||
import {MatInputModule} from '@angular/material/input';
|
||||
import {TranslateModule, TranslateService} from '@ngx-translate/core';
|
||||
import {TranslatePipe, TranslateService} from '@ngx-translate/core';
|
||||
import {MatSnackBar} from '@angular/material/snack-bar';
|
||||
|
||||
import {DEFAULT_GRID_COLS, DEFAULT_GRID_ROWS, MAX_GRID_PX, MAX_GRID_SIZE, MIN_GRID_SIZE, FourColorNode, Region} from './four-color.models';
|
||||
@@ -25,7 +25,7 @@ import {UrlConstants} from '../../../constants/UrlConstants';
|
||||
MatCardModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
TranslateModule,
|
||||
TranslatePipe,
|
||||
Information
|
||||
],
|
||||
templateUrl: './four-color.component.html',
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
|
||||
<mat-button-toggle-group class="rounded overflow-hidden" [(ngModel)]="selectedNodeType" aria-label="Node Type Selection">
|
||||
<mat-button-toggle-group class="rounded-sm overflow-hidden" [(ngModel)]="selectedNodeType" aria-label="Node Type Selection">
|
||||
<mat-button-toggle [value]="NodeType.Start">{{ 'PATHFINDING.START_NODE' | translate }}</mat-button-toggle>
|
||||
<mat-button-toggle [value]="NodeType.End">{{ 'PATHFINDING.END_NODE' | translate }}</mat-button-toggle>
|
||||
<mat-button-toggle [value]="NodeType.Wall">{{ 'PATHFINDING.WALL' | translate }}</mat-button-toggle>
|
||||
|
||||
@@ -8,7 +8,7 @@ import {MatFormFieldModule} from '@angular/material/form-field';
|
||||
import {MatInputModule} from '@angular/material/input';
|
||||
import {MatSnackBar} from '@angular/material/snack-bar';
|
||||
|
||||
import {TranslateModule, TranslateService} from '@ngx-translate/core';
|
||||
import {TranslatePipe, TranslateService} from '@ngx-translate/core';
|
||||
|
||||
import {DEFAULT_GRID_COLS, DEFAULT_GRID_ROWS, MAX_GRID_PX, MAX_GRID_SIZE, MAX_RANDOM_WALLS_FACTORS, MIN_GRID_SIZE, Node} from './pathfinding.models';
|
||||
import {PathfindingService} from './service/pathfinding.service';
|
||||
@@ -35,7 +35,7 @@ enum NodeType {
|
||||
MatButtonToggleModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
TranslateModule,
|
||||
TranslatePipe,
|
||||
MatCard,
|
||||
MatCardHeader,
|
||||
MatCardTitle,
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
<div class="flex items-end h-[clamp(200px,40vh,400px)] border-b border-app-fg mb-[clamp(10px,3vw,20px)] gap-px bg-card-bg">
|
||||
@for (item of sortArray; track $index) {
|
||||
<div
|
||||
class="grow w-2.5 min-w-px transition-all duration-[50ms]"
|
||||
class="grow w-2.5 min-w-px transition-all duration-50"
|
||||
[style.height.px]="item.value * 3"
|
||||
[ngClass]="{
|
||||
'bg-[#424242]': item.state === 'unsorted',
|
||||
|
||||
@@ -5,7 +5,7 @@ import {MatFormFieldModule} from "@angular/material/form-field";
|
||||
import {MatSelectModule} from "@angular/material/select";
|
||||
import {MatButtonModule} from "@angular/material/button";
|
||||
import {MatIconModule} from "@angular/material/icon";
|
||||
import {TranslateModule} from "@ngx-translate/core";
|
||||
import {TranslatePipe} from "@ngx-translate/core";
|
||||
import { SortingService } from './service/sorting.service';
|
||||
import { SortingAudioService } from './service/sorting-audio.service';
|
||||
import {SortData, SortSnapshot} from './sorting.models';
|
||||
@@ -16,7 +16,7 @@ import {AlgorithmInformation} from '../information/information.models';
|
||||
import {Information} from '../information/information';
|
||||
@Component({
|
||||
selector: 'app-sorting',
|
||||
imports: [CommonModule, MatCardModule, MatFormFieldModule, MatSelectModule, MatButtonModule, MatIconModule, TranslateModule, FormsModule, MatInput, Information],
|
||||
imports: [CommonModule, MatCardModule, MatFormFieldModule, MatSelectModule, MatButtonModule, MatIconModule, TranslatePipe, FormsModule, MatInput, Information],
|
||||
templateUrl: './sorting.component.html',
|
||||
styleUrl: './sorting.component.scss'
|
||||
})
|
||||
|
||||
@@ -13,9 +13,9 @@
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-[repeat(auto-fit,minmax(300px,1fr))] sm-dialog:grid-cols-1 gap-4 mb-4">
|
||||
<div class="p-5 rounded-xl bg-black/[.03] border border-black/[.05] dark:bg-white/[.05] dark:border-white/10">
|
||||
<div class="p-5 rounded-xl bg-black/3 border border-black/5 dark:bg-white/5 dark:border-white/10">
|
||||
<div class="flex items-center gap-3 mb-3 text-link">
|
||||
<mat-icon class="!text-[24px] !w-6 !h-6">settings_suggest</mat-icon>
|
||||
<mat-icon class="text-[24px]! w-6! h-6!">settings_suggest</mat-icon>
|
||||
<h3 class="m-0 text-sm uppercase tracking-wider font-semibold">{{ 'PROJECTS.SECTION.TECHNICAL' | translate }}</h3>
|
||||
</div>
|
||||
<ul class="m-0 pl-5 text-[0.95rem] leading-relaxed opacity-85">
|
||||
@@ -25,9 +25,9 @@
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="p-5 rounded-xl bg-black/[.03] border border-black/[.05] dark:bg-white/[.05] dark:border-white/10">
|
||||
<div class="p-5 rounded-xl bg-black/3 border border-black/5 dark:bg-white/5 dark:border-white/10">
|
||||
<div class="flex items-center gap-3 mb-3 text-link">
|
||||
<mat-icon class="!text-[24px] !w-6 !h-6">psychology</mat-icon>
|
||||
<mat-icon class="text-[24px]! w-6! h-6!">psychology</mat-icon>
|
||||
<h3 class="m-0 text-sm uppercase tracking-wider font-semibold">{{ 'PROJECTS.SECTION.LEARNINGS' | translate }}</h3>
|
||||
</div>
|
||||
<ul class="m-0 pl-5 text-[0.95rem] leading-relaxed opacity-85">
|
||||
@@ -46,7 +46,7 @@
|
||||
[attr.pagination]="true" [attr.keyboard]="true" style="width: 100%;">
|
||||
@for (img of project.images; track img) {
|
||||
<swiper-slide class="rounded-xl overflow-hidden flex flex-col bg-[#222]">
|
||||
<img class="w-full h-auto !max-h-[clamp(300px,60vh,512px)] object-contain block shrink-0" [src]="img.url" [alt]="project.title | translate" />
|
||||
<img class="w-full h-auto max-h-[clamp(300px,60vh,512px)]! object-contain block shrink-0" [src]="img.url" [alt]="project.title | translate" />
|
||||
@if (img.source) {
|
||||
<div class="text-xs text-[#aaa] bg-[#2a2a2a] p-2 text-right border-t border-[#444]">
|
||||
{{ img.source }}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div class="grid gap-fluid-md grid-cols-[repeat(auto-fill,minmax(min(100%,450px),1fr))] max-w-app mx-4 mt-auto">
|
||||
@if (featuredProject(); as project) {
|
||||
<mat-card class="card-gradient-bar transition-transform duration-200 ease-in-out flex flex-col h-full col-span-full hover:-translate-y-[5px] hover:shadow-[0_4px_20px_rgba(0,0,0,0.15)]">
|
||||
<mat-card class="card-gradient-bar transition-transform duration-200 ease-in-out flex flex-col h-full col-span-full hover:translate-y-[-5px] hover:shadow-[0_4px_20px_rgba(0,0,0,0.15)]">
|
||||
<mat-card-header class="pb-4">
|
||||
<mat-card-title>{{ project.title | translate }}</mat-card-title>
|
||||
<mat-card-subtitle>{{ project.shortDescription | translate }}</mat-card-subtitle>
|
||||
@@ -27,7 +27,7 @@
|
||||
}
|
||||
|
||||
@for (project of otherProjects(); track project) {
|
||||
<mat-card class="card-gradient-bar transition-transform duration-200 ease-in-out flex flex-col h-full hover:-translate-y-[5px] hover:shadow-[0_4px_20px_rgba(0,0,0,0.15)]">
|
||||
<mat-card class="card-gradient-bar transition-transform duration-200 ease-in-out flex flex-col h-full hover:translate-y-[-5px] hover:shadow-[0_4px_20px_rgba(0,0,0,0.15)]">
|
||||
<mat-card-header class="pb-4">
|
||||
<mat-card-title>{{ project.title | translate }}</mat-card-title>
|
||||
</mat-card-header>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<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)]'
|
||||
? 'scale-110 text-(--mat-sys-primary)! ring-(--mat-sys-primary)! shadow-[0_0_40px_var(--mat-sys-primary)]'
|
||||
: ''"
|
||||
>
|
||||
{{ display() }}
|
||||
@@ -37,6 +37,8 @@
|
||||
{{ 'STOPWATCH.INTERVAL_ENABLED' | translate }}
|
||||
</mat-checkbox>
|
||||
|
||||
<p class="m-0 font-medium text-sm">{{ 'STOPWATCH.FIRST_INTERVAL' | translate }}</p>
|
||||
|
||||
<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>
|
||||
@@ -46,8 +48,8 @@
|
||||
min="0"
|
||||
step="1"
|
||||
[disabled]="!intervalEnabled()"
|
||||
[ngModel]="intervalMinutes()"
|
||||
(ngModelChange)="intervalMinutes.set($event)"
|
||||
[ngModel]="firstIntervalMinutes()"
|
||||
(ngModelChange)="updateIntervalValue(firstIntervalMinutes, $event)"
|
||||
/>
|
||||
</mat-form-field>
|
||||
|
||||
@@ -60,15 +62,60 @@
|
||||
max="59"
|
||||
step="1"
|
||||
[disabled]="!intervalEnabled()"
|
||||
[ngModel]="intervalSeconds()"
|
||||
(ngModelChange)="intervalSeconds.set($event)"
|
||||
[ngModel]="firstIntervalSeconds()"
|
||||
(ngModelChange)="updateIntervalValue(firstIntervalSeconds, $event)"
|
||||
/>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<mat-checkbox
|
||||
[checked]="secondIntervalEnabled()"
|
||||
[disabled]="!intervalEnabled()"
|
||||
(change)="secondIntervalEnabled.set($event.checked)"
|
||||
>
|
||||
{{ 'STOPWATCH.SECOND_INTERVAL_ENABLED' | translate }}
|
||||
</mat-checkbox>
|
||||
|
||||
@if (secondIntervalEnabled()) {
|
||||
<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]="secondIntervalMinutes()"
|
||||
(ngModelChange)="updateIntervalValue(secondIntervalMinutes, $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]="secondIntervalSeconds()"
|
||||
(ngModelChange)="updateIntervalValue(secondIntervalSeconds, $event)"
|
||||
/>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
}
|
||||
|
||||
<p class="m-0 opacity-70 text-sm text-center">
|
||||
{{ 'STOPWATCH.INTERVAL_HINT' | translate }}
|
||||
</p>
|
||||
|
||||
@if (intervalEnabled() && schedulePreview()) {
|
||||
<p class="m-0 opacity-70 text-sm text-center">
|
||||
{{ 'STOPWATCH.INTERVAL_SCHEDULE' | translate }} {{ schedulePreview() }} …
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import {collectBeepTimes, countBeeps, slotOfBeep} from './stopwatch.component';
|
||||
|
||||
const MINUTE = 60_000;
|
||||
|
||||
describe('stopwatch interval scheduling', () => {
|
||||
const firstMs = 1 * MINUTE;
|
||||
const secondMs = 2 * MINUTE;
|
||||
|
||||
it('counts no beeps without a configured first interval', () => {
|
||||
expect(countBeeps(10 * MINUTE, 0, secondMs)).toBe(0);
|
||||
});
|
||||
|
||||
it('falls back to a single repeating interval when the second one is off', () => {
|
||||
expect(countBeeps(0.9 * MINUTE, firstMs, 0)).toBe(0);
|
||||
expect(countBeeps(1 * MINUTE, firstMs, 0)).toBe(1);
|
||||
expect(countBeeps(3 * MINUTE, firstMs, 0)).toBe(3);
|
||||
});
|
||||
|
||||
it('alternates both intervals so beeps fall on 1, 3, 4, 6, 7 minutes', () => {
|
||||
const expectedBeepCountPerMinute = [0, 1, 1, 2, 3, 3, 4, 5, 5, 6];
|
||||
|
||||
expectedBeepCountPerMinute.forEach((expected, minute) => {
|
||||
expect(countBeeps(minute * MINUTE, firstMs, secondMs)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
it('reports the alternating beep times', () => {
|
||||
const times = collectBeepTimes(firstMs, secondMs, 5);
|
||||
|
||||
expect(times).toEqual([1 * MINUTE, 3 * MINUTE, 4 * MINUTE, 6 * MINUTE, 7 * MINUTE]);
|
||||
});
|
||||
|
||||
it('assigns odd beeps to the first interval and even ones to the second', () => {
|
||||
expect(slotOfBeep(1, secondMs)).toBe('first');
|
||||
expect(slotOfBeep(2, secondMs)).toBe('second');
|
||||
expect(slotOfBeep(3, secondMs)).toBe('first');
|
||||
expect(slotOfBeep(2, 0)).toBe('first');
|
||||
});
|
||||
});
|
||||
@@ -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