Upgraded dependencies and extended stopwatch #37
4
.gitattributes
vendored
4
.gitattributes
vendored
@@ -34,7 +34,7 @@ Dockerfile text eol=lf
|
||||
*.adoc text eol=lf
|
||||
*.txt text eol=lf
|
||||
*.csv text eol=lf
|
||||
*.svg text eol=lf # svg ist Text
|
||||
*.svg text eol=lf
|
||||
|
||||
# ---- binary files never convert ----
|
||||
*.jar binary
|
||||
@@ -57,4 +57,4 @@ Dockerfile text eol=lf
|
||||
*.so binary
|
||||
*.dll binary
|
||||
*.dylib binary
|
||||
*.exe binary
|
||||
*.exe binary
|
||||
|
||||
5
.postcssrc.json
Normal file
5
.postcssrc.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"plugins": {
|
||||
"@tailwindcss/postcss": {}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ This is the frontend of the Playground project, built with Angular 21 and Angula
|
||||
* **Internationalization:** ngx-translate
|
||||
* **Server:** NGINX (for serving the SPA)
|
||||
* **Containerization:** Docker
|
||||
* **CI/CD:** GitHub Actions
|
||||
* **CI/CD:** Gitea Actions (self-hosted, `git.andreas-dahm.eu`)
|
||||
|
||||
## Building and Running
|
||||
|
||||
@@ -77,7 +77,7 @@ ng build
|
||||
|
||||
* **Language:** TypeScript
|
||||
* **Framework:** Angular
|
||||
* **Styling:** Tailwind CSS (v3) is the primary styling approach. Use utility classes in templates. SCSS (`styles.scss`) is only for Angular Material theme setup, Material component overrides with `!important`, Swiper `::part()` selectors, and component `:host` blocks. Shared Tailwind component classes live in `src/tailwind.css` via `@layer components`.
|
||||
* **Styling:** Tailwind CSS (v4, CSS-first config) is the primary styling approach. Use utility classes in templates. Theme tokens and custom variants are declared in `src/tailwind.css` via `@theme` and `@custom-variant` — there is no `tailwind.config.js`. Preflight stays disabled by importing `tailwindcss/theme.css` and `tailwindcss/utilities.css` individually, so Angular Material's reset stays in charge. SCSS (`styles.scss`) is only for Angular Material theme setup, Material component overrides with `!important`, Swiper `::part()` selectors, and component `:host` blocks. Shared Tailwind component classes live in `src/tailwind.css` via `@utility`.
|
||||
* **Linting:** ESLint is configured (see `eslint.config.js` and `package.json` scripts).
|
||||
* **Internationalization:** Uses `ngx-translate` with `en.json` and `de.json` asset files.
|
||||
|
||||
|
||||
133
README.md
133
README.md
@@ -1,97 +1,148 @@
|
||||
## 📗 `playground-frontend/README.md`
|
||||
|
||||
# 🎨 Playground Frontend
|
||||
|
||||
This is the **frontend** of the Playground project.
|
||||
Built with **Angular 21** and **Angular Material**, including a simple light/dark theme toggle and multi-language support via **ngx-translate**.
|
||||
This is the **frontend** of the Playground project.
|
||||
Built with **Angular 21** and **Angular Material**, including a light/dark theme toggle and
|
||||
multi-language support (EN/DE) via **ngx-translate**.
|
||||
|
||||
The app is built as a static **Single Page Application (SPA)** served by **NGINX**,
|
||||
deployed at:
|
||||
The app is built as a static **Single Page Application (SPA)** served by **NGINX**,
|
||||
deployed at:
|
||||
👉 [https://app.andreas-dahm.eu](https://app.andreas-dahm.eu)
|
||||
|
||||
---
|
||||
|
||||
## 🧩 Tech Stack
|
||||
|
||||
| Component | Purpose |
|
||||
|------------|----------|
|
||||
| Angular 21 | Frontend framework |
|
||||
| Angular Material | UI components & theming |
|
||||
| ngx-translate | i18n / instant translation |
|
||||
| --------- | ------- |
|
||||
| Angular 21 | Frontend framework (standalone components, signals, zoneless change detection) |
|
||||
| Angular Material 21 | UI components & theming |
|
||||
| Tailwind CSS 4 | Utility-first styling (CSS-first config, no `tailwind.config.js`) |
|
||||
| ngx-translate 18 | i18n / instant translation |
|
||||
| Babylon.js 9 | WebGL/WebGPU rendering for the algorithm demos |
|
||||
| Swiper 14 | Image carousels in the project dialogs |
|
||||
| Angular Service Worker | PWA / offline caching |
|
||||
| NGINX | Serves the compiled SPA |
|
||||
| Docker + GitHub Actions | Automated build & image publishing |
|
||||
| Docker + Gitea Actions | Automated build, test & image publishing |
|
||||
|
||||
Requires **Node.js 22**.
|
||||
|
||||
---
|
||||
|
||||
## 📁 Project Structure
|
||||
|
||||
```
|
||||
playground-frontend/
|
||||
├─ src/
|
||||
│ ├─ app/
|
||||
│ │ ├─ app.component.ts
|
||||
│ │ ├─ theme.service.ts
|
||||
│ │ └─ ...
|
||||
│ ├─ assets/i18n/
|
||||
│ │ ├─ en.json
|
||||
│ │ └─ de.json
|
||||
│ └─ index.html
|
||||
│ ├─ app/
|
||||
│ │ ├─ layout/ # app shell, topbar, dialogs
|
||||
│ │ ├─ pages/ # about, projects, algorithms, imprint, stopwatch, not-found
|
||||
│ │ ├─ service/ # theme, language, SEO, GPU capability
|
||||
│ │ ├─ shared/ # reusable components & helpers
|
||||
│ │ ├─ constants/
|
||||
│ │ ├─ app.config.ts
|
||||
│ │ └─ app.routes.ts
|
||||
│ ├─ assets/
|
||||
│ │ ├─ i18n/ # en.json / de.json (keys must stay in sync)
|
||||
│ │ ├─ logos/, flags/, icons/
|
||||
│ │ └─ projects/ # project screenshots
|
||||
│ ├─ tailwind.css # Tailwind entry + @theme tokens
|
||||
│ ├─ styles.scss # Material theme setup & component overrides
|
||||
│ └─ index.html
|
||||
├─ scripts/check-i18n.mjs # fails the build on i18n key drift
|
||||
├─ Dockerfile
|
||||
├─ nginx.conf
|
||||
└─ .github/workflows/docker.yml
|
||||
├─ ngsw-config.json # service worker caching rules
|
||||
├─ lighthouserc.json
|
||||
└─ .gitea/workflows/build-Frontend-a.yml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Local Development
|
||||
|
||||
# 1. Install dependencies
|
||||
**1. Install dependencies**
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
# 2. Start development server
|
||||
**2. Start development server**
|
||||
```bash
|
||||
ng serve --open
|
||||
```
|
||||
|
||||
App runs at http://localhost:4200
|
||||
|
||||
|
||||
# 3. 🐳 Docker Build (local)
|
||||
**3. Docker build (local)**
|
||||
```bash
|
||||
docker build -t playground-frontend:local .
|
||||
docker run -p 8080:80 playground-frontend:local
|
||||
```
|
||||
|
||||
Then open http://localhost:8080
|
||||
|
||||
## ⚙️ GitHub Actions (CI/CD)
|
||||
---
|
||||
|
||||
On every push to main, GitHub Actions will:
|
||||
## 🧪 Scripts
|
||||
|
||||
Build the Angular project
|
||||
| Command | Purpose |
|
||||
| ------- | ------- |
|
||||
| `npm start` | Dev server |
|
||||
| `npm run build` | Production build |
|
||||
| `npm run watch` | Rebuild on change (development configuration) |
|
||||
| `npm test` | Unit tests (Karma + Jasmine, headless Chrome) |
|
||||
| `npm run lint` | ESLint over `src/**/*.ts` and `src/**/*.html` |
|
||||
| `npm run i18n:check` | Verifies `en.json` and `de.json` share the exact same keys |
|
||||
|
||||
1. Create a Docker image
|
||||
2. Push it to Docker Hub (docker.io/andreasdahm/playground-frontend:main)
|
||||
3. Workflow file: .github/workflows/docker.yml
|
||||
---
|
||||
|
||||
Required repository secrets:
|
||||
## ⚙️ Gitea Actions (CI/CD)
|
||||
|
||||
Name Description
|
||||
DOCKERHUB_USERNAME Your Docker Hub username
|
||||
DOCKERHUB_TOKEN Personal access token for Docker Hub
|
||||
CI/CD runs on a **self-hosted Gitea instance** at `git.andreas-dahm.eu`.
|
||||
Workflow file: `.gitea/workflows/build-Frontend-a.yml`
|
||||
|
||||
Triggered on every push to `main` and on pull requests targeting `main`.
|
||||
|
||||
**Job 1 — `quality-check`** (runs on both pushes and PRs):
|
||||
|
||||
1. Lint & type check — `npm run lint`
|
||||
2. i18n key sync — `npm run i18n:check`
|
||||
3. Unit tests — `npx ng test --watch=false`
|
||||
4. Production build — `npx ng build --configuration production`
|
||||
5. Lighthouse audit — `npx lhci autorun` (performance & SEO)
|
||||
|
||||
**Job 2 — `docker`** (only on a push to `main`, and only if `quality-check` passed):
|
||||
|
||||
Builds the image and pushes it to the Gitea container registry at `git.andreas-dahm.eu`
|
||||
with these tags:
|
||||
|
||||
```
|
||||
git.andreas-dahm.eu/<owner>/playground:frontend-a-<branch>
|
||||
git.andreas-dahm.eu/<owner>/playground:frontend-a-<branch>-<short-sha>
|
||||
git.andreas-dahm.eu/<owner>/playground:frontend-a-main
|
||||
```
|
||||
|
||||
**Required repository secret:**
|
||||
|
||||
| Name | Description |
|
||||
| ---- | ----------- |
|
||||
| `TOKEN_GITEA` | Gitea access token used to authenticate against the container registry |
|
||||
|
||||
---
|
||||
|
||||
## 🌐 Deployment
|
||||
|
||||
The built image is deployed via
|
||||
https://github.com/lobothedark/playground-deploy
|
||||
|
||||
on the Hostinger KVM server using Traefik.
|
||||
The built image is deployed via the `playground-deploy` repository on the Hostinger KVM
|
||||
server using **Traefik**:
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
✅ Live site at https://app.andreas-dahm.eu
|
||||
|
||||
---
|
||||
|
||||
## 🪄 Maintainer
|
||||
|
||||
Andreas Dahm
|
||||
📧 andreas.dahm@gmail.com
|
||||
🌐 https://andreas-dahm.eu
|
||||
🌐 https://andreas-dahm.eu
|
||||
|
||||
4770
package-lock.json
generated
4770
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
36
package.json
36
package.json
@@ -13,42 +13,44 @@
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular-slider/ngx-slider": "^21.0.0",
|
||||
"@angular/animations": "~21.2.9",
|
||||
"@angular/animations": "~21.2.21",
|
||||
"@angular/cdk": "~21.2.6",
|
||||
"@angular/common": "~21.2.9",
|
||||
"@angular/compiler": "~21.2.9",
|
||||
"@angular/core": "~21.2.9",
|
||||
"@angular/forms": "~21.2.9",
|
||||
"@angular/common": "~21.2.21",
|
||||
"@angular/compiler": "~21.2.21",
|
||||
"@angular/core": "~21.2.21",
|
||||
"@angular/forms": "~21.2.21",
|
||||
"@angular/material": "~21.2.6",
|
||||
"@angular/platform-browser": "~21.2.9",
|
||||
"@angular/router": "~21.2.9",
|
||||
"@angular/service-worker": "21.2.9",
|
||||
"@babylonjs/core": "^8.54.1",
|
||||
"@ngx-translate/core": "^17.0.0",
|
||||
"@ngx-translate/http-loader": "^17.0.0",
|
||||
"@angular/platform-browser": "~21.2.21",
|
||||
"@angular/router": "~21.2.21",
|
||||
"@angular/service-worker": "~21.2.21",
|
||||
"@babylonjs/core": "^9.22.0",
|
||||
"@ngx-translate/core": "^18.0.0",
|
||||
"@ngx-translate/http-loader": "^18.0.0",
|
||||
"rxjs": "~7.8.2",
|
||||
"swiper": "~12.1.0",
|
||||
"swiper": "^14.1.0",
|
||||
"tslib": "~2.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular/build": "~21.2.7",
|
||||
"@angular/build": "~21.2.21",
|
||||
"@angular/cli": "~21.2.7",
|
||||
"@angular/compiler-cli": "~21.2.9",
|
||||
"@angular/compiler-cli": "~21.2.21",
|
||||
"@eslint/js": "~10.0.1",
|
||||
"@lhci/cli": "^0.15.1",
|
||||
"@tailwindcss/postcss": "^4.3.3",
|
||||
"@types/jasmine": "~6.0.0",
|
||||
"@webgpu/types": "^0.1.72",
|
||||
"angular-eslint": "21.3.1",
|
||||
"eslint": "^10.0.3",
|
||||
"jasmine-core": "~6.1.0",
|
||||
"jasmine-core": "^7.0.2",
|
||||
"karma": "^6.4.4",
|
||||
"karma-chrome-launcher": "^3.2.0",
|
||||
"karma-coverage": "^2.2.1",
|
||||
"karma-jasmine": "^5.1.0",
|
||||
"karma-jasmine-html-reporter": "^2.2.0",
|
||||
"puppeteer": "^25.1.0",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "8.58.2"
|
||||
"typescript-eslint": "^8.67.0"
|
||||
},
|
||||
"overrides": {
|
||||
"tmp": "^0.2.3",
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<app-particles-background></app-particles-background>
|
||||
<app-topbar />
|
||||
<main class="w-full max-w-app mx-auto mt-4 grow text-app-fg transition-colors duration-[220ms]">
|
||||
<main class="w-full max-w-app mx-auto mt-4 grow text-app-fg transition-colors duration-220">
|
||||
<router-outlet />
|
||||
</main>
|
||||
|
||||
<footer class="border-t border-black/[.08] p-fluid-md text-center opacity-80 bg-app-bg">
|
||||
<footer class="border-t border-black/8 p-fluid-md text-center opacity-80 bg-app-bg">
|
||||
<small>© {{ currentYear }} Andreas Dahm - {{ `APP.COPYRIGHT` | translate }}</small>
|
||||
<br />
|
||||
<small>{{ `APP.AI_NOTICE` | translate }}</small>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<mat-toolbar class="!flex !items-center !p-[clamp(0.5rem,1vw,1rem)] !backdrop-blur-[8px] !backdrop-saturate-[1.1] !bg-white/80 dark:!bg-[#313131]/80 !border-b !border-black/[.08]" color="primary" (keydown)="onKeydown($event)">
|
||||
<mat-toolbar class="flex! items-center! p-[clamp(0.5rem,1vw,1rem)]! backdrop-blur-sm! backdrop-saturate-[1.1]! bg-white/80! dark:bg-[#313131]/80! border-b! border-black/8!" color="primary" (keydown)="onKeydown($event)">
|
||||
<a class="flex items-center gap-[clamp(0.4rem,1vw,0.6rem)] text-inherit no-underline" routerLink="/">
|
||||
<img class="w-[clamp(36px,10vw,48px)] h-[clamp(36px,10vw,48px)] rounded-full" src="{{AssetsConstants.LOGO}}" alt="" aria-hidden="true" draggable="false"
|
||||
oncontextmenu="return false;">
|
||||
@@ -6,15 +6,15 @@
|
||||
</a>
|
||||
|
||||
<nav class="absolute left-1/2 -translate-x-1/2 flex gap-[clamp(0.25rem,1vw,0.5rem)] justify-center mobile:hidden">
|
||||
<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"
|
||||
<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-xs after:scale-x-0 after:transition-transform [&.active]:opacity-100 [&.active]:after:scale-x-100"
|
||||
[routerLink]="RouterConstants.ABOUT.LINK" routerLinkActive="active" mat-button>{{ 'TOPBAR.ABOUT' | 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"
|
||||
<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-xs after:scale-x-0 after:transition-transform [&.active]:opacity-100 [&.active]:after:scale-x-100"
|
||||
[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"
|
||||
<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-xs 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"
|
||||
<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-xs 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"
|
||||
<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-xs 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>
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { ThemeService } from '../../service/theme.service';
|
||||
import { LanguageService } from '../../service/language.service';
|
||||
import { MatDivider } from '@angular/material/divider';
|
||||
@@ -17,7 +17,7 @@ import {RouterConstants} from '../../constants/RouterConstants';
|
||||
imports: [
|
||||
RouterLink, RouterLinkActive,
|
||||
MatToolbarModule, MatIconModule, MatButtonModule, MatMenuModule, MatTooltipModule,
|
||||
TranslateModule, MatDivider
|
||||
TranslatePipe, MatDivider
|
||||
],
|
||||
templateUrl: './topbar.component.html',
|
||||
styleUrl: './topbar.component.scss'
|
||||
|
||||
@@ -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>
|
||||
|
||||
39
src/app/pages/stopwatch/stopwatch.component.spec.ts
Normal file
39
src/app/pages/stopwatch/stopwatch.component.spec.ts
Normal file
@@ -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);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div class="flex justify-center items-center w-full max-w-[1000px] mx-auto">
|
||||
<canvas #gridCanvas
|
||||
class="block w-full h-auto aspect-square min-w-[200px] max-w-[1000px] touch-none rounded-[clamp(10px,2vw,20px)] outline-none">
|
||||
class="block w-full h-auto aspect-square min-w-[200px] max-w-[1000px] touch-none rounded-[clamp(10px,2vw,20px)] outline-hidden">
|
||||
</canvas>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div class="flex justify-center items-center w-full max-w-[1000px] mx-auto">
|
||||
<canvas #renderCanvas
|
||||
class="block w-full h-auto aspect-square min-w-[200px] max-w-[1000px] touch-none rounded-[clamp(10px,2vw,20px)] outline-none">
|
||||
class="block w-full h-auto aspect-square min-w-[200px] max-w-[1000px] touch-none rounded-[clamp(10px,2vw,20px)] outline-hidden">
|
||||
</canvas>
|
||||
</div>
|
||||
|
||||
@@ -598,9 +598,12 @@
|
||||
"STOP": "Stopp",
|
||||
"RESET": "Zurücksetzen",
|
||||
"INTERVAL_ENABLED": "Signalton bei Intervall",
|
||||
"FIRST_INTERVAL": "Erstes Intervall",
|
||||
"INTERVAL_MINUTES": "Minuten",
|
||||
"INTERVAL_SECONDS": "Sekunden",
|
||||
"INTERVAL_HINT": "Spielt einen kurzen Ton ab, sobald die verstrichene Zeit ein Vielfaches des Intervalls überschreitet."
|
||||
"SECOND_INTERVAL_ENABLED": "Zweites, abwechselndes Intervall hinzufügen",
|
||||
"INTERVAL_HINT": "Spielt einen kurzen Ton ab, sobald ein Intervall abgelaufen ist. Mit aktiviertem zweiten Intervall wechseln sich beide ab, das zweite startet direkt nach dem ersten.",
|
||||
"INTERVAL_SCHEDULE": "Nächste Signaltöne bei:"
|
||||
},
|
||||
"NOT_FOUND": {
|
||||
"TITLE": "Seite nicht gefunden",
|
||||
|
||||
@@ -598,9 +598,12 @@
|
||||
"STOP": "Stop",
|
||||
"RESET": "Reset",
|
||||
"INTERVAL_ENABLED": "Beep at interval",
|
||||
"FIRST_INTERVAL": "First interval",
|
||||
"INTERVAL_MINUTES": "Minutes",
|
||||
"INTERVAL_SECONDS": "Seconds",
|
||||
"INTERVAL_HINT": "Plays a short tone whenever the elapsed time crosses a multiple of the interval."
|
||||
"SECOND_INTERVAL_ENABLED": "Add a second, alternating interval",
|
||||
"INTERVAL_HINT": "Plays a short tone every time an interval elapses. With a second interval enabled both alternate, so the second one starts right after the first.",
|
||||
"INTERVAL_SCHEDULE": "Next beeps at:"
|
||||
},
|
||||
"NOT_FOUND": {
|
||||
"TITLE": "Page not found",
|
||||
|
||||
@@ -1,6 +1,67 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
/*
|
||||
Tailwind v4 dropped `corePlugins`, so preflight can no longer be switched off
|
||||
from the config. Importing the layers individually and leaving out
|
||||
`preflight.css` keeps Angular Material's own reset in charge.
|
||||
*/
|
||||
@layer theme, base, components, utilities;
|
||||
|
||||
@import 'tailwindcss/theme.css' layer(theme);
|
||||
@import 'tailwindcss/utilities.css' layer(utilities);
|
||||
|
||||
/*
|
||||
The theme is driven by the CSS custom properties that styles.scss sets per theme.
|
||||
`inline` is required: the theme service toggles `.dark` on <body>, while a plain
|
||||
`@theme` would resolve these references once at `:root` (<html>) and freeze the
|
||||
light values. Inlining makes the utilities emit `var(--app-fg)` directly, so they
|
||||
resolve on the element and follow the `.dark` override again.
|
||||
*/
|
||||
@theme inline {
|
||||
--color-app-bg: var(--app-bg);
|
||||
--color-app-fg: var(--app-fg);
|
||||
--color-card-bg: var(--card-bg);
|
||||
--color-link: var(--link-color);
|
||||
--color-link-hover: var(--link-color-hover);
|
||||
--color-logo-bg: var(--app-logo-bg);
|
||||
|
||||
--container-app: var(--app-maxWidth);
|
||||
--radius-card: var(--card-radius);
|
||||
|
||||
--font-sans: Inter, Roboto, Arial, sans-serif;
|
||||
|
||||
--spacing-fluid-sm: clamp(0.5rem, 2vw, 1rem);
|
||||
--spacing-fluid-md: clamp(1rem, 3vw, 1.5rem);
|
||||
--spacing-fluid-lg: clamp(1rem, 4vw, 2rem);
|
||||
}
|
||||
|
||||
/* The theme service toggles a `dark` class instead of relying on the media query. */
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@custom-variant mobile (@media (width <= 760px));
|
||||
@custom-variant sm-dialog (@media (width <= 600px));
|
||||
@custom-variant tablet (@media (width <= 900px));
|
||||
|
||||
@utility legend-swatch {
|
||||
@apply inline-block w-[15px] h-[15px] border border-gray-300 align-middle mr-[5px];
|
||||
}
|
||||
|
||||
@utility card-gradient-bar {
|
||||
@apply relative;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--mat-sys-primary),
|
||||
var(--mat-sys-tertiary)
|
||||
);
|
||||
border-radius: var(--card-radius) var(--card-radius) 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
*,
|
||||
@@ -44,24 +105,3 @@
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.legend-swatch {
|
||||
@apply inline-block w-[15px] h-[15px] border border-gray-300 align-middle mr-[5px];
|
||||
}
|
||||
|
||||
.card-gradient-bar {
|
||||
@apply relative;
|
||||
}
|
||||
|
||||
.card-gradient-bar::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, var(--mat-sys-primary), var(--mat-sys-tertiary));
|
||||
border-radius: var(--card-radius) var(--card-radius) 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ['./src/**/*.{html,ts}'],
|
||||
darkMode: 'class',
|
||||
corePlugins: { preflight: false },
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
'app-bg': 'var(--app-bg)',
|
||||
'app-fg': 'var(--app-fg)',
|
||||
'card-bg': 'var(--card-bg)',
|
||||
'link': 'var(--link-color)',
|
||||
'link-hover': 'var(--link-color-hover)',
|
||||
'logo-bg': 'var(--app-logo-bg)',
|
||||
},
|
||||
maxWidth: {
|
||||
'app': 'var(--app-maxWidth)',
|
||||
},
|
||||
borderRadius: {
|
||||
'card': 'var(--card-radius)',
|
||||
},
|
||||
fontFamily: {
|
||||
'sans': ['Inter', 'Roboto', 'Arial', 'sans-serif'],
|
||||
},
|
||||
spacing: {
|
||||
'fluid-sm': 'clamp(0.5rem, 2vw, 1rem)',
|
||||
'fluid-md': 'clamp(1rem, 3vw, 1.5rem)',
|
||||
'fluid-lg': 'clamp(1rem, 4vw, 2rem)',
|
||||
},
|
||||
screens: {
|
||||
'mobile': { 'max': '760px' },
|
||||
'sm-dialog': { 'max': '600px' },
|
||||
'tablet': { 'max': '900px' },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -5,7 +5,9 @@
|
||||
"compilerOptions": {
|
||||
"resolveJsonModule": true,
|
||||
"outDir": "./out-tsc/app",
|
||||
"types": []
|
||||
"types": [
|
||||
"@webgpu/types"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/spec",
|
||||
"types": [
|
||||
"jasmine"
|
||||
"jasmine",
|
||||
"@webgpu/types"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
|
||||
Reference in New Issue
Block a user