Compare commits

..

134 Commits

Author SHA1 Message Date
Andreas Dahm
16142178f4 Some clean up stuff
All checks were successful
Build, Test & Push Frontend / quality-check (pull_request) Successful in 1m18s
Build, Test & Push Frontend / docker (pull_request) Has been skipped
2026-08-26 11:43:53 +02:00
Andreas Dahm
db2bc7ab97 Imporved the site 2026-08-26 11:26:00 +02:00
Andreas Dahm
888fb6289d pimped the site a little but and fixed impress 2026-08-26 10:48:39 +02:00
Andreas Dahm
f543a5e2f3 Upgraded dependencies and extended stopwatch
Some checks failed
Build, Test & Push Frontend / docker (pull_request) Has been cancelled
Build, Test & Push Frontend / quality-check (pull_request) Has been cancelled
2026-08-20 12:00:39 +02:00
3f1093e3c8 Merge pull request 'Lots of technical improvements' (#36) from feat/technical-improvements into main
All checks were successful
Build, Test & Push Frontend / quality-check (push) Successful in 1m37s
Build, Test & Push Frontend / docker (push) Successful in 1m4s
Reviewed-on: #36
2026-06-02 15:59:05 +02:00
Andreas Dahm
1087fc40e0 Try to fix test
All checks were successful
Build, Test & Push Frontend / quality-check (pull_request) Successful in 1m45s
Build, Test & Push Frontend / docker (pull_request) Has been skipped
2026-06-02 15:56:15 +02:00
Andreas Dahm
a26443af8f Lots of technical improvements
Some checks failed
Build, Test & Push Frontend / quality-check (pull_request) Failing after 41s
Build, Test & Push Frontend / docker (pull_request) Has been skipped
2026-06-02 15:48:31 +02:00
Andreas Dahm
102cdfcb52 Added ai info
Some checks failed
Build, Test & Push Frontend / quality-check (push) Failing after 1m21s
Build, Test & Push Frontend / docker (push) Has been skipped
2026-05-22 11:36:50 +02:00
01cf680f79 README.md aktualisiert
Some checks failed
Build, Test & Push Frontend / quality-check (push) Failing after 1m22s
Build, Test & Push Frontend / docker (push) Has been skipped
2026-05-20 11:28:05 +02:00
Andreas Dahm
fb911b897a Added update for tabbar
All checks were successful
Build, Test & Push Frontend / quality-check (push) Successful in 1m31s
Build, Test & Push Frontend / docker (push) Successful in 58s
2026-05-08 13:31:10 +02:00
e20aa11927 Merge pull request 'Added timer component' (#35) from feat/online-timer into main
All checks were successful
Build, Test & Push Frontend / quality-check (push) Successful in 1m28s
Build, Test & Push Frontend / docker (push) Successful in 1m2s
Reviewed-on: #35
2026-05-06 10:47:20 +02:00
Andreas Dahm
39b90a0b81 Added timer component
All checks were successful
Build, Test & Push Frontend / quality-check (pull_request) Successful in 1m30s
Build, Test & Push Frontend / docker (pull_request) Has been skipped
2026-05-06 10:46:57 +02:00
b47bce16b5 Merge pull request 'fix(deps): override ip-address to ^10.1.1 to patch XSS vulnerability' (#34) from fix/dependency-vulnerabilities into main
All checks were successful
Build, Test & Push Frontend / quality-check (push) Successful in 59s
Build, Test & Push Frontend / docker (push) Successful in 44s
Reviewed-on: #34
2026-05-06 08:07:43 +02:00
Andreas Dahm
da44d3816e fix(deps): override ip-address to ^10.1.1 to patch XSS vulnerability
All checks were successful
Build, Test & Push Frontend / quality-check (pull_request) Successful in 1m6s
Build, Test & Push Frontend / docker (pull_request) Has been skipped
Resolves 4 moderate npm audit findings stemming from ip-address <=10.1.0
(GHSA-v2v4-37r5-5v8g) reaching the tree via
@angular/cli -> @modelcontextprotocol/sdk -> express-rate-limit -> ip-address.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 08:07:12 +02:00
ffe7bbb25e Merge pull request 'AI-tryouts' (#33) from AI-tryouts into main
All checks were successful
Build, Test & Push Frontend / quality-check (push) Successful in 10m49s
Build, Test & Push Frontend / docker (push) Successful in 55s
Reviewed-on: #33
2026-04-16 13:26:41 +02:00
Andreas Dahm
9a6e91ea9d Using tailwind instead of scss as much as possible
Some checks failed
Build, Test & Push Frontend / docker (pull_request) Has been cancelled
Build, Test & Push Frontend / quality-check (pull_request) Has been cancelled
2026-04-16 11:58:16 +02:00
Andreas Dahm
f9797493ce Removed unused parameter 2026-04-16 10:06:57 +02:00
Andreas Dahm
a349f630c6 Updated the webgpu stuff to have webgl as fallback 2026-04-16 10:04:48 +02:00
Andreas Dahm
4e24cb5df1 Updated dependencies 2026-04-16 08:57:55 +02:00
Andreas Dahm
0d3411aeb2 Updated about page 2026-04-16 08:22:39 +02:00
9a6b5198ad .gitea/workflows/build-Frontend-a.yml aktualisiert
All checks were successful
Build, Test & Push Frontend / quality-check (push) Successful in 1m11s
Build, Test & Push Frontend / docker (push) Successful in 1m0s
2026-03-28 13:05:00 +01:00
Andreas Dahm
2d82cdfd6b Merge remote-tracking branch 'origin/main'
All checks were successful
Build, Test & Push Frontend / quality-check (push) Successful in 1m21s
Build, Test & Push Frontend / docker (push) Successful in 1m16s
2026-03-26 09:09:25 +01:00
Andreas Dahm
00ebedfc90 Updated about page 2026-03-26 09:09:15 +01:00
4bf5b3e2c4 Merge pull request 'feature/4ColorProblem' (#32) from feature/4ColorProblem into main
All checks were successful
Build, Test & Push Frontend / quality-check (push) Successful in 1m32s
Build, Test & Push Frontend / docker (push) Successful in 1m7s
Reviewed-on: #32
2026-03-08 11:11:19 +01:00
ab730f8f3b removed unused method
Some checks failed
Build, Test & Push Frontend / docker (pull_request) Has been cancelled
Build, Test & Push Frontend / quality-check (pull_request) Has been cancelled
2026-03-08 11:09:57 +01:00
0104bad59f optimized limits a little bit 2026-03-08 11:08:11 +01:00
54b33daa40 Add Four-Color algorithm page
Introduce an interactive Four Color Theorem demo: add FourColorComponent (template, styles, standalone component) with canvas-based grid, region generation, adjacency detection and a backtracking solver; include models for nodes/regions and grid constants. Wire up route and RouterConstants/UrlConstants, add menu entry in AlgorithmsService, and add i18n entries for English and German. Also add global color styles in styles.scss and a few minor i18n text adjustments.
2026-03-08 11:01:14 +01:00
61defae20e Update styles.scss
All checks were successful
Build, Test & Push Frontend / quality-check (push) Successful in 1m28s
Build, Test & Push Frontend / docker (push) Successful in 1m4s
2026-03-07 17:38:54 +01:00
64842d388b fixed linting issues
All checks were successful
Build, Test & Push Frontend / quality-check (push) Successful in 1m36s
Build, Test & Push Frontend / docker (push) Successful in 1m6s
2026-03-07 17:30:29 +01:00
40c3b9dd5a Merge pull request 'bugfix/someImporvements' (#31) from bugfix/someImporvements into main
Some checks failed
Build, Test & Push Frontend / quality-check (push) Failing after 34s
Build, Test & Push Frontend / docker (push) Has been skipped
Reviewed-on: #31
2026-03-07 17:20:37 +01:00
66643d8e18 Added sound oszilator for search display
Some checks failed
Build, Test & Push Frontend / docker (pull_request) Has been cancelled
Build, Test & Push Frontend / quality-check (pull_request) Has been cancelled
2026-03-07 17:19:53 +01:00
2ab1d2dd85 Topbar active links, algorithm icons & styles
Add active state support to topbar links (routerLinkActive) and CSS underline/hover styling; expose icons for algorithm categories and render them in the algorithms list. Update AlgorithmCategory interface and AlgorithmsService to include icon names, import MatIconModule where needed, and adjust algorithms template to show icon, title and description layout. Global style tweaks: dark theme background, canvas shadows, card hover/gradient accents, and new styles for algorithm cards and page title for improved visual polish.
2026-03-07 17:02:40 +01:00
150306333e Added one cloth parameter elongation 2026-03-07 16:53:36 +01:00
7ff59bf734 Add TimSort implementation and UI entries
Introduce TimSort support across the app: add TIM_SORT_WIKI to UrlConstants, implement timSort (with insertionSortRange and mergeRanges) in SortingService to produce SortSnapshot sequences (uses RUN=32, sorts runs then merges, marks comparing/unsorted/sorted states), and wire the algorithm into SortingComponent (UI entry and case branch). Also add TIMSORT_EXPLANATION translations in de.json and en.json, and add a few UI label keys (STIFFNESS, ELONGATION, RESTART_SIMULATION) to both locale files.
2026-03-07 16:38:39 +01:00
5c97667ec1 Use loadComponent for routes and cleanup
Switch route definitions to lazy-load components via loadComponent dynamic imports and remove direct component references from RouterConstants. Remove several components' standalone flags and adjust component metadata (styleUrl vs styleUrls) and imports accordingly. Make AlgorithmsService and AlgorithmsComponent synchronous (getCategories() now returns an array and template iterates categories directly). Replace alert in PathfindingComponent with MatSnackBar and inject it. Simplify LanguageService initialization to use existing translate configuration. Remove unused ReloadService. Make GenericGridComponent.lastCell protected. Miscellaneous tidy-ups across related files.
2026-03-07 16:12:16 +01:00
f46a1ed0bf Add CLAUDE.md, use snackbar for WebGPU errors
Add a new CLAUDE.md project guidelines file. Update BabylonCanvas to replace alert() with an Angular Material MatSnackBar and use ngx-translate for a localized WebGPU-not-supported message (injects MatSnackBar and TranslateService and updates imports). Add the corresponding WEBGPU.NOT_SUPPORTED entries to en.json and de.json for localization.
2026-03-07 15:50:31 +01:00
f7557efa88 updated dependencies 2026-03-07 15:34:19 +01:00
408702be5c Update package-lock.json 2026-03-07 15:26:43 +01:00
88ca86ae54 Merge pull request 'Fixed problem if webgpu is not available and added alert to show the problem. Maybe somewhere in the future i will use toasts for this.' (#30) from bugfix/webgpu into main
All checks were successful
Build, Test & Push Frontend / quality-check (push) Successful in 1m39s
Build, Test & Push Frontend / docker (push) Successful in 1m31s
Reviewed-on: #30
2026-03-05 10:58:54 +01:00
Andreas Dahm
24c4dd3290 Fixed problem if webgpu is not available and added alert to show the problem. Maybe somewhere in the future i will use toasts for this.
Some checks failed
Build, Test & Push Frontend / docker (pull_request) Has been cancelled
Build, Test & Push Frontend / quality-check (pull_request) Has been cancelled
2026-03-05 10:57:07 +01:00
548cac026d Merge pull request 'Added new position' (#29) from feature/newPosition into main
All checks were successful
Build, Test & Push Frontend / quality-check (push) Successful in 1m31s
Build, Test & Push Frontend / docker (push) Successful in 1m18s
Reviewed-on: #29
2026-03-04 20:39:33 +01:00
Andreas Dahm
a3ec68acc5 Added new position
All checks were successful
Build, Test & Push Frontend / quality-check (pull_request) Successful in 1m44s
Build, Test & Push Frontend / docker (pull_request) Has been skipped
2026-03-04 20:37:04 +01:00
61a2057291 src/assets/i18n/en.json aktualisiert
All checks were successful
Build, Test & Push Frontend / quality-check (push) Successful in 1m30s
Build, Test & Push Frontend / docker (push) Successful in 1m10s
2026-02-26 08:10:05 +01:00
737601636f src/assets/i18n/de.json aktualisiert
Some checks failed
Build, Test & Push Frontend / quality-check (push) Has been cancelled
Build, Test & Push Frontend / docker (push) Has been cancelled
2026-02-26 08:09:08 +01:00
0037502b00 src/app/pages/projects/projects.component.ts aktualisiert
All checks were successful
Build, Test & Push Frontend / quality-check (push) Successful in 1m34s
Build, Test & Push Frontend / docker (push) Successful in 1m7s
2026-02-26 07:12:19 +01:00
5485e57bdb Updated wiki links
All checks were successful
Build, Test & Push Frontend / quality-check (push) Successful in 1m35s
Build, Test & Push Frontend / docker (push) Successful in 1m9s
2026-02-25 09:10:41 +01:00
f4104d02e8 Bump deps and refactor layouts/styles
All checks were successful
Build, Test & Push Frontend / quality-check (push) Successful in 1m40s
Build, Test & Push Frontend / docker (push) Successful in 1m8s
Update package-lock with multiple dependency bumps (Angular CLI/DevKit/schematics to 21.1.5, ajv to 8.18.0, minimatch/brace-expansion updates, tar, qs, swiper, hono, etc.). Wrap the generic grid canvas in a container and clean up HTML structure/indentation in algorithms and pathfinding templates (add card-grid wrapper and reformat content). Adjust global styles: refine .algo-container and app-root margins/gaps, and refactor hero/photo/intro styles for responsive layout. Changes are primarily dependency updates and UI/layout refinements.
2026-02-25 08:50:28 +01:00
28bde29c8b Merge pull request 'feature/clothsimulation' (#28) from feature/clothsimulation into main
All checks were successful
Build, Test & Push Frontend / quality-check (push) Successful in 1m31s
Build, Test & Push Frontend / docker (push) Successful in 1m42s
Reviewed-on: #28
2026-02-24 09:31:35 +01:00
ab3bca4395 Cloth: add info, outline, diagonals, shader
All checks were successful
Build, Test & Push Frontend / quality-check (pull_request) Successful in 2m1s
Build, Test & Push Frontend / docker (pull_request) Has been skipped
Add an informational panel and mesh-outline toggle to the cloth demo, plus richer physics and shading. The cloth component now provides AlgorithmInformation to an <app-information> view and a toggleMesh() that flips the mesh wireframe. Constraint generation was extended with four diagonal phases (constraintsP4..P7) and the solver loop was generalized to iterate solver pipelines, improving parallel XPBD constraint handling. The WGSL vertex/fragment shaders were updated to pass world positions, compute normals, add simple lighting and a grid-based base color. Also update information template/model to support optional translated entry names and expand i18n (DE/EN) with cloth texts and a Docker key.
2026-02-24 09:28:16 +01:00
12411e58bf Refactored the cloth class for better reading 2026-02-24 08:51:53 +01:00
14d7a78ac4 Added wind to cloth simulation 2026-02-24 08:46:57 +01:00
ed0e370e9d Fixed some styling issues 2026-02-24 08:27:59 +01:00
f656206691 Refactor cloth component and shaders
Improve readability, typing and structure for the cloth simulation component and WGSL shaders. Changes include: formatted imports, added file/header JSDoc and inline comments, made renderConfig and lifecycle methods public with explicit types, renamed component selector to 'app-cloth', converted several functions to typed helpers (e.g. createAndPopulateBuffer, addConstraint), consolidated buffer creation, and cleaned up compute shader binding mappings. Shader file receives file header and minor comment clarifications and a bounds check comment; overall changes are stylistic and organizational to increase maintainability and clarity without altering core algorithm behavior.
2026-02-23 11:30:05 +01:00
728dbc047f Use 4-phase graph-coloring for constraints
Split cloth constraints into 4 graph-colored phases (horizontal even/odd, vertical even/odd) instead of one big constraints array. Create dynamic JS arrays (constraintsP0..P3) with an addConstraint helper, allocate four GPU constraint buffers and four corresponding solve compute shaders (csSolve0..csSolve3) via a createSolver helper, and dispatch them per substep to avoid write-write races. Update integrate/velocity shader bindings setup and dispatch logic; keep positions/prevPositions/velocities buffers as before. In WGSL, mark constraints as read-only and use arrayLength(&constraints) to bound-check the constraint index instead of relying on a CPU-side count. Also tweak sim parameter (compliance lowered) and minor refactors/cleanups for clarity and consistency.
2026-02-23 11:19:28 +01:00
746022c48d Update cloth.component.ts 2026-02-23 11:07:09 +01:00
954211b3cf Add cloth simulation page with WGSL shaders
Introduce a new cloth simulation feature: adds ClothComponent (TS/HTML/SCSS) and WGSL compute/vertex/fragment shaders implementing an XPBD-based cloth sim. Wire up routing and RouterConstants, add the cloth entry to the algorithms list, and add English/German i18n strings. Also include small refactors/renames for algorithm-category and algorithms.service imports and update BabylonCanvas to tolerate optional shader configuration and avoid null access during setup.
2026-02-23 11:02:54 +01:00
885e609082 Update particles-background.component.ts
All checks were successful
Build, Test & Push Frontend / quality-check (push) Successful in 1m38s
Build, Test & Push Frontend / docker (push) Successful in 1m41s
2026-02-23 10:08:36 +01:00
b61eb4eb73 Update particles-background.component.ts
Some checks failed
Build, Test & Push Frontend / quality-check (push) Failing after 31s
Build, Test & Push Frontend / docker (push) Has been skipped
2026-02-23 10:07:26 +01:00
96d4659652 Merge pull request 'Added background animation to habe a little bit more interesting page' (#27) from feature/backgroundAnimation into main
Some checks failed
Build, Test & Push Frontend / quality-check (push) Failing after 44s
Build, Test & Push Frontend / docker (push) Has been skipped
Reviewed-on: #27
2026-02-23 10:01:28 +01:00
ad43459173 Added background animation to habe a little bit more interesting page
Some checks failed
Build, Test & Push Frontend / docker (pull_request) Has been cancelled
Build, Test & Push Frontend / quality-check (pull_request) Has been cancelled
2026-02-23 10:01:01 +01:00
32ecfcb621 Merge pull request 'feature/scss-cleanup' (#26) from feature/scss-cleanup into main
All checks were successful
Build, Test & Push Frontend / quality-check (push) Successful in 1m31s
Build, Test & Push Frontend / docker (push) Successful in 1m53s
Reviewed-on: #26
2026-02-23 09:46:12 +01:00
aceb0ea24e optimized pendulum visual
All checks were successful
Build, Test & Push Frontend / quality-check (pull_request) Successful in 1m58s
Build, Test & Push Frontend / docker (pull_request) Has been skipped
2026-02-23 09:43:34 +01:00
c160fb4bc8 overworked the project detail pages 2026-02-23 09:37:26 +01:00
45c11e42cd Added an image for the playground project 2026-02-23 09:11:16 +01:00
e66206c518 Fixed hero page 2026-02-23 09:07:33 +01:00
d0c4ad770b Cleaned and focused about page 2026-02-23 08:56:34 +01:00
5f8b1de20f Changed link from github to codeberg 2026-02-23 08:50:24 +01:00
30965afcbd Changed layout, taht everything is max 1200px width and centered 2026-02-23 08:41:49 +01:00
6330d45b4e Fixed topbar 2026-02-22 12:48:20 +01:00
c6edc922fe Refactor topbar HTML, projects TS and styles
Minor markup and formatting cleanup plus layout adjustments.

- topbar.component.html: removed mat-toolbar color attribute, compacted/normalized element attributes and spacing, fixed small markup spacing issues.
- projects.component.ts: code formatting and whitespace normalization (imports, property spacing, object literals, small function signature/timeout formatting); no logic changes.
- styles.scss: reorganized topbar rules (added app-topbar wrapper, display and color), switched .hero from grid to flex with responsive flex-basis, adjusted photo and intro flex behavior, increased project grid column min width and centered grid with max-width, removed featured card full-width grid span.

These changes improve consistency, readability and adjust layout/responsiveness of the topbar/hero/project grid.
2026-02-22 12:27:20 +01:00
0e78e6b471 Update styles.scss 2026-02-22 12:02:17 +01:00
8f21b0e6b0 Update styles.scss 2026-02-22 11:59:36 +01:00
cb2ffa2d80 Consolidate and modernize SCSS into global styles
Move component-level styles into src/styles.scss and remove duplicated rules from several component SCSS files (app, topbar, about, algorithms, sorting, imprint, project dialog, projects, babylon-canvas). The global stylesheet now centralizes layout and typography (clamp-based sizing), sorting visualization & canvas rules, topbar/menu overrides, project/swiper styles, and shared utilities. This reduces duplication, improves responsiveness, and simplifies stylesheet management.
2026-02-22 11:48:21 +01:00
5ebd1d19ea Merge pull request 'feature/webGPU' (#25) from feature/webGPU into main
All checks were successful
Build, Test & Push Frontend / quality-check (push) Successful in 1m33s
Build, Test & Push Frontend / docker (push) Successful in 1m40s
Reviewed-on: #25
2026-02-21 12:32:03 +01:00
eed7e8c0fa Update fractal.component.ts
All checks were successful
Build, Test & Push Frontend / quality-check (pull_request) Successful in 1m30s
Build, Test & Push Frontend / docker (pull_request) Has been skipped
fixed linting issue
2026-02-21 12:27:07 +01:00
24d6d9cdbe Finalized Algorithm
Some checks failed
Build, Test & Push Frontend / quality-check (pull_request) Failing after 56s
Build, Test & Push Frontend / docker (pull_request) Has been skipped
Added final descriptions and polished the system
2026-02-21 12:25:07 +01:00
34148aade2 Added UI and painted pendulum in different colors 2026-02-21 11:47:57 +01:00
5721b2e48e Added resize callback to restart simulation new if canvas is resized 2026-02-21 10:25:20 +01:00
2bfa8ba9a1 Added trail effect 2026-02-21 10:21:21 +01:00
13f99ac7ae Refactored #2
- Refactored shader code and typescript code
- Made it more clear
- Added some comments
2026-02-21 10:03:01 +01:00
66df3a7f88 Added some comments and removed unused UVs 2026-02-21 09:53:14 +01:00
598013a7d0 Rendeirng problems fixed
Problem was broken uv coordinates in the fragment shader
2026-02-21 09:46:55 +01:00
f499b78fd5 Adding uniform buffers
But still resolution problem
2026-02-20 17:25:42 +01:00
0d2e7c97ec See pendulum
I can see the pendulum, but something is not correct with the resolution
2026-02-20 17:14:58 +01:00
13b59d0b36 Smaller refactoring
- Put shader in own file
- renamed package
2026-02-20 16:50:24 +01:00
55ece27e1c Add pendulum demo and WGSL support
Introduce a new Pendulum demo (component, template, stylesheet) and wire it into routing and the algorithms list. Extend Babylon canvas API to emit a SceneReadyEvent (scene + engine) and accept a shaderLanguage option in RenderConfig so materials/shaders can target WGSL; update Fractal to consume the new SceneReadyEvent signature. Also add i18n entries for the pendulum demo.
2026-02-18 11:58:25 +01:00
68e21489ea Update babylon-canvas.component.ts 2026-02-17 10:41:51 +01:00
796fdf4a79 Fixed small visual problems with canvas 2026-02-17 09:39:37 +01:00
270716551d Merge pull request 'Fixed resizing problem for 2d canvas' (#24) from bugfix/resize2dCanvas into main
All checks were successful
Build, Test & Push Frontend / quality-check (push) Successful in 1m29s
Build, Test & Push Frontend / docker (push) Successful in 1m7s
Reviewed-on: #24
2026-02-17 09:29:26 +01:00
a494c8156d Fixed resizing problem for 2d canvas
All checks were successful
Build, Test & Push Frontend / quality-check (pull_request) Successful in 1m30s
Build, Test & Push Frontend / docker (pull_request) Has been skipped
2026-02-17 09:27:21 +01:00
5691cb408d Merge pull request 'Changed slider handling for 2d' (#23) from debug/fractalsliderProblem into main
All checks were successful
Build, Test & Push Frontend / quality-check (push) Successful in 1m0s
Build, Test & Push Frontend / docker (push) Successful in 53s
Reviewed-on: #23
2026-02-13 14:15:45 +01:00
c2ad2ae992 Changed slider handling for 2d
All checks were successful
Build, Test & Push Frontend / quality-check (pull_request) Successful in 1m5s
Build, Test & Push Frontend / docker (pull_request) Has been skipped
2026-02-13 14:15:22 +01:00
1e8ba020e2 Merge pull request 'Imporoved pinching for 3d fractals' (#22) from bugfix/betterPinching into main
All checks were successful
Build, Test & Push Frontend / quality-check (push) Successful in 17m23s
Build, Test & Push Frontend / docker (push) Successful in 53s
Reviewed-on: #22
2026-02-12 21:13:01 +01:00
4685a94f1d Imporoved pinching for 3d fractals
Some checks failed
Build, Test & Push Frontend / docker (pull_request) Has been cancelled
Build, Test & Push Frontend / quality-check (pull_request) Has been cancelled
2026-02-12 21:12:40 +01:00
d45b651d2d Merge pull request 'feature/portToBabylon' (#21) from feature/portToBabylon into main
All checks were successful
Build, Test & Push Frontend / quality-check (push) Successful in 1m2s
Build, Test & Push Frontend / docker (push) Successful in 54s
Reviewed-on: #21
2026-02-12 10:14:58 +01:00
c409cd08b1 Changed 2d fractals to webgl for more performance
Some checks failed
Build, Test & Push Frontend / docker (pull_request) Has been cancelled
Build, Test & Push Frontend / quality-check (pull_request) Has been cancelled
2026-02-12 10:14:22 +01:00
cc6997e732 Excluded the rendering in an own component 2026-02-12 09:13:35 +01:00
ea15e66c50 optimized handling a little bit and fixed small scss problems 2026-02-12 08:35:26 +01:00
085201913f Merge pull request 'Update angular.json' (#20) from bugfix/budget into main
All checks were successful
Build, Test & Push Frontend / quality-check (push) Successful in 1m2s
Build, Test & Push Frontend / docker (push) Successful in 57s
Reviewed-on: #20
2026-02-11 11:15:40 +01:00
525bec30fe Update angular.json
Some checks failed
Build, Test & Push Frontend / docker (pull_request) Has been cancelled
Build, Test & Push Frontend / quality-check (pull_request) Has been cancelled
2026-02-11 11:15:07 +01:00
d01a6b0c6b Merge pull request 'festuare/fractal3d' (#19) from festuare/fractal3d into main
Some checks failed
Build, Test & Push Frontend / quality-check (push) Failing after 48s
Build, Test & Push Frontend / docker (push) Has been skipped
Reviewed-on: #19
2026-02-11 11:09:45 +01:00
159d82d602 Finally added 3d fractals
Some checks failed
Build, Test & Push Frontend / quality-check (pull_request) Failing after 43s
Build, Test & Push Frontend / docker (pull_request) Has been skipped
2026-02-11 11:09:07 +01:00
ba3dc4d928 First image of fractal
Next the nasty stuff like movement and ui :-D
2026-02-11 08:31:27 +01:00
12ebbb09ce Merge pull request 'feature/3dFractal' (#18) from feature/3dFractal into main
Some checks failed
Build, Test & Push Frontend / quality-check (push) Failing after 28s
Build, Test & Push Frontend / docker (push) Has been skipped
Reviewed-on: #18
2026-02-11 08:12:10 +01:00
07b6296294 Added new component
Some checks failed
Build, Test & Push Frontend / quality-check (pull_request) Failing after 30s
Build, Test & Push Frontend / docker (pull_request) Has been skipped
2026-02-11 08:11:42 +01:00
6cb033bd9f updated package json and low vulnerability issues 2026-02-11 07:56:00 +01:00
42c86ecb70 Merge pull request 'Add fractal visualization feature' (#17) from feature/fractals into main
All checks were successful
Build, Test & Push Frontend / quality-check (push) Successful in 58s
Build, Test & Push Frontend / docker (push) Successful in 45s
Reviewed-on: #17
2026-02-10 14:52:22 +01:00
5d162b57ab Add fractal visualization feature
All checks were successful
Build, Test & Push Frontend / quality-check (pull_request) Successful in 55s
Build, Test & Push Frontend / docker (pull_request) Has been skipped
Introduce a new Fractal visualization: adds FractalComponent (template, styles, TS), FractalService (rendering, palettes, Mandelbrot/Julia/Burning Ship/Newton implementations), and Fractal model/types. Wire up routing and router constants (route and component import), add wiki links to UrlConstants, and expose the new algorithm in AlgorithmsService. Also add i18n entries (en/de) for UI labels and explanations. Component supports canvas zoom/drag, color schemes and iteration controls.
2026-02-10 14:49:17 +01:00
dab7c51b90 Merge pull request 'fesature/maze-gen' (#16) from fesature/maze-gen into main
All checks were successful
Build, Test & Push Frontend / quality-check (push) Successful in 50s
Build, Test & Push Frontend / docker (push) Successful in 49s
Reviewed-on: #16
2026-02-09 14:57:36 +01:00
e8354bfecd Add Prim/Kruskal maze gen & nodeData refactor
All checks were successful
Build, Test & Push Frontend / quality-check (pull_request) Successful in 1m10s
Build, Test & Push Frontend / docker (pull_request) Has been skipped
Implement Prim and Kruskal maze generation in the Labyrinth component with animated generation and UI guards. Introduces isAnimationRunning signal, mazeAnimationSpeed, maze node order tracking and animateMazeGeneration; createRandom(now takes a boolean) triggers either Prim or Kruskal flow, sets random start/end, and animates. Refactor Node.distance -> nodeData across models, components and the PathfindingService (Dijkstra/A*) to use nodeData for g-scores/ids. Add SharedFunctions.shuffleArray utility and update i18n (EN/DE) with labels for Prim/Kruskal. Misc: minor cleanup/init changes and drawing logic adjustments to support the new maze flows.
2026-02-09 14:55:05 +01:00
bbec113f5d Add labyrinth maze generator and integrate routes
Introduce a new Labyrinth feature: add LabyrinthComponent (TS/HTML/SCSS) implementing maze generation (Prim's/Kruskal) and visualization using the existing generic grid. Wire the component into RouterConstants and app.routes, and add the algorithm entry to AlgorithmsService. Refactor pathfinding internals: rename Node.previousNode -> Node.linkedNode and update PathfindingService and PathfindingComponent accordingly. Add SharedFunctions.random helpers and replace local random utilities. Rename Conway component files/class to ConwayGolComponent and update template path. Add i18n entries for labyrinth (en/de). Minor housekeeping: bump package version to 1.0.0 and disable @typescript-eslint/prefer-for-of in ESLint config.
2026-02-09 10:57:24 +01:00
950ec75f07 Merge pull request 'Update en.json' (#15) from bugfix/translationbuf into main
All checks were successful
Build, Test & Push Frontend / quality-check (push) Successful in 52s
Build, Test & Push Frontend / docker (push) Successful in 47s
Reviewed-on: #15
2026-02-08 14:03:19 +01:00
854d558e6b Update en.json
All checks were successful
Build, Test & Push Frontend / quality-check (pull_request) Successful in 54s
Build, Test & Push Frontend / docker (pull_request) Has been skipped
2026-02-08 14:02:52 +01:00
bc740af0bf Merge pull request 'feature/optimize' (#14) from feature/optimize into main
All checks were successful
Build, Test & Push Frontend / quality-check (push) Successful in 52s
Build, Test & Push Frontend / docker (push) Successful in 45s
Reviewed-on: #14
2026-02-07 11:14:32 +01:00
f6ed2057a4 lighthouserc.json aktualisiert
All checks were successful
Build, Test & Push Frontend / quality-check (pull_request) Successful in 56s
Build, Test & Push Frontend / docker (pull_request) Has been skipped
2026-02-07 11:12:19 +01:00
a6d8405916 .gitea/workflows/build-Frontend-a.yml aktualisiert
Some checks failed
Build, Test & Push Frontend / quality-check (pull_request) Failing after 49s
Build, Test & Push Frontend / docker (pull_request) Has been skipped
2026-02-07 11:01:54 +01:00
41ec7a862f lighthouserc.json aktualisiert
Some checks failed
Build, Test & Push Frontend / quality-check (pull_request) Failing after 7s
Build, Test & Push Frontend / docker (pull_request) Has been skipped
2026-02-07 10:50:01 +01:00
b9f6564771 .gitea/workflows/build-Frontend-a.yml aktualisiert
Some checks failed
Build, Test & Push Frontend / quality-check (pull_request) Failing after 8s
Build, Test & Push Frontend / docker (pull_request) Has been skipped
2026-02-07 10:49:39 +01:00
d8611b0968 .gitea/workflows/build-Frontend-a.yml aktualisiert
Some checks failed
Build, Test & Push Frontend / quality-check (pull_request) Failing after 1m42s
Build, Test & Push Frontend / docker (pull_request) Has been skipped
2026-02-07 10:45:06 +01:00
4d27643d74 .gitea/workflows/build-Frontend-a.yml aktualisiert
Some checks failed
Build, Test & Push Frontend / quality-check (pull_request) Failing after 16s
Build, Test & Push Frontend / docker (pull_request) Has been skipped
2026-02-07 10:43:43 +01:00
48e74ed3e8 .gitea/workflows/build-Frontend-a.yml aktualisiert
Some checks failed
Build, Test & Push Frontend / quality-check (pull_request) Failing after 16s
Build, Test & Push Frontend / docker (pull_request) Has been skipped
2026-02-07 10:42:20 +01:00
e1b1643eb2 .gitea/workflows/build-Frontend-a.yml aktualisiert
Some checks failed
Build, Test & Push Frontend / quality-check (pull_request) Failing after 17s
Build, Test & Push Frontend / docker (pull_request) Has been skipped
2026-02-07 10:39:47 +01:00
0e520ead26 Add Lighthouse CI and expand frontend CI/CD
Some checks failed
Build, Test & Push Frontend / quality-check (pull_request) Failing after 48s
Build, Test & Push Frontend / docker (pull_request) Has been skipped
Replace the previous frontend workflow with an expanded Build, Test & Push workflow. Adds a quality-check job (runs on push and PRs to main) that sets up Node.js, installs deps, runs lint/type checks, unit tests (ChromeHeadless), production build and runs LHCI. The docker job now depends on the quality-check job and only runs on pushes to main. Added lighthouserc.json and ignored .lighthouseci, relaxed two ESLint rules (@typescript-eslint/no-inferrable-types and no-explicit-any), and updated package.json (and lock) to include LHCI tooling.
2026-02-07 10:32:00 +01:00
70ed047059 Optimize Conway and generic grid rendering
Conway GOL: add executionTime tracking and display; switch to double-buffered read/write grids with structuredClone initialization; refactor life update logic (checkLifeRules, swapGrids) to avoid mutating the source while computing the next generation; adjust per-frame delay to account for execution time; increase MAX_GRID_SIZE from 100 to 200; fix grid binding to use readGrid so UI reflects internal state.

Generic grid: add backgroundColor input and use it to clear canvas each frame; only draw cells whose color differs from background and draw grid lines conditionally based on node size to reduce overdraw; adjust drawNode to only stroke borders for larger nodes.

Templates: set backgroundColor='lightgray' for Conway and Pathfinding grid usages; display execution time in Conway UI.

Misc: remove a debug console.log in sorting component. These changes improve rendering performance, reduce flicker, and surface per-frame timing for tuning the Game of Life simulation.
2026-02-07 09:52:58 +01:00
16cc8afd4a Merge pull request 'Increase default grid and rename simple scene' (#13) from bugfix/smallFix into main
All checks were successful
Build & Push Frontend A / docker (push) Successful in 44s
Reviewed-on: #13
2026-02-06 22:06:31 +01:00
2a808c1d96 Increase default grid and rename simple scene
Raise default Conway's Game of Life grid size from 40x40 to 50x50 (src/app/pages/algorithms/conway-gol/conway-gol.models.ts) to provide a larger initial viewport. Update i18n labels for the simple scene to 'Glider'/'Gleiter' in English and German respectively (src/assets/i18n/en.json, src/assets/i18n/de.json) for clearer naming.
2026-02-06 22:06:02 +01:00
5d48118add Merge pull request 'feature/gameOfLife' (#12) from feature/gameOfLife into main
All checks were successful
Build & Push Frontend A / docker (push) Successful in 42s
Reviewed-on: #12
2026-02-06 22:03:48 +01:00
bf46c57db0 Conway GOL: add scenarios & start/pause loop
Add predefined scenarios (SIMPLE, PULSAR, GUN) and UI controls to generate them; introduce a start/pause game loop driven by an Angular signal. Reduce default grid to 40x40 and max grid to 100, speed up default generation to 30ms, and pause the game when grid size changes. Implement scenario setup helpers (simple, pulsar, glider gun), life-rule evaluation, neighbor counting, grid swapping and a delay helper. Update template to show scenario buttons and conditional start/pause button, and add corresponding i18n entries for English and German.
2026-02-06 22:03:18 +01:00
930f0110b0 Extract generic grid component and refactor uses
Add a reusable GenericGridComponent (canvas + input handling) and migrate Conway Game of Life and Pathfinding pages to use it. New files: shared/components/generic-grid/{html,scss,ts} implement canvas rendering, resizing, input listeners and a callback API (createNodeFn, getNodeColorFn, applySelectionFn, initializationFn) plus gridChange events. Updated templates to replace raw <canvas> with <app-generic-grid> and switched grid size inputs to ngModelChange bindings. Conway and Pathfinding components: remove direct canvas/mouse handling, wire the generic grid callbacks, keep algorithm-specific logic (node creation, coloring, selection, scenarios and animations) but delegate drawing and interaction to GenericGridComponent. This centralizes grid rendering/interaction and simplifies per-algorithm components.
2026-02-06 20:59:56 +01:00
3795090cea Refactor controls UI, styles; bump deps
Consolidate and refactor component styling and markup: remove component-specific SCSS for Conway's Game of Life and Pathfinding and drop their styleUrls, rename per-component .controls blocks to .controls-panel in pathfinding and sorting templates, and move the outer container class onto the mat-card. Add global styles in src/styles.scss for .controls-panel, grid/form sizing, container max-width, and sorting visualization (bar states and transitions). Also update package-lock.json with minor/patch dependency bumps for Angular CLI/devkit/schematics/@schematics/angular, @modelcontextprotocol/sdk and several transitive packages.
2026-02-06 15:06:54 +01:00
59148db295 Enhance Conway's Game of Life UI & interaction
Add interactive controls and drawing support for Conway's Game of Life: introduce Node.alive, Scenario enum, spawn/speed/time constants, random/empty generation, and mouse/touch drawing (click-drag/touch to toggle cells). Update template to include control buttons, speed input, legend, and expose Scenario constants. Implement grid initialization, random seeding, grid position mapping, and optimized node drawing/color logic.

Also update i18n (de/en) with GOL strings and move GRID label keys to ALGORITHM, switch some label usages accordingly. Move generic container/legend styles into global styles.scss (adjust canvas border color), and simplify component SCSS files. Change CONWAYS_WIKI URL to German wiki and remove now-unused UrlConstants references from components.
2026-02-06 14:40:49 +01:00
a22dd17869 Created default build up
- next make grid be a own component with a lot of callbacks
- after this start the game implementtion
2026-02-06 11:52:17 +01:00
da43213808 Created new component and refactored
- Created new component to display the game of life algo
- created an algo info component to combine the algo header for all algos
2026-02-06 09:59:12 +01:00
ff1ee9b5f6 Merge pull request 'feature/pathfinding-finetuning' (#11) from feature/pathfinding-finetuning into main
All checks were successful
Build & Push Frontend A / docker (push) Successful in 50s
Reviewed-on: #11
2026-02-05 09:25:41 +01:00
136 changed files with 16344 additions and 4720 deletions

2
.gitattributes vendored
View File

@@ -34,7 +34,7 @@ Dockerfile text eol=lf
*.adoc text eol=lf *.adoc text eol=lf
*.txt text eol=lf *.txt text eol=lf
*.csv text eol=lf *.csv text eol=lf
*.svg text eol=lf # svg ist Text *.svg text eol=lf
# ---- binary files never convert ---- # ---- binary files never convert ----
*.jar binary *.jar binary

View File

@@ -1,12 +1,87 @@
name: Build & Push Frontend A name: Build, Test & Push Frontend
run-name: ${{ gitea.actor }} build and test Angular 🚀
on: on:
push: push:
branches: branches:
- main - main
pull_request:
branches:
- main
jobs: jobs:
docker: # ------------------------------------------------------------------
# JOB 1: Code integrity and quality gates (CI)
# ------------------------------------------------------------------
quality-check:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- name: Install Linux Libs
run: |
sudo apt-get update
sudo apt-get install -y \
libnss3 \
libnspr4 \
libatk1.0-0 \
libatk-bridge2.0-0 \
libcups2t64 \
libdrm2 \
libxkbcommon0 \
libxcomposite1 \
libxdamage1 \
libxfixes3 \
libxrandr2 \
libgbm1 \
libasound2t64 \
libpango-1.0-0 \
libcairo2
- name: Install Dependencies
run: npm ci
# 1. Linting (Code-Style)
- name: Lint & Type Check
run: npm run lint --if-present
# 2. i18n key sync (en/de must share the same keys)
- name: i18n Key Check
run: npm run i18n:check
# 3. Unit Tests (Logic) — browser/launcher configured in karma.conf.js
- name: Unit Tests
run: npx ng test --watch=false
# 4. Build Production (necessary for lighthouse)
- name: Build Production
run: npx ng build --configuration production
# 5. Lighthouse Audit (Performance & SEO)
# Puppeteer is a devDependency, so `npm ci` already installed it and
# downloaded Chrome. executablePath() is async in Puppeteer v25, so await it.
- name: Lighthouse CI
run: |
CHROME_PATH=$(node -e 'require("puppeteer").executablePath().then(p => console.log(p))')
export CHROME_PATH=$CHROME_PATH
npx lhci autorun
# ------------------------------------------------------------------
# JOB 2: Docker Build & Push (CD)
# Runs only if 'quality-check' are successfully and we are on branch main.
# ------------------------------------------------------------------
docker:
needs: quality-check
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -20,12 +95,18 @@ jobs:
echo "branch=$BRANCH" >> "$GITHUB_OUTPUT" echo "branch=$BRANCH" >> "$GITHUB_OUTPUT"
echo "sha=${GITHUB_SHA::12}" >> "$GITHUB_OUTPUT" echo "sha=${GITHUB_SHA::12}" >> "$GITHUB_OUTPUT"
# Convert repository owner to lowercase
OWNER="${GITHUB_REPOSITORY_OWNER}"
OWNER="$(echo "$OWNER" | tr '[:upper:]' '[:lower:]')"
echo "owner=$OWNER" >> "$GITHUB_OUTPUT"
- uses: docker/setup-buildx-action@v3 - uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3 - uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} registry: git.andreas-dahm.eu
password: ${{ secrets.DOCKERHUB_TOKEN }} username: ${{ github.repository_owner }}
password: ${{ secrets.TOKEN_GITEA }}
- name: Build & Push (branch + sha tags) - name: Build & Push (branch + sha tags)
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
@@ -33,14 +114,13 @@ jobs:
context: . context: .
push: true push: true
tags: | tags: |
docker.io/${{ secrets.DOCKERHUB_USERNAME }}/playground:frontend-a-${{ steps.prep.outputs.branch }} git.andreas-dahm.eu/${{ steps.prep.outputs.owner }}/playground:frontend-a-${{ steps.prep.outputs.branch }}
docker.io/${{ secrets.DOCKERHUB_USERNAME }}/playground:frontend-a-${{ steps.prep.outputs.branch }}-${{ steps.prep.outputs.sha }} git.andreas-dahm.eu/${{ steps.prep.outputs.owner }}/playground:frontend-a-${{ steps.prep.outputs.branch }}-${{ steps.prep.outputs.sha }}
- name: Also push moving main tag (only on main) - name: Also push moving main tag
if: ${{ github.ref == 'refs/heads/main' }}
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
context: . context: .
push: true push: true
tags: | tags: |
docker.io/${{ secrets.DOCKERHUB_USERNAME }}/playground:frontend-a-main git.andreas-dahm.eu/${{ steps.prep.outputs.owner }}/playground:frontend-a-main

7
.gitignore vendored
View File

@@ -41,3 +41,10 @@ __screenshots__/
# System files # System files
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# Lighthouse
.lighthouseci/
.claude/settings.local.json
# claude
.claude/

5
.postcssrc.json Normal file
View File

@@ -0,0 +1,5 @@
{
"plugins": {
"@tailwindcss/postcss": {}
}
}

91
CLAUDE.md Normal file
View File

@@ -0,0 +1,91 @@
# Claude Code Guidelines
## Project Overview
This is the frontend of the Playground project, built with Angular 21 and Angular Material. It includes features like a light/dark theme toggle and multi-language support via ngx-translate. The application is a static Single Page Application (SPA) served by NGINX.
**Key Technologies:**
* **Frontend Framework:** Angular 21
* **UI Components & Theming:** Angular Material
* **Internationalization:** ngx-translate
* **Server:** NGINX (for serving the SPA)
* **Containerization:** Docker
* **CI/CD:** Gitea Actions (self-hosted, `git.andreas-dahm.eu`)
## Building and Running
### Local Development
1. **Install dependencies:**
```bash
npm install
```
2. **Start development server:**
```bash
ng serve --open
```
The app will run at `http://localhost:4200`.
### Building for Production
To build the project for production, which creates the optimized static files:
```bash
ng build
```
## Language
- All code must be written in **English** — including variable names, function names, comments, and commit messages.
## Clean Code
- Write simple, clear, and readable code.
- Avoid deeply nested or chained calls. Break complex expressions into named intermediate variables or separate steps.
- Code should be understandable by junior developers without additional explanation.
## Braces
- Always use curly braces `{}` for branches and loops — even for single-line bodies.
```ts
// ✅ correct
if (isValid) {
doSomething();
}
// ❌ avoid
if (isValid) doSomething();
```
## Functions
- Extract reusable or logically distinct logic into separate, well-named functions.
- A function should do one thing and do it well.
- Prefer short functions that are easy to test and understand.
## Comments
- Only add comments when they explain the **why** or the **idea** behind the code — not the **what**.
- Do not comment on things that are already obvious from the code itself.
```ts
// ✅ useful comment — explains intent
// Retry once to handle transient network errors
const result = await fetchWithRetry(url);
// ❌ useless comment — just restates the code
// Call fetchWithRetry with url
const result = await fetchWithRetry(url);
```
## Development Conventions
* **Language:** TypeScript
* **Framework:** Angular
* **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.
## Project Structure (Key Areas)
* `src/app/`: Contains the main application logic, components, services, and routing.
* `src/app/pages/`: Specific pages of the application (e.g., about, algorithms, imprint, projects).
* `src/assets/`: Static assets including images, internationalization files (`i18n`), and logos.
* `Dockerfile`: Defines the Docker image for the application.
* `nginx.conf`: NGINX configuration for serving the SPA.
* `.gitea/workflows/`: Contains CI/CD workflows (e.g., `build-Frontend-a.yml`).

View File

@@ -1,6 +1,8 @@
# Build # Build
FROM node:22-alpine AS build FROM node:22-alpine AS build
WORKDIR /app WORKDIR /app
# The production build never runs tests, so skip Puppeteer's Chrome download.
ENV PUPPETEER_SKIP_DOWNLOAD=1
COPY package*.json ./ COPY package*.json ./
RUN npm ci RUN npm ci
COPY . . COPY . .

View File

@@ -65,7 +65,7 @@ To build and run the application using Docker locally:
* **Language:** TypeScript * **Language:** TypeScript
* **Framework:** Angular * **Framework:** Angular
* **Styling:** SCSS (based on `styles.scss` and component-specific `.scss` files). * **Styling:** Only use tailwind, as much as possible. If not possible use SCSS (based on `styles.scss` and component-specific `.scss` files).
* **Linting:** ESLint is configured (see `eslint.config.js` and `package.json` scripts). * **Linting:** ESLint is configured (see `eslint.config.js` and `package.json` scripts).
* **Internationalization:** Uses `ngx-translate` with `en.json` and `de.json` asset files. * **Internationalization:** Uses `ngx-translate` with `en.json` and `de.json` asset files.

121
README.md
View File

@@ -1,9 +1,8 @@
## 📗 `playground-frontend/README.md`
# 🎨 Playground Frontend # 🎨 Playground Frontend
This is the **frontend** of the Playground project. 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**. 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**, The app is built as a static **Single Page Application (SPA)** served by **NGINX**,
deployed at: deployed at:
@@ -12,84 +11,136 @@ deployed at:
--- ---
## 🧩 Tech Stack ## 🧩 Tech Stack
| Component | Purpose | | Component | Purpose |
|------------|----------| | --------- | ------- |
| Angular 21 | Frontend framework | | Angular 21 | Frontend framework (standalone components, signals, zoneless change detection) |
| Angular Material | UI components & theming | | Angular Material 21 | UI components & theming |
| ngx-translate | i18n / instant translation | | 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 | | 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 ## 📁 Project Structure
```
playground-frontend/ playground-frontend/
├─ src/ ├─ src/
│ ├─ app/ │ ├─ app/
├─ app.component.ts ├─ layout/ # app shell, topbar, dialogs
├─ theme.service.ts ├─ pages/ # about, projects, algorithms, imprint, stopwatch, not-found
│ │ └─ ... ├─ service/ # theme, language, SEO, GPU capability
├─ assets/i18n/ │ ├─ shared/ # reusable components & helpers
├─ en.json ├─ constants/
│ │ └─ de.json ├─ 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 │ └─ index.html
├─ scripts/check-i18n.mjs # fails the build on i18n key drift
├─ Dockerfile ├─ Dockerfile
├─ nginx.conf ├─ nginx.conf
.github/workflows/docker.yml ngsw-config.json # service worker caching rules
├─ lighthouserc.json
└─ .gitea/workflows/build-Frontend-a.yml
```
--- ---
## 🚀 Local Development ## 🚀 Local Development
# 1. Install dependencies **1. Install dependencies**
```bash ```bash
npm install npm install
``` ```
# 2. Start development server **2. Start development server**
```bash ```bash
ng serve --open ng serve --open
``` ```
App runs at http://localhost:4200 App runs at http://localhost:4200
**3. Docker build (local)**
# 3. 🐳 Docker Build (local)
```bash ```bash
docker build -t playground-frontend:local . docker build -t playground-frontend:local .
docker run -p 8080:80 playground-frontend:local docker run -p 8080:80 playground-frontend:local
``` ```
Then open http://localhost:8080 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 CI/CD runs on a **self-hosted Gitea instance** at `git.andreas-dahm.eu`.
DOCKERHUB_USERNAME Your Docker Hub username Workflow file: `.gitea/workflows/build-Frontend-a.yml`
DOCKERHUB_TOKEN Personal access token for Docker Hub
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 ## 🌐 Deployment
The built image is deployed via The built image is deployed via the `playground-deploy` repository on the Hostinger KVM
https://github.com/lobothedark/playground-deploy server using **Traefik**:
on the Hostinger KVM server using Traefik.
```bash
docker compose pull docker compose pull
docker compose up -d docker compose up -d
```
✅ Live site at https://app.andreas-dahm.eu ✅ Live site at https://app.andreas-dahm.eu
---
## 🪄 Maintainer ## 🪄 Maintainer
Andreas Dahm Andreas Dahm

View File

@@ -29,6 +29,7 @@
"inlineStyleLanguage": "scss", "inlineStyleLanguage": "scss",
"assets": [ "assets": [
"src/assets/favicon.ico", "src/assets/favicon.ico",
"src/manifest.webmanifest",
{ {
"glob": "**/*", "glob": "**/*",
"input": "public" "input": "public"
@@ -40,6 +41,7 @@
} }
], ],
"styles": [ "styles": [
"src/tailwind.css",
"src/styles.scss", "src/styles.scss",
"node_modules/swiper/swiper-bundle.css" "node_modules/swiper/swiper-bundle.css"
] ]
@@ -49,8 +51,8 @@
"budgets": [ "budgets": [
{ {
"type": "initial", "type": "initial",
"maximumWarning": "1MB", "maximumWarning": "8MB",
"maximumError": "1MB" "maximumError": "15MB"
}, },
{ {
"type": "anyComponentStyle", "type": "anyComponentStyle",
@@ -58,7 +60,8 @@
"maximumError": "8kB" "maximumError": "8kB"
} }
], ],
"outputHashing": "all" "outputHashing": "all",
"serviceWorker": "ngsw-config.json"
}, },
"development": { "development": {
"optimization": false, "optimization": false,
@@ -86,6 +89,7 @@
"test": { "test": {
"builder": "@angular/build:karma", "builder": "@angular/build:karma",
"options": { "options": {
"karmaConfig": "karma.conf.js",
"tsConfig": "tsconfig.spec.json", "tsConfig": "tsconfig.spec.json",
"inlineStyleLanguage": "scss", "inlineStyleLanguage": "scss",
"assets": [ "assets": [
@@ -95,6 +99,7 @@
} }
], ],
"styles": [ "styles": [
"src/tailwind.css",
"src/styles.scss" "src/styles.scss"
] ]
} }

View File

@@ -15,6 +15,9 @@ module.exports = defineConfig([
], ],
processor: angular.processInlineTemplates, processor: angular.processInlineTemplates,
rules: { rules: {
"@typescript-eslint/no-inferrable-types": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/prefer-for-of": "off",
"@angular-eslint/directive-selector": [ "@angular-eslint/directive-selector": [
"error", "error",
{ {

43
karma.conf.js Normal file
View File

@@ -0,0 +1,43 @@
const os = require('os');
const path = require('path');
const { computeExecutablePath, Browser } = require('@puppeteer/browsers');
const puppeteer = require('puppeteer');
// Resolve the Chrome that Puppeteer downloaded so tests run without a
// system-installed browser, both locally and in CI containers.
// Puppeteer v25 made executablePath() async, which a sync karma.conf cannot
// await — so we compute the path synchronously via @puppeteer/browsers instead.
const buildId = puppeteer.PUPPETEER_REVISIONS.chrome;
const cacheDir = process.env.PUPPETEER_CACHE_DIR || path.join(os.homedir(), '.cache', 'puppeteer');
process.env.CHROME_BIN = computeExecutablePath({ browser: Browser.CHROME, buildId, cacheDir });
module.exports = function (config) {
config.set({
basePath: '',
frameworks: ['jasmine'],
plugins: [
require('karma-jasmine'),
require('karma-chrome-launcher'),
require('karma-jasmine-html-reporter'),
require('karma-coverage'),
],
jasmineHtmlReporter: {
suppressAll: true,
},
coverageReporter: {
dir: path.join(__dirname, 'coverage', 'playground-frontend'),
subdir: '.',
reporters: [{ type: 'html' }, { type: 'text-summary' }],
},
reporters: ['progress', 'kjhtml'],
browsers: ['ChromeHeadlessNoSandbox'],
customLaunchers: {
ChromeHeadlessNoSandbox: {
base: 'ChromeHeadless',
// CI containers run as root with no sandbox; /dev/shm is often too small.
flags: ['--no-sandbox', '--headless', '--disable-gpu', '--disable-dev-shm-usage'],
},
},
restartOnFileChange: true,
});
};

14
lighthouserc.json Normal file
View File

@@ -0,0 +1,14 @@
{
"ci": {
"collect": {
"numberOfRuns": 1,
"staticDistDir": "./dist/playground-frontend/browser",
"settings": {
"chromeFlags": "--no-sandbox --headless --disable-gpu --disable-dev-shm-usage"
}
},
"upload": {
"target": "temporary-public-storage"
}
}
}

View File

@@ -11,6 +11,30 @@ server {
try_files $uri =404; try_files $uri =404;
} }
# Service worker control files must never be cached hard, otherwise the
# PWA can no longer pick up new deployments. Exact matches win over the
# generic *.js rule below.
location = /ngsw-worker.js {
add_header Cache-Control "no-cache, max-age=0, must-revalidate";
try_files $uri =404;
}
location = /ngsw.json {
add_header Cache-Control "no-cache, max-age=0, must-revalidate";
try_files $uri =404;
}
location = /safety-worker.js {
add_header Cache-Control "no-cache, max-age=0, must-revalidate";
try_files $uri =404;
}
location = /worker-basic.min.js {
add_header Cache-Control "no-cache, max-age=0, must-revalidate";
try_files $uri =404;
}
location = /manifest.webmanifest {
add_header Cache-Control "no-cache, max-age=0, must-revalidate";
try_files $uri =404;
}
# Angular Bundles cache hard # Angular Bundles cache hard
location ~* \.(?:js|css)$ { location ~* \.(?:js|css)$ {
add_header Cache-Control "public, max-age=31536000, immutable"; add_header Cache-Control "public, max-age=31536000, immutable";

41
ngsw-config.json Normal file
View File

@@ -0,0 +1,41 @@
{
"$schema": "./node_modules/@angular/service-worker/config/schema.json",
"index": "/index.html",
"assetGroups": [
{
"name": "app",
"installMode": "prefetch",
"resources": {
"files": [
"/favicon.ico",
"/index.html",
"/manifest.webmanifest",
"/*.css",
"/*.js"
]
}
},
{
"name": "assets",
"installMode": "lazy",
"updateMode": "prefetch",
"resources": {
"files": [
"/assets/**",
"/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)"
]
}
}
],
"dataGroups": [
{
"name": "i18n",
"urls": ["/assets/i18n/**"],
"cacheConfig": {
"maxSize": 10,
"maxAge": "7d",
"strategy": "freshness"
}
}
]
}

9601
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,40 +1,64 @@
{ {
"name": "playground-frontend", "name": "playground-frontend",
"version": "0.2.0", "version": "1.0.0",
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",
"start": "ng serve", "start": "ng serve",
"build": "ng build", "build": "ng build",
"watch": "ng build --watch --configuration development", "watch": "ng build --watch --configuration development",
"test": "ng test", "test": "ng test",
"lint": "ng lint" "lint": "ng lint",
"i18n:check": "node scripts/check-i18n.mjs"
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@angular/animations": "~21.1.0", "@angular-slider/ngx-slider": "^21.0.0",
"@angular/cdk": "~21.1.0", "@angular/animations": "~21.2.21",
"@angular/common": "~21.1.0", "@angular/cdk": "~21.2.6",
"@angular/compiler": "~21.1.0", "@angular/common": "~21.2.21",
"@angular/core": "~21.1.0", "@angular/compiler": "~21.2.21",
"@angular/forms": "~21.1.0", "@angular/core": "~21.2.21",
"@angular/material": "~21.1.0", "@angular/forms": "~21.2.21",
"@angular/platform-browser": "~21.1.0", "@angular/material": "~21.2.6",
"@angular/router": "~21.1.0", "@angular/platform-browser": "~21.2.21",
"@ngx-translate/core": "~17.0.0", "@angular/router": "~21.2.21",
"@ngx-translate/http-loader": "~17.0.0", "@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", "rxjs": "~7.8.2",
"swiper": "~12.1.0", "swiper": "^14.1.0",
"tslib": "~2.8.1" "tslib": "~2.8.1"
}, },
"devDependencies": { "devDependencies": {
"@angular/build": "~21.1.0", "@angular/build": "~21.2.21",
"@angular/cli": "~21.1.0", "@angular/cli": "~21.2.7",
"@angular/compiler-cli": "~21.1.0", "@angular/compiler-cli": "~21.2.21",
"@types/jasmine": "~5.1.15", "@eslint/js": "~10.0.1",
"angular-eslint": "21.2.0", "@lhci/cli": "^0.15.1",
"eslint": "^9.39.2", "@puppeteer/browsers": "3.2.1",
"jasmine-core": "~6.0.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": "^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": "^4.3.3",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "8.50.1" "typescript-eslint": "^8.67.0"
},
"overrides": {
"tmp": "^0.2.3",
"ip-address": "^10.1.1",
"@lhci/cli": {
"lighthouse": "^13.4.1",
"uuid": "^11.1.1"
}
} }
} }

65
scripts/check-i18n.mjs Normal file
View File

@@ -0,0 +1,65 @@
#!/usr/bin/env node
// Verifies that all i18n translation files share the exact same set of keys.
// Exits with a non-zero status (and prints the differences) if any key is
// present in one language but missing in another, so CI can catch drift.
import { readFileSync, readdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const scriptDir = dirname(fileURLToPath(import.meta.url));
const i18nDir = join(scriptDir, '..', 'src', 'assets', 'i18n');
function collectLeafKeys(value, prefix, keys) {
for (const key of Object.keys(value)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
const child = value[key];
const isNestedObject = child !== null && typeof child === 'object' && !Array.isArray(child);
if (isNestedObject) {
collectLeafKeys(child, fullKey, keys);
} else {
keys.add(fullKey);
}
}
return keys;
}
function loadKeys(fileName) {
const content = readFileSync(join(i18nDir, fileName), 'utf8');
const parsed = JSON.parse(content);
return collectLeafKeys(parsed, '', new Set());
}
const files = readdirSync(i18nDir).filter((name) => name.endsWith('.json'));
if (files.length < 2) {
console.log('i18n check: nothing to compare (fewer than two language files).');
process.exit(0);
}
const keysByFile = new Map(files.map((file) => [file, loadKeys(file)]));
const allKeys = new Set();
for (const keys of keysByFile.values()) {
for (const key of keys) {
allKeys.add(key);
}
}
let hasError = false;
for (const [file, keys] of keysByFile) {
const missing = [...allKeys].filter((key) => !keys.has(key)).sort();
if (missing.length > 0) {
hasError = true;
console.error(`\n[${file}] missing ${missing.length} key(s):`);
for (const key of missing) {
console.error(` - ${key}`);
}
}
}
if (hasError) {
console.error('\ni18n check failed: translation files are out of sync.');
process.exit(1);
}
console.log(`i18n check passed: ${files.join(', ')} share ${allKeys.size} keys.`);

View File

@@ -1,9 +1,10 @@
import {ApplicationConfig, inject, provideAppInitializer, provideBrowserGlobalErrorListeners, provideZonelessChangeDetection} from '@angular/core'; import {ApplicationConfig, inject, isDevMode, provideAppInitializer, provideBrowserGlobalErrorListeners, provideZonelessChangeDetection} from '@angular/core';
import { provideRouter, withInMemoryScrolling } from '@angular/router'; import { provideRouter, withInMemoryScrolling } from '@angular/router';
import { routes } from './app.routes'; import { routes } from './app.routes';
import {provideAnimations} from '@angular/platform-browser/animations'; import {provideAnimations} from '@angular/platform-browser/animations';
import {provideHttpClient} from '@angular/common/http'; import {provideHttpClient} from '@angular/common/http';
import {provideServiceWorker} from '@angular/service-worker';
import {provideTranslateService} from '@ngx-translate/core'; import {provideTranslateService} from '@ngx-translate/core';
import {provideTranslateHttpLoader} from '@ngx-translate/http-loader'; import {provideTranslateHttpLoader} from '@ngx-translate/http-loader';
import {LocalStoreConstants} from './constants/LocalStoreConstants'; import {LocalStoreConstants} from './constants/LocalStoreConstants';
@@ -47,11 +48,15 @@ export const appConfig: ApplicationConfig = {
sanitizer.bypassSecurityTrustResourceUrl('assets/logos/linkedIn.svg') sanitizer.bypassSecurityTrustResourceUrl('assets/logos/linkedIn.svg')
); );
}), }),
provideServiceWorker('ngsw-worker.js', {
enabled: !isDevMode(),
registrationStrategy: 'registerWhenStable:30000'
}),
] ]
}; };
function getInitialLang(): string { function getInitialLang(): string {
const saved = localStorage.getItem(LocalStoreConstants.LANGUAGE_KEY); const saved = typeof localStorage !== 'undefined' ? localStorage.getItem(LocalStoreConstants.LANGUAGE_KEY) : null;
if (saved) return saved; if (saved) return saved;
const nav = typeof navigator !== 'undefined' ? navigator.language?.toLowerCase() : 'en'; const nav = typeof navigator !== 'undefined' ? navigator.language?.toLowerCase() : 'en';
return nav?.startsWith('de') ? 'de' : 'en'; return nav?.startsWith('de') ? 'de' : 'en';

View File

@@ -1,14 +1,21 @@
import { Routes } from '@angular/router'; import { Routes } from '@angular/router';
import {AboutComponent} from './pages/about/about.component';
import {RouterConstants} from './constants/RouterConstants'; import {RouterConstants} from './constants/RouterConstants';
export const routes: Routes = [ export const routes: Routes = [
{ path: '', component: AboutComponent }, { path: '', loadComponent: () => import('./pages/about/about.component').then(m => m.AboutComponent), data: { seo: 'ABOUT' } },
{ path: RouterConstants.ABOUT.PATH, component: RouterConstants.ABOUT.COMPONENT}, { path: RouterConstants.ABOUT.PATH, loadComponent: () => import('./pages/about/about.component').then(m => m.AboutComponent), data: { seo: 'ABOUT' } },
{ path: RouterConstants.PROJECTS.PATH, component: RouterConstants.PROJECTS.COMPONENT}, { path: RouterConstants.PROJECTS.PATH, loadComponent: () => import('./pages/projects/projects.component').then(m => m.ProjectsComponent), data: { seo: 'PROJECTS' } },
{ path: RouterConstants.ALGORITHMS.PATH, component: RouterConstants.ALGORITHMS.COMPONENT}, { path: RouterConstants.ALGORITHMS.PATH, loadComponent: () => import('./pages/algorithms/algorithms.component').then(m => m.AlgorithmsComponent), data: { seo: 'ALGORITHMS' } },
{ path: RouterConstants.PATHFINDING.PATH, component: RouterConstants.PATHFINDING.COMPONENT}, { path: RouterConstants.PATHFINDING.PATH, loadComponent: () => import('./pages/algorithms/pathfinding/pathfinding.component').then(m => m.PathfindingComponent), data: { seo: 'ALGORITHMS' } },
{ path: RouterConstants.SORTING.PATH, component: RouterConstants.SORTING.COMPONENT}, { path: RouterConstants.SORTING.PATH, loadComponent: () => import('./pages/algorithms/sorting/sorting.component').then(m => m.SortingComponent), data: { seo: 'ALGORITHMS' } },
{ path: RouterConstants.IMPRINT.PATH, component: RouterConstants.IMPRINT.COMPONENT}, { path: RouterConstants.IMPRINT.PATH, loadComponent: () => import('./pages/imprint/imprint.component').then(m => m.ImprintComponent), data: { seo: 'IMPRINT' } },
{ path: RouterConstants.GOL.PATH, loadComponent: () => import('./pages/algorithms/conway-gol/conway-gol.component').then(m => m.ConwayGolComponent), data: { seo: 'ALGORITHMS' } },
{ path: RouterConstants.LABYRINTH.PATH, loadComponent: () => import('./pages/algorithms/pathfinding/labyrinth/labyrinth.component').then(m => m.LabyrinthComponent), data: { seo: 'ALGORITHMS' } },
{ path: RouterConstants.FRACTAL.PATH, loadComponent: () => import('./pages/algorithms/fractal/fractal.component').then(m => m.FractalComponent), data: { seo: 'ALGORITHMS' } },
{ path: RouterConstants.FRACTAL3d.PATH, loadComponent: () => import('./pages/algorithms/fractal3d/fractal3d.component').then(m => m.Fractal3dComponent), data: { seo: 'ALGORITHMS' } },
{ path: RouterConstants.PENDULUM.PATH, loadComponent: () => import('./pages/algorithms/pendulum/pendulum.component').then(m => m.default), data: { seo: 'ALGORITHMS' } },
{ path: RouterConstants.CLOTH.PATH, loadComponent: () => import('./pages/algorithms/cloth/cloth.component').then(m => m.ClothComponent), data: { seo: 'ALGORITHMS' } },
{ path: RouterConstants.FOUR_COLOR.PATH, loadComponent: () => import('./pages/algorithms/four-color/four-color.component').then(m => m.FourColorComponent), data: { seo: 'ALGORITHMS' } },
{ path: RouterConstants.STOPWATCH.PATH, loadComponent: () => import('./pages/stopwatch/stopwatch.component').then(m => m.StopwatchComponent), data: { seo: 'STOPWATCH' } },
{ path: '**', loadComponent: () => import('./pages/not-found/not-found.component').then(m => m.NotFoundComponent), data: { seo: 'NOT_FOUND' } },
]; ];

View File

@@ -1,7 +1,7 @@
export class AssetsConstants { export class AssetsConstants {
static readonly ME = '/assets/me.webp'; static readonly ME = '/assets/me.webp';
static readonly LOGO = '/assets/favicon.ico'; static readonly LOGO = '/assets/logos/logo-wolf.png';
static readonly FLAG_DE = '/assets/flags/de.svg'; static readonly FLAG_DE = '/assets/flags/de.svg';
static readonly FLAG_EN = '/assets/flags/gb.svg'; static readonly FLAG_EN = '/assets/flags/gb.svg';
@@ -15,6 +15,10 @@ export class AssetsConstants {
static readonly DIPLOMA = '/assets/projects/diploma/Dahm2010-Diplomarbeit.pdf'; static readonly DIPLOMA = '/assets/projects/diploma/Dahm2010-Diplomarbeit.pdf';
//project images //project images
static readonly PLAYGROUND_IMAGES = [
'/assets/projects/playground/1.png'
];
static readonly EL_MUCHO_IMAGES = [ static readonly EL_MUCHO_IMAGES = [
'/assets/projects/el-mucho/1.jpg', '/assets/projects/el-mucho/1.jpg',
'/assets/projects/el-mucho/2.jpg', '/assets/projects/el-mucho/2.jpg',
@@ -37,6 +41,10 @@ export class AssetsConstants {
'/assets/projects/diploma/6.jpg' '/assets/projects/diploma/6.jpg'
]; ];
static readonly CLAUDE_BOX_IMAGES = [
'/assets/projects/claude-in-a-box/1.svg'
];
static readonly TRIBBLE_IMAGES = [ static readonly TRIBBLE_IMAGES = [
'/assets/projects/tribble-the-homeserver/1.png', '/assets/projects/tribble-the-homeserver/1.png',
'/assets/projects/tribble-the-homeserver/2.png', '/assets/projects/tribble-the-homeserver/2.png',

View File

@@ -1,46 +1,73 @@
import {AboutComponent} from '../pages/about/about.component'; export class RouterConstants {
import {ProjectsComponent} from '../pages/projects/projects.component';
import {ImprintComponent} from '../pages/imprint/imprint.component';
import {AlgorithmsComponent} from '../pages/algorithms/algorithms.component';
import {PathfindingComponent} from '../pages/algorithms/pathfinding/pathfinding.component';
import {SortingComponent} from '../pages/algorithms/sorting/sorting.component';
export class RouterConstants {
static readonly ABOUT = { static readonly ABOUT = {
PATH: 'about', PATH: 'about',
LINK: '/about', LINK: '/about',
COMPONENT: AboutComponent
}; };
static readonly PROJECTS = { static readonly PROJECTS = {
PATH: 'projects', PATH: 'projects',
LINK: '/projects', LINK: '/projects',
COMPONENT: ProjectsComponent
}; };
static readonly ALGORITHMS = { static readonly ALGORITHMS = {
PATH: 'algorithms', PATH: 'algorithms',
LINK: '/algorithms', LINK: '/algorithms',
COMPONENT: AlgorithmsComponent
}; };
static readonly PATHFINDING = { static readonly PATHFINDING = {
PATH: 'algorithms/pathfinding', PATH: 'algorithms/pathfinding',
LINK: '/algorithms/pathfinding', LINK: '/algorithms/pathfinding',
COMPONENT: PathfindingComponent
}; };
static readonly SORTING = { static readonly SORTING = {
PATH: 'algorithms/sorting', PATH: 'algorithms/sorting',
LINK: '/algorithms/sorting', LINK: '/algorithms/sorting',
COMPONENT: SortingComponent };
static readonly GOL = {
PATH: 'algorithms/gol',
LINK: '/algorithms/gol',
};
static readonly LABYRINTH = {
PATH: 'algorithms/labyrinth',
LINK: '/algorithms/labyrinth',
};
static readonly FRACTAL = {
PATH: 'algorithms/fractal',
LINK: '/algorithms/fractal',
};
static readonly FRACTAL3d = {
PATH: 'algorithms/fractal3d',
LINK: '/algorithms/fractal3d',
};
static readonly PENDULUM = {
PATH: 'algorithms/pendulum',
LINK: '/algorithms/pendulum',
};
static readonly CLOTH = {
PATH: 'algorithms/cloth',
LINK: '/algorithms/cloth',
};
static readonly FOUR_COLOR = {
PATH: 'algorithms/four_color',
LINK: '/algorithms/four_color',
}; };
static readonly IMPRINT = { static readonly IMPRINT = {
PATH: 'imprint', PATH: 'imprint',
LINK: '/imprint', LINK: '/imprint',
COMPONENT: ImprintComponent };
static readonly STOPWATCH = {
PATH: 'stopwatch',
LINK: '/stopwatch',
}; };
} }

View File

@@ -1,10 +1,28 @@
export class UrlConstants { export class UrlConstants {
static readonly LINKED_IN = 'https://www.linkedin.com/in/andreas-dahm-2395991ba'; static readonly LINKED_IN = 'https://www.linkedin.com/in/andreas-dahm-2395991ba';
static readonly GIT_HUB = 'https://github.com/LoboTheDark'; static readonly CODEBERG = 'https://codeberg.org/LoboTheDark';
static readonly DIJKSTRA_WIKI = 'https://de.wikipedia.org/wiki/Dijkstra-Algorithmus' static readonly DIJKSTRA_WIKI = 'https://de.wikipedia.org/wiki/Dijkstra-Algorithmus'
static readonly ASTAR_WIKI = 'https://de.wikipedia.org/wiki/A*-Algorithmus' static readonly ASTAR_WIKI = 'https://de.wikipedia.org/wiki/A*-Algorithmus'
static readonly BUBBLE_SORT_WIKI = 'https://de.wikipedia.org/wiki/Bubblesort' static readonly BUBBLE_SORT_WIKI = 'https://de.wikipedia.org/wiki/Bubblesort'
static readonly QUICK_SORT_WIKI = 'https://de.wikipedia.org/wiki/Quicksort' static readonly QUICK_SORT_WIKI = 'https://de.wikipedia.org/wiki/Quicksort'
static readonly HEAP_SORT_WIKI = 'https://de.wikipedia.org/wiki/Heapsort' static readonly HEAP_SORT_WIKI = 'https://de.wikipedia.org/wiki/Heapsort'
static readonly SHAKE_SORT_WIKI = 'https://de.wikipedia.org/wiki/Shakersort' static readonly SHAKE_SORT_WIKI = 'https://de.wikipedia.org/wiki/Shakersort'
static readonly TIM_SORT_WIKI = 'https://de.wikipedia.org/wiki/Timsort'
static readonly CONWAYS_WIKI = 'https://de.wikipedia.org/wiki/Conways_Spiel_des_Lebens'
static readonly PRIMS_WIKI = 'https://de.wikipedia.org/wiki/Algorithmus_von_Prim'
static readonly KRUSKAL_WIKI = 'https://de.wikipedia.org/wiki/Algorithmus_von_Kruskal'
static readonly MANDELBROT_WIKI = 'https://de.wikipedia.org/wiki/Mandelbrot-Menge'
static readonly JULIA_WIKI = 'https://de.wikipedia.org/wiki/Julia-Menge'
static readonly NEWTON_FRACTAL_WIKI = 'https://de.wikipedia.org/wiki/Newtonfraktal'
static readonly BURNING_SHIP_WIKI = 'https://de.wikipedia.org/wiki/Burning_ship_(Fraktal)'
static readonly MANDELBULB_WIKI = 'https://de.wikipedia.org/wiki/Mandelknolle'
static readonly MANDELBOX_WIKI = 'https://de.wikipedia.org/wiki/Mandelbox'
static readonly JULIA3D_WIKI = 'https://de.wikipedia.org/wiki/Mandelknolle'
static readonly DOUBLE_PENDULUM_WIKI = 'https://de.wikipedia.org/wiki/Doppelpendel'
static readonly CLOTH_SIMULATION_WIKI = 'https://en.wikipedia.org/wiki/Cloth_modeling'
static readonly XPBD_WIKI = 'https://www.emergentmind.com/topics/extended-position-based-dynamics-xpbd'
static readonly GPU_COMPUTING_WIKI = 'https://en.wikipedia.org/wiki/General-purpose_computing_on_graphics_processing_units'
static readonly DATA_STRUCTURE_WIKI = 'https://de.wikipedia.org/wiki/Datenstruktur'
static readonly FOUR_COLOR_THEOREM = 'https://de.wikipedia.org/wiki/Vier-Farben-Satz'
} }

View File

@@ -1,9 +1,14 @@
<app-particles-background></app-particles-background>
<app-topbar /> <app-topbar />
<main class="w-full max-w-app mx-auto mt-4 grow text-app-fg transition-colors duration-220">
<main class="container app-surface">
<router-outlet /> <router-outlet />
</main> </main>
<footer class="foot"> <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> <small>© {{ currentYear }} Andreas Dahm - {{ `APP.COPYRIGHT` | translate }}</small>
<br />
<small>
{{ 'APP.AI_NOTICE' | translate }}
<a [routerLink]="['/projects']" [queryParams]="{ project: 'claude-in-a-box' }">{{ 'APP.AI_NOTICE_LINK' | translate }}</a>
</small>
</footer> </footer>

View File

@@ -1,10 +1 @@
.container { max-width: 1100px; margin: 0 auto; padding: 1rem; }
.app-surface {
background: var(--app-bg);
color: var(--app-fg);
transition: background-color 220ms ease, color 220ms ease;
}
.foot {
border-top: 1px solid rgba(0,0,0,.08);
padding: 1rem; text-align: center; opacity: .8;
}

View File

@@ -1,16 +1,22 @@
import { Component } from '@angular/core'; import { Component, inject } from '@angular/core';
import { RouterOutlet } from '@angular/router'; import { RouterLink, RouterOutlet } from '@angular/router';
import {TopbarComponent} from '../topbar/topbar.component'; import {TopbarComponent} from '../topbar/topbar.component';
import {TranslatePipe} from '@ngx-translate/core'; import {TranslatePipe} from '@ngx-translate/core';
import {ParticleBackgroundComponent} from '../../shared/components/particles-background/particles-background.component';
import {SeoService} from '../../service/seo.service';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
standalone: true, imports: [RouterLink, RouterOutlet, TopbarComponent, TranslatePipe, ParticleBackgroundComponent],
imports: [RouterOutlet, TopbarComponent, TranslatePipe],
templateUrl: './app.component.html', templateUrl: './app.component.html',
styleUrl: './app.component.scss' styleUrl: './app.component.scss'
}) })
export class AppComponent { export class AppComponent {
private readonly seo = inject(SeoService);
currentYear = new Date().getFullYear(); currentYear = new Date().getFullYear();
constructor() {
this.seo.init();
}
} }

View File

@@ -4,7 +4,6 @@ import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
@Component({ @Component({
standalone: true,
imports: [MatDialogModule, MatButtonModule, MatIconModule], imports: [MatDialogModule, MatButtonModule, MatIconModule],
template: ` template: `
<div class="dialog"> <div class="dialog">

View File

@@ -1,31 +1,29 @@
<mat-toolbar class="topbar" 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="brand" routerLink="/"> <a class="flex items-center gap-[clamp(0.4rem,1vw,0.6rem)] text-inherit no-underline" routerLink="/">
<img class="logo-dot" <img class="w-[clamp(36px,10vw,48px)] h-auto rounded-full" src="{{AssetsConstants.LOGO}}" width="117" height="128" alt="" aria-hidden="true" draggable="false"
src="{{AssetsConstants.LOGO}}" oncontextmenu="return false;">
alt="" aria-hidden="true" <span class="font-semibold tracking-[0.2px] text-[clamp(1rem,3vw,1.2rem)]">{{ 'APP.TITLE' | translate }}</span>
draggable="false"
oncontextmenu="return false;"
>
<span class="brand-text">{{ 'APP.TITLE' | translate }}</span>
</a> </a>
<nav class="nav"> <nav class="absolute left-1/2 -translate-x-1/2 flex gap-[clamp(0.25rem,1vw,0.5rem)] justify-center mobile:hidden">
<a [routerLink]="RouterConstants.ABOUT.LINK" 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-xs after:scale-x-0 after:transition-transform [&.active]:opacity-100 [&.active]:after:scale-x-100"
<a [routerLink]="RouterConstants.PROJECTS.LINK" mat-button>{{ 'TOPBAR.PROJECTS' | translate }}</a> [routerLink]="RouterConstants.ABOUT.LINK" routerLinkActive="active" mat-button>{{ 'TOPBAR.ABOUT' | translate }}</a>
<a [routerLink]="RouterConstants.ALGORITHMS.LINK" 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-xs after:scale-x-0 after:transition-transform [&.active]:opacity-100 [&.active]:after:scale-x-100"
<a [routerLink]="RouterConstants.IMPRINT.LINK" mat-button>{{ 'TOPBAR.IMPRINT' | translate }}</a> [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-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-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-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> </nav>
<!-- Mobile nav menu button --> <!-- Mobile nav menu button -->
<button <button mat-icon-button class="hidden mobile:inline-flex" [matMenuTriggerFor]="navMenu" aria-label="Open navigation">
mat-icon-button
class="nav-menu-btn"
[matMenuTriggerFor]="navMenu"
aria-label="Open navigation">
<mat-icon>menu</mat-icon> <mat-icon>menu</mat-icon>
</button> </button>
<span class="spacer"></span> <span class="flex-1"></span>
<!-- Mobile nav menu --> <!-- Mobile nav menu -->
<mat-menu #navMenu="matMenu" xPosition="before"> <mat-menu #navMenu="matMenu" xPosition="before">
@@ -38,15 +36,19 @@
<button mat-menu-item [routerLink]="RouterConstants.ALGORITHMS.LINK"> <button mat-menu-item [routerLink]="RouterConstants.ALGORITHMS.LINK">
{{ 'TOPBAR.ALGORITHMS' | translate }} {{ 'TOPBAR.ALGORITHMS' | translate }}
</button> </button>
<button mat-menu-item [routerLink]="RouterConstants.STOPWATCH.LINK">
{{ 'TOPBAR.STOPWATCH' | translate }}
</button>
<button mat-menu-item [routerLink]="RouterConstants.IMPRINT.LINK"> <button mat-menu-item [routerLink]="RouterConstants.IMPRINT.LINK">
{{ 'TOPBAR.IMPRINT' | translate }} {{ 'TOPBAR.IMPRINT' | translate }}
</button> </button>
</mat-menu> </mat-menu>
<span class="spacer"></span> <span class="flex-1"></span>
<!-- Settings: Sprache + Theme --> <!-- Settings: Sprache + Theme -->
<button mat-icon-button [matMenuTriggerFor]="settingsMenu" aria-label="Open settings" matTooltip="{{ 'TOPBAR.SETTINGS' | translate }}"> <button mat-icon-button [matMenuTriggerFor]="settingsMenu" aria-label="Open settings"
matTooltip="{{ 'TOPBAR.SETTINGS' | translate }}">
<mat-icon>tune</mat-icon> <mat-icon>tune</mat-icon>
</button> </button>

View File

@@ -1,83 +1,6 @@
.topbar { :host {
position: sticky; top: 0; z-index: 100; position: sticky;
backdrop-filter: saturate(1.1) blur(8px); top: 0;
background: z-index: 100;
color-mix(in oklab, var(--app-topbar-bg) 80%, transparent); display: block;
border-bottom: 1px solid rgba(0,0,0,.08);
.brand {
display:flex; align-items:center; gap:.6rem;
color: inherit; text-decoration: none;
.logo-dot {
width: 48px; height: 48px; border-radius: 50%;
}
.brand-text { font-weight: 600; letter-spacing:.2px; }
}
.nav { display:flex; gap:.25rem; margin-left:.5rem; }
.spacer { flex: 1; }
.flag-icon { width: 18px; height: 18px; border-radius: 2px; margin-right:.5rem; }
.menu-section { padding:.25rem .5rem .5rem; }
.menu-title { font-size:.75rem; opacity:.75; padding:.25rem .75rem .5rem; }
.kbd {
margin-left:auto; font-size:.7rem; opacity:.65; border:1px solid currentColor;
border-radius:4px; padding:0 .35rem;
}
}
::ng-deep .mat-mdc-menu-item .mdc-list-item__primary-text {
display: flex;
align-items: center;
gap: .5rem;
}
::ng-deep .mat-mdc-menu-item .kbd {
margin-left: auto;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
font-size: 11px;
line-height: 1.6;
padding: 0 .35rem;
border: 0px solid currentColor;
border-radius: 4px;
opacity: .65;
}
::ng-deep .mat-mdc-menu-item .mat-icon {
width: 20px; height: 20px; font-size: 20px;
}
::ng-deep .mat-mdc-menu-item .flag-icon {
width: 20px !important;
height: 14px !important;
object-fit: cover;
border-radius: 2px;
margin-right: .5rem;
vertical-align: middle;
}
::ng-deep .mat-mdc-menu-panel {
border-radius: 10px !important;
border: 1px solid rgba(0,0,0,.14);
}
.dark ::ng-deep .mat-mdc-menu-panel {
border-color: rgba(255,255,255,.06);
}
/* Responsive: Collapse navigation to icon if width is smaller than 760px */
.nav-menu-btn {
display: none;
}
@media (max-width: 760px) {
.topbar .nav {
display: none;
}
.nav-menu-btn {
display: inline-flex;
}
} }

View File

@@ -1,11 +1,11 @@
import { Component, computed, inject } from '@angular/core'; import { Component, computed, inject } from '@angular/core';
import { RouterLink } from '@angular/router'; import { RouterLink, RouterLinkActive } from '@angular/router';
import { MatToolbarModule } from '@angular/material/toolbar'; import { MatToolbarModule } from '@angular/material/toolbar';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
import { MatTooltipModule } from '@angular/material/tooltip'; 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 { ThemeService } from '../../service/theme.service';
import { LanguageService } from '../../service/language.service'; import { LanguageService } from '../../service/language.service';
import { MatDivider } from '@angular/material/divider'; import { MatDivider } from '@angular/material/divider';
@@ -14,11 +14,10 @@ import {RouterConstants} from '../../constants/RouterConstants';
@Component({ @Component({
selector: 'app-topbar', selector: 'app-topbar',
standalone: true,
imports: [ imports: [
RouterLink, RouterLink, RouterLinkActive,
MatToolbarModule, MatIconModule, MatButtonModule, MatMenuModule, MatTooltipModule, MatToolbarModule, MatIconModule, MatButtonModule, MatMenuModule, MatTooltipModule,
TranslateModule, MatDivider TranslatePipe, MatDivider
], ],
templateUrl: './topbar.component.html', templateUrl: './topbar.component.html',
styleUrl: './topbar.component.scss' styleUrl: './topbar.component.scss'

View File

@@ -1,179 +1,179 @@
<section class="about"> <section class="grid gap-fluid-md max-w-app mx-4 mt-auto">
<mat-card class="hero"> <mat-card class="card-gradient-bar">
<div class="photo"> <div class="flex flex-wrap gap-fluid-lg p-fluid-md items-start">
<img <div class="flex-[1_1_min(100%,425px)] max-w-full flex justify-center">
[ngSrc]="AssetsConstants.ME" <img class="block w-full h-auto max-w-[425px] rounded-xl shadow-[0_6px_24px_rgba(0,0,0,0.25)] object-cover"
width="421" height="512" [ngSrc]="AssetsConstants.ME" width="421" height="512" alt="{{ 'ABOUT.ALT.PROFILE' | translate }}"
alt="{{ 'ABOUT.ALT.PROFILE' | translate }}" draggable="false" oncontextmenu="return false;" priority />
draggable="false"
oncontextmenu="return false;"
priority />
</div> </div>
<div class="intro"> <div class="flex-[999_1_min(100%,400px)]">
<h1>{{ 'ABOUT.HELLO' | translate }}</h1> <h1 class="mt-0 mb-2 text-[clamp(1.5rem,5vw,2.5rem)]">{{ 'ABOUT.HELLO' | translate }}</h1>
<p class="lead"> <p class="opacity-90 my-2 mb-4 text-[clamp(1rem,2.5vw,1.15rem)]">
{{ 'ABOUT.LEAD' | translate }} {{ 'ABOUT.LEAD' | translate }}
</p> </p>
<div class="meta"> <div class="flex flex-col gap-1 mb-4">
<div class="row"> <div class="flex items-center flex-wrap gap-[0.4rem]">
<mat-icon aria-hidden="true">work</mat-icon> <mat-icon aria-hidden="true">work</mat-icon>
<span>{{ 'ABOUT.ROLE' | translate }}</span> <span>{{ 'ABOUT.ROLE' | translate }}</span>
</div> </div>
<div class="row"> <div class="flex items-center flex-wrap gap-[0.4rem]">
<mat-icon aria-hidden="true">location_on</mat-icon> <mat-icon aria-hidden="true">location_on</mat-icon>
<span>{{ 'ABOUT.LOCATION' | translate }}</span> <span>{{ 'ABOUT.LOCATION' | translate }}</span>
</div> </div>
<div class="row"> <div class="flex items-center flex-wrap gap-[0.4rem]">
<mat-icon aria-hidden="true">email</mat-icon> <mat-icon aria-hidden="true">email</mat-icon>
<a href="" (click)="SharedFunctions.openMail($event)"> <button type="button" class="link-button" data-testid="contact-mail"
(click)="SharedFunctions.openMail($event)">
{{ 'ABOUT.CONTACT_ME' | translate }} {{ 'ABOUT.CONTACT_ME' | translate }}
</a> </button>
</div> </div>
<div class="row"> <div class="flex items-center flex-wrap gap-[0.4rem]">
<mat-icon svgIcon="github"></mat-icon> <mat-icon aria-hidden="true">data_object</mat-icon>
<a href="{{UrlConstants.GIT_HUB}}" target="_blank" rel="noopener">GitHub</a> <a href="{{UrlConstants.CODEBERG}}" target="_blank" rel="noopener">Codeberg</a>
<span>·</span> <span>·</span>
<mat-icon svgIcon="linkedin"></mat-icon> <mat-icon svgIcon="linkedin" aria-hidden="true"></mat-icon>
<a href="{{UrlConstants.LINKED_IN}}" target="_blank" rel="noopener">LinkedIn</a> <a href="{{UrlConstants.LINKED_IN}}" target="_blank" rel="noopener">LinkedIn</a>
</div> </div>
</div> </div>
</div>
</mat-card>
<mat-card class="skills"> <div class="flex flex-wrap gap-fluid-sm">
<h2>{{ 'ABOUT.SECTION.SKILLS' | translate }}</h2> <a mat-flat-button [routerLink]="['/projects']">
<div class="chip-groups"> <mat-icon aria-hidden="true">grid_view</mat-icon>
<div> {{ 'ABOUT.VIEW_PROJECTS' | translate }}
<h3>{{ 'ABOUT.SECTION.PRIMARY' | translate }}</h3> </a>
<mat-chip-set aria-label="Primary skills">
@for (s of primarySkills; track s) {
<mat-chip >{{ s | translate }}</mat-chip>
}
</mat-chip-set>
</div> </div>
<div>
<h3>{{ 'ABOUT.SECTION.TOOLSET' | translate }}</h3>
<mat-chip-set aria-label="Toolset">
@for (t of toolset; track t) {
<mat-chip>{{ t | translate }}</mat-chip>
}
</mat-chip-set>
</div> </div>
</div> </div>
</mat-card> </mat-card>
<mat-card class="experdience"> <mat-card class="p-[clamp(5px,2vw,15px)]">
<h2 style="margin-left: 0.5rem;">{{ 'ABOUT.SECTION.EXPERIENCE' | translate }}</h2> <h2 class="mt-1 ml-1 text-[clamp(1.2rem,4vw,1.8rem)]">{{ 'ABOUT.SECTION.SKILLS' | translate }}</h2>
<div class="xp-list"> <div class="grid grid-cols-[repeat(auto-fit,minmax(min(100%,250px),1fr))] gap-fluid-sm ml-1 mb-2">
@for (entry of xpKeys; track entry.key) { @for (group of skillGroups; track group.titleKey) {
<div class="xp-item"> <div>
<div class="xp-head-grid"> <h3 class="my-1 text-[0.95rem] opacity-85">{{ group.titleKey | translate }}</h3>
<div class="logo-wrap"> <mat-chip-set [attr.aria-label]="group.titleKey | translate">
<img @for (skillKey of group.skillKeys; track skillKey) {
src="{{entry.logo}}" <mat-chip>{{ skillKey | translate }}</mat-chip>
alt="" }
class="company-logo" </mat-chip-set>
aria-hidden="true"
/>
</div> </div>
<div class="head-row"> }
<strong>{{ (entry.key + '.ROLE') | translate }}</strong>
<span class="time">{{ (entry.key + '.TIME') | translate }}</span>
</div> </div>
</mat-card>
<div class="company-row"> <mat-card class="p-[clamp(5px,2vw,15px)]">
<h2 class="mt-1 ml-2 text-[clamp(1.2rem,4vw,1.8rem)]">{{ 'ABOUT.SECTION.EXPERIENCE' | translate }}</h2>
<div class="relative ml-2">
<span class="absolute left-5 top-2 bottom-2 w-px bg-current opacity-15" aria-hidden="true"></span>
@for (entry of visibleExperience(); track entry.key; let i = $index) {
<div class="relative pl-14 pb-fluid-sm">
@if (isFirstRoleAtCompany(i)) {
<img src="{{entry.logo}}" alt="" aria-hidden="true"
class="absolute left-0 top-0 w-10 h-10 object-contain opacity-90 rounded-[10%] bg-logo-bg" />
<div class="font-semibold opacity-90 text-[clamp(0.9rem,2.2vw,1.05rem)] leading-10" data-testid="company">
{{ (entry.key + '.COMPANY') | translate }} {{ (entry.key + '.COMPANY') | translate }}
</div> </div>
} @else {
<span class="absolute left-5 top-[0.6rem] -translate-x-1/2 w-2 h-2 rounded-full bg-current opacity-30"
aria-hidden="true"></span>
}
<div class="flex flex-wrap items-baseline gap-x-4 gap-y-1">
<strong class="text-[clamp(0.95rem,2.5vw,1.1rem)]" data-testid="role">{{ (entry.key + '.ROLE') | translate }}</strong>
<span class="opacity-75 text-[clamp(0.85rem,2vw,0.95rem)]">{{ (entry.key + '.TIME') | translate }}</span>
</div> </div>
<div class="highlights">
<ul> <ul class="mt-[0.4rem]">
<li>{{ entry.key + '.HIGHLIGHTS.P1' | translate }}</li> @for (highlight of highlightKeys(entry); track highlight) {
<li>{{ entry.key + '.HIGHLIGHTS.P2' | translate }}</li> <li class="my-1 text-[clamp(0.9rem,2vw,1rem)]">{{ highlight | translate }}</li>
<li>{{ entry.key + '.HIGHLIGHTS.P3' | translate }}</li> }
</ul> </ul>
@if (isLastRoleAtCompany(i) && !$last) {
<mat-divider class="mt-fluid-sm!"></mat-divider>
}
</div> </div>
}
</div> </div>
@if(entry.key !== xpKeys.at(xpKeys.length-1)?.key) <button mat-button type="button" class="mt-1 ml-2" data-testid="toggle-experience" (click)="toggleEarlierExperience()"
{ [attr.aria-expanded]="showEarlierExperience()">
<mat-divider></mat-divider> <mat-icon aria-hidden="true">{{ showEarlierExperience() ? 'expand_less' : 'expand_more' }}</mat-icon>
} {{ (showEarlierExperience() ? 'ABOUT.SECTION.SHOW_LESS' : 'ABOUT.SECTION.SHOW_EARLIER_XP') | translate }}
} </button>
</div>
</mat-card> </mat-card>
<mat-card class="projects">
<h2>{{ 'ABOUT.SECTION.PROJECTS' | translate }}</h2>
<div class="xp-list"> <mat-card class="p-[clamp(5px,2vw,15px)]">
<h2 class="mt-1 ml-1 text-[clamp(1.2rem,4vw,1.8rem)]">{{ 'ABOUT.SECTION.PROJECTS' | translate }}</h2>
<div class="ml-1 grid gap-fluid-sm">
@for (entry of projectKeys; track entry.key) { @for (entry of projectKeys; track entry.key) {
<div class="xp-item"> <div>
<div class="head-row"> <div class="flex flex-wrap items-baseline gap-x-4 gap-y-1">
<strong>{{ (entry.key + '.TITLE') | translate }}</strong> <strong class="text-[clamp(0.95rem,2.5vw,1.1rem)]">{{ (entry.key + '.TITLE') | translate }}</strong>
</div> </div>
<div class="company-row"> <div class="mt-[0.1rem] opacity-85 text-[clamp(0.85rem,2vw,1rem)]">
{{ (entry.key + '.DESCRIPTION') | translate }} {{ (entry.key + '.DESCRIPTION') | translate }}
</div> </div>
@if (entry.externalLink) { @if (entry.externalLink) {
<div class="link-row"> <div class="mt-[0.1rem] opacity-85">
<a class="link-with-icon" <a class="inline-flex items-center gap-[0.35rem] leading-none" href="{{entry.externalLink}}" target="_blank" rel="noopener noreferrer">
href="{{entry.externalLink}}" <mat-icon class="text-[18px]! w-[18px]! h-[18px]!" aria-hidden="true">open_in_new</mat-icon>
target="_blank"
rel="noopener noreferrer">
<mat-icon>open_in_new</mat-icon>
{{ (entry.key + '.LINK_EXTERNAL') | translate }} {{ (entry.key + '.LINK_EXTERNAL') | translate }}
</a> </a>
</div> </div>
} }
<div class="link-row"> <div class="mt-[0.1rem] opacity-85">
<a class="link-with-icon" <a class="inline-flex items-center gap-[0.35rem] leading-none" [routerLink]="['/projects']" [queryParams]="{ project: entry.identifier }"
[routerLink]="['/projects']"
[queryParams]="{ project: entry.identifier }"
rel="noopener noreferrer"> rel="noopener noreferrer">
<mat-icon>link</mat-icon> <mat-icon class="text-[18px]! w-[18px]! h-[18px]!" aria-hidden="true">link</mat-icon>
{{ (entry.key + '.LINK_INTERNAL') | translate }} {{ (entry.key + '.LINK_INTERNAL') | translate }}
</a> </a>
</div> </div>
<div class="highlights-noMargin"> <div>
<ul> <ul>
<li>{{ entry.key + '.HIGHLIGHTS.P1' | translate }}</li> <li class="my-1 text-[clamp(0.9rem,2vw,1rem)]">{{ entry.key + '.HIGHLIGHTS.P1' | translate }}</li>
<li>{{ entry.key + '.HIGHLIGHTS.P2' | translate }}</li> <li class="my-1 text-[clamp(0.9rem,2vw,1rem)]">{{ entry.key + '.HIGHLIGHTS.P2' | translate }}</li>
<li>{{ entry.key + '.HIGHLIGHTS.P3' | translate }}</li> <li class="my-1 text-[clamp(0.9rem,2vw,1rem)]">{{ entry.key + '.HIGHLIGHTS.P3' | translate }}</li>
</ul> </ul>
</div> </div>
</div> </div>
@if(entry.key !== projectKeys.at(projectKeys.length-1)?.key) @if (!$last) {
{
<mat-divider></mat-divider> <mat-divider></mat-divider>
} }
} }
</div> </div>
</mat-card> </mat-card>
<mat-card class="education"> <mat-card class="p-[clamp(5px,2vw,15px)]">
<h2>{{ 'ABOUT.SECTION.EDUCATION' | translate }}</h2> <h2 class="mt-1 ml-1 text-[clamp(1.2rem,4vw,1.8rem)]">{{ 'ABOUT.SECTION.EDUCATION' | translate }}</h2>
<div class="xp-list"> <div class="ml-1 grid gap-fluid-sm">
<div class="xp-item"> <div>
@for (entry of educationKeys; track entry.key) { @for (key of visibleEducationKeys(); track key) {
<div class="head-row"> <div class="flex flex-wrap items-baseline gap-x-4 gap-y-1">
<strong>{{ (entry.key + '.WHERE') | translate }}</strong> <strong class="text-[clamp(0.95rem,2.5vw,1.1rem)]" data-testid="education-where">{{ (key + '.WHERE') | translate }}</strong>
<span class="time">{{ (entry.key + '.WHEN') | translate }}</span> <span class="opacity-75 text-[clamp(0.85rem,2vw,0.95rem)]">{{ (key + '.WHEN') | translate }}</span>
</div> </div>
<div class="company-row"> <div class="mt-[0.1rem] opacity-85 text-[clamp(0.85rem,2vw,1rem)]">
{{ (entry.key + '.WHAT') | translate }} {{ (key + '.WHAT') | translate }}
</div> </div>
@if (!$last) {
@if(entry.key !== educationKeys.at(educationKeys.length-1)?.key) <mat-divider class="my-2!"></mat-divider>
{
<mat-divider style="margin-top: .5rem; margin-bottom: .5rem"></mat-divider>
} }
} }
</div> </div>
</div> </div>
<button mat-button type="button" class="mt-1" data-testid="toggle-education" (click)="toggleEarlierEducation()"
[attr.aria-expanded]="showEarlierEducation()">
<mat-icon aria-hidden="true">{{ showEarlierEducation() ? 'expand_less' : 'expand_more' }}</mat-icon>
{{ (showEarlierEducation() ? 'ABOUT.SECTION.SHOW_LESS' : 'ABOUT.SECTION.SHOW_EARLIER_EDU') | translate }}
</button>
</mat-card> </mat-card>
</section> </section>

View File

@@ -1,183 +0,0 @@
.about {
display: grid;
gap: 1rem;
}
/* Hero block: Photo + Intro */
.hero {
display: grid;
grid-template-columns: 425px 1fr;
gap: 1.25rem;
border-radius: 16px;
background: var(--app-card-background);
.photo {
align-items:flex-start; justify-content:center;
img {
display:block;
width: 100%; height: auto;
max-width: 425px;
border-radius: 12px;
box-shadow: 0 6px 24px rgba(0,0,0,.25);
object-fit: cover;
}
}
.intro {
display:flex; flex-direction:column; gap:.5rem;
h1 { margin-top: .25rem }
.lead { opacity:.9; margin: .25rem 0 0.5rem; }
.meta {
display:flex; flex-direction:column; gap:.25rem; margin-bottom: 0.5rem;
.row {
display:flex; align-items:center; gap:.4rem;
a { color: inherit; }
}
}
.actions {
display:flex; gap:.5rem; flex-wrap:wrap; margin-top:.5rem;
.mat-icon { margin-right:.25rem; }
}
}
}
/* Skills block */
.skills {
padding: 5px;
h2 { margin-top: .25rem; margin-left: .25rem; }
.chip-groups {
margin-left: .25rem;
display:grid; gap:1rem;
grid-template-columns: 1fr 1fr;
margin-bottom: .5rem;
h3 { margin: .2rem 0 .4rem; font-size: .95rem; opacity:.85; }
mat-chip-set {
display:flex; flex-wrap:wrap; gap:.4rem;
}
}
}
/* Experience block */
.experience {
padding: 5px;
h2 { margin-top: .25rem; margin-left: .25rem; }
.xp-item {
.xp-head {
display:flex; align-items:baseline; gap:.5rem;
.time { opacity:.75; font-size:.9rem; }
}
.xp-sub { opacity:.9; margin-bottom:.25rem; }
ul { margin: .25rem 0 .5rem 1.15rem; }
}
}
/* Experience block */
.projects {
padding: 5px;
h2 { margin-top: .25rem;margin-left: .25rem; }
.xp-list {
margin-left: .25rem;
display: grid; gap: .75rem;
}
.xp-item {
.xp-head {
display:flex; align-items:baseline; gap:.5rem;
.time { opacity:.75; font-size:.9rem; }
}
.xp-sub { opacity:.9; margin-bottom:.25rem; }
ul { margin: .25rem 0 .5rem 1.15rem; }
}
}
/* Experience block */
.education {
padding: 5px;
h2 { margin-top: .25rem;margin-left: .25rem; }
.xp-list {
margin-left: .25rem;
display: grid; gap: .75rem;
}
.xp-item {
.xp-head {
display:flex; align-items:baseline; gap:.5rem;
.time { opacity:.75; font-size:.9rem; }
}
.xp-sub { opacity:.9; margin-bottom:.25rem; }
ul { margin: .25rem 0 .5rem 1.15rem; }
}
}
/* Responsive */
@media (max-width: 900px) {
.hero { grid-template-columns: 1fr; }
.hero .photo { justify-content: flex-start; }
.skills .chip-groups { grid-template-columns: 1fr; }
}
.xp-head-grid {
display: grid;
grid-template-columns: calc(48px + .75rem) 1fr; /* 1: Logo, 2: Text */
grid-template-rows: auto auto; /* 1: Role/Time, 2: Company */
column-gap: .75rem;
}
.logo-wrap {
grid-row: 1 / span 2;
grid-column: 1;
display: flex;
align-items: center;
}
.company-logo {
width: 48px;
height: 48px;
object-fit: contain;
opacity: .9;
border-radius: 10%;
background-color: var(--app-logo-bg);
}
.head-row {
grid-row: 1;
grid-column: 2;
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: .5rem 1rem;
strong {
font-size: 1rem;
}
.time {
opacity: .75; font-size: .9rem;
}
}
.company-row {
grid-row: 2;
grid-column: 2;
margin-top: .1rem;
opacity: .85;
}
.highlights {
margin-top: .4rem;
margin-left: .75rem;
padding-left: 1.2rem;
li {
margin: .2rem 0;
}
}
.highlights-noMargin {
margin-top: .4rem;
li {
margin: .2rem 0;
}
}

View File

@@ -0,0 +1,214 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Component, provideZonelessChangeDetection } from '@angular/core';
import { MatCardModule } from '@angular/material/card';
import { By } from '@angular/platform-browser';
import { provideRouter } from '@angular/router';
import { provideTranslateService } from '@ngx-translate/core';
import { AboutComponent } from './about.component';
/**
* No translations are loaded, so the translate pipe echoes the key back. That is
* exactly what these tests want: they assert which key is rendered where, which
* is what the mismatched skill headings got wrong. Expected values are written
* out literally rather than read off the component, so a change to the component
* cannot silently move the assertion with it.
*/
describe('AboutComponent', () => {
let fixture: ComponentFixture<AboutComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AboutComponent],
providers: [
provideZonelessChangeDetection(),
provideRouter([]),
provideTranslateService({ lang: 'en', fallbackLang: 'en' }),
],
}).compileComponents();
fixture = TestBed.createComponent(AboutComponent);
fixture.detectChanges();
});
function textOf(selector: string): string[] {
return fixture.debugElement
.queryAll(By.css(selector))
.map(element => element.nativeElement.textContent.trim());
}
function click(testId: string): void {
fixture.debugElement
.query(By.css(`[data-testid="${testId}"]`))
.nativeElement.click();
fixture.detectChanges();
}
function skillGroupTexts(): { heading: string; chips: string[] }[] {
return fixture.debugElement.queryAll(By.css('mat-chip-set')).map(chipSet => {
const group = chipSet.parent!;
const heading = group.query(By.css('h3')).nativeElement.textContent.trim();
const chips = group
.queryAll(By.css('mat-chip'))
.map(chip => chip.nativeElement.textContent.trim());
return { heading, chips };
});
}
describe('skills', () => {
it('renders the skill groups in the documented order', () => {
expect(skillGroupTexts().map(group => group.heading)).toEqual([
'ABOUT.SECTION.LANGUAGES',
'ABOUT.SECTION.ARCH_CLOUD',
'ABOUT.SECTION.SIM_ALGO',
'ABOUT.SECTION.AI_ENG',
]);
});
it('lists languages under the languages heading, not under architecture', () => {
const languages = skillGroupTexts().find(group => group.heading === 'ABOUT.SECTION.LANGUAGES');
expect(languages).toBeDefined();
expect(languages!.chips).toContain('ABOUT.SKILLS.JAVA');
expect(languages!.chips).not.toContain('ABOUT.TOOLS.DOCKER');
});
it('lists infrastructure tools under the architecture heading', () => {
const architecture = skillGroupTexts().find(group => group.heading === 'ABOUT.SECTION.ARCH_CLOUD');
expect(architecture).toBeDefined();
expect(architecture!.chips).toContain('ABOUT.TOOLS.DOCKER');
expect(architecture!.chips).not.toContain('ABOUT.SKILLS.JAVA');
});
it('advertises AI-assisted engineering as its own group', () => {
const ai = skillGroupTexts().find(group => group.heading === 'ABOUT.SECTION.AI_ENG');
expect(ai).toBeDefined();
expect(ai!.chips).toContain('ABOUT.SKILLS.AI_HARNESS');
});
});
describe('experience', () => {
it('hides the pre-2010 positions until they are requested', () => {
expect(textOf('[data-testid="role"]')).toContain('ABOUT.XP.COMPANY9.ROLE');
expect(textOf('[data-testid="role"]')).not.toContain('ABOUT.XP.COMPANY0.ROLE');
});
it('reveals the earlier positions when the toggle button is clicked', () => {
click('toggle-experience');
expect(textOf('[data-testid="role"]')).toContain('ABOUT.XP.COMPANY0.ROLE');
});
it('reports the expanded state of the experience toggle to assistive technology', () => {
const toggle = fixture.debugElement.query(By.css('[data-testid="toggle-experience"]')).nativeElement;
expect(toggle.getAttribute('aria-expanded')).toBe('false');
click('toggle-experience');
expect(toggle.getAttribute('aria-expanded')).toBe('true');
});
it('draws the employer header once per run of consecutive roles', () => {
const companies = textOf('[data-testid="company"]');
// COMPANY5/4/3 are three consecutive Assyst roles and share one header.
expect(companies).toContain('ABOUT.XP.COMPANY5.COMPANY');
expect(companies).not.toContain('ABOUT.XP.COMPANY4.COMPANY');
expect(companies).not.toContain('ABOUT.XP.COMPANY3.COMPANY');
});
it('treats the two ColorDigital spells as separate blocks so the timeline stays chronological', () => {
const companies = textOf('[data-testid="company"]');
expect(companies).toContain('ABOUT.XP.COMPANY9.COMPANY');
expect(companies).toContain('ABOUT.XP.COMPANY7.COMPANY');
});
it('keeps the employer header at the seam when the earlier roles are appended', () => {
click('toggle-experience');
// COMPANY3 (Assyst) and COMPANY2 (TH Bingen) meet at the seam, so the
// first earlier role must still get its own header.
expect(textOf('[data-testid="company"]')).toContain('ABOUT.XP.COMPANY2.COMPANY');
});
});
describe('education', () => {
it('shows only the post-school entries until the toggle button is clicked', () => {
expect(textOf('[data-testid="education-where"]')).toContain('ABOUT.EDUCATION.E6.WHERE');
expect(textOf('[data-testid="education-where"]')).not.toContain('ABOUT.EDUCATION.E0.WHERE');
click('toggle-education');
expect(textOf('[data-testid="education-where"]')).toContain('ABOUT.EDUCATION.E0.WHERE');
});
});
describe('call to action', () => {
it('offers no download link, so nothing can point at a missing file', () => {
expect(fixture.debugElement.queryAll(By.css('a[download]')).length).toBe(0);
});
it('exposes the mail contact as a button rather than an empty link', () => {
const emptyLinks = fixture.debugElement
.queryAll(By.css('a'))
.filter(anchor => anchor.nativeElement.getAttribute('href') === '');
expect(emptyLinks.length).toBe(0);
expect(fixture.debugElement.query(By.css('[data-testid="contact-mail"]'))).toBeTruthy();
});
it('keeps the mail button in the same font as the links it sits next to', () => {
const mailButton = fixture.debugElement.query(By.css('[data-testid="contact-mail"]')).nativeElement;
const siblingLink = fixture.debugElement
.queryAll(By.css('a'))
.find(anchor => anchor.nativeElement.textContent.trim() === 'Codeberg')!.nativeElement;
const buttonStyle = getComputedStyle(mailButton);
const linkStyle = getComputedStyle(siblingLink);
expect(buttonStyle.fontSize).toBe(linkStyle.fontSize);
expect(buttonStyle.fontFamily).toBe(linkStyle.fontFamily);
});
});
});
/**
* Material typography (`brand-family` in styles.scss) and the Tailwind body
* stack are declared in two separate places and are easy to change apart. When
* they disagree, only visitors who happen to have the extra font installed
* locally see it, so it will not show up in review.
*/
describe('global font stack', () => {
@Component({
imports: [MatCardModule],
template: `<mat-card><mat-card-title>probe</mat-card-title></mat-card>`,
})
class FontProbeComponent {}
function normalizeStack(value: string): string {
return value
.replace(/["']/g, '')
.split(',')
.map(family => family.trim())
.filter(family => family.length > 0)
.join(', ');
}
it('resolves Material card titles to the same font stack as body copy', async () => {
await TestBed.configureTestingModule({
imports: [FontProbeComponent],
providers: [provideZonelessChangeDetection()],
}).compileComponents();
const probe = TestBed.createComponent(FontProbeComponent);
probe.detectChanges();
const title = probe.nativeElement.querySelector('.mat-mdc-card-title') as HTMLElement;
const titleStack = normalizeStack(getComputedStyle(title).fontFamily);
const bodyStack = normalizeStack(getComputedStyle(document.body).fontFamily);
expect(titleStack).toBe(bodyStack);
});
});

View File

@@ -1,20 +1,31 @@
import { Component} from '@angular/core'; import { Component, computed, signal } from '@angular/core';
import { NgOptimizedImage } from '@angular/common'; import { NgOptimizedImage } from '@angular/common';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { MatChipsModule } from '@angular/material/chips'; import { MatChipsModule } from '@angular/material/chips';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatDividerModule } from '@angular/material/divider'; import { MatDividerModule } from '@angular/material/divider';
import { TranslateModule } from '@ngx-translate/core'; import { TranslatePipe } from '@ngx-translate/core';
import {UrlConstants} from '../../constants/UrlConstants'; import {UrlConstants} from '../../constants/UrlConstants';
import {AssetsConstants} from '../../constants/AssetsConstants'; import {AssetsConstants} from '../../constants/AssetsConstants';
import {RouterLink} from '@angular/router'; import {RouterLink} from '@angular/router';
import {SharedFunctions} from '../../shared/SharedFunctions'; import {SharedFunctions} from '../../shared/SharedFunctions';
/** One role. Consecutive entries sharing a `company` render as a single employer block. */
interface ExperienceEntry {
key: string;
company: string;
logo: string;
highlightCount: number;
}
interface SkillGroup {
titleKey: string;
skillKeys: string[];
}
@Component({ @Component({
selector: 'app-about', selector: 'app-about',
standalone: true,
imports: [ imports: [
NgOptimizedImage, NgOptimizedImage,
MatCardModule, MatCardModule,
@@ -22,7 +33,7 @@ import {SharedFunctions} from '../../shared/SharedFunctions';
MatIconModule, MatIconModule,
MatButtonModule, MatButtonModule,
MatDividerModule, MatDividerModule,
TranslateModule, TranslatePipe,
RouterLink RouterLink
], ],
templateUrl: './about.component.html', templateUrl: './about.component.html',
@@ -30,46 +41,83 @@ import {SharedFunctions} from '../../shared/SharedFunctions';
}) })
export class AboutComponent { export class AboutComponent {
xpKeys = [ /** Roles older than the current decade are hidden until the reader asks for them. */
readonly showEarlierExperience = signal(false);
readonly showEarlierEducation = signal(false);
readonly recentExperience: ExperienceEntry[] = [
{
key: 'ABOUT.XP.COMPANY9',
company: 'colordigital',
logo: AssetsConstants.COLORDIGITAL_LOGO,
highlightCount: 3
},
{ {
key: 'ABOUT.XP.COMPANY8', key: 'ABOUT.XP.COMPANY8',
logo: AssetsConstants.TERAPORT_LOGO company: 'teraport',
logo: AssetsConstants.TERAPORT_LOGO,
highlightCount: 3
}, },
{ {
key: 'ABOUT.XP.COMPANY7', key: 'ABOUT.XP.COMPANY7',
logo: AssetsConstants.COLORDIGITAL_LOGO company: 'colordigital-earlier',
logo: AssetsConstants.COLORDIGITAL_LOGO,
highlightCount: 3
}, },
{ {
key: 'ABOUT.XP.COMPANY6', key: 'ABOUT.XP.COMPANY6',
logo: AssetsConstants.COLORDIGITAL_LOGO company: 'colordigital-earlier',
logo: AssetsConstants.COLORDIGITAL_LOGO,
highlightCount: 3
}, },
{ {
key: 'ABOUT.XP.COMPANY5', key: 'ABOUT.XP.COMPANY5',
logo: AssetsConstants.ASSYST_LOGO company: 'assyst',
logo: AssetsConstants.ASSYST_LOGO,
highlightCount: 3
}, },
{ {
key: 'ABOUT.XP.COMPANY4', key: 'ABOUT.XP.COMPANY4',
logo: AssetsConstants.ASSYST_LOGO company: 'assyst',
logo: AssetsConstants.ASSYST_LOGO,
highlightCount: 3
}, },
{ {
key: 'ABOUT.XP.COMPANY3', key: 'ABOUT.XP.COMPANY3',
logo: AssetsConstants.ASSYST_LOGO company: 'assyst',
}, logo: AssetsConstants.ASSYST_LOGO,
{ highlightCount: 3
key: 'ABOUT.XP.COMPANY2',
logo: AssetsConstants.TH_BINGEN_LOGO
},
{
key: 'ABOUT.XP.COMPANY1',
logo: AssetsConstants.TH_BINGEN_LOGO
},
{
key: 'ABOUT.XP.COMPANY0',
logo: AssetsConstants.CHAMAELEON_LOGO
} }
]; ];
projectKeys = [ readonly earlierExperience: ExperienceEntry[] = [
{
key: 'ABOUT.XP.COMPANY2',
company: 'th-bingen',
logo: AssetsConstants.TH_BINGEN_LOGO,
highlightCount: 3
},
{
key: 'ABOUT.XP.COMPANY1',
company: 'th-bingen',
logo: AssetsConstants.TH_BINGEN_LOGO,
highlightCount: 3
},
{
key: 'ABOUT.XP.COMPANY0',
company: 'chamaeleon',
logo: AssetsConstants.CHAMAELEON_LOGO,
highlightCount: 3
}
];
readonly projectKeys = [
{
key: 'ABOUT.PROJECT.CLAUDE_BOX',
externalLink: '',
internalLink: 'projects',
identifier: 'claude-in-a-box',
},
{ {
key: 'ABOUT.PROJECT.P2', key: 'ABOUT.PROJECT.P2',
externalLink: 'https://andreas-dahm.eu/', externalLink: 'https://andreas-dahm.eu/',
@@ -102,52 +150,115 @@ export class AboutComponent {
} }
]; ];
educationKeys = [ readonly recentEducationKeys = [
{ 'ABOUT.EDUCATION.E6',
key: 'ABOUT.EDUCATION.E6' 'ABOUT.EDUCATION.E5',
}, 'ABOUT.EDUCATION.E4'
{
key: 'ABOUT.EDUCATION.E5'
},
{
key: 'ABOUT.EDUCATION.E4'
},
{
key: 'ABOUT.EDUCATION.E3'
},
{
key: 'ABOUT.EDUCATION.E2'
},
{
key: 'ABOUT.EDUCATION.E1'
},
{
key: 'ABOUT.EDUCATION.E0'
}
]
primarySkills = [
'ABOUT.SKILLS.JAVA',
'ABOUT.SKILLS.SPRING',
'ABOUT.SKILLS.ANGULAR',
'ABOUT.SKILLS.DOCKER',
'ABOUT.SKILLS.UNITY',
'ABOUT.SKILLS.PYTHON',
'ABOUT.SKILLS.CSHARP',
'ABOUT.SKILLS.TYPESCRIPT'
]; ];
toolset = [ readonly earlierEducationKeys = [
'ABOUT.TOOLS.GIT', 'ABOUT.EDUCATION.E3',
'ABOUT.TOOLS.GITHUB', 'ABOUT.EDUCATION.E2',
'ABOUT.TOOLS.GITLAB', 'ABOUT.EDUCATION.E1',
'ABOUT.TOOLS.JENKINS', 'ABOUT.EDUCATION.E0'
];
/**
* Heading and chips travel together so the two can no longer drift apart —
* they used to be declared in separate places and had ended up mismatched.
*/
readonly skillGroups: SkillGroup[] = [
{
titleKey: 'ABOUT.SECTION.LANGUAGES',
skillKeys: [
'ABOUT.SKILLS.JAVA',
'ABOUT.SKILLS.SPRING',
'ABOUT.SKILLS.TYPESCRIPT',
'ABOUT.SKILLS.ANGULAR',
'ABOUT.SKILLS.CSHARP',
'ABOUT.SKILLS.PYTHON',
'ABOUT.SKILLS.VUE',
'ABOUT.SKILLS.REACT'
]
},
{
titleKey: 'ABOUT.SECTION.ARCH_CLOUD',
skillKeys: [
'ABOUT.SKILLS.ARCH_MICROSERVICES',
'ABOUT.SKILLS.ARCH_CLOUD',
'ABOUT.TOOLS.DOCKER',
'ABOUT.TOOLS.K8S', 'ABOUT.TOOLS.K8S',
'ABOUT.TOOLS.POSTGRES', 'ABOUT.TOOLS.POSTGRES',
'ABOUT.TOOLS.MONGO', 'ABOUT.TOOLS.MONGO',
'ABOUT.TOOLS.GRAFANA', 'ABOUT.TOOLS.GITLAB',
'ABOUT.TOOLS.JENKINS'
]
},
{
titleKey: 'ABOUT.SECTION.SIM_ALGO',
skillKeys: [
'ABOUT.SKILLS.ENG_ALGO',
'ABOUT.SKILLS.ENG_SIM',
'ABOUT.SKILLS.ENG_GPU',
'ABOUT.SKILLS.ENG_PERF',
'ABOUT.SKILLS.UNITY'
]
},
{
titleKey: 'ABOUT.SECTION.AI_ENG',
skillKeys: [
'ABOUT.SKILLS.AI_AGENTS',
'ABOUT.SKILLS.AI_MCP',
'ABOUT.SKILLS.AI_CONTEXT',
'ABOUT.SKILLS.AI_HARNESS',
'ABOUT.SKILLS.AI_GATES'
]
}
]; ];
readonly visibleExperience = computed(() => {
if (this.showEarlierExperience()) {
return [...this.recentExperience, ...this.earlierExperience];
}
return this.recentExperience;
});
readonly visibleEducationKeys = computed(() => {
if (this.showEarlierEducation()) {
return [...this.recentEducationKeys, ...this.earlierEducationKeys];
}
return this.recentEducationKeys;
});
toggleEarlierExperience(): void {
this.showEarlierExperience.update(shown => !shown);
}
toggleEarlierEducation(): void {
this.showEarlierEducation.update(shown => !shown);
}
/** The employer header is drawn once per run of consecutive roles at the same company. */
isFirstRoleAtCompany(index: number): boolean {
if (index === 0) {
return true;
}
const entries = this.visibleExperience();
return entries[index - 1].company !== entries[index].company;
}
isLastRoleAtCompany(index: number): boolean {
const entries = this.visibleExperience();
if (index === entries.length - 1) {
return true;
}
return entries[index + 1].company !== entries[index].company;
}
highlightKeys(entry: ExperienceEntry): string[] {
return Array.from({ length: entry.highlightCount }, (_, i) => `${entry.key}.HIGHLIGHTS.P${i + 1}`);
}
protected readonly UrlConstants = UrlConstants; protected readonly UrlConstants = UrlConstants;
protected readonly AssetsConstants = AssetsConstants; protected readonly AssetsConstants = AssetsConstants;
protected readonly SharedFunctions = SharedFunctions; protected readonly SharedFunctions = SharedFunctions;

View File

@@ -3,4 +3,5 @@ export interface AlgorithmCategory {
title: string; title: string;
description: string; description: string;
routerLink: string; routerLink: string;
icon: string;
} }

View File

@@ -1,15 +1,16 @@
<div class="container"> <div class="grid gap-fluid-md grid-cols-[repeat(auto-fill,minmax(min(100%,450px),1fr))] max-w-app mx-4 mt-auto">
<h1>{{ 'ALGORITHM.TITLE' |translate }}</h1> <h1 class="m-0 mb-2 text-[clamp(1.4rem,4vw,2rem)]">{{ 'ALGORITHM.TITLE' | translate }}</h1>
<div class="category-cards"> </div>
@for (category of categories$ | async; track category.id) { <div class="grid gap-fluid-md grid-cols-[repeat(auto-fill,minmax(min(100%,450px),1fr))] max-w-app mx-4 mt-auto">
<mat-card [routerLink]="[category.routerLink]"> @for (category of categories; track category.id) {
<mat-card-header> <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-title>{{ category.title | translate }}</mat-card-title>
</mat-card-header>
<mat-card-content> <mat-card-content>
<p>{{ category.description | translate}}</p> <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>
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
} }
</div> </div>
</div>

View File

@@ -1,16 +0,0 @@
.container {
padding: 2rem;
}
.category-cards {
display: flex;
flex-wrap: wrap;
gap: 1rem;
margin-top: 2rem;
mat-card {
cursor: pointer;
min-width: 300px;
max-width: 400px;
}
}

View File

@@ -1,25 +1,19 @@
import { Component, OnInit, inject } from '@angular/core'; import { Component, inject } from '@angular/core';
import { AlgorithmsService } from './service/algorithms.service'; import { AlgorithmsService } from './algorithms.service';
import { AlgorithmCategory } from './models/algorithm-category'; import { AlgorithmCategory } from './algorithm-category';
import { Observable } from 'rxjs';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon';
import {TranslatePipe} from '@ngx-translate/core'; import {TranslatePipe} from '@ngx-translate/core';
@Component({ @Component({
selector: 'app-algorithms', selector: 'app-algorithms',
templateUrl: './algorithms.component.html', templateUrl: './algorithms.component.html',
styleUrls: ['./algorithms.component.scss'], styleUrl: './algorithms.component.scss',
standalone: true, imports: [RouterLink, MatCardModule, MatIconModule, TranslatePipe],
imports: [CommonModule, RouterLink, MatCardModule, TranslatePipe],
}) })
export class AlgorithmsComponent implements OnInit { export class AlgorithmsComponent {
private readonly algorithmsService = inject(AlgorithmsService); private readonly algorithmsService = inject(AlgorithmsService);
categories$: Observable<AlgorithmCategory[]> | undefined; readonly categories: AlgorithmCategory[] = this.algorithmsService.getCategories();
ngOnInit(): void {
this.categories$ = this.algorithmsService.getCategories();
}
} }

View File

@@ -0,0 +1,79 @@
import { Injectable } from '@angular/core';
import { AlgorithmCategory } from './algorithm-category';
import {RouterConstants} from '../../constants/RouterConstants';
@Injectable({
providedIn: 'root'
})
export class AlgorithmsService {
private readonly categories: AlgorithmCategory[] = [
{
id: 'pathfinding',
title: 'ALGORITHM.PATHFINDING.TITLE',
description: 'ALGORITHM.PATHFINDING.DESCRIPTION',
routerLink: RouterConstants.PATHFINDING.LINK,
icon: 'route'
},
{
id: 'sorting',
title: 'ALGORITHM.SORTING.TITLE',
description: 'ALGORITHM.SORTING.DESCRIPTION',
routerLink: RouterConstants.SORTING.LINK,
icon: 'sort'
},
{
id: 'gameOfLife',
title: 'ALGORITHM.GOL.TITLE',
description: 'ALGORITHM.GOL.DESCRIPTION',
routerLink: RouterConstants.GOL.LINK,
icon: 'grid_on'
},
{
id: 'labyrinth',
title: 'ALGORITHM.LABYRINTH.TITLE',
description: 'ALGORITHM.LABYRINTH.DESCRIPTION',
routerLink: RouterConstants.LABYRINTH.LINK,
icon: 'grid_view'
},
{
id: 'fractal',
title: 'ALGORITHM.FRACTAL.TITLE',
description: 'ALGORITHM.FRACTAL.DESCRIPTION',
routerLink: RouterConstants.FRACTAL.LINK,
icon: 'blur_on'
},
{
id: 'fractal3d',
title: 'ALGORITHM.FRACTAL3D.TITLE',
description: 'ALGORITHM.FRACTAL3D.DESCRIPTION',
routerLink: RouterConstants.FRACTAL3d.LINK,
icon: 'view_in_ar'
},
{
id: 'pendulum',
title: 'ALGORITHM.PENDULUM.TITLE',
description: 'ALGORITHM.PENDULUM.DESCRIPTION',
routerLink: RouterConstants.PENDULUM.LINK,
icon: 'rotate_right'
},
{
id: 'cloth',
title: 'ALGORITHM.CLOTH.TITLE',
description: 'ALGORITHM.CLOTH.DESCRIPTION',
routerLink: RouterConstants.CLOTH.LINK,
icon: 'texture'
},
{
id: 'fourColor',
title: 'ALGORITHM.FOUR_COLOR.TITLE',
description: 'ALGORITHM.FOUR_COLOR.DESCRIPTION',
routerLink: RouterConstants.FOUR_COLOR.LINK,
icon: 'palette'
}
];
getCategories(): AlgorithmCategory[] {
return this.categories;
}
}

View File

@@ -0,0 +1,48 @@
/**
* GLSL shaders for cloth rendering on WebGL.
* Replicates the visual output of the WGSL cloth shaders:
* checkerboard pattern with Lambertian lighting.
*/
export const CLOTH_VERTEX_SHADER_GLSL = `
precision highp float;
attribute vec3 position;
attribute vec2 uv;
uniform mat4 viewProjection;
varying vec2 vUV;
varying vec3 vWorldPos;
void main() {
vUV = uv;
vWorldPos = position;
gl_Position = viewProjection * vec4(position, 1.0);
}
`;
export const CLOTH_FRAGMENT_SHADER_GLSL = `
#extension GL_OES_standard_derivatives : enable
precision highp float;
varying vec2 vUV;
varying vec3 vWorldPos;
void main() {
vec3 dx = dFdx(vWorldPos);
vec3 dy = dFdy(vWorldPos);
vec3 normal = normalize(cross(dx, dy));
vec3 lightDir = normalize(vec3(1.0, 1.0, 0.5));
float diffuse = max(0.0, abs(dot(normal, lightDir)));
float ambient = 0.3;
float lightIntensity = ambient + (diffuse * 0.7);
float grid = mod(floor(vUV.x * 20.0) + floor(vUV.y * 20.0), 2.0);
vec3 baseColor = mix(vec3(0.8, 0.4, 0.15), vec3(0.9, 0.5, 0.2), grid);
vec3 finalColor = baseColor * lightIntensity;
gl_FragColor = vec4(finalColor, 1.0);
}
`;

View File

@@ -0,0 +1,32 @@
<mat-card class="w-full max-w-[1920px] p-5">
<mat-card-header>
<mat-card-title>{{ 'CLOTH.TITLE' | translate }}</mat-card-title>
</mat-card-header>
<mat-card-content>
<app-information [algorithmInformation]="algoInformation"/>
<div class="flex flex-col mb-4">
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
<button mat-raised-button color="primary" (click)="toggleWind()">
{{ isWindActive ? ('CLOTH.WIND_OFF' | translate) : ('CLOTH.WIND_ON' | translate) }}
</button>
<button mat-raised-button color="primary" (click)="toggleMesh()">
{{ isOutlineActive ? ('CLOTH.OUTLINE_OFF' | translate) : ('CLOTH.OUTLINE_ON' | translate) }}
</button>
<button mat-raised-button color="accent" (click)="restartSimulation()">
{{ 'CLOTH.RESTART_SIMULATION' | translate }}
</button>
</div>
<div class="flex items-center gap-2.5">
<span>{{ 'CLOTH.ELONGATION' | translate }}: {{ elongation }}</span>
<mat-slider min="0.5" max="2.0" step="0.1">
<input matSliderThumb [(ngModel)]="elongation">
</mat-slider>
</div>
</div>
<app-babylon-canvas
[config]="renderConfig"
(sceneReady)="onSceneReady($event)"
(sceneResized)="onSceneReady($event)"
/>
</mat-card-content>
</mat-card>

View File

@@ -0,0 +1,158 @@
import {Component} from '@angular/core';
import {FormsModule} from '@angular/forms';
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
import {MatSliderModule} from '@angular/material/slider';
import {TranslatePipe} from '@ngx-translate/core';
import {BabylonCanvas, RenderConfig, SceneEventData} from '../../../shared/components/render-canvas/babylon-canvas.component';
import {MatButton} from '@angular/material/button';
import {ClothConfig} from './cloth.model';
import {Information} from '../information/information';
import {AlgorithmInformation} from '../information/information.models';
import {UrlConstants} from '../../../constants/UrlConstants';
import {ClothSimulationStrategy} from './strategies/cloth-simulation.strategy';
import {ClothGpuStrategy} from './strategies/cloth-gpu.strategy';
import {ClothCpuStrategy} from './strategies/cloth-cpu.strategy';
@Component({
selector: 'app-cloth',
imports: [
MatCard,
MatCardContent,
MatCardHeader,
MatCardTitle,
TranslatePipe,
BabylonCanvas,
MatButton,
MatSliderModule,
FormsModule,
Information
],
templateUrl: './cloth.component.html',
styleUrl: './cloth.component.scss',
})
export class ClothComponent {
private currentSceneData: SceneEventData | null = null;
private simulationTime: number = 0;
private strategy: ClothSimulationStrategy | null = null;
public isWindActive: boolean = false;
public isOutlineActive: boolean = false;
public stiffness: number = 80;
public elongation: number = 1.0;
public renderConfig: RenderConfig = {
mode: '3D',
initialViewSize: 20
};
algoInformation: AlgorithmInformation = {
title: 'CLOTH.EXPLANATION.TITLE',
entries: [
{
name: 'CLOTH.EXPLANATION.CLOTH_SIMULATION_EXPLANATION_TITLE',
description: 'CLOTH.EXPLANATION.CLOTH_SIMULATION_EXPLANATION',
link: UrlConstants.CLOTH_SIMULATION_WIKI,
translateName: true
},
{
name: 'CLOTH.EXPLANATION.XPBD_EXPLANATION_TITLE',
description: 'CLOTH.EXPLANATION.XPBD_EXPLANATION',
link: UrlConstants.XPBD_WIKI,
translateName: true
},
{
name: 'CLOTH.EXPLANATION.GPU_PARALLELIZATION_EXPLANATION_TITLE',
description: 'CLOTH.EXPLANATION.GPU_PARALLELIZATION_EXPLANATION',
link: UrlConstants.GPU_COMPUTING_WIKI,
translateName: true
},
{
name: 'CLOTH.EXPLANATION.DATA_STRUCTURES_EXPLANATION_TITLE',
description: 'CLOTH.EXPLANATION.DATA_STRUCTURES_EXPLANATION',
link: UrlConstants.DATA_STRUCTURE_WIKI,
translateName: true
}
],
disclaimer: 'CLOTH.EXPLANATION.DISCLAIMER',
disclaimerBottom: '',
disclaimerListEntry: ['CLOTH.EXPLANATION.DISCLAIMER_1', 'CLOTH.EXPLANATION.DISCLAIMER_2', 'CLOTH.EXPLANATION.DISCLAIMER_3', 'CLOTH.EXPLANATION.DISCLAIMER_4']
};
public onSceneReady(event: SceneEventData): void {
this.currentSceneData = event;
this.createSimulation();
}
public toggleWind(): void {
this.isWindActive = !this.isWindActive;
}
public toggleMesh(): void {
this.isOutlineActive = !this.isOutlineActive;
const mesh = this.strategy?.getMesh();
if (!mesh?.material) {
return;
}
mesh.material.wireframe = this.isOutlineActive;
}
public restartSimulation(): void {
this.simulationTime = 0;
this.createSimulation();
}
private createSimulation(): void {
if (!this.currentSceneData) {
return;
}
const {engine, scene, gpuTier} = this.currentSceneData;
const config = this.getClothConfig();
if (this.strategy) {
this.strategy.dispose();
}
this.strategy = gpuTier === 'webgpu'
? new ClothGpuStrategy()
: new ClothCpuStrategy();
this.strategy.init(scene, engine, config);
this.startParamUpdateLoop(scene, engine);
}
private startParamUpdateLoop(scene: any, engine: any): void {
scene.onAfterRenderObservable.clear();
scene.onAfterRenderObservable.add(() => {
this.simulationTime += engine.getDeltaTime() / 1000.0;
if (this.strategy) {
this.strategy.updateParams({
stiffness: this.stiffness,
elongation: this.elongation,
isWindActive: this.isWindActive,
simulationTime: this.simulationTime,
deltaTime: engine.getDeltaTime() / 1000.0
});
}
});
}
private getClothConfig(): ClothConfig {
const gridWidth = 100;
const gridHeight = 100;
const spacing = 0.05;
const density = 1.0;
const particleArea = spacing * spacing;
const particleMass = density * particleArea;
return {
gridWidth,
gridHeight,
spacing,
density,
numVertices: gridWidth * gridHeight,
particleInvMass: 1.0 / particleMass
};
}
}

View File

@@ -0,0 +1,16 @@
export interface ClothConfig {
gridWidth: number;
gridHeight: number;
spacing: number;
density: number;
numVertices: number;
particleInvMass: number;
}
export interface ClothData {
positions: Float32Array;
prevPositions: Float32Array;
velocities: Float32Array;
constraints: number[][];
params: Float32Array;
}

View File

@@ -0,0 +1,215 @@
/**
* File: cloth.shader.ts
* Description: WGSL shaders for cloth simulation and rendering.
*/
// --- SHARED DATA STRUCTURES ---
export const CLOTH_SHARED_STRUCTS = `
struct Params {
dt: f32,
gravity_y: f32,
compliance: f32,
numVertices: f32,
wind_x: f32,
wind_y: f32,
wind_z: f32,
time: f32,
elongation: f32
};
`;
// ==========================================
// VERTEX SHADER
// ==========================================
export const CLOTH_VERTEX_SHADER_WGSL = `
attribute uv : vec2<f32>;
var<storage, read> positions : array<vec4<f32>>;
uniform viewProjection : mat4x4<f32>;
varying vUV : vec2<f32>;
varying vWorldPos : vec3<f32>;
@vertex
fn main(input : VertexInputs) -> FragmentInputs {
var output : FragmentInputs;
let worldPos = positions[input.vertexIndex].xyz;
output.position = uniforms.viewProjection * vec4<f32>(worldPos, 1.0);
output.vUV = input.uv;
output.vWorldPos = worldPos;
return output;
}
`;
// ==========================================
// FRAGMENT SHADER
// ==========================================
export const CLOTH_FRAGMENT_SHADER_WGSL = `
varying vUV : vec2<f32>;
varying vWorldPos : vec3<f32>;
@fragment
fn main(input: FragmentInputs) -> FragmentOutputs {
var output: FragmentOutputs;
let dx = dpdx(input.vWorldPos);
let dy = dpdy(input.vWorldPos);
let normal = normalize(cross(dx, dy));
let lightDir = normalize(vec3<f32>(1.0, 1.0, 0.5));
let diffuse = max(0.0, abs(dot(normal, lightDir)));
let ambient = 0.3;
let lightIntensity = ambient + (diffuse * 0.7);
let grid = (floor(input.vUV.x * 20.0) + floor(input.vUV.y * 20.0)) % 2.0;
let baseColor = mix(vec3<f32>(0.8, 0.4, 0.15), vec3<f32>(0.9, 0.5, 0.2), grid);
let finalColor = baseColor * lightIntensity;
output.color = vec4<f32>(finalColor, 1.0);
return output;
}
`;
// =====================================================================
// PASS 1: INTEGRATION (Apply Forces & Predict Positions)
// =====================================================================
export const CLOTH_INTEGRATE_COMPUTE_WGSL = CLOTH_SHARED_STRUCTS + `
@group(0) @binding(0) var<storage, read> p : Params;
@group(0) @binding(1) var<storage, read_write> positions : array<vec4<f32>>;
@group(0) @binding(2) var<storage, read_write> prev_positions : array<vec4<f32>>;
@group(0) @binding(3) var<storage, read_write> velocities : array<vec4<f32>>;
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) global_id : vec3<u32>) {
let idx = global_id.x;
if (f32(idx) >= p.numVertices) { return; }
var pos = positions[idx];
var vel = velocities[idx];
let invMass = pos.w;
if (invMass > 0.0) {
vel.y = vel.y + (p.gravity_y * p.dt);
let flutter = sin(pos.x * 2.0 + p.time * 5.0) * cos(pos.y * 2.0 + p.time * 3.0);
let windForce = vec3<f32>(
p.wind_x + (flutter * p.wind_x * 0.8),
p.wind_y + (flutter * 2.0), // Leichter Auftrieb durchs Flattern
p.wind_z + (flutter * p.wind_z * 0.8)
);
vel.x = vel.x + (windForce.x * p.dt);
vel.y = vel.y + (windForce.y * p.dt);
vel.z = vel.z + (windForce.z * p.dt);
prev_positions[idx] = pos;
pos.x = pos.x + vel.x * p.dt;
pos.y = pos.y + vel.y * p.dt;
pos.z = pos.z + vel.z * p.dt;
positions[idx] = pos;
velocities[idx] = vel;
}
}
`;
// =====================================================================
// PASS 2: SOLVE CONSTRAINTS (The core of XPBD)
// =====================================================================
export const CLOTH_SOLVE_COMPUTE_WGSL = CLOTH_SHARED_STRUCTS + `
@group(0) @binding(0) var<storage, read> p : Params;
@group(0) @binding(1) var<storage, read_write> positions : array<vec4<f32>>;
@group(0) @binding(2) var<storage, read> constraints : array<vec4<f32>>; // <--- Read-only as we do not modify them here
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) global_id : vec3<u32>) {
let idx = global_id.x;
// Query the GPU directly for the length of the passed array
if (idx >= arrayLength(&constraints)) { return; }
let constraint = constraints[idx];
// constraint.w: 0.0 = inactive, 1.0 = horizontal/diagonal, 2.0 = vertical
if (constraint.w < 0.5) { return; }
let idA = u32(constraint.x);
let idB = u32(constraint.y);
// constraint.w encodes type: 1.0 = horizontal/diagonal, 2.0 = vertical (elongation applies)
let restLength =constraint.z * p.elongation;
var pA = positions[idA];
var pB = positions[idB];
let wA = pA.w;
let wB = pB.w;
let wSum = wA + wB;
if (wSum <= 0.0) { return; }
let dir = pA.xyz - pB.xyz;
let dist = length(dir);
if (dist < 0.0001) { return; }
let n = dir / dist;
let C = dist - restLength;
let alpha = p.compliance / (p.dt * p.dt);
let lambda = -C / (wSum + alpha);
let corrA = n * (lambda * wA);
let corrB = n * (-lambda * wB);
// This is because we are using graph coloring to be thread safe
if (wA > 0.0) {
positions[idA].x = positions[idA].x + corrA.x;
positions[idA].y = positions[idA].y + corrA.y;
positions[idA].z = positions[idA].z + corrA.z;
}
if (wB > 0.0) {
positions[idB].x = positions[idB].x + corrB.x;
positions[idB].y = positions[idB].y + corrB.y;
positions[idB].z = positions[idB].z + corrB.z;
}
}
`;
// =====================================================================
// PASS 3: VELOCITY UPDATE (Derive velocity from position changes)
// =====================================================================
export const CLOTH_VELOCITY_COMPUTE_WGSL = CLOTH_SHARED_STRUCTS + `
@group(0) @binding(0) var<storage, read> p : Params;
@group(0) @binding(1) var<storage, read_write> positions : array<vec4<f32>>;
@group(0) @binding(2) var<storage, read_write> prev_positions : array<vec4<f32>>;
@group(0) @binding(3) var<storage, read_write> velocities : array<vec4<f32>>;
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) global_id : vec3<u32>) {
let idx = global_id.x;
if (f32(idx) >= p.numVertices) { return; }
let pos = positions[idx];
let prev = prev_positions[idx];
let invMass = pos.w;
if (invMass > 0.0) {
var vel = velocities[idx];
// v = (p - p_prev) / dt
vel.x = (pos.x - prev.x) / p.dt;
vel.y = (pos.y - prev.y) / p.dt;
vel.z = (pos.z - prev.z) / p.dt;
// Optional: Add simple damping here
// vel = vel * 0.99;
velocities[idx] = vel;
}
}
`;

View File

@@ -0,0 +1,149 @@
/**
* CPU-side cloth physics mirroring the WGSL compute shaders.
* All data uses the same Float32Array vec4 layout: [x, y, z, invMass] per vertex.
*/
export interface ClothPhysicsParams {
dt: number;
gravityY: number;
compliance: number;
numVertices: number;
windX: number;
windY: number;
windZ: number;
time: number;
elongation: number;
}
/**
* Mirrors CLOTH_INTEGRATE_COMPUTE_WGSL:
* Applies gravity and wind forces, predicts new positions.
*/
export function integratePositions(
positions: Float32Array,
prevPositions: Float32Array,
velocities: Float32Array,
params: ClothPhysicsParams
): void {
for (let idx = 0; idx < params.numVertices; idx++) {
const base = idx * 4;
const invMass = positions[base + 3];
if (invMass <= 0.0) {
continue;
}
velocities[base + 1] += params.gravityY * params.dt;
const posX = positions[base + 0];
const posY = positions[base + 1];
const flutter = Math.sin(posX * 2.0 + params.time * 5.0) * Math.cos(posY * 2.0 + params.time * 3.0);
const windForceX = params.windX + (flutter * params.windX * 0.8);
const windForceY = params.windY + (flutter * 2.0);
const windForceZ = params.windZ + (flutter * params.windZ * 0.8);
velocities[base + 0] += windForceX * params.dt;
velocities[base + 1] += windForceY * params.dt;
velocities[base + 2] += windForceZ * params.dt;
prevPositions[base + 0] = positions[base + 0];
prevPositions[base + 1] = positions[base + 1];
prevPositions[base + 2] = positions[base + 2];
prevPositions[base + 3] = positions[base + 3];
positions[base + 0] += velocities[base + 0] * params.dt;
positions[base + 1] += velocities[base + 1] * params.dt;
positions[base + 2] += velocities[base + 2] * params.dt;
}
}
/**
* Mirrors CLOTH_SOLVE_COMPUTE_WGSL:
* XPBD constraint solving for one phase of constraints.
* Each constraint is stored as [idA, idB, restLength, type] (4 floats).
*/
export function solveConstraints(
positions: Float32Array,
constraints: Float32Array,
params: ClothPhysicsParams
): void {
const numConstraints = constraints.length / 4;
for (let idx = 0; idx < numConstraints; idx++) {
const cBase = idx * 4;
const constraintType = constraints[cBase + 3];
if (constraintType < 0.5) {
continue;
}
const idA = constraints[cBase + 0];
const idB = constraints[cBase + 1];
const restLength = constraints[cBase + 2] * params.elongation;
const baseA = idA * 4;
const baseB = idB * 4;
const wA = positions[baseA + 3];
const wB = positions[baseB + 3];
const wSum = wA + wB;
if (wSum <= 0.0) {
continue;
}
const dirX = positions[baseA + 0] - positions[baseB + 0];
const dirY = positions[baseA + 1] - positions[baseB + 1];
const dirZ = positions[baseA + 2] - positions[baseB + 2];
const dist = Math.sqrt(dirX * dirX + dirY * dirY + dirZ * dirZ);
if (dist < 0.0001) {
continue;
}
const nX = dirX / dist;
const nY = dirY / dist;
const nZ = dirZ / dist;
const c = dist - restLength;
const alpha = params.compliance / (params.dt * params.dt);
const lambda = -c / (wSum + alpha);
if (wA > 0.0) {
positions[baseA + 0] += nX * (lambda * wA);
positions[baseA + 1] += nY * (lambda * wA);
positions[baseA + 2] += nZ * (lambda * wA);
}
if (wB > 0.0) {
positions[baseB + 0] += nX * (-lambda * wB);
positions[baseB + 1] += nY * (-lambda * wB);
positions[baseB + 2] += nZ * (-lambda * wB);
}
}
}
/**
* Mirrors CLOTH_VELOCITY_COMPUTE_WGSL:
* Derives velocity from position changes: v = (pos - prevPos) / dt
*/
export function updateVelocities(
positions: Float32Array,
prevPositions: Float32Array,
velocities: Float32Array,
params: ClothPhysicsParams
): void {
for (let idx = 0; idx < params.numVertices; idx++) {
const base = idx * 4;
const invMass = positions[base + 3];
if (invMass <= 0.0) {
continue;
}
velocities[base + 0] = (positions[base + 0] - prevPositions[base + 0]) / params.dt;
velocities[base + 1] = (positions[base + 1] - prevPositions[base + 1]) / params.dt;
velocities[base + 2] = (positions[base + 2] - prevPositions[base + 2]) / params.dt;
}
}

View File

@@ -0,0 +1,216 @@
import {ArcRotateCamera, Engine, GroundMesh, MeshBuilder, Scene, ShaderMaterial, VertexBuffer, WebGPUEngine} from '@babylonjs/core';
import {ClothConfig, ClothData} from '../cloth.model';
import {CLOTH_FRAGMENT_SHADER_GLSL, CLOTH_VERTEX_SHADER_GLSL} from '../cloth-glsl.shader';
import {ClothPhysicsParams, integratePositions, solveConstraints, updateVelocities} from './cloth-cpu-physics';
import {ClothSimulationParams, ClothSimulationStrategy} from './cloth-simulation.strategy';
export class ClothCpuStrategy implements ClothSimulationStrategy {
private clothMesh: GroundMesh | null = null;
private scene: Scene | null = null;
private config: ClothConfig | null = null;
private positions!: Float32Array;
private prevPositions!: Float32Array;
private velocities!: Float32Array;
private constraintPhases!: Float32Array[];
private physicsParams: ClothPhysicsParams = {
dt: 0.016,
gravityY: -9.81,
compliance: 0.00001,
numVertices: 0,
windX: 0,
windY: 0,
windZ: 0,
time: 0,
elongation: 1.0
};
init(scene: Scene, engine: WebGPUEngine | Engine, config: ClothConfig): void {
this.scene = scene;
this.config = config;
this.physicsParams.numVertices = config.numVertices;
const clothData = this.generateClothData(config);
this.positions = clothData.positions;
this.prevPositions = clothData.prevPositions;
this.velocities = clothData.velocities;
this.constraintPhases = clothData.constraints.map(c => new Float32Array(c));
this.setupRenderMesh(scene, config);
this.startRenderLoop(scene, config);
}
updateParams(params: ClothSimulationParams): void {
const windX = params.isWindActive ? 5.0 : 0.0;
const windZ = params.isWindActive ? 15.0 : 0.0;
const softCompliance = 10.0;
const rigidCompliance = 0.00001;
const t = (params.stiffness - 1) / 99.0;
this.physicsParams.compliance = softCompliance * Math.pow(rigidCompliance / softCompliance, t);
this.physicsParams.windX = windX;
this.physicsParams.windY = 0.0;
this.physicsParams.windZ = windZ;
this.physicsParams.time = params.simulationTime;
this.physicsParams.elongation = params.elongation;
}
getMesh(): GroundMesh | null {
return this.clothMesh;
}
dispose(): void {
if (this.scene && this.clothMesh) {
this.scene.removeMesh(this.clothMesh);
}
this.clothMesh = null;
}
private generateClothData(config: ClothConfig): ClothData {
const positionsData = new Float32Array(config.numVertices * 4);
const prevPositionsData = new Float32Array(config.numVertices * 4);
const velocitiesData = new Float32Array(config.numVertices * 4);
const constraintsP0: number[] = [];
const constraintsP1: number[] = [];
const constraintsP2: number[] = [];
const constraintsP3: number[] = [];
const addHorizontalConstraint = (arr: number[], a: number, b: number): void => {
arr.push(a, b, config.spacing, 1.0);
};
const addVerticalConstraint = (arr: number[], a: number, b: number): void => {
arr.push(a, b, config.spacing, 2.0);
};
for (let y = 0; y < config.gridHeight; y++) {
for (let x = 0; x < config.gridWidth; x++) {
const idx = (y * config.gridWidth + x) * 4;
positionsData[idx + 0] = (x - config.gridWidth / 2) * config.spacing;
positionsData[idx + 1] = 5.0 - (y * config.spacing);
positionsData[idx + 2] = 0.0;
positionsData[idx + 3] = (y === 0) ? 0.0 : config.particleInvMass;
prevPositionsData[idx + 0] = positionsData[idx + 0];
prevPositionsData[idx + 1] = positionsData[idx + 1];
prevPositionsData[idx + 2] = positionsData[idx + 2];
prevPositionsData[idx + 3] = positionsData[idx + 3];
}
}
for (let y = 0; y < config.gridHeight; y++) {
for (let x = 0; x < config.gridWidth - 1; x += 2) { addHorizontalConstraint(constraintsP0, y * config.gridWidth + x, y * config.gridWidth + x + 1); }
for (let x = 1; x < config.gridWidth - 1; x += 2) { addHorizontalConstraint(constraintsP1, y * config.gridWidth + x, y * config.gridWidth + x + 1); }
}
for (let y = 0; y < config.gridHeight - 1; y += 2) {
for (let x = 0; x < config.gridWidth; x++) { addVerticalConstraint(constraintsP2, y * config.gridWidth + x, (y + 1) * config.gridWidth + x); }
}
for (let y = 1; y < config.gridHeight - 1; y += 2) {
for (let x = 0; x < config.gridWidth; x++) { addVerticalConstraint(constraintsP3, y * config.gridWidth + x, (y + 1) * config.gridWidth + x); }
}
const constraintsP4: number[] = [];
const constraintsP5: number[] = [];
const constraintsP6: number[] = [];
const constraintsP7: number[] = [];
const diagSpacing = config.spacing * Math.SQRT2;
const addDiagConstraint = (arr: number[], a: number, b: number): void => {
arr.push(a, b, diagSpacing, 1.0);
};
for (let y = 0; y < config.gridHeight - 1; y++) {
const arr = (y % 2 === 0) ? constraintsP4 : constraintsP5;
for (let x = 0; x < config.gridWidth - 1; x++) {
addDiagConstraint(arr, y * config.gridWidth + x, (y + 1) * config.gridWidth + (x + 1));
}
}
for (let y = 0; y < config.gridHeight - 1; y++) {
const arr = (y % 2 === 0) ? constraintsP6 : constraintsP7;
for (let x = 0; x < config.gridWidth - 1; x++) {
addDiagConstraint(arr, y * config.gridWidth + (x + 1), (y + 1) * config.gridWidth + x);
}
}
return {
positions: positionsData,
prevPositions: prevPositionsData,
velocities: velocitiesData,
constraints: [
constraintsP0, constraintsP1, constraintsP2, constraintsP3,
constraintsP4, constraintsP5, constraintsP6, constraintsP7
],
params: new Float32Array(9)
};
}
private setupRenderMesh(scene: Scene, config: ClothConfig): void {
if (this.clothMesh) {
scene.removeMesh(this.clothMesh);
}
this.clothMesh = MeshBuilder.CreateGround("cloth", {
width: 10,
height: 10,
subdivisions: config.gridWidth - 1,
updatable: true
}, scene);
const clothMaterial = new ShaderMaterial("clothMat", scene, {
vertexSource: CLOTH_VERTEX_SHADER_GLSL,
fragmentSource: CLOTH_FRAGMENT_SHADER_GLSL
}, {
attributes: ["position", "uv"],
uniforms: ["viewProjection"]
});
clothMaterial.backFaceCulling = false;
this.clothMesh.material = clothMaterial;
const camera = scene.activeCamera as ArcRotateCamera;
if (camera) {
camera.alpha = Math.PI / 4;
camera.beta = Math.PI / 2.5;
camera.radius = 15;
}
}
/**
* Extracts xyz from the vec4 positions array into a vec3 array for mesh vertex update.
*/
private extractPositionsVec3(positions: Float32Array, numVertices: number): Float32Array {
const result = new Float32Array(numVertices * 3);
for (let i = 0; i < numVertices; i++) {
result[i * 3 + 0] = positions[i * 4 + 0];
result[i * 3 + 1] = positions[i * 4 + 1];
result[i * 3 + 2] = positions[i * 4 + 2];
}
return result;
}
private startRenderLoop(scene: Scene, config: ClothConfig): void {
const substeps = 15;
scene.onBeforeRenderObservable.clear();
scene.onBeforeRenderObservable.add(() => {
if (!this.clothMesh) {
return;
}
integratePositions(this.positions, this.prevPositions, this.velocities, this.physicsParams);
for (let i = 0; i < substeps; i++) {
for (const phase of this.constraintPhases) {
solveConstraints(this.positions, phase, this.physicsParams);
}
}
updateVelocities(this.positions, this.prevPositions, this.velocities, this.physicsParams);
const posVec3 = this.extractPositionsVec3(this.positions, config.numVertices);
this.clothMesh.updateVerticesData(VertexBuffer.PositionKind, posVec3);
});
}
}

View File

@@ -0,0 +1,275 @@
import {ArcRotateCamera, ComputeShader, Engine, GroundMesh, MeshBuilder, Scene, ShaderLanguage, ShaderMaterial, StorageBuffer, WebGPUEngine} from '@babylonjs/core';
import {ClothConfig, ClothData} from '../cloth.model';
import {
CLOTH_FRAGMENT_SHADER_WGSL,
CLOTH_INTEGRATE_COMPUTE_WGSL,
CLOTH_SOLVE_COMPUTE_WGSL,
CLOTH_VELOCITY_COMPUTE_WGSL,
CLOTH_VERTEX_SHADER_WGSL
} from '../cloth.shader';
import {ClothSimulationParams, ClothSimulationStrategy} from './cloth-simulation.strategy';
interface GpuBuffers {
positions: StorageBuffer;
prevPositions: StorageBuffer;
velocities: StorageBuffer;
params: StorageBuffer;
constraints: StorageBuffer[];
}
interface GpuPipelines {
integrate: ComputeShader;
solvers: ComputeShader[];
velocity: ComputeShader;
}
export class ClothGpuStrategy implements ClothSimulationStrategy {
private clothMesh: GroundMesh | null = null;
private scene: Scene | null = null;
private paramsData = new Float32Array(9);
private buffers: GpuBuffers | null = null;
private pipelines: GpuPipelines | null = null;
private dispatchXConstraints: number[] = [];
private dispatchXVertices = 0;
private numVertices = 0;
init(scene: Scene, engine: WebGPUEngine | Engine, config: ClothConfig): void {
this.scene = scene;
this.numVertices = config.numVertices;
const gpuEngine = engine as WebGPUEngine;
const clothData = this.generateClothData(config);
this.buffers = this.createStorageBuffers(gpuEngine, clothData);
this.pipelines = this.setupComputePipelines(gpuEngine, this.buffers);
this.setupRenderMesh(scene, config, this.buffers.positions);
this.setupDispatchSizes(config, this.buffers);
this.startRenderLoop(scene);
}
updateParams(params: ClothSimulationParams): void {
if (!this.buffers) {
return;
}
const windX = params.isWindActive ? 5.0 : 0.0;
const windZ = params.isWindActive ? 15.0 : 0.0;
const softCompliance = 10.0;
const rigidCompliance = 0.00001;
const t = (params.stiffness - 1) / 99.0;
const compliance = softCompliance * Math.pow(rigidCompliance / softCompliance, t);
this.paramsData[0] = 0.016;
this.paramsData[1] = -9.81;
this.paramsData[2] = compliance;
this.paramsData[3] = this.numVertices;
this.paramsData[4] = windX;
this.paramsData[5] = 0.0;
this.paramsData[6] = windZ;
this.paramsData[7] = params.simulationTime;
this.paramsData[8] = params.elongation;
}
getMesh(): GroundMesh | null {
return this.clothMesh;
}
dispose(): void {
if (this.scene && this.clothMesh) {
this.scene.removeMesh(this.clothMesh);
}
this.clothMesh = null;
this.buffers = null;
this.pipelines = null;
}
private generateClothData(config: ClothConfig): ClothData {
const positionsData = new Float32Array(config.numVertices * 4);
const prevPositionsData = new Float32Array(config.numVertices * 4);
const velocitiesData = new Float32Array(config.numVertices * 4);
const constraintsP0: number[] = [];
const constraintsP1: number[] = [];
const constraintsP2: number[] = [];
const constraintsP3: number[] = [];
const addHorizontalConstraint = (arr: number[], a: number, b: number): void => {
arr.push(a, b, config.spacing, 1.0);
};
const addVerticalConstraint = (arr: number[], a: number, b: number): void => {
arr.push(a, b, config.spacing, 2.0);
};
for (let y = 0; y < config.gridHeight; y++) {
for (let x = 0; x < config.gridWidth; x++) {
const idx = (y * config.gridWidth + x) * 4;
positionsData[idx + 0] = (x - config.gridWidth / 2) * config.spacing;
positionsData[idx + 1] = 5.0 - (y * config.spacing);
positionsData[idx + 2] = 0.0;
positionsData[idx + 3] = (y === 0) ? 0.0 : config.particleInvMass;
prevPositionsData[idx + 0] = positionsData[idx + 0];
prevPositionsData[idx + 1] = positionsData[idx + 1];
prevPositionsData[idx + 2] = positionsData[idx + 2];
prevPositionsData[idx + 3] = positionsData[idx + 3];
}
}
for (let y = 0; y < config.gridHeight; y++) {
for (let x = 0; x < config.gridWidth - 1; x += 2) { addHorizontalConstraint(constraintsP0, y * config.gridWidth + x, y * config.gridWidth + x + 1); }
for (let x = 1; x < config.gridWidth - 1; x += 2) { addHorizontalConstraint(constraintsP1, y * config.gridWidth + x, y * config.gridWidth + x + 1); }
}
for (let y = 0; y < config.gridHeight - 1; y += 2) {
for (let x = 0; x < config.gridWidth; x++) { addVerticalConstraint(constraintsP2, y * config.gridWidth + x, (y + 1) * config.gridWidth + x); }
}
for (let y = 1; y < config.gridHeight - 1; y += 2) {
for (let x = 0; x < config.gridWidth; x++) { addVerticalConstraint(constraintsP3, y * config.gridWidth + x, (y + 1) * config.gridWidth + x); }
}
const constraintsP4: number[] = [];
const constraintsP5: number[] = [];
const constraintsP6: number[] = [];
const constraintsP7: number[] = [];
const diagSpacing = config.spacing * Math.SQRT2;
const addDiagConstraint = (arr: number[], a: number, b: number): void => {
arr.push(a, b, diagSpacing, 1.0);
};
for (let y = 0; y < config.gridHeight - 1; y++) {
const arr = (y % 2 === 0) ? constraintsP4 : constraintsP5;
for (let x = 0; x < config.gridWidth - 1; x++) {
addDiagConstraint(arr, y * config.gridWidth + x, (y + 1) * config.gridWidth + (x + 1));
}
}
for (let y = 0; y < config.gridHeight - 1; y++) {
const arr = (y % 2 === 0) ? constraintsP6 : constraintsP7;
for (let x = 0; x < config.gridWidth - 1; x++) {
addDiagConstraint(arr, y * config.gridWidth + (x + 1), (y + 1) * config.gridWidth + x);
}
}
return {
positions: positionsData,
prevPositions: prevPositionsData,
velocities: velocitiesData,
constraints: [
constraintsP0, constraintsP1, constraintsP2, constraintsP3,
constraintsP4, constraintsP5, constraintsP6, constraintsP7
],
params: new Float32Array(9)
};
}
private createStorageBuffers(engine: WebGPUEngine, data: ClothData): GpuBuffers {
const createBuffer = (arrayData: Float32Array | number[]): StorageBuffer => {
const buffer = new StorageBuffer(engine, arrayData.length * 4);
buffer.update(arrayData instanceof Float32Array ? arrayData : new Float32Array(arrayData));
return buffer;
};
return {
positions: createBuffer(data.positions),
prevPositions: createBuffer(data.prevPositions),
velocities: createBuffer(data.velocities),
params: createBuffer(data.params),
constraints: data.constraints.map(cData => createBuffer(cData))
};
}
private setupComputePipelines(engine: WebGPUEngine, buffers: GpuBuffers): GpuPipelines {
const createBasicShader = (name: string, source: string) => {
const cs = new ComputeShader(name, engine, {computeSource: source}, {
bindingsMapping: {
"p": {group: 0, binding: 0},
"positions": {group: 0, binding: 1},
"prev_positions": {group: 0, binding: 2},
"velocities": {group: 0, binding: 3}
}
});
cs.setStorageBuffer("p", buffers.params);
cs.setStorageBuffer("positions", buffers.positions);
cs.setStorageBuffer("prev_positions", buffers.prevPositions);
cs.setStorageBuffer("velocities", buffers.velocities);
return cs;
};
const createSolverShader = (name: string, constraintBuffer: StorageBuffer) => {
const cs = new ComputeShader(name, engine, {computeSource: CLOTH_SOLVE_COMPUTE_WGSL}, {
bindingsMapping: {
"p": {group: 0, binding: 0},
"positions": {group: 0, binding: 1},
"constraints": {group: 0, binding: 2}
}
});
cs.setStorageBuffer("p", buffers.params);
cs.setStorageBuffer("positions", buffers.positions);
cs.setStorageBuffer("constraints", constraintBuffer);
return cs;
};
return {
integrate: createBasicShader("integrate", CLOTH_INTEGRATE_COMPUTE_WGSL),
solvers: buffers.constraints.map((cBuffer, i) => createSolverShader(`solve${i}`, cBuffer)),
velocity: createBasicShader("velocity", CLOTH_VELOCITY_COMPUTE_WGSL)
};
}
private setupRenderMesh(scene: Scene, config: ClothConfig, positionsBuffer: StorageBuffer): void {
if (this.clothMesh) {
scene.removeMesh(this.clothMesh);
}
this.clothMesh = MeshBuilder.CreateGround("cloth", {width: 10, height: 10, subdivisions: config.gridWidth - 1}, scene);
const clothMaterial = new ShaderMaterial("clothMat", scene, {
vertexSource: CLOTH_VERTEX_SHADER_WGSL,
fragmentSource: CLOTH_FRAGMENT_SHADER_WGSL
}, {
attributes: ["position", "uv"],
uniforms: ["viewProjection"],
storageBuffers: ["positions"],
shaderLanguage: ShaderLanguage.WGSL
});
clothMaterial.backFaceCulling = false;
clothMaterial.setStorageBuffer("positions", positionsBuffer);
this.clothMesh.material = clothMaterial;
const camera = scene.activeCamera as ArcRotateCamera;
if (camera) {
camera.alpha = Math.PI / 4;
camera.beta = Math.PI / 2.5;
camera.radius = 15;
}
}
private setupDispatchSizes(config: ClothConfig, buffers: GpuBuffers): void {
const constraintsLength = buffers.constraints.map(b => (b as any)._buffer.capacity / 4 / 4);
this.dispatchXConstraints = constraintsLength.map(len => Math.ceil(len / 64));
this.dispatchXVertices = Math.ceil(config.numVertices / 64);
}
private startRenderLoop(scene: Scene): void {
const substeps = 15;
scene.onBeforeRenderObservable.clear();
scene.onBeforeRenderObservable.add(() => {
if (!this.buffers || !this.pipelines) {
return;
}
this.buffers.params.update(this.paramsData);
this.pipelines.integrate.dispatch(this.dispatchXVertices, 1, 1);
for (let i = 0; i < substeps; i++) {
for (let phase = 0; phase < this.pipelines.solvers.length; phase++) {
this.pipelines.solvers[phase].dispatch(this.dispatchXConstraints[phase], 1, 1);
}
}
this.pipelines.velocity.dispatch(this.dispatchXVertices, 1, 1);
});
}
}

View File

@@ -0,0 +1,17 @@
import {Engine, GroundMesh, Scene, WebGPUEngine} from '@babylonjs/core';
import {ClothConfig} from '../cloth.model';
export interface ClothSimulationParams {
stiffness: number;
elongation: number;
isWindActive: boolean;
simulationTime: number;
deltaTime: number;
}
export interface ClothSimulationStrategy {
init(scene: Scene, engine: WebGPUEngine | Engine, config: ClothConfig): void;
updateParams(params: ClothSimulationParams): void;
getMesh(): GroundMesh | null;
dispose(): void;
}

View File

@@ -0,0 +1,93 @@
<mat-card class="w-full max-w-[1920px] p-5">
<mat-card-header>
<mat-card-title>{{ 'GOL.TITLE' | translate }}</mat-card-title>
</mat-card-header>
<mat-card-content>
<app-information [algorithmInformation]="algoInformation"/>
<div class="flex flex-col mb-4">
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
<button mat-raised-button (click)="generate(Scenario.SIMPLE)">
<mat-icon>arrow_right</mat-icon> {{ 'GOL.SIMPLE_SCENE' | translate }}
</button>
<button mat-raised-button (click)="generate(Scenario.PULSAR)">
<mat-icon>arrow_right</mat-icon> {{ 'GOL.PULSAR_SCENE' | translate }}
</button>
<button mat-raised-button (click)="generate(Scenario.GUN)">
<mat-icon>arrow_right</mat-icon> {{ 'GOL.GUN_SCENE' | translate }}
</button>
<button mat-raised-button (click)="generate(Scenario.RANDOM)">
<mat-icon>shuffle</mat-icon> {{ 'GOL.RANDOM_SCENE' | translate }}
</button>
<button mat-raised-button (click)="generate(Scenario.EMPTY)">
<mat-icon>check_box_outline_blank</mat-icon> {{ 'GOL.EMPTY_SCENE' | translate }}
</button>
</div>
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
@if (gameStarted())
{
<button mat-raised-button (click)="pauseGame()">
<mat-icon>pause</mat-icon> {{ 'GOL.PAUSE' | translate }}
</button>
} @else {
<button mat-raised-button (click)="startGame()">
<mat-icon>play_arrow</mat-icon> {{ 'GOL.START' | translate }}
</button>
}
<p>{{ 'SORTING.EXECUTION_TIME' | translate }}: {{ executionTime }} ms</p>
</div>
<div class="flex gap-3 items-center flex-wrap">
<mat-form-field appearance="outline" class="w-[150px]">
<mat-label>{{ 'ALGORITHM.GRID_HEIGHT' | translate }}</mat-label>
<input
matInput
type="number"
[(ngModel)]="gridRows"
[min]="MIN_GRID_SIZE"
[max]="MAX_GRID_SIZE"
(ngModelChange)="pauseGame(); genericGridComponent.gridRows = gridRows; genericGridComponent.applyGridSize()"
/>
</mat-form-field>
<mat-form-field appearance="outline" class="w-[150px]">
<mat-label>{{ 'ALGORITHM.GRID_WIDTH' | translate }}</mat-label>
<input
matInput
type="number"
[(ngModel)]="gridCols"
[min]="MIN_GRID_SIZE"
[max]="MAX_GRID_SIZE"
(ngModelChange)="pauseGame(); genericGridComponent.gridCols = gridCols; genericGridComponent.applyGridSize()"
/>
</mat-form-field>
<mat-form-field appearance="outline" class="w-[150px]">
<mat-label>{{ 'GOL.SPEED' | translate }}</mat-label>
<input
matInput
type="number"
[(ngModel)]="lifeSpeed"
[min]="MIN_TIME_PER_GENERATION"
[max]="MAX_TIME_PER_GENERATION"
(blur)="applySpeed()"
(keyup.enter)="applySpeed()"
/>
</mat-form-field>
</div>
<div class="flex flex-wrap gap-4 items-center text-[0.9em]">
<span><span class="legend-swatch bg-black"></span> {{ 'GOL.ALIVE' | translate }}</span>
<span><span class="legend-swatch bg-gray-300"></span> {{ 'GOL.DEAD' | translate }}</span>
</div>
</div>
<app-generic-grid
[gridRows]="gridRows"
[gridCols]="gridCols"
[minGridSize]="MIN_GRID_SIZE"
[maxGridSize]="MAX_GRID_SIZE"
[maxGridPx]="MAX_GRID_PX"
[createNodeFn]="createConwayNode"
[getNodeColorFn]="getConwayNodeColor"
[applySelectionFn]="applyConwaySelection"
[backgroundColor]="'lightgray'"
(gridChange)="readGrid = $event"
></app-generic-grid>
</mat-card-content>
</mat-card>

View File

@@ -0,0 +1,259 @@
import {AfterViewInit, Component, signal, ViewChild} from '@angular/core';
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from "@angular/material/card";
import {TranslatePipe} from "@ngx-translate/core";
import {UrlConstants} from '../../../constants/UrlConstants';
import {Information} from '../information/information';
import {AlgorithmInformation} from '../information/information.models';
import {MatButton} from '@angular/material/button';
import {MatIcon} from '@angular/material/icon';
import {MatFormField, MatInput, MatLabel} from '@angular/material/input';
import {FormsModule, ReactiveFormsModule} from '@angular/forms';
import {DEFAULT_GRID_COLS, DEFAULT_GRID_ROWS, DEFAULT_TIME_PER_GENERATION, LIVE_SPAWN_PROBABILITY, MAX_GRID_PX, MAX_GRID_SIZE, MAX_TIME_PER_GENERATION, MIN_GRID_SIZE, MIN_TIME_PER_GENERATION, Node, Scenario} from './conway-gol.models';
import {GenericGridComponent, GridPos} from '../../../shared/components/generic-grid/generic-grid';
@Component({
selector: 'app-conway-gol',
imports: [
MatCard,
MatCardContent,
MatCardHeader,
MatCardTitle,
TranslatePipe,
Information,
MatButton,
MatIcon,
MatFormField,
MatInput,
MatLabel,
ReactiveFormsModule,
FormsModule,
GenericGridComponent
],
templateUrl: './conway-gol.component.html',
})
export class ConwayGolComponent implements AfterViewInit {
algoInformation: AlgorithmInformation = {
title: 'GOL.EXPLANATION.TITLE',
entries: [
{
name: '',
description: 'GOL.EXPLANATION.EXPLANATION',
link: UrlConstants.CONWAYS_WIKI
}
],
disclaimer: 'GOL.EXPLANATION.DISCLAIMER',
disclaimerBottom: '',
disclaimerListEntry: ['GOL.EXPLANATION.DISCLAIMER_1', 'GOL.EXPLANATION.DISCLAIMER_2', 'GOL.EXPLANATION.DISCLAIMER_3', 'GOL.EXPLANATION.DISCLAIMER_4']
};
protected gridCols = DEFAULT_GRID_COLS;
protected gridRows = DEFAULT_GRID_ROWS;
protected lifeSpeed = DEFAULT_TIME_PER_GENERATION;
protected readonly MIN_GRID_SIZE = MIN_GRID_SIZE;
protected readonly MAX_GRID_SIZE = MAX_GRID_SIZE;
protected readonly MAX_GRID_PX = MAX_GRID_PX;
readGrid: Node[][] = [];
writeGrid: Node[][] = [];
executionTime = 0;
currentScenario: Scenario = Scenario.SIMPLE;
readonly gameStarted = signal(false);
@ViewChild(GenericGridComponent) genericGridComponent!: GenericGridComponent;
ngAfterViewInit(): void {
if (this.genericGridComponent) {
this.genericGridComponent.initializationFn = this.initializeConwayGrid;
this.genericGridComponent.createNodeFn = this.createConwayNode;
this.genericGridComponent.getNodeColorFn = this.getConwayNodeColor;
this.genericGridComponent.applySelectionFn = this.applyConwaySelection;
this.genericGridComponent.gridRows = this.gridRows;
this.genericGridComponent.gridCols = this.gridCols;
this.genericGridComponent.minGridSize = this.MIN_GRID_SIZE;
this.genericGridComponent.maxGridSize = this.MAX_GRID_SIZE;
this.genericGridComponent.maxGridPx = this.MAX_GRID_PX;
this.genericGridComponent.initializeGrid();
}
this.gameStarted.set(false);
}
generate(scene: Scenario): void {
this.currentScenario = scene;
this.genericGridComponent.initializationFn = this.initializeConwayGrid;
this.genericGridComponent.initializeGrid();
}
applySpeed(): void {
this.lifeSpeed = Math.min(Math.max(this.lifeSpeed, MIN_TIME_PER_GENERATION), MAX_TIME_PER_GENERATION);
}
// --- Callbacks for GenericGridComponent ---
createConwayNode = (row: number, col: number): Node => {
return {
row,
col,
alive: false
};
};
getConwayNodeColor = (node: Node): string => {
return node.alive ? 'black' : 'lightgray';
};
applyConwaySelection = (pos: GridPos, grid: Node[][]): void => {
this.readGrid = grid; // Keep internal grid in sync
const node = grid[pos.row][pos.col];
node.alive = !node.alive; // Toggle alive status
};
initializeConwayGrid = (grid: Node[][]): void => {
this.gameStarted.set(false);
this.readGrid = grid;
switch(this.currentScenario) {
case Scenario.RANDOM: this.setupRandomLives(); break;
case Scenario.SIMPLE: this.setupSimpleLive(); break;
case Scenario.PULSAR: this.setupPulsar(); break;
case Scenario.GUN: this.setupGliderGun(); break;
}
this.writeGrid = structuredClone(this.readGrid);
};
// --- Conway-specific logic (kept local) ---
setupRandomLives(): void {
for (let row = 0; row < this.gridRows; row++) {
for (let col = 0; col < this.gridCols; col++) {
this.readGrid[row][col].alive = Math.random() <= LIVE_SPAWN_PROBABILITY;
}
}
}
setupSimpleLive(): void {
this.readGrid[3][4].alive = true;
this.readGrid[4][5].alive = true;
this.readGrid[5][3].alive = true;
this.readGrid[5][4].alive = true;
this.readGrid[5][5].alive = true;
}
setupPulsar(): void {
const centerRow = Math.floor(this.gridRows / 2);
const centerCol = Math.floor(this.gridCols / 2);
const rows = [-6, -1, 1, 6];
const offsets = [2, 3, 4];
rows.forEach(r => {
offsets.forEach(c => {
this.setAlive(centerRow + r, centerCol + c);
this.setAlive(centerRow + r, centerCol - c);
this.setAlive(centerRow + c, centerCol + r);
this.setAlive(centerRow - c, centerCol + r);
});
});
}
setupGliderGun(): void {
const r = 5;
const c = 5;
const dots = [
[r+4, c], [r+4, c+1], [r+5, c], [r+5, c+1], // Block links
[r+4, c+10], [r+5, c+10], [r+6, c+10], [r+3, c+11], [r+7, c+11],
[r+2, c+12], [r+8, c+12], [r+2, c+13], [r+8, c+13], [r+5, c+14],
[r+3, c+15], [r+7, c+15], [r+4, c+16], [r+5, c+16], [r+6, c+16], [r+5, c+17],
[r+2, c+20], [r+3, c+20], [r+4, c+20], [r+2, c+21], [r+3, c+21], [r+4, c+21],
[r+1, c+22], [r+5, c+22], [r+0, c+24], [r+1, c+24], [r+5, c+24], [r+6, c+24],
[r+2, c+34], [r+3, c+34], [r+2, c+35], [r+3, c+35]
];
dots.forEach(([row, col]) => this.setAlive(row, col));
}
// --- The rules of the game
pauseGame(): void {
this.gameStarted.set(false);
}
async startGame(): Promise<void> {
this.gameStarted.set(true);
while (this.gameStarted()){
const startTime = performance.now();
let lifeIsDead = true;
for (let row = 0; row < this.gridRows; row++) {
for (let col = 0; col < this.gridCols; col++) {
lifeIsDead = this.checkLifeRules(row, col, this.writeGrid) && lifeIsDead;
}
}
this.swapGrids();
const endTime = performance.now();
this.executionTime = Number.parseFloat((endTime - startTime).toFixed(4));
if (lifeIsDead){
this.gameStarted.set(false);
}
const delta = Math.max(this.lifeSpeed - this.executionTime, 0);
await this.delay(delta);
}
this.executionTime = 0;
}
private checkLifeRules(row: number, col: number, writeGrid: Node[][]): boolean {
const currentCell = this.readGrid[row][col];
const aliveNeighbors = this.howManyNeighborsAreLiving(row, col);
const oldLifeState = currentCell.alive;
const nextStateAlive = (currentCell.alive && (aliveNeighbors === 2 || aliveNeighbors === 3)) || (!currentCell.alive && aliveNeighbors === 3);
writeGrid[row][col].alive = nextStateAlive;
//only if at least one cell changes the game is still alive
return (nextStateAlive == oldLifeState);
}
private swapGrids() {
const tmp = this.readGrid;
this.readGrid = this.writeGrid;
this.writeGrid = tmp;
if (this.genericGridComponent) {
this.genericGridComponent.grid = this.readGrid;
this.genericGridComponent.drawGrid();
}
}
private howManyNeighborsAreLiving(row: number, col: number): number {
let aliveNeighborCount = 0;
const minRow = Math.max(row - 1, 0);
const minCol = Math.max(col - 1, 0);
const maxRow = Math.min(row + 1, this.gridRows - 1);
const maxCol = Math.min(col + 1, this.gridCols - 1);
for (let nRow = minRow; nRow <= maxRow; nRow++) {
for (let nCol = minCol; nCol <= maxCol; nCol++) {
if (nRow == row && nCol == col) {
continue;
}
if (this.readGrid[nRow][nCol].alive) {
aliveNeighborCount++;
}
}
}
return aliveNeighborCount;
}
// --- Other methods ---
protected readonly Scenario = Scenario;
protected readonly MIN_TIME_PER_GENERATION = MIN_TIME_PER_GENERATION;
protected readonly MAX_TIME_PER_GENERATION = MAX_TIME_PER_GENERATION;
delay(ms: number) {
return new Promise( resolve => setTimeout(resolve, ms) );
}
private setAlive(r: number, c: number): void {
if (r >= 0 && r < this.gridRows && c >= 0 && c < this.gridCols) {
this.readGrid[r][c].alive = true;
}
}
}

View File

@@ -0,0 +1,26 @@
export interface Node {
row: number;
col: number;
alive: boolean;
}
export enum Scenario {
RANDOM = 0,
EMPTY = 1,
SIMPLE = 2,
PULSAR = 3,
GUN = 4
}
export const DEFAULT_GRID_ROWS = 50;
export const DEFAULT_GRID_COLS = 50;
export const MIN_GRID_SIZE = 20;
export const MAX_GRID_SIZE = 200;
export const DEFAULT_TIME_PER_GENERATION = 30;
export const MIN_TIME_PER_GENERATION = 20;
export const MAX_TIME_PER_GENERATION = 200;
export const MAX_GRID_PX = 1000;
export const LIVE_SPAWN_PROBABILITY = 0.37;

View File

@@ -0,0 +1,72 @@
<mat-card class="w-full max-w-[1920px] p-5">
<mat-card-header>
<mat-card-title>{{ 'FOUR_COLOR.TITLE' | translate }}</mat-card-title>
</mat-card-header>
<mat-card-content>
<app-information [algorithmInformation]="algoInformation"/>
<div class="flex flex-col mb-4">
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
<button mat-flat-button color="primary" (click)="generateNewMap()">{{ 'FOUR_COLOR.GENERATE' | translate }}</button>
<button mat-flat-button color="accent" (click)="autoSolve()">{{ 'FOUR_COLOR.SOLVE' | translate }}</button>
<button mat-stroked-button (click)="resetColors()">{{ 'FOUR_COLOR.CLEAR' | translate }}</button>
</div>
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
<div class="flex gap-3 items-center flex-wrap">
<mat-form-field appearance="outline" class="w-[150px]">
<mat-label>{{ 'ALGORITHM.GRID_HEIGHT' | translate }}</mat-label>
<input
matInput
type="number"
[min]="MIN_GRID_SIZE"
[max]="MAX_GRID_SIZE"
[(ngModel)]="gridRows"
(ngModelChange)="applyGridSize()"
/>
</mat-form-field>
<mat-form-field appearance="outline" class="w-[150px]">
<mat-label>{{ 'ALGORITHM.GRID_WIDTH' | translate }}</mat-label>
<input
matInput
type="number"
[min]="MIN_GRID_SIZE"
[max]="MAX_GRID_SIZE"
[(ngModel)]="gridCols"
(ngModelChange)="applyGridSize()"
/>
</mat-form-field>
</div>
</div>
<div class="flex flex-wrap gap-4 items-center text-[0.9em]">
<span><span class="legend-swatch bg-[#FF5252]"></span> {{ 'FOUR_COLOR.COLOR_1' | translate }}</span>
<span><span class="legend-swatch bg-[#448AFF]"></span> {{ 'FOUR_COLOR.COLOR_2' | translate }}</span>
<span><span class="legend-swatch bg-[#4CAF50]"></span> {{ 'FOUR_COLOR.COLOR_3' | translate }}</span>
<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-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',
'border-l-orange-500 bg-orange-50 text-orange-700': solutionStatus === 'CONFLICTS',
'border-l-red-500 bg-red-50 text-red-800': solutionStatus === 'INVALID'
}">
<span class="uppercase text-[0.8em] opacity-70">{{ 'FOUR_COLOR.STATUS.LABEL' | translate }}:</span>
<span>{{ 'FOUR_COLOR.STATUS.' + solutionStatus | translate }}</span>
</div>
</div>
<div class="flex justify-center items-center w-full max-w-[1000px] mx-auto">
<canvas #fourColorCanvas
class="cursor-pointer max-w-full h-auto [image-rendering:pixelated]"
(mousedown)="onMouseDown($event)"
(mousemove)="onMouseMove($event)"
(touchstart)="onTouchStart($event)"
(touchmove)="onTouchMove($event)"
></canvas>
</div>
</mat-card-content>
</mat-card>

View File

@@ -0,0 +1,374 @@
import {AfterViewInit, Component, ElementRef, inject, ViewChild} from '@angular/core';
import {CommonModule} from '@angular/common';
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 {MatInputModule} from '@angular/material/input';
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';
import {AlgorithmInformation} from '../information/information.models';
import {Information} from '../information/information';
import {GridPos} from '../../../shared/components/generic-grid/generic-grid';
import {SharedFunctions} from '../../../shared/SharedFunctions';
import {UrlConstants} from '../../../constants/UrlConstants';
@Component({
selector: 'app-four-color',
standalone: true,
imports: [
CommonModule,
FormsModule,
MatButtonModule,
MatCardModule,
MatFormFieldModule,
MatInputModule,
TranslatePipe,
Information
],
templateUrl: './four-color.component.html',
styleUrl: './four-color.component.scss'
})
export class FourColorComponent implements AfterViewInit {
private readonly translate = inject(TranslateService);
private readonly snackBar = inject(MatSnackBar);
readonly MIN_GRID_SIZE = MIN_GRID_SIZE;
readonly MAX_GRID_SIZE = MAX_GRID_SIZE;
algoInformation: AlgorithmInformation = {
title: 'FOUR_COLOR.EXPLANATION.TITLE',
entries: [
{
name: 'FOUR_COLOR.TITLE',
translateName: true,
description: 'FOUR_COLOR.EXPLANATION.EXPLANATION',
link: UrlConstants.FOUR_COLOR_THEOREM
}
],
disclaimer: 'FOUR_COLOR.EXPLANATION.DISCLAIMER',
disclaimerBottom: 'FOUR_COLOR.EXPLANATION.DISCLAIMER_BOTTOM',
disclaimerListEntry: [
'FOUR_COLOR.EXPLANATION.DISCLAIMER_1',
'FOUR_COLOR.EXPLANATION.DISCLAIMER_2',
'FOUR_COLOR.EXPLANATION.DISCLAIMER_3',
'FOUR_COLOR.EXPLANATION.DISCLAIMER_4'
]
};
gridRows = DEFAULT_GRID_ROWS;
gridCols = DEFAULT_GRID_COLS;
grid: FourColorNode[][] = [];
regions: Region[] = [];
executionTime = 0;
solutionStatus: 'INCOMPLETE' | 'SOLVED' | 'CONFLICTS' | 'INVALID' = 'INCOMPLETE';
@ViewChild('fourColorCanvas') canvasRef!: ElementRef<HTMLCanvasElement>;
private ctx!: CanvasRenderingContext2D;
private nodeSize = 0;
ngAfterViewInit(): void {
this.ctx = this.canvasRef.nativeElement.getContext('2d')!;
this.initializeGrid();
}
applyGridSize(): void {
if (this.gridRows < MIN_GRID_SIZE) this.gridRows = MIN_GRID_SIZE;
if (this.gridRows > MAX_GRID_SIZE) this.gridRows = MAX_GRID_SIZE;
if (this.gridCols < MIN_GRID_SIZE) this.gridCols = MIN_GRID_SIZE;
if (this.gridCols > MAX_GRID_SIZE) this.gridCols = MAX_GRID_SIZE;
this.initializeGrid();
}
initializeGrid(): void {
this.grid = [];
this.solutionStatus = 'INCOMPLETE';
for (let r = 0; r < this.gridRows; r++) {
const row: FourColorNode[] = [];
for (let c = 0; c < this.gridCols; c++) {
row.push({
row: r,
col: c,
regionId: -1,
color: 0,
});
}
this.grid.push(row);
}
this.generateRegions();
this.resizeCanvas();
this.drawGrid();
}
private resizeCanvas(): void {
const canvas = this.canvasRef.nativeElement;
const maxDim = Math.max(this.gridRows, this.gridCols);
this.nodeSize = Math.floor(MAX_GRID_PX / maxDim);
canvas.width = this.gridCols * this.nodeSize;
canvas.height = this.gridRows * this.nodeSize;
}
generateRegions(): void {
const numRegions = Math.floor((this.gridRows * this.gridCols) / 30);
this.regions = [];
const seeds = this.determineSeeds(numRegions);
this.regionGrowth(seeds);
this.determineAdjacency();
}
private determineAdjacency() {
for (let row = 0; row < this.gridRows; row++) {
for (let col = 0; col < this.gridCols; col++) {
const currentRegionId = this.grid[row][col].regionId;
const neighbors = this.getNeighbors(row, col);
for (const neighbor of neighbors) {
const neighborRegionId = this.grid[neighbor.row][neighbor.col].regionId;
if (neighborRegionId !== -1 && neighborRegionId !== currentRegionId) {
this.regions[currentRegionId].neighbors.add(neighborRegionId);
this.regions[neighborRegionId].neighbors.add(currentRegionId);
}
}
}
}
}
private regionGrowth(seeds: GridPos[]) {
const queue: GridPos[] = [...seeds];
while (queue.length > 0) {
const {row, col} = queue.shift()!;
const regionId = this.grid[row][col].regionId;
const neighbors = this.getNeighbors(row, col);
for (const neighbor of neighbors) {
if (this.grid[neighbor.row][neighbor.col].regionId === -1) {
this.grid[neighbor.row][neighbor.col].regionId = regionId;
queue.push(neighbor);
}
}
}
}
private determineSeeds(numRegions: number) {
const seeds: GridPos[] = [];
for (let i = 0; i < numRegions; i++) {
let r = SharedFunctions.randomIntFromInterval(0, this.gridRows - 1);
let c = SharedFunctions.randomIntFromInterval(0, this.gridCols - 1);
while (this.grid[r][c].regionId !== -1) {
r = SharedFunctions.randomIntFromInterval(0, this.gridRows - 1);
c = SharedFunctions.randomIntFromInterval(0, this.gridCols - 1);
}
this.grid[r][c].regionId = i;
seeds.push({row: r, col: c});
this.regions.push({id: i, color: 0, neighbors: new Set<number>()});
}
return seeds;
}
private getNeighbors(row: number, col: number): GridPos[] {
const res: GridPos[] = [];
if (row > 0) res.push({row: row - 1, col});
if (row < this.gridRows - 1) res.push({row: row + 1, col});
if (col > 0) res.push({row, col: col - 1});
if (col < this.gridCols - 1) res.push({row, col: col + 1});
return res;
}
drawGrid(): void {
if (!this.ctx) return;
this.ctx.clearRect(0, 0, this.canvasRef.nativeElement.width, this.canvasRef.nativeElement.height);
// 1. Draw Cell Backgrounds
for (let r = 0; r < this.gridRows; r++) {
for (let c = 0; c < this.gridCols; c++) {
const node = this.grid[r][c];
this.ctx.fillStyle = this.getNodeColor(node);
this.ctx.fillRect(c * this.nodeSize, r * this.nodeSize, this.nodeSize, this.nodeSize);
}
}
// 2. Draw Region Borders
this.ctx.strokeStyle = '#000';
this.ctx.lineWidth = 2;
this.ctx.beginPath();
for (let r = 0; r < this.gridRows; r++) {
for (let c = 0; c < this.gridCols; c++) {
const currentRegion = this.grid[r][c].regionId;
// Right border
if (c < this.gridCols - 1 && this.grid[r][c+1].regionId !== currentRegion) {
this.ctx.moveTo((c + 1) * this.nodeSize, r * this.nodeSize);
this.ctx.lineTo((c + 1) * this.nodeSize, (r + 1) * this.nodeSize);
}
// Bottom border
if (r < this.gridRows - 1 && this.grid[r+1][c].regionId !== currentRegion) {
this.ctx.moveTo(c * this.nodeSize, (r + 1) * this.nodeSize);
this.ctx.lineTo((c + 1) * this.nodeSize, (r + 1) * this.nodeSize);
}
}
}
this.ctx.stroke();
// 3. Draw Outer Border
this.ctx.strokeStyle = '#000';
this.ctx.lineWidth = 2;
this.ctx.strokeRect(0, 0, this.gridCols * this.nodeSize, this.gridRows * this.nodeSize);
}
private getNodeColor(node: FourColorNode): string {
switch (node.color) {
case 1: return '#FF5252'; // Red
case 2: return '#448AFF'; // Blue
case 3: return '#4CAF50'; // Green
case 4: return '#FFEB3B'; // Yellow
default: return 'white';
}
}
onMouseDown(event: MouseEvent): void {
const pos = this.getGridPos(event);
if (pos) this.handleInteraction(pos);
}
onMouseMove(event: MouseEvent): void {
if (event.buttons !== 1){
return;
}
this.getGridPos(event);
}
onTouchStart(event: TouchEvent): void {
event.preventDefault();
const touch = event.touches[0];
const pos = this.getGridPos(touch);
if (pos) this.handleInteraction(pos);
}
onTouchMove(event: TouchEvent): void {
event.preventDefault();
}
private getGridPos(event: MouseEvent | Touch): GridPos | null {
const rect = this.canvasRef.nativeElement.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
const col = Math.floor(x / (rect.width / this.gridCols));
const row = Math.floor(y / (rect.height / this.gridRows));
if (row >= 0 && row < this.gridRows && col >= 0 && col < this.gridCols) {
return {row, col};
}
return null;
}
private handleInteraction(pos: GridPos): void {
const node = this.grid[pos.row][pos.col];
if (node.regionId === -1){
return;
}
const region = this.regions[node.regionId];
region.color = (region.color % 4) + 1;
this.updateRegionColors(region);
this.checkSolution();
this.drawGrid();
}
private updateRegionColors(region: Region): void {
for (let row = 0; row < this.gridRows; row++) {
for (let col = 0; col < this.gridCols; col++) {
if (this.grid[row][col].regionId === region.id) {
this.grid[row][col].color = region.color;
}
}
}
}
resetColors(): void {
for (const region of this.regions) {
region.color = 0;
this.updateRegionColors(region);
}
this.solutionStatus = 'INCOMPLETE';
this.drawGrid();
}
autoSolve(): void {
const startTime = performance.now();
this.resetColors();
const success = this.backtrackSolve(0);
const endTime = performance.now();
this.executionTime = endTime - startTime;
if (success) {
this.checkSolution();
this.drawGrid();
} else {
const message = this.translate.instant('FOUR_COLOR.ALERT.NO_SOLUTION');
this.snackBar.open(message, 'ALERT');
}
}
private backtrackSolve(regionIndex: number): boolean {
if (regionIndex === this.regions.length) return true;
const region = this.regions[regionIndex];
const availableColors = [1, 2, 3, 4];
for (const color of availableColors) {
if (this.isColorValid(region, color)) {
region.color = color;
this.updateRegionColors(region);
if (this.backtrackSolve(regionIndex + 1)) {
return true;
}
region.color = 0;
//this.updateRegionColors(region);
}
}
return false;
}
private isColorValid(region: Region, color: number): boolean {
for (const neighborId of region.neighbors) {
if (this.regions[neighborId].color === color){
return false;
}
}
return true;
}
checkSolution(): void {
let allColored = true;
let hasConflicts = false;
for (const region of this.regions) {
if (region.color === 0) {
allColored = false;
}
if (region.color > 0 && !this.isColorValid(region, region.color)) {
hasConflicts = true;
}
}
if (hasConflicts) {
this.solutionStatus = allColored ? 'INVALID' : 'CONFLICTS';
} else {
this.solutionStatus = allColored ? 'SOLVED' : 'INCOMPLETE';
}
}
generateNewMap(): void {
this.initializeGrid();
}
}

View File

@@ -0,0 +1,18 @@
export interface FourColorNode {
row: number;
col: number;
regionId: number;
color: number;
}
export interface Region {
id: number;
color: number;
neighbors: Set<number>;
}
export const DEFAULT_GRID_ROWS = 25;
export const DEFAULT_GRID_COLS = 25;
export const MIN_GRID_SIZE = 20;
export const MAX_GRID_SIZE = 38;
export const MAX_GRID_PX = 600;

View File

@@ -0,0 +1,45 @@
<mat-card class="w-full max-w-[1920px] p-5">
<mat-card-header>
<mat-card-title>{{ 'FRACTAL.TITLE' | translate }}</mat-card-title>
</mat-card-header>
<mat-card-content>
<app-information [algorithmInformation]="algoInformation"/>
<div class="flex flex-col mb-4">
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
<mat-form-field class="w-[200px]" appearance="fill">
<mat-label>{{ 'FRACTAL.ALGORITHM' | translate }}</mat-label>
<mat-select [value]="'Mandelbrot'" (selectionChange)="onAlgorithmChange($event.value)">
<mat-option value="Mandelbrot">Mandelbrot</mat-option>
<mat-option value="Julia">Julia</mat-option>
<mat-option value="Burning Ship">Burning Ship</mat-option>
<mat-option value="Newton">Newton</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="w-[200px]" appearance="fill">
<mat-label>{{ 'FRACTAL.COLOR_SCHEME' | translate }}</mat-label>
<mat-select [value]="'Blue-Gold'" (selectionChange)="onColorChanged($event.value)">
<mat-option value="Blue-Gold">Blue-Gold</mat-option>
<mat-option value="Greyscale">Greyscale</mat-option>
<mat-option value="Fire">Fire</mat-option>
<mat-option value="Rainbow">Rainbow</mat-option>
</mat-select>
</mat-form-field>
<button mat-raised-button color="primary" (click)="onReset()">
<mat-icon>undo</mat-icon> {{ 'FRACTAL.RESET' | translate }}
</button>
</div>
<div style="display: flex; align-items: center; gap: 10px;">
<mat-icon>zoom_out</mat-icon>
<ngx-slider [(value)]="sliderValue" [options]="options" (valueChange)="onSliderChange($event)" ></ngx-slider>
<mat-icon>zoom_in</mat-icon>
</div>
</div>
<app-babylon-canvas
[config]="renderConfig"
[renderCallback]="onRender"
(sceneReady)="onSceneReady($event)"
/>
</mat-card-content>
</mat-card>

View File

@@ -0,0 +1,265 @@
import {Component, OnInit, signal} from '@angular/core';
import {Information} from '../information/information';
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
import {TranslatePipe} from '@ngx-translate/core';
import {MatFormField, MatLabel} from '@angular/material/input';
import {MatOption} from '@angular/material/core';
import {MatSelect} from '@angular/material/select';
import {AlgorithmInformation} from '../information/information.models';
import {UrlConstants} from '../../../constants/UrlConstants';
import {FormsModule} from '@angular/forms';
import {BabylonCanvas, RenderCallback, RenderConfig, SceneEventData} from '../../../shared/components/render-canvas/babylon-canvas.component';
import {FRACTAL2D_FRAGMENT, FRACTAL2D_VERTEX} from './fractal.shader';
import {PointerEventTypes, PointerInfo, ShaderMaterial, Vector2} from '@babylonjs/core';
import {MatButton} from '@angular/material/button';
import {MatIcon} from '@angular/material/icon';
import {NgxSliderModule, Options} from '@angular-slider/ngx-slider';
@Component({
selector: 'app-fractal',
imports: [
Information,
MatCard,
MatCardContent,
MatCardHeader,
MatCardTitle,
TranslatePipe,
MatFormField,
MatLabel,
MatOption,
MatSelect,
FormsModule,
BabylonCanvas,
MatButton,
MatIcon,
NgxSliderModule
],
templateUrl: './fractal.component.html',
styleUrl: './fractal.component.scss',
})
export class FractalComponent implements OnInit {
algoInformation: AlgorithmInformation = {
title: 'FRACTAL.EXPLANATION.TITLE',
entries: [
{
name: 'Mandelbrot',
description: 'FRACTAL.EXPLANATION.MANDELBROT_EXPLANATION',
link: UrlConstants.MANDELBROT_WIKI
},
{
name: 'Julia',
description: 'FRACTAL.EXPLANATION.JULIA_EXPLANATION',
link: UrlConstants.JULIA_WIKI
},
{
name: 'Burning Ship',
description: 'FRACTAL.EXPLANATION.BURNING_SHIP_EXPLANATION',
link: UrlConstants.BURNING_SHIP_WIKI
},
{
name: 'Newton',
description: 'FRACTAL.EXPLANATION.NEWTON_EXPLANATION',
link: UrlConstants.NEWTON_FRACTAL_WIKI
}
],
disclaimer: 'FRACTAL.EXPLANATION.DISCLAIMER',
disclaimerBottom: 'FRACTAL.EXPLANATION.DISCLAIMER_BOTTOM',
disclaimerListEntry: [
'FRACTAL.EXPLANATION.DISCLAIMER_1',
'FRACTAL.EXPLANATION.DISCLAIMER_2',
'FRACTAL.EXPLANATION.DISCLAIMER_3',
'FRACTAL.EXPLANATION.DISCLAIMER_4'
]
};
renderConfig: RenderConfig = {
mode: '2D',
initialViewSize: 100,
vertexShader: FRACTAL2D_VERTEX,
fragmentShader: FRACTAL2D_FRAGMENT,
uniformNames: ["worldViewProjection", "time", "targetPosition","center", "zoom", "maxIterations", "algorithm", "colorScheme", "juliaC"]
};
// --- State ---
readonly minZoomValue = 0.2;
readonly maxZoomValue = 64000;
private isDragging = false;
private dragStartPoint: { x: number, y: number } | null = null;
selectedAlgorithm = 0;
selectedColorScheme = 0;
sliderValue = signal(0);
options: Options = {
floor: this.minZoomValue,
ceil: this.maxZoomValue,
logScale: true,
step: 0.01,
showTicks: false,
hideLimitLabels: true,
hidePointerLabels: true
};
zoom = 0;
offsetX = 0;
offsetY = 0;
maxIterations = 0;
juliaReal = -0.7;
juliaImag = 0.27015;
// --- Render Callback ---
onRender: RenderCallback = (material: ShaderMaterial) => {
material.setVector2("center", new Vector2(this.offsetX, this.offsetY));
material.setFloat("zoom", this.zoom);
material.setInt("maxIterations", this.maxIterations);
material.setInt("algorithm", this.selectedAlgorithm);
material.setInt("colorScheme", this.selectedColorScheme);
material.setVector2("juliaC", new Vector2(this.juliaReal, this.juliaImag));
};
ngOnInit() {
this.onReset();
}
onAlgorithmChange(algoName: string): void {
switch(algoName) {
case 'Mandelbrot': this.selectedAlgorithm = 0; break;
case 'Julia': this.selectedAlgorithm = 1; break;
case 'Burning Ship': this.selectedAlgorithm = 2; break;
case 'Newton': this.selectedAlgorithm = 3; break;
}
this.onReset()
}
onColorChanged(schemeName: string): void {
switch(schemeName) {
case 'Blue-Gold': this.selectedColorScheme = 0; break;
case 'Greyscale': this.selectedColorScheme = 1; break;
case 'Fire': this.selectedColorScheme = 2; break;
case 'Rainbow': this.selectedColorScheme = 3; break;
}
}
onReset(): void {
this.offsetY = 0.0;
this.zoom = 0.2
this.maxIterations = 100;
switch(this.selectedAlgorithm) {
case 0: this.offsetX = -0.5; break;
case 1: this.offsetX = 0; break;
case 2: this.offsetX = -1.75; this.zoom = 8; this.offsetY = -0.03;break;
case 3: this.offsetX = 0; break;
default: this.offsetX = 0.0;
}
}
onSceneReady(event: SceneEventData): void {
event.scene.onPointerObservable.add((pointerInfo) => {
switch (pointerInfo.type) {
case PointerEventTypes.POINTERDOWN:
this.onPointerDown(pointerInfo);
break;
case PointerEventTypes.POINTERUP:
this.onPointerUp();
break;
case PointerEventTypes.POINTERMOVE:
this.onPointerMove(pointerInfo);
break;
case PointerEventTypes.POINTERWHEEL:
this.onPointerWheel(pointerInfo);
break;
}
});
}
private onPointerDown(info: PointerInfo): void {
if (info.event.button !== 0) {
return;
}
this.isDragging = true;
this.dragStartPoint = { x: info.event.clientX, y: info.event.clientY };
}
private onPointerUp(): void {
this.isDragging = false;
this.dragStartPoint = null;
}
private onPointerMove(info: PointerInfo): void {
if (!this.isDragging || !this.dragStartPoint) {
return;
}
const event = info.event as PointerEvent;
const deltaX = event.clientX - this.dragStartPoint.x;
const deltaY = event.clientY - this.dragStartPoint.y;
const element = event.target as HTMLElement;
const height = element.clientHeight;
const scaleFactor = 1 / (height * this.zoom);
this.offsetX += deltaX * scaleFactor;
this.offsetY += deltaY * scaleFactor;
this.dragStartPoint = { x: event.clientX, y: event.clientY };
}
private onPointerWheel(info: PointerInfo): void {
const event = info.event as WheelEvent;
event.preventDefault();
const element = event.target as HTMLElement;
const rect = element.getBoundingClientRect();
const mouseXPixels = -(event.clientX - rect.left - rect.width / 2);
const mouseYPixels = -(event.clientY - rect.top - rect.height / 2);
const mouseXView = mouseXPixels / rect.height;
const mouseYView = mouseYPixels / rect.height;
const mouseXWorld = mouseXView / this.zoom + this.offsetX;
const mouseYWorld = mouseYView / this.zoom + this.offsetY;
const zoomFactor = 1.1;
if (event.deltaY < 0) {
this.zoom *= zoomFactor;
} else {
this.zoom /= zoomFactor;
}
this.zoom = Math.max(Math.min(this.zoom, this.maxZoomValue), this.minZoomValue);
this.sliderValue.set(this.zoom);
const optimalIterations = this.getIterationsForZoom(this.zoom);
this.maxIterations = Math.min(optimalIterations, 3000);
this.offsetX = mouseXWorld - mouseXView / this.zoom;
this.offsetY = mouseYWorld - mouseYView / this.zoom;
}
onSliderChange(newValue: number): void {
this.zoom = newValue;
this.zoom = Math.max(Math.min(this.zoom, this.maxZoomValue), this.minZoomValue);
this.maxIterations = this.getIterationsForZoom(this.zoom);
}
private getIterationsForZoom(zoom: number): number {
const baseIterations = 100;
const factor = 200;
if (zoom <= 1) {
return baseIterations;
}
return Math.floor(baseIterations + Math.log10(zoom) * factor);
}
}

View File

@@ -0,0 +1,155 @@
export const FRACTAL2D_VERTEX = `
precision highp float;
attribute vec3 position;
attribute vec2 uv;
uniform mat4 worldViewProjection;
varying vec2 vUV;
void main() {
gl_Position = worldViewProjection * vec4(position, 1.0);
vUV = uv;
}
`;
export const FRACTAL2D_FRAGMENT = `
precision highp float;
varying vec2 vUV;
uniform vec2 resolution;
uniform vec2 center; // OffsetX, OffsetY
uniform float zoom;
uniform int maxIterations;
uniform int algorithm; // 0:Mandel, 1:Julia, 2:Ship, 3:Newton
uniform int colorScheme; // 0:BlueGold, 1:Greyscale, 2:Fire, 3:Rainbow
uniform vec2 juliaC;
vec3 hsv2rgb(vec3 c) {
vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0);
vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
}
// --- Color Schemes ---
vec3 getColor(float t, int iter, int maxIter, int root) {
if (iter >= maxIter) return vec3(0.0);
// special newton coloring
if (algorithm == 3) {
float val = 1.0 - (float(iter) / 20.0); // Weicheres Shading für Newton
val = clamp(val, 0.0, 1.0);
if (root == 1) return vec3(val, 0.0, 0.0); // Rot
if (root == 2) return vec3(0.0, val, 0.0); // Grün
if (root == 3) return vec3(0.0, 0.0, val); // Blau
return vec3(0.0);
}
// default color
if (colorScheme == 0) { // Blue-Gold
return vec3(
9.0 * (1.0-t)*t*t*t,
15.0 * (1.0-t)*(1.0-t)*t*t,
8.5 * (1.0-t)*(1.0-t)*(1.0-t)*t
) * 3.0;
}
if (colorScheme == 1) { // Greyscale
return vec3(t);
}
if (colorScheme == 2) { // Fire
float f = sqrt(t);
return vec3(f * 2.0, (f - 0.3) * 3.0, (f - 0.6) * 6.0);
}
if (colorScheme == 3) { // Rainbow
return hsv2rgb(vec3(t * 5.0, 1.0, 1.0));
}
return vec3(t);
}
void main(void) {
float aspect = resolution.x / resolution.y;
vec2 uv = (vUV - 0.5) * vec2(aspect, 1.0);
vec2 c = uv / zoom + center;
vec2 z = c;
// For Julia is c fix, z changes. For Mandel is z=0, c changes.
if (algorithm == 1) {
z = c;
c = juliaC;
} else if (algorithm != 3) {
z = vec2(0.0);
}
int iter = 0;
int root = 0;
// --- Algorithms ---
if (algorithm == 3) { // Newton: z^3 - 1
z = c;
for(int i=0; i<100; i++) {
if (i >= maxIterations) break;
// z^3 - 1
// z_new = z - (z^3 - 1) / (3*z^2)
// simplified: z_new = (2*z^3 + 1) / (3*z^2)
float zx2 = z.x * z.x;
float zy2 = z.y * z.y;
float denom = 3.0 * (zx2 + zy2) * (zx2 + zy2); // |3z^2|^2 simplified
// z -= (z^3-1)/(3z^2)
vec2 z2 = vec2(z.x*z.x - z.y*z.y, 2.0*z.x*z.y);
vec2 z3 = vec2(z2.x*z.x - z2.y*z.y, z2.x*z.y + z2.y*z.x);
vec2 num = z3 - vec2(1.0, 0.0);
vec2 den = 3.0 * z2;
// Division Complex: (a+bi)/(c+di) = ((ac+bd) + (bc-ad)i) / (c^2+d^2)
float d = den.x*den.x + den.y*den.y;
if(d < 0.000001) { iter=maxIterations; break; }
vec2 div = vec2(
(num.x*den.x + num.y*den.y)/d,
(num.y*den.x - num.x*den.y)/d
);
z -= div;
iter++;
// Roots check
// 1. (1, 0)
if (distance(z, vec2(1.0, 0.0)) < 0.001) { root = 1; break; }
// 2. (-0.5, 0.866)
if (distance(z, vec2(-0.5, 0.866)) < 0.001) { root = 2; break; }
// 3. (-0.5, -0.866)
if (distance(z, vec2(-0.5, -0.866)) < 0.001) { root = 3; break; }
}
}
else { // Mandelbrot (0), Julia (1), Burning Ship (2)
for(int i=0; i<2000; i++) {
if (i >= maxIterations) break;
float x2 = z.x * z.x;
float y2 = z.y * z.y;
if (x2 + y2 > 4.0) {
iter = i;
break;
}
iter = i;
if (algorithm == 2) { // Burning Ship
z.y = abs(z.y);
z.x = abs(z.x);
}
// z = z^2 + c
float nextX = x2 - y2 + c.x;
z.y = 2.0 * z.x * z.y + c.y;
z.x = nextX;
}
}
float t = float(iter) / float(maxIterations);
vec3 color = getColor(t, iter, maxIterations, root);
gl_FragColor = vec4(color, 1.0);
}
`;

View File

@@ -0,0 +1,19 @@
<mat-card class="w-full max-w-[1920px] p-5">
<mat-card-header>
<mat-card-title>{{ 'FRACTAL3D.TITLE' | translate }}</mat-card-title>
</mat-card-header>
<mat-card-content>
<app-information [algorithmInformation]="algoInformation"/>
<div class="flex flex-col mb-4">
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
<button matButton="filled" (click)="onFractalTypeChange(0)">{{ 'FRACTAL3D.MANDELBULB' | translate }}</button>
<button matButton="filled" (click)="onFractalTypeChange(1)">{{ 'FRACTAL3D.MANDELBOX' | translate }}</button>
<button matButton="filled" (click)="onFractalTypeChange(2)">{{ 'FRACTAL3D.JULIA' | translate }}</button>
</div>
</div>
<app-babylon-canvas
[config]="fractalConfig"
[renderCallback]="onRender"
/>
</mat-card-content>
</mat-card>

View File

@@ -0,0 +1,85 @@
import {Component} from '@angular/core';
import {ArcRotateCamera, Camera, ShaderMaterial} from '@babylonjs/core';
import {MANDELBULB_FRAGMENT, MANDELBULB_VERTEX} from './fractal3d.shader';
import {Information} from '../information/information';
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
import {TranslatePipe} from '@ngx-translate/core';
import {AlgorithmInformation} from '../information/information.models';
import {UrlConstants} from '../../../constants/UrlConstants';
import {MatButton} from '@angular/material/button';
import {BabylonCanvas, RenderCallback, RenderConfig} from '../../../shared/components/render-canvas/babylon-canvas.component';
@Component({
selector: 'app-fractal3d',
imports: [
Information,
MatCard,
MatCardContent,
MatCardHeader,
MatCardTitle,
TranslatePipe,
MatButton,
BabylonCanvas
],
templateUrl: './fractal3d.component.html',
styleUrl: './fractal3d.component.scss',
})
export class Fractal3dComponent {
algoInformation: AlgorithmInformation = {
title: 'FRACTAL3D.EXPLANATION.TITLE',
entries: [
{
name: 'Mandel-Bulb',
description: 'FRACTAL3D.EXPLANATION.MANDELBULB_EXPLANATION',
link: UrlConstants.MANDELBULB_WIKI
},
{
name: 'Mandelbox',
description: 'FRACTAL3D.EXPLANATION.MANDELBOX_EXPLANATION',
link: UrlConstants.MANDELBOX_WIKI
},
{
name: 'Julia-Bulb',
description: 'FRACTAL3D.EXPLANATION.JULIA_EXPLANATION',
link: UrlConstants.JULIA3D_WIKI
}
],
disclaimer: 'FRACTAL3D.EXPLANATION.DISCLAIMER',
disclaimerBottom: '',
disclaimerListEntry: ['FRACTAL3D.EXPLANATION.DISCLAIMER_1', 'FRACTAL3D.EXPLANATION.DISCLAIMER_2', 'FRACTAL3D.EXPLANATION.DISCLAIMER_3', 'FRACTAL3D.EXPLANATION.DISCLAIMER_4']
};
fractalConfig: RenderConfig = {
mode: '3D',
initialViewSize: 4,
vertexShader: MANDELBULB_VERTEX,
fragmentShader: MANDELBULB_FRAGMENT,
uniformNames: ["time", "power", "fractalType"]
};
private readonly fractalPower = 8;
private time = 0;
private oldType = 0;
public currentFractalType = 0;
onRender: RenderCallback = (material: ShaderMaterial, camera: Camera) => {
this.time += 0.005;
if (this.oldType != this.currentFractalType && camera instanceof ArcRotateCamera) {
this.oldType = this.currentFractalType;
camera.radius = this.currentFractalType == 1 ? 15 : 4;
}
material.setFloat("time", this.time);
material.setFloat("power", this.fractalPower);
material.setInt("fractalType", this.currentFractalType);
};
onFractalTypeChange(type: number): void {
this.currentFractalType = type;
}
}

View File

@@ -0,0 +1,220 @@
export const MANDELBULB_VERTEX = /* glsl */`
precision highp float;
attribute vec3 position;
attribute vec2 uv;
varying vec2 vUV;
void main(void) {
gl_Position = vec4(position, 1.0);
vUV = uv;
}
`;
export const MANDELBULB_FRAGMENT = /* glsl */`
precision highp float;
uniform float time;
uniform vec2 resolution;
uniform vec3 cameraPosition;
uniform vec3 targetPosition;
uniform float power;
uniform int fractalType; // 0 = Bulb, 1 = Box, 2 = Julia
// --- Palettes ---
vec3 palette( in float t, in vec3 a, in vec3 b, in vec3 c, in vec3 d ) {
return a + b*cos( 6.28318*(c*t+d) );
}
// Global trap for coloring
float minTrap = 1000.0;
// --- Shape 1: Mandelbulb ---
float mapMandelbulb(vec3 pos, out float trap) {
vec3 z = pos;
float dr = 1.0;
float r = 0.0;
trap = 1000.0;
for (int i = 0; i < 8; i++) {
r = length(z);
if (r > 100.0) break;
trap = min(trap, r);
float theta = acos(z.y / r);
float phi = atan(z.z, z.x);
dr = pow(r, power - 1.0) * power * dr + 1.0;
float zr = pow(r, power);
theta = theta * power;
phi = phi * power;
z = zr * vec3(sin(theta) * cos(phi), cos(theta), sin(theta) * sin(phi));
z += pos;
}
return 0.5 * log(r) * r / dr;
}
// --- Shape 2: Mandelbox ---
float mapMandelbox(vec3 pos, out float trap) {
vec3 z = pos;
float dr = 1.0;
float scale = 2.8; // Fixed scale for good look
trap = 1000.0;
for (int i = 0; i < 15; i++) {
// Box fold
z = clamp(z, -1.0, 1.0) * 2.0 - z;
// Sphere fold
float r2 = dot(z, z);
trap = min(trap, r2); // Trap based on sphere fold
if (r2 < 0.25) {
z = z * 4.0;
dr = dr * 4.0;
} else if (r2 < 1.0) {
z = z / r2;
dr = dr / r2;
}
z = z * scale + pos;
dr = dr * abs(scale) + 1.0;
}
return (length(z) - abs(scale - 1.0)) / dr;
}
// --- Shape 3: Julia Bulb ---
float mapJulia(vec3 pos, out float trap) {
vec3 z = pos;
float dr = 1.0;
float r = 0.0;
trap = 1000.0;
// Constant C for Julia set (animating slightly makes it alive)
vec3 c = vec3(0.35, 0.45, -0.1) + vec3(sin(time*0.1)*0.2);
for (int i = 0; i < 8; i++) {
r = length(z);
if (r > 100.0) break; // Higher escape radius for Julia
trap = min(trap, r);
float theta = acos(z.y / r);
float phi = atan(z.z, z.x);
dr = pow(r, power - 1.0) * power * dr + 1.0;
float zr = pow(r, power);
theta = theta * power;
phi = phi * power;
z = zr * vec3(sin(theta) * cos(phi), cos(theta), sin(theta) * sin(phi));
z += c; // Add C instead of pos
}
return 0.5 * log(r) * r / dr;
}
// --- Main Map Dispatcher ---
float map(vec3 pos) {
float d = 0.0;
float currentTrap = 0.0;
if (fractalType == 1) {
d = mapMandelbox(pos, currentTrap);
} else if (fractalType == 2) {
d = mapJulia(pos, currentTrap);
} else {
d = mapMandelbulb(pos, currentTrap);
}
minTrap = currentTrap; // Update global
return d;
}
// --- Raymarching ---
bool intersectSphere(vec3 ro, vec3 rd, vec3 c, float r, out float t0, out float t1) {
vec3 oc = ro - c;
float b = dot(oc, rd);
float c2 = dot(oc, oc) - r * r;
float h = b*b - c2;
if (h < 0.0) return false;
h = sqrt(h);
t0 = -b - h;
t1 = -b + h;
return true;
}
float raymarch(vec3 ro, vec3 rd) {
// Bounding sphere around fractal center (here: origin)
vec3 center = vec3(0.0);
float radius = 6.0;
float tEnter, tExit;
if (!intersectSphere(ro, rd, center, radius, tEnter, tExit)) {
return -1.0;
}
float t = max(tEnter, 0.0);
float tMax = tExit;
for (int i = 0; i < 128; i++) {
vec3 pos = ro + t * rd;
float d = map(pos);
// distance-based epsilon is more stable for zoom-out
float eps = max(0.001, 0.0005 * t);
if (d < eps) return t;
t += d * 0.8; // safety factor against overshoot
if (t > tMax) break;
}
return -1.0;
}
vec3 getNormal(vec3 p) {
float d = map(p);
vec2 e = vec2(0.001, 0.0);
return normalize(vec3(
d - map(p - e.xyy),
d - map(p - e.yxy),
d - map(p - e.yyx)
));
}
void main(void) {
vec2 uv = (gl_FragCoord.xy - 0.5 * resolution.xy) / resolution.y;
vec3 ro = cameraPosition;
vec3 ta = targetPosition;
vec3 fwd = normalize(ta - ro);
vec3 right = normalize(cross(vec3(0.0, 1.0, 0.0), fwd));
vec3 up = normalize(cross(fwd, right));
vec3 rd = normalize(fwd + uv.x * right + uv.y * up);
vec3 color = vec3(0.1);
float t = raymarch(ro, rd);
if(t > 0.0) {
vec3 pos = ro + t * rd;
vec3 nor = getNormal(pos);
// Different colors for different shapes
vec3 colParamsA = vec3(0.5, 0.5, 0.5);
vec3 colParamsB = vec3(0.5, 0.5, 0.5);
vec3 colParamsC = vec3(1.0, 1.0, 1.0);
vec3 colParamsD = vec3(0.80, 0.90, 0.30);
if (fractalType == 1) { // Box: Sci-Fi Blue/Grey
colParamsD = vec3(0.0, 0.1, 0.2);
}
if (fractalType == 2) { // Julia: Alien Green/Purple
colParamsD = vec3(0.8, 0.0, 0.2);
}
vec3 materialColor = palette(minTrap, colParamsA, colParamsB, colParamsC, colParamsD);
float camLight = max(0.0, dot(nor, -rd));
float ambient = 0.4;
vec3 lighting = vec3(1.0) * (camLight * 0.7 + ambient);
color = materialColor * lighting;
}
color = pow(color, vec3(0.8));
gl_FragColor = vec4(color, 1.0);
}
`;

View File

@@ -0,0 +1,42 @@
<div class="mb-4 py-3 px-4 border border-[#ddd] rounded-lg">
<h3 class="m-0 mb-2">{{ algorithmInformation.title | translate }}</h3>
@if(algorithmInformation.entries && algorithmInformation.entries.length > 0){
@for (algo of algorithmInformation.entries; track algo)
{
<p class="my-2">
<strong>
@if(algo.translateName){
{{ algo.name | translate}}
} @else {
{{ algo.name }}
}
</strong>
{{ algo.description | translate }}
<a href="{{algo.link}}" target="_blank" rel="noopener noreferrer">Wikipedia</a>
</p>
}
}
@if (algorithmInformation.disclaimer !== '')
{
<p class="my-2">
<strong>{{ 'ALGORITHM.NOTE' | translate}}</strong> {{ algorithmInformation.disclaimer | translate}}
</p>
@if (algorithmInformation.disclaimerListEntry && algorithmInformation.disclaimerListEntry.length > 0)
{
<ul>
@for (entry of algorithmInformation.disclaimerListEntry; track entry)
{
<li>{{ entry | translate}}</li>
}
</ul>
}
@if (algorithmInformation.disclaimerBottom !== '')
{
<p>
{{ algorithmInformation.disclaimerBottom | translate}}
</p>
}
}
</div>

View File

@@ -0,0 +1,14 @@
export interface AlgorithmInformation {
title: string;
entries: AlgorithmEntry[];
disclaimer: string;
disclaimerBottom: string;
disclaimerListEntry: string[];
}
export interface AlgorithmEntry {
name: string;
description: string;
link: string;
translateName?: boolean;
}

View File

@@ -0,0 +1,16 @@
import {Component, Input} from '@angular/core';
import {TranslatePipe} from "@ngx-translate/core";
import {AlgorithmInformation} from './information.models';
@Component({
selector: 'app-information',
imports: [
TranslatePipe
],
templateUrl: './information.html',
styleUrl: './information.scss',
})
export class Information {
@Input({ required: true }) algorithmInformation!: AlgorithmInformation;
}

View File

@@ -0,0 +1,42 @@
<mat-card class="w-full max-w-[1920px] p-5">
<mat-card-header>
<mat-card-title>{{ 'LABYRINTH.TITLE' | translate }}</mat-card-title>
</mat-card-header>
<mat-card-content>
<app-information [algorithmInformation]="algoInformation"/>
<div class="flex flex-col mb-4">
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
<button matButton="filled" [disabled]="isAnimationRunning()" (click)="visualize('dijkstra')">{{ 'PATHFINDING.DIJKSTRA' | translate }}</button>
<button matButton="filled" [disabled]="isAnimationRunning()" (click)="visualize('astar')">{{ 'PATHFINDING.ASTAR' | translate }}</button>
</div>
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
<button matButton="filled" [disabled]="isAnimationRunning()" (click)="createRandom(true)">{{ 'LABYRINTH.PRIM' | translate }}</button>
<button matButton="filled" [disabled]="isAnimationRunning()" (click)="createRandom(false)">{{ 'LABYRINTH.KRUSKAL' | translate }}</button>
</div>
<div class="flex flex-wrap gap-4 items-center text-[0.9em]">
<span><span class="legend-swatch bg-green-600"></span> {{ 'PATHFINDING.START_NODE' | translate }}</span>
<span><span class="legend-swatch bg-red-600"></span> {{ 'PATHFINDING.END_NODE' | translate }}</span>
<span><span class="legend-swatch bg-black"></span> {{ 'PATHFINDING.WALL' | translate }}</span>
<span><span class="legend-swatch bg-sky-300"></span> {{ 'PATHFINDING.VISITED' | translate }}</span>
<span><span class="legend-swatch bg-[gold]"></span> {{ 'PATHFINDING.PATH' | translate }}</span>
</div>
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
<p>{{ 'PATHFINDING.PATH_LENGTH' | translate }}: {{ pathLength }}</p>
<p>{{ 'PATHFINDING.EXECUTION_TIME' | translate }}: {{ executionTime | number:'1.2-2' }} ms</p>
</div>
</div>
<app-generic-grid
[gridRows]="gridRows"
[gridCols]="gridCols"
[minGridSize]="MIN_GRID_SIZE"
[maxGridSize]="MAX_GRID_SIZE"
[maxGridPx]="MAX_GRID_PX"
[createNodeFn]="createMazeNode"
[getNodeColorFn]="getMazeColor"
[applySelectionFn]="applyNoSelection"
[backgroundColor]="'lightgray'"
(gridChange)="grid = $event"
></app-generic-grid>
</mat-card-content>
</mat-card>

View File

@@ -0,0 +1,458 @@
import {AfterViewInit, Component, inject, signal, ViewChild} from '@angular/core';
import {Information} from '../../information/information';
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
import {TranslatePipe} from '@ngx-translate/core';
import {GenericGridComponent, GridPos} from '../../../../shared/components/generic-grid/generic-grid';
import {AlgorithmInformation} from '../../information/information.models';
import {UrlConstants} from '../../../../constants/UrlConstants';
import {Node} from '../pathfinding.models';
import {SharedFunctions} from '../../../../shared/SharedFunctions';
import {MatButton} from '@angular/material/button';
import {DecimalPipe} from '@angular/common';
import {PathfindingService} from '../service/pathfinding.service';
@Component({
selector: 'app-labyrinth',
imports: [
Information,
MatCard,
MatCardContent,
MatCardHeader,
MatCardTitle,
TranslatePipe,
GenericGridComponent,
MatButton,
DecimalPipe
],
templateUrl: './labyrinth.component.html',
styleUrl: './labyrinth.component.scss',
})
export class LabyrinthComponent implements AfterViewInit {
protected readonly gridRows = 101;
protected readonly gridCols = 101;
protected readonly MAX_GRID_SIZE = 101;
protected readonly MAX_GRID_PX = 1000;
protected readonly MIN_GRID_SIZE = 101;
private readonly pathfindingService = inject(PathfindingService);
algoInformation: AlgorithmInformation = {
title: 'LABYRINTH.EXPLANATION.TITLE',
entries: [
{
name: 'Prims',
description: 'LABYRINTH.EXPLANATION.PRIM_EXPLANATION',
link: UrlConstants.PRIMS_WIKI
},
{
name: 'Kruskals',
description: 'LABYRINTH.EXPLANATION.KRUSKAL_EXPLANATION',
link: UrlConstants.KRUSKAL_WIKI
}
],
disclaimer: 'LABYRINTH.EXPLANATION.DISCLAIMER',
disclaimerBottom: '',
disclaimerListEntry: ['LABYRINTH.EXPLANATION.DISCLAIMER_1', 'LABYRINTH.EXPLANATION.DISCLAIMER_2', 'LABYRINTH.EXPLANATION.DISCLAIMER_3', 'LABYRINTH.EXPLANATION.DISCLAIMER_4']
};
@ViewChild(GenericGridComponent) genericGridComponent!: GenericGridComponent;
grid: Node[][] = [];
startNode: Node | null = null;
endNode: Node | null = null;
animationSpeed = 3;
mazeAnimationSpeed = 1;
pathLength = "0";
executionTime = 0;
private timeoutIds: number[] = [];
protected mazeNodesInOrder: Node[] = [];
readonly isAnimationRunning = signal(false);
ngAfterViewInit(): void {
if (this.genericGridComponent) {
this.genericGridComponent.initializationFn = this.initializeMazeGrid;
this.genericGridComponent.createNodeFn = this.createMazeNode;
this.genericGridComponent.getNodeColorFn = this.getMazeColor;
this.genericGridComponent.applySelectionFn = this.applyNoSelection;
this.genericGridComponent.gridRows = this.gridRows;
this.genericGridComponent.gridCols = this.gridCols;
this.genericGridComponent.minGridSize = this.MIN_GRID_SIZE;
this.genericGridComponent.maxGridSize = this.MAX_GRID_SIZE;
this.genericGridComponent.maxGridPx = 1000;
this.genericGridComponent.applyGridSize();
this.genericGridComponent.initializeGrid();
}
}
initializeMazeGrid = (grid: Node[][]): void => {
this.grid = grid;
this.createRandom(true);
};
createRandom(prim: boolean): void {
this.isAnimationRunning.set(true);
this.stopAnimations();
this.clearPath();
this.startNode = null;
this.endNode = null;
if (prim)
{
this.createPrimMaze();
}
else{
this.createKruskalMaze();
}
this.cleanupGrid();
this.genericGridComponent.drawGrid();
}
// ------- Kuskal -------
private createKruskalMaze(): void {
this.initKuskal();
this.mazeNodesInOrder = [];
const walls = this.findWallsWithADistanceOfTwoRooms();
SharedFunctions.shuffleArray(walls);
for (const wallInfo of walls) {
const { row, col, roomA, roomB } = wallInfo;
if (roomA.nodeData !== roomB.nodeData) {
const wallNode = this.grid[row][col];
wallNode.isWall = true;
this.mazeNodesInOrder.push(wallNode);
const oldId = roomB.nodeData;
const newId = roomA.nodeData;
this.mergeSets(oldId, newId);
}
}
this.setRandomStartAndEnd();
this.animateMazeGeneration();
}
private initKuskal() {
let roomId = 0;
for (let row = 0; row < this.gridRows; row++) {
for (let col = 0; col < this.gridCols; col++) {
const node = this.grid[row][col];
node.isStart = false;
node.isEnd = false;
if (row % 2 === 0 && col % 2 === 0) {
node.isWall = false;
node.nodeData = roomId++;
} else {
node.isWall = true;
}
}
}
}
private mergeSets(oldId: number, newId: number): void {
for (let r = 0; r < this.gridRows; r += 2) {
for (let c = 0; c < this.gridCols; c += 2) {
if (this.grid[r][c].nodeData === oldId) {
this.grid[r][c].nodeData = newId;
}
}
}
}
private findWallsWithADistanceOfTwoRooms() {
const walls: { row: number, col: number, roomA: Node, roomB: Node }[] = [];
for (let row = 0; row < this.gridRows; row++) {
for (let col = 0; col < this.gridCols; col++) {
if (row % 2 === 0 && col % 2 !== 0 && col > 0 && col < this.gridCols - 1) {
walls.push({
row, col,
roomA: this.grid[row][col - 1],
roomB: this.grid[row][col + 1]
});
}
if (row % 2 !== 0 && col % 2 === 0 && row > 0 && row < this.gridRows - 1) {
walls.push({
row, col,
roomA: this.grid[row - 1][col],
roomB: this.grid[row + 1][col]
});
}
}
}
return walls;
}
private setRandomStartAndEnd(): void {
const lastRow = Math.floor((this.gridRows - 1) / 2) * 2;
const lastCol = Math.floor((this.gridCols - 1) / 2) * 2;
const corners = [
{ r: 0, c: 0 },
{ r: 0, c: lastCol },
{ r: lastRow, c: 0 },
{ r: lastRow, c: lastCol }
];
const startIndex = Math.floor(Math.random() * corners.length);
let endIndex = Math.floor(Math.random() * corners.length);
while (endIndex === startIndex) {
endIndex = Math.floor(Math.random() * corners.length);
}
const start = corners[startIndex];
const end = corners[endIndex];
this.startNode = this.grid[start.r][start.c];
this.startNode.isStart = true;
this.startNode.isWall = false;
this.endNode =this.grid[end.r][end.c];
this.endNode.isEnd = true;
this.endNode.isWall = false;
}
// ------- PRIM -------
private createPrimMaze(): void {
this.initPrim();
this.mazeNodesInOrder = [];
const frontier: Node[] = [];
const {startRow, startCol, startNode} = this.findStartNode();
this.mazeNodesInOrder.push(startNode);
this.getNeighborWalls(startRow, startCol, frontier);
while (frontier.length > 0) {
const randomIndex = SharedFunctions.randomIntFromInterval(0, frontier.length - 1);
const lastIndex = frontier.length - 1;
[frontier[randomIndex], frontier[lastIndex]] = [frontier[lastIndex], frontier[randomIndex]];
const wallNode = frontier.pop()!;
const target = wallNode.linkedNode;
if (!target || target.isVisited) {
continue;
}
wallNode.isVisited = true;
target.isVisited = true;
this.mazeNodesInOrder.push(wallNode, target);
this.getNeighborWalls(target.row, target.col, frontier);
}
this.setRandomStartAndEnd();
this.animateMazeGeneration();
}
private initPrim() {
for (let row = 0; row < this.grid.length; row++) {
for (let col = 0; col < this.grid[row].length; col++) {
this.grid[row][col].isWall = true;
this.grid[row][col].isStart = false;
this.grid[row][col].isEnd = false;
}
}
}
private cleanupGrid() {
for (let row = 0; row < this.grid.length; row++) {
for (let col = 0; col < this.grid[row].length; col++) {
this.grid[row][col].isVisited = false;
this.grid[row][col].linkedNode = null;
}
}
}
private findStartNode() {
const startRow: number = SharedFunctions.randomEventIntFromInterval(this.gridRows - 1);
const startCol: number = SharedFunctions.randomEventIntFromInterval(this.gridCols - 1);
const startNode = this.grid[startRow][startCol];
startNode.isWall = false;
startNode.isVisited = true;
return {startRow, startCol, startNode};
}
visualize(algorithm: string): void {
this.stopAnimations();
this.clearPath();
const startTime = performance.now();
let result;
switch (algorithm) {
case 'dijkstra': result = this.pathfindingService.dijkstra(
this.grid,
this.grid[this.startNode!.row][this.startNode!.col],
this.grid[this.endNode!.row][this.endNode!.col]
);
break;
case 'astar': result = this.pathfindingService.aStar(
this.grid,
this.grid[this.startNode!.row][this.startNode!.col],
this.grid[this.endNode!.row][this.endNode!.col]
);
break;
}
if (!result)
{
return;
}
const endTime = performance.now();
const lengthOfShortestPath = result.nodesInShortestPathOrder.length;
if (lengthOfShortestPath === 0)
{
this.pathLength = "∞"
}
else
{
this.pathLength = result.nodesInShortestPathOrder.length + "";
}
this.executionTime = endTime - startTime;
this.animateAlgorithm(result.visitedNodesInOrder, result.nodesInShortestPathOrder);
}
createMazeNode = (row: number, col: number): Node => {
return {
row,
col,
isStart: false,
isEnd: false,
isWall: false,
isVisited: false,
isPath: false,
nodeData: Infinity,
linkedNode: null,
hScore: 0,
fScore: Infinity,
};
};
getMazeColor = (node: Node): string => {
if (node.isStart) return 'green';
if (node.isEnd) return 'red';
if (node.isPath) return 'gold';
if (node.isVisited) return 'skyblue';
if (node.isWall) return 'black';
return 'lightgray';
};
applyNoSelection = (pos: GridPos, grid: Node[][]): void => {
this.grid = grid;
//dont need a selection for the maze case
}
// --- Animation (adapted to use genericGridComponent for redraw) ---
private stopAnimations(): void {
for (const id of this.timeoutIds) {
clearTimeout(id);
}
this.timeoutIds = [];
}
private clearPath(): void {
for (let row = 0; row < this.gridRows; row++) {
for (let col = 0; col < this.gridCols; col++) {
const node = this.grid[row][col];
node.isVisited = false;
node.isPath = false;
node.nodeData = Infinity;
node.linkedNode = null;
}
}
this.genericGridComponent?.drawGrid(); // Redraw the grid via generic grid component
}
private animateAlgorithm(visited: Node[], path: Node[]): void {
for (let i = 0; i <= visited.length; i++) {
if (i === visited.length) {
const id = globalThis.setTimeout(() => this.animateShortestPath(path), this.animationSpeed * i);
this.timeoutIds.push(id);
return;
}
const node = visited[i];
const id = globalThis.setTimeout(() => {
if (!node.isStart && !node.isEnd) {
node.isVisited = true;
this.genericGridComponent?.drawNode(node);
}
}, this.animationSpeed * i);
this.timeoutIds.push(id);
}
}
private animateShortestPath(path: Node[]): void {
for (let i = 0; i < path.length; i++) {
const node = path[i];
const id = globalThis.setTimeout(() => {
if (!node.isStart && !node.isEnd) {
node.isPath = true;
this.genericGridComponent?.drawNode(node); // Redraw single node
}
}, this.animationSpeed * i);
this.timeoutIds.push(id);
}
}
private animateMazeGeneration(): void {
for (let i = 0; i < this.mazeNodesInOrder.length; i++) {
const id = globalThis.setTimeout(() => {
const node = this.mazeNodesInOrder[i];
node.isWall = false;
this.genericGridComponent?.drawNode(node);
if (i === this.mazeNodesInOrder.length - 1) {
this.cleanupGrid();
if (this.startNode) {
this.genericGridComponent?.drawNode(this.startNode);
}
if (this.endNode) {
this.genericGridComponent?.drawNode(this.endNode);
}
}
if (i == this.mazeNodesInOrder.length - 1) {
this.isAnimationRunning.set(false);
}
}, this.mazeAnimationSpeed * i);
this.timeoutIds.push(id);
}
}
//utility
private getNeighborWalls(row: number, col: number, frontier: Node[]): void{
const directions = [
[0, 2], [0, -2], [2, 0], [-2, 0]
];
for (const [dr, dc] of directions) {
const nextRow = row + dr;
const nextCol = col + dc;
if (this.isValid(nextRow, nextCol) && this.grid[nextRow][nextCol].isWall && !this.grid[nextRow][nextCol].isVisited) {
const wallRow = row + dr / 2;
const wallCol = col + dc / 2;
const node = this.grid[wallRow][wallCol];
node.linkedNode = this.grid[nextRow][nextCol];
frontier.push(node);
}
}
}
isValid = (row: number, col: number): boolean => {
return row >= 0 && row < this.gridRows && col >= 0 && col < this.gridCols;
};
}

View File

@@ -1,90 +1,80 @@
<mat-card class="container"> <mat-card class="w-full max-w-[1920px] p-5">
<mat-card-header> <mat-card-header>
<mat-card-title>{{ 'PATHFINDING.TITLE' | translate }}</mat-card-title> <mat-card-title>{{ 'PATHFINDING.TITLE' | translate }}</mat-card-title>
</mat-card-header> </mat-card-header>
<mat-card-content> <mat-card-content>
<div class="algo-info"> <app-information [algorithmInformation]="algoInformation"/>
<h3>{{ 'PATHFINDING.EXPLANATION.TITLE' | translate }}</h3>
<p> <div class="flex flex-col mb-4">
<strong>Dijkstra</strong> {{ 'PATHFINDING.EXPLANATION.DIJKSTRA_EXPLANATION' | translate }} <div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
<a href="{{UrlConstants.DIJKSTRA_WIKI}}" target="_blank" rel="noopener noreferrer">Wikipedia</a>
</p>
<p>
<strong>A*</strong> {{ 'PATHFINDING.EXPLANATION.ASTAR_EXPLANATION' | translate}}
<a href="{{UrlConstants.ASTAR_WIKI}}" target="_blank" rel="noopener noreferrer">Wikipedia</a>
</p>
<p>
<strong>{{ 'PATHFINDING.EXPLANATION.NOTE' | translate}}</strong> {{ 'PATHFINDING.EXPLANATION.DISCLAIMER' | translate}}
</p>
</div>
<div class="controls-container">
<div class="controls">
<button matButton="filled" (click)="visualize('dijkstra')">{{ 'PATHFINDING.DIJKSTRA' | translate }}</button> <button matButton="filled" (click)="visualize('dijkstra')">{{ 'PATHFINDING.DIJKSTRA' | translate }}</button>
<button matButton="filled" (click)="visualize('astar')">{{ 'PATHFINDING.ASTAR' | translate }}</button> <button matButton="filled" (click)="visualize('astar')">{{ 'PATHFINDING.ASTAR' | translate }}</button>
</div> </div>
<div class="controls"> <div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
<button matButton="filled" (click)="createCase({withWalls: true, scenario: 'normal'})">{{ 'PATHFINDING.NORMAL_CASE' | translate }}</button> <button matButton="filled" (click)="createCase({withWalls: true, scenario: 'normal'})">{{ 'PATHFINDING.NORMAL_CASE' | translate }}</button>
<button matButton="filled" (click)="createCase({withWalls: true, scenario: 'random'})">{{ 'PATHFINDING.RANDOM_CASE' | translate }}</button> <button matButton="filled" (click)="createCase({withWalls: true, scenario: 'random'})">{{ 'PATHFINDING.RANDOM_CASE' | translate }}</button>
<button matButton="filled" (click)="createCase({withWalls: true, scenario: 'edge'})">{{ 'PATHFINDING.EDGE_CASE' | translate }}</button> <button matButton="filled" (click)="createCase({withWalls: true, scenario: 'edge'})">{{ 'PATHFINDING.EDGE_CASE' | translate }}</button>
<button matButton="filled" (click)="createCase({withWalls: false, scenario: 'normal'})">{{ 'PATHFINDING.CLEAR_BOARD' | translate }}</button> <button matButton="filled" (click)="createCase({withWalls: false, scenario: 'normal'})">{{ 'PATHFINDING.CLEAR_BOARD' | translate }}</button>
</div> </div>
<div class="controls"> <div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
<mat-button-toggle-group [(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.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.End">{{ 'PATHFINDING.END_NODE' | translate }}</mat-button-toggle>
<mat-button-toggle [value]="NodeType.Wall">{{ 'PATHFINDING.WALL' | translate }}</mat-button-toggle> <mat-button-toggle [value]="NodeType.Wall">{{ 'PATHFINDING.WALL' | translate }}</mat-button-toggle>
<mat-button-toggle [value]="NodeType.None">{{ 'PATHFINDING.CLEAR_NODE' | translate }}</mat-button-toggle> <mat-button-toggle [value]="NodeType.None">{{ 'PATHFINDING.CLEAR_NODE' | translate }}</mat-button-toggle>
</mat-button-toggle-group> </mat-button-toggle-group>
</div> </div>
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
<div class="controls"> <div class="flex gap-3 items-center flex-wrap">
<div class="grid-size"> <mat-form-field appearance="outline" class="w-[150px]">
<mat-form-field appearance="outline" class="grid-field"> <mat-label>{{ 'ALGORITHM.GRID_HEIGHT' | translate }}</mat-label>
<mat-label>{{ 'PATHFINDING.GRID_HEIGHT' | translate }}</mat-label>
<input <input
matInput matInput
type="number" type="number"
[min]="MIN_GRID_SIZE" [min]="MIN_GRID_SIZE"
[max]="MAX_GRID_SIZE" [max]="MAX_GRID_SIZE"
[(ngModel)]="gridRows" [(ngModel)]="gridRows"
(blur)="applyGridSize()" (ngModelChange)="genericGridComponent.gridRows = gridRows; genericGridComponent.applyGridSize()"
(keyup.enter)="applyGridSize()" /> </mat-form-field>
/>
</mat-form-field>
<mat-form-field appearance="outline" class="grid-field"> <mat-form-field appearance="outline" class="w-[150px]">
<mat-label>{{ 'PATHFINDING.GRID_WIDTH' | translate }}</mat-label> <mat-label>{{ 'ALGORITHM.GRID_WIDTH' | translate }}</mat-label>
<input <input
matInput matInput
type="number" type="number"
[min]="MIN_GRID_SIZE" [min]="MIN_GRID_SIZE"
[max]="MAX_GRID_SIZE" [max]="MAX_GRID_SIZE"
[(ngModel)]="gridCols" [(ngModel)]="gridCols"
(blur)="applyGridSize()" (ngModelChange)="genericGridComponent.gridCols = gridCols; genericGridComponent.applyGridSize()"
(keyup.enter)="applyGridSize()" /> </mat-form-field>
/>
</mat-form-field>
</div> </div>
</div> </div>
<div class="legend"> <div class="flex flex-wrap gap-4 items-center text-[0.9em]">
<span><span class="legend-color start"></span> {{ 'PATHFINDING.START_NODE' | translate }}</span> <span><span class="legend-swatch bg-green-600"></span> {{ 'PATHFINDING.START_NODE' | translate }}</span>
<span><span class="legend-color end"></span> {{ 'PATHFINDING.END_NODE' | translate }}</span> <span><span class="legend-swatch bg-red-600"></span> {{ 'PATHFINDING.END_NODE' | translate }}</span>
<span><span class="legend-color wall"></span> {{ 'PATHFINDING.WALL' | translate }}</span> <span><span class="legend-swatch bg-black"></span> {{ 'PATHFINDING.WALL' | translate }}</span>
<span><span class="legend-color visited"></span> {{ 'PATHFINDING.VISITED' | translate }}</span> <span><span class="legend-swatch bg-sky-300"></span> {{ 'PATHFINDING.VISITED' | translate }}</span>
<span><span class="legend-color path"></span> {{ 'PATHFINDING.PATH' | translate }}</span> <span><span class="legend-swatch bg-[gold]"></span> {{ 'PATHFINDING.PATH' | translate }}</span>
</div> </div>
</div> <div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
<div class="results-container">
<p>{{ 'PATHFINDING.PATH_LENGTH' | translate }}: {{ pathLength }}</p> <p>{{ 'PATHFINDING.PATH_LENGTH' | translate }}: {{ pathLength }}</p>
<p>{{ 'PATHFINDING.EXECUTION_TIME' | translate }}: {{ executionTime | number:'1.2-2' }} ms</p> <p>{{ 'PATHFINDING.EXECUTION_TIME' | translate }}: {{ executionTime | number:'1.2-2' }} ms</p>
</div> </div>
</div>
<canvas #gridCanvas></canvas> <app-generic-grid
[gridRows]="gridRows"
[gridCols]="gridCols"
[minGridSize]="MIN_GRID_SIZE"
[maxGridSize]="MAX_GRID_SIZE"
[maxGridPx]="MAX_GRID_PX"
[createNodeFn]="createPathfindingNode"
[getNodeColorFn]="getPathfindingNodeColor"
[applySelectionFn]="applyPathfindingSelection"
[backgroundColor]="'lightgray'"
(gridChange)="grid = $event"
></app-generic-grid>
</mat-card-content> </mat-card-content>
</mat-card>

View File

@@ -1,62 +0,0 @@
.container {
padding: 2rem;
}
.controls-container {
display: flex;
flex-direction: column;
margin-bottom: 1rem;
}
.controls {
display: flex;
flex-wrap: wrap;
gap: 1rem;
margin-bottom: 1rem;
align-items: center;
mat-button-toggle-group {
border-radius: 4px;
overflow: hidden;
}
}
.grid-size {
display: flex;
gap: 0.75rem;
align-items: center;
flex-wrap: wrap;
}
.grid-field {
width: 150px;
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: center;
font-size: 0.9em;
.legend-color {
display: inline-block;
width: 15px;
height: 15px;
border: 1px solid #ccc;
vertical-align: middle;
margin-right: 5px;
&.start { background-color: green; }
&.end { background-color: red; }
&.wall { background-color: black; }
&.visited { background-color: skyblue; }
&.path { background-color: gold; }
}
}
canvas {
border: 1px solid #ccc;
display: block;
max-width: 100%;
}

View File

@@ -1,4 +1,4 @@
import {AfterViewInit, Component, ElementRef, inject, ViewChild} from '@angular/core'; import {AfterViewInit, Component, inject, ViewChild} from '@angular/core';
import {CommonModule} from '@angular/common'; import {CommonModule} from '@angular/common';
import {FormsModule} from '@angular/forms'; import {FormsModule} from '@angular/forms';
@@ -6,13 +6,18 @@ import {MatButtonModule} from '@angular/material/button';
import {MatButtonToggleModule} from '@angular/material/button-toggle'; import {MatButtonToggleModule} from '@angular/material/button-toggle';
import {MatFormFieldModule} from '@angular/material/form-field'; import {MatFormFieldModule} from '@angular/material/form-field';
import {MatInputModule} from '@angular/material/input'; 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 {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'; import {PathfindingService} from './service/pathfinding.service';
import {UrlConstants} from '../../../constants/UrlConstants'; import {UrlConstants} from '../../../constants/UrlConstants';
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card'; import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
import {Information} from '../information/information';
import {AlgorithmInformation} from '../information/information.models';
import {GenericGridComponent, GridPos} from '../../../shared/components/generic-grid/generic-grid';
import {SharedFunctions} from '../../../shared/SharedFunctions';
enum NodeType { enum NodeType {
Start = 'start', Start = 'start',
@@ -21,11 +26,8 @@ enum NodeType {
None = 'none' None = 'none'
} }
interface GridPos { row: number; col: number }
@Component({ @Component({
selector: 'app-pathfinding', selector: 'app-pathfinding',
standalone: true,
imports: [ imports: [
CommonModule, CommonModule,
FormsModule, FormsModule,
@@ -33,41 +35,54 @@ interface GridPos { row: number; col: number }
MatButtonToggleModule, MatButtonToggleModule,
MatFormFieldModule, MatFormFieldModule,
MatInputModule, MatInputModule,
TranslateModule, TranslatePipe,
MatCard, MatCard,
MatCardHeader, MatCardHeader,
MatCardTitle, MatCardTitle,
MatCardContent MatCardContent,
Information,
GenericGridComponent
], ],
templateUrl: './pathfinding.component.html', templateUrl: './pathfinding.component.html',
styleUrls: ['./pathfinding.component.scss']
}) })
export class PathfindingComponent implements AfterViewInit { export class PathfindingComponent implements AfterViewInit {
private readonly pathfindingService = inject(PathfindingService); private readonly pathfindingService = inject(PathfindingService);
private readonly translate = inject(TranslateService); private readonly translate = inject(TranslateService);
private readonly snackBar = inject(MatSnackBar);
readonly NodeType = NodeType; readonly NodeType = NodeType;
readonly MIN_GRID_SIZE = MIN_GRID_SIZE; readonly MIN_GRID_SIZE = MIN_GRID_SIZE;
readonly MAX_GRID_SIZE = MAX_GRID_SIZE; readonly MAX_GRID_SIZE = MAX_GRID_SIZE;
readonly MAX_GRID_PX = MAX_GRID_PX;
@ViewChild('gridCanvas', { static: true }) algoInformation: AlgorithmInformation = {
canvas!: ElementRef<HTMLCanvasElement>; title: 'PATHFINDING.EXPLANATION.TITLE',
entries: [
private ctx!: CanvasRenderingContext2D; {
name: 'Dijkstra',
description: 'PATHFINDING.EXPLANATION.DIJKSTRA_EXPLANATION',
link: UrlConstants.DIJKSTRA_WIKI
},
{
name: 'A*',
description: 'PATHFINDING.EXPLANATION.ASTAR_EXPLANATION',
link: UrlConstants.ASTAR_WIKI
}
],
disclaimer: 'PATHFINDING.EXPLANATION.DISCLAIMER',
disclaimerBottom: '',
disclaimerListEntry: []
};
gridRows = DEFAULT_GRID_ROWS; gridRows = DEFAULT_GRID_ROWS;
gridCols = DEFAULT_GRID_COLS; gridCols = DEFAULT_GRID_COLS;
nodeSize = 10;
grid: Node[][] = []; grid: Node[][] = [];
startNode: Node | null = null; startNode: Node | null = null;
endNode: Node | null = null; endNode: Node | null = null;
selectedNodeType: NodeType = NodeType.None; selectedNodeType: NodeType = NodeType.None;
private shouldAddWall = true; // Moved here
isDrawing = false;
private lastCell: GridPos | null = null;
private shouldAddWall = true;
animationSpeed = 3; animationSpeed = 3;
pathLength = "0"; pathLength = "0";
@@ -75,58 +90,79 @@ export class PathfindingComponent implements AfterViewInit {
private timeoutIds: number[] = []; private timeoutIds: number[] = [];
@ViewChild(GenericGridComponent) genericGridComponent!: GenericGridComponent;
ngAfterViewInit(): void { ngAfterViewInit(): void {
this.ctx = this.getContextOrThrow(); // Canvas logic is now handled by GenericGridComponent
this.applyGridSize(true); // Ensure genericGridComponent is initialized
if (this.genericGridComponent) {
const el = this.canvas.nativeElement; this.genericGridComponent.initializationFn = this.initializePathfindingGrid;
el.addEventListener('mousedown', (e) => this.onMouseDown(e)); this.genericGridComponent.createNodeFn = this.createPathfindingNode;
el.addEventListener('mousemove', (e) => this.onMouseMove(e)); this.genericGridComponent.getNodeColorFn = this.getPathfindingNodeColor;
el.addEventListener('mouseup', () => this.onMouseUp()); this.genericGridComponent.applySelectionFn = this.applyPathfindingSelection;
el.addEventListener('mouseleave', () => this.onMouseUp()); this.genericGridComponent.gridRows = this.gridRows;
this.genericGridComponent.gridCols = this.gridCols;
el.addEventListener('touchstart', (e) => { this.genericGridComponent.minGridSize = this.MIN_GRID_SIZE;
if(e.cancelable) e.preventDefault(); this.genericGridComponent.maxGridSize = this.MAX_GRID_SIZE;
this.onMouseDown(e as never); this.genericGridComponent.maxGridPx = MAX_GRID_PX;
}, { passive: false }); this.genericGridComponent.applyGridSize(); // Trigger initial grid setup
}
el.addEventListener('touchmove', (e) => { this.createCase({withWalls: true, scenario: "normal"});
if(e.cancelable) e.preventDefault();
this.onMouseMove(e as never);
}, { passive: false });
el.addEventListener('touchend', () => {
this.onMouseUp();
});
} }
applyGridSize(skipReset?: boolean): void { // --- Callbacks for GenericGridComponent ---
this.gridRows = this.clampGridSize(this.gridRows, DEFAULT_GRID_ROWS); createPathfindingNode = (row: number, col: number): Node => {
this.gridCols = this.clampGridSize(this.gridCols, DEFAULT_GRID_COLS); return {
this.nodeSize = this.computeNodeSize(this.gridRows, this.gridCols); row,
this.resizeCanvas(); col,
isStart: false,
isEnd: false,
isWall: false,
isVisited: false,
isPath: false,
nodeData: Infinity,
linkedNode: null,
hScore: 0,
fScore: Infinity,
};
};
if (this.gridRows === this.grid.length && this.gridCols === this.grid[0].length) getPathfindingNodeColor = (node: Node): string => {
{ if (node.isStart) return 'green';
this.drawGrid(); if (node.isEnd) return 'red';
return; if (node.isPath) return 'gold';
if (node.isVisited) return 'skyblue';
if (node.isWall) return 'black';
return 'lightgray';
};
applyPathfindingSelection = (pos: GridPos, grid: Node[][]): void => {
this.grid = grid; // Keep internal grid in sync
const node = grid[pos.row][pos.col];
// Determine if we should add or remove a wall
if (this.selectedNodeType === NodeType.Wall && this.genericGridComponent.isDrawing && this.genericGridComponent['lastCell'] === null) {
this.shouldAddWall = !node.isWall;
} }
if (skipReset) { switch (this.selectedNodeType) {
this.initializeGrid({withWalls: true, scenario: 'normal'}); case NodeType.Start:
this.drawGrid(); this.trySetStart(node);
return; break;
}
this.createCase({withWalls: true, scenario: 'normal'}); case NodeType.End:
} this.trySetEnd(node);
break;
createCase({withWalls, scenario}: { withWalls: boolean, scenario: "normal" | "edge" | "random" }): void case NodeType.Wall:
{ this.tryToggleWall(node, this.shouldAddWall);
this.stopAnimations(); break;
this.initializeGrid({withWalls, scenario});
this.drawGrid(); case NodeType.None:
this.tryClearNode(node);
break;
} }
};
visualize(algorithm: string): void { visualize(algorithm: string): void {
if (!this.ensureStartAndEnd()) { if (!this.ensureStartAndEnd()) {
@@ -174,320 +210,19 @@ export class PathfindingComponent implements AfterViewInit {
this.animateAlgorithm(result.visitedNodesInOrder, result.nodesInShortestPathOrder); this.animateAlgorithm(result.visitedNodesInOrder, result.nodesInShortestPathOrder);
} }
// Mouse interactions initializePathfindingGrid = (grid: Node[][]): void => {
private onMouseDown(event: MouseEvent): void { this.grid = grid; // Update the component's grid reference
this.stopAnimations(); const {start, end} = this.getScenarioStartEnd('normal'); // Default scenario
this.clearPath();
const pos = this.getGridPosition(event);
if (!pos) {
return;
}
this.shouldAddWall = this.shouldStartWallStroke(pos);
this.isDrawing = true;
this.lastCell = null;
this.applySelectionAt(pos);
}
private onMouseMove(event: MouseEvent): void {
if (!this.isDrawing) {
return;
}
const pos = this.getGridPosition(event);
if (!pos) {
return;
}
if (this.isSameCell(pos, this.lastCell)) {
return;
}
this.applySelectionAt(pos);
}
private onMouseUp(): void {
this.isDrawing = false;
this.lastCell = null;
}
private applySelectionAt(pos: GridPos): void {
const node = this.grid[pos.row][pos.col];
switch (this.selectedNodeType) {
case NodeType.Start:
this.trySetStart(node);
break;
case NodeType.End:
this.trySetEnd(node);
break;
case NodeType.Wall:
this.tryToggleWall(node, this.shouldAddWall);
break;
case NodeType.None:
this.tryClearNode(node);
break;
}
this.lastCell = pos;
this.drawNode(node);
}
// Grid init
private initializeGrid({withWalls, scenario}: { withWalls: boolean, scenario: "normal" | "edge" | "random" }): void {
this.grid = this.createEmptyGrid();
const { start, end } = this.getScenarioStartEnd(scenario);
this.startNode = this.grid[start.row][start.col]; this.startNode = this.grid[start.row][start.col];
this.endNode = this.grid[end.row][end.col]; this.endNode = this.grid[end.row][end.col];
this.startNode.isStart = true; this.startNode.isStart = true;
this.endNode.isEnd = true; this.endNode.isEnd = true;
if (withWalls) { this.placeDefaultDiagonalWall('normal');
this.placeDefaultDiagonalWall(scenario);
}
}
private createEmptyGrid(): Node[][] {
const grid: Node[][] = [];
for (let row = 0; row < this.gridRows; row++) {
const currentRow: Node[] = [];
for (let col = 0; col < this.gridCols; col++) {
currentRow.push(this.createNode(row, col));
}
grid.push(currentRow);
}
return grid;
}
private createNode(row: number, col: number): Node {
return {
row,
col,
isStart: false,
isEnd: false,
isWall: false,
isVisited: false,
isPath: false,
distance: Infinity,
previousNode: null,
hScore: 0,
fScore: Infinity,
}; };
}
private getScenarioStartEnd(scenario: 'normal' | 'edge' | 'random'): { start: GridPos; end: GridPos } { // --- Helper methods for node manipulation (kept local) ---
if (scenario === 'edge') {
return {
start: { row: 0, col: 0 },
end: { row: this.gridRows - 1, col: this.gridCols - 1 }
};
}
else if (scenario === 'random') {
return this.createRandomStartEndPosition();
}
else {
// normal: mid-left -> mid-right
const midRow = Math.floor(this.gridRows / 2);
return {
start: { row: midRow, col: 0 },
end: { row: midRow, col: this.gridCols - 1 }
};
}
}
private createRandomStartEndPosition() {
const midCol = Math.floor(this.gridCols / 2);
const startRow: number = this.randomIntFromInterval(0, this.gridRows - 1);
const startCol: number = this.randomIntFromInterval(0, this.gridCols - 1);
const endRow: number = this.randomIntFromInterval(0, this.gridRows - 1);
let endCol: number;
if (startCol <= midCol) {
endCol = this.randomIntFromInterval(midCol + 1, this.gridCols - 1);
} else {
endCol = this.randomIntFromInterval(0, midCol);
}
return {
start: {row: startRow, col: startCol},
end: {row: endRow, col: endCol}
};
}
private placeDefaultDiagonalWall(scenario: 'normal' | 'edge' | 'random'): void {
if (scenario === 'edge') {
this.createDiagonalWall();
}
else if (scenario === 'normal') {
this.createVerticalWall();
}
else if (scenario === 'random') {
this.createRandomWalls();
}
}
private createRandomWalls(){
const maxNumberOfWalls = Math.floor(MAX_RANDOM_WALLS_FACTORS * this.gridCols * this.gridRows);
for (let wall = 0; wall < maxNumberOfWalls; wall++) {
const row: number = this.randomIntFromInterval(0, this.gridRows - 1);
const col: number = this.randomIntFromInterval(0, this.gridCols - 1);
if (!this.isValidPosition(row, col)) {
wall--;
continue;
}
const node = this.grid[row][col];
if (node.isStart || node.isEnd) {
wall--;
continue;
}
node.isWall = true;
}
}
private createVerticalWall() {
const height = this.gridRows;
const startCol = Math.floor(this.gridCols / 2);
for (let i = 5; i < (height - 5); i++) {
const row = i;
if (!this.isValidPosition(row, startCol)) {
continue;
}
const node = this.grid[row][startCol];
if (node.isStart || node.isEnd) {
continue;
}
node.isWall = true;
}
}
private createDiagonalWall() {
// Diagonal-ish wall; avoids start/end
const len = Math.min(this.gridRows, this.gridCols);
const startCol = Math.floor((this.gridCols - len) / 2);
for (let i = 0; i < Math.max(0, len - 10); i++) {
const row = len - i - 1;
const col = startCol + i;
if (!this.isValidPosition(row, col)) {
continue;
}
const node = this.grid[row][col];
if (node.isStart || node.isEnd) {
continue;
}
node.isWall = true;
}
}
// Path state
private clearPath(): void {
for (let row = 0; row < this.gridRows; row++) {
for (let col = 0; col < this.gridCols; col++) {
const node = this.grid[row][col];
node.isVisited = false;
node.isPath = false;
node.distance = Infinity;
node.previousNode = null;
}
}
this.drawGrid();
}
// Animation
private stopAnimations(): void {
for (const id of this.timeoutIds) {
clearTimeout(id);
}
this.timeoutIds = [];
}
private animateAlgorithm(visited: Node[], path: Node[]): void {
for (let i = 0; i <= visited.length; i++) {
if (i === visited.length) {
const id = globalThis.setTimeout(() => this.animateShortestPath(path), this.animationSpeed * i);
this.timeoutIds.push(id);
return;
}
const node = visited[i];
const id = globalThis.setTimeout(() => {
if (!node.isStart && !node.isEnd) {
node.isVisited = true;
this.drawNode(node);
}
}, this.animationSpeed * i);
this.timeoutIds.push(id);
}
}
private animateShortestPath(path: Node[]): void {
for (let i = 0; i < path.length; i++) {
const node = path[i];
const id = globalThis.setTimeout(() => {
if (!node.isStart && !node.isEnd) {
node.isPath = true;
this.drawNode(node);
}
}, this.animationSpeed * i);
this.timeoutIds.push(id);
}
}
// Drawing
private drawGrid(): void {
this.ctx.clearRect(0, 0, this.canvas.nativeElement.width, this.canvas.nativeElement.height);
for (let row = 0; row < this.gridRows; row++) {
for (let col = 0; col < this.gridCols; col++) {
this.drawNode(this.grid[row][col]);
}
}
}
private drawNode(node: Node): void {
this.ctx.fillStyle = this.getNodeColor(node);
this.ctx.fillRect(node.col * this.nodeSize, node.row * this.nodeSize, this.nodeSize, this.nodeSize);
this.ctx.strokeStyle = '#ccc';
this.ctx.strokeRect(node.col * this.nodeSize, node.row * this.nodeSize, this.nodeSize, this.nodeSize);
}
private getNodeColor(node: Node): string {
if (node.isStart) return 'green';
if (node.isEnd) return 'red';
if (node.isPath) return 'gold';
if (node.isVisited) return 'skyblue';
if (node.isWall) return 'black';
return 'lightgray';
}
// Placement rules (readability helpers)
private trySetStart(node: Node): void { private trySetStart(node: Node): void {
if (!this.canBeStart(node)) { if (!this.canBeStart(node)) {
return; return;
@@ -495,7 +230,7 @@ export class PathfindingComponent implements AfterViewInit {
if (this.startNode) { if (this.startNode) {
this.startNode.isStart = false; this.startNode.isStart = false;
this.drawNode(this.startNode); this.genericGridComponent.drawNode(this.startNode); // Redraw old start node
} }
node.isStart = true; node.isStart = true;
@@ -509,7 +244,7 @@ export class PathfindingComponent implements AfterViewInit {
if (this.endNode) { if (this.endNode) {
this.endNode.isEnd = false; this.endNode.isEnd = false;
this.drawNode(this.endNode); this.genericGridComponent.drawNode(this.endNode); // Redraw old end node
} }
node.isEnd = true; node.isEnd = true;
@@ -553,95 +288,205 @@ export class PathfindingComponent implements AfterViewInit {
return !node.isStart && !node.isEnd; return !node.isStart && !node.isEnd;
} }
private shouldStartWallStroke(pos: GridPos): boolean { // --- Grid manipulation for scenarios (kept local) ---
if (this.selectedNodeType !== NodeType.Wall) { createCase({withWalls, scenario}: { withWalls: boolean, scenario: "normal" | "edge" | "random" }): void {
return true; this.stopAnimations();
// Reinitialize grid through the generic component
this.genericGridComponent.initializationFn = (grid) => {
this.grid = grid;
const {start, end} = this.getScenarioStartEnd(scenario);
this.startNode = this.grid[start.row][start.col];
this.endNode = this.grid[end.row][end.col];
this.startNode.isStart = true;
this.endNode.isEnd = true;
if (withWalls) {
this.placeDefaultDiagonalWall(scenario);
}
};
this.genericGridComponent.initializeGrid(); // Trigger re-initialization and redraw
} }
const node = this.grid[pos.row][pos.col]; private getScenarioStartEnd(scenario: 'normal' | 'edge' | 'random'): { start: GridPos; end: GridPos } {
return !node.isWall; if (scenario === 'edge') {
return {
start: {row: 0, col: 0},
end: {row: this.gridRows - 1, col: this.gridCols - 1}
};
} else if (scenario === 'random') {
return this.createRandomStartEndPosition();
} else {
// normal: mid-left -> mid-right
const midRow = Math.floor(this.gridRows / 2);
return {
start: {row: midRow, col: 0},
end: {row: midRow, col: this.gridCols - 1}
};
}
} }
// Validation private createRandomStartEndPosition(): { start: GridPos; end: GridPos } {
const midCol = Math.floor(this.gridCols / 2);
const startRow: number = SharedFunctions.randomIntFromInterval(0, this.gridRows - 1);
const startCol: number = SharedFunctions.randomIntFromInterval(0, this.gridCols - 1);
const endRow: number = SharedFunctions.randomIntFromInterval(0, this.gridRows - 1);
let endCol: number;
if (startCol <= midCol) {
endCol = SharedFunctions.randomIntFromInterval(midCol + 1, this.gridCols - 1);
} else {
endCol = SharedFunctions.randomIntFromInterval(0, midCol);
}
return {
start: {row: startRow, col: startCol},
end: {row: endRow, col: endCol}
};
}
private placeDefaultDiagonalWall(scenario: 'normal' | 'edge' | 'random'): void {
if (scenario === 'edge') {
this.createDiagonalWall();
} else if (scenario === 'normal') {
this.createVerticalWall();
} else if (scenario === 'random') {
this.createRandomWalls();
}
}
private createRandomWalls() {
const maxNumberOfWalls = Math.floor(MAX_RANDOM_WALLS_FACTORS * this.gridCols * this.gridRows);
for (let wall = 0; wall < maxNumberOfWalls; wall++) {
const row: number = SharedFunctions.randomIntFromInterval(0, this.gridRows - 1);
const col: number = SharedFunctions.randomIntFromInterval(0, this.gridCols - 1);
if (!this.grid[row][col]) { // Use the grid passed from GenericGrid
wall--;
continue;
}
const node = this.grid[row][col];
if (node.isStart || node.isEnd) {
wall--;
continue;
}
node.isWall = true;
}
}
private createVerticalWall() {
const height = this.gridRows;
const startCol = Math.floor(this.gridCols / 2);
for (let i = 5; i < (height - 5); i++) {
const row = i;
if (!this.grid[row]?.[startCol]) {
continue;
}
const node = this.grid[row][startCol];
if (node.isStart || node.isEnd) {
continue;
}
node.isWall = true;
}
}
private createDiagonalWall() {
// Diagonal-ish wall; avoids start/end
const len = Math.min(this.gridRows, this.gridCols);
const startCol = Math.floor((this.gridCols - len) / 2);
for (let i = 0; i < Math.max(0, len - 10); i++) {
const row = len - i - 1;
const col = startCol + i;
if (!this.grid[row]?.[col]) {
continue;
}
const node = this.grid[row][col];
if (node.isStart || node.isEnd) {
continue;
}
node.isWall = true;
}
}
// --- Animation (adapted to use genericGridComponent for redraw) ---
private stopAnimations(): void {
for (const id of this.timeoutIds) {
clearTimeout(id);
}
this.timeoutIds = [];
}
private clearPath(): void {
for (let row = 0; row < this.gridRows; row++) {
for (let col = 0; col < this.gridCols; col++) {
const node = this.grid[row][col];
node.isVisited = false;
node.isPath = false;
node.nodeData = Infinity;
node.linkedNode = null;
}
}
this.genericGridComponent?.drawGrid(); // Redraw the grid via generic grid component
}
private animateAlgorithm(visited: Node[], path: Node[]): void {
for (let i = 0; i <= visited.length; i++) {
if (i === visited.length) {
const id = globalThis.setTimeout(() => this.animateShortestPath(path), this.animationSpeed * i);
this.timeoutIds.push(id);
return;
}
const node = visited[i];
const id = globalThis.setTimeout(() => {
if (!node.isStart && !node.isEnd) {
node.isVisited = true;
this.genericGridComponent?.drawNode(node); // Redraw single node
}
}, this.animationSpeed * i);
this.timeoutIds.push(id);
}
}
private animateShortestPath(path: Node[]): void {
for (let i = 0; i < path.length; i++) {
const node = path[i];
const id = globalThis.setTimeout(() => {
if (!node.isStart && !node.isEnd) {
node.isPath = true;
this.genericGridComponent?.drawNode(node); // Redraw single node
}
}, this.animationSpeed * i);
this.timeoutIds.push(id);
}
}
// --- Validation ---
private ensureStartAndEnd(): boolean { private ensureStartAndEnd(): boolean {
if (this.startNode && this.endNode) { if (this.startNode && this.endNode) {
return true; return true;
} }
alert(this.translate.instant('PATHFINDING.ALERT.START_END_NODES')); const message = this.translate.instant('PATHFINDING.ALERT.START_END_NODES');
this.snackBar.open(message, 'OK', { duration: 5000, horizontalPosition: 'center', verticalPosition: 'top' });
return false; return false;
} }
// Grid sizing
private clampGridSize(value: number, fallback: number): number {
const parsed = Math.floor(Number(value));
const safe = Number.isFinite(parsed) ? parsed : fallback;
return Math.min(Math.max(MIN_GRID_SIZE, safe), MAX_GRID_SIZE);
}
private computeNodeSize(rows: number, cols: number): number {
const sizeByWidth = Math.floor(MAX_GRID_PX / cols);
const sizeByHeight = Math.floor(MAX_GRID_PX / rows);
return Math.max(1, Math.min(sizeByWidth, sizeByHeight));
}
private resizeCanvas(): void {
const el = this.canvas.nativeElement;
el.width = this.gridCols * this.nodeSize;
el.height = this.gridRows * this.nodeSize;
}
// Mouse -> grid cell
private getGridPosition(event: MouseEvent | TouchEvent): GridPos | null {
const canvas = this.canvas.nativeElement;
const rect = canvas.getBoundingClientRect();
let clientX, clientY;
if (event instanceof MouseEvent) {
clientX = event.clientX;
clientY = event.clientY;
} else if (event instanceof TouchEvent && event.touches.length > 0) {
clientX = event.touches[0].clientX;
clientY = event.touches[0].clientY;
} else {
return null;
}
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
const x = (clientX - rect.left) * scaleX;
const y = (clientY - rect.top) * scaleY;
const col = Math.floor(x / this.nodeSize);
const row = Math.floor(y / this.nodeSize);
if (!this.isValidPosition(row, col)) {
return null;
}
return { row, col };
}
private isValidPosition(row: number, col: number): boolean {
return row >= 0 && row < this.gridRows && col >= 0 && col < this.gridCols;
}
private isSameCell(a: GridPos, b: GridPos | null): boolean {
return !!b && a.row === b.row && a.col === b.col;
}
private getContextOrThrow(): CanvasRenderingContext2D {
const ctx = this.canvas.nativeElement.getContext('2d');
if (!ctx) {
throw new Error('CanvasRenderingContext2D not available.');
}
return ctx;
}
private randomIntFromInterval(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1) + min);
}
protected readonly UrlConstants = UrlConstants;
} }

View File

@@ -6,8 +6,8 @@ export interface Node {
isWall: boolean; isWall: boolean;
isVisited: boolean; isVisited: boolean;
isPath: boolean; isPath: boolean;
distance: number; nodeData: number; //can be used as distance or id or something
previousNode: Node | null; linkedNode: Node | null;
fScore: number; fScore: number;
hScore: number; hScore: number;
} }

View File

@@ -0,0 +1,121 @@
import { PathfindingService } from './pathfinding.service';
import { Node } from '../pathfinding.models';
function createNode(row: number, col: number): Node {
return {
row,
col,
isStart: false,
isEnd: false,
isWall: false,
isVisited: false,
isPath: false,
nodeData: Infinity,
linkedNode: null,
fScore: Infinity,
hScore: Infinity,
};
}
function createGrid(rows: number, cols: number): Node[][] {
const grid: Node[][] = [];
for (let row = 0; row < rows; row++) {
const currentRow: Node[] = [];
for (let col = 0; col < cols; col++) {
currentRow.push(createNode(row, col));
}
grid.push(currentRow);
}
return grid;
}
describe('PathfindingService', () => {
let service: PathfindingService;
beforeEach(() => {
service = new PathfindingService();
});
it('should be created', () => {
expect(service).toBeTruthy();
});
describe('getUnvisitedNeighbors', () => {
it('returns the four orthogonal neighbors for an inner node', () => {
const grid = createGrid(3, 3);
const neighbors = service.getUnvisitedNeighbors(grid[1][1], grid);
expect(neighbors.length).toBe(4);
});
it('excludes walls and already visited nodes', () => {
const grid = createGrid(3, 3);
grid[0][1].isWall = true;
grid[1][0].isVisited = true;
const neighbors = service.getUnvisitedNeighbors(grid[1][1], grid);
expect(neighbors.length).toBe(2);
});
it('returns only in-bounds neighbors for a corner node', () => {
const grid = createGrid(3, 3);
const neighbors = service.getUnvisitedNeighbors(grid[0][0], grid);
expect(neighbors.length).toBe(2);
});
});
describe('getNodesInShortestPath', () => {
it('reconstructs the path by following linkedNode references', () => {
const grid = createGrid(1, 3);
grid[0][1].linkedNode = grid[0][0];
grid[0][2].linkedNode = grid[0][1];
const path = service.getNodesInShortestPath(grid[0][2]);
expect(path).toEqual([grid[0][0], grid[0][1], grid[0][2]]);
});
});
describe('dijkstra', () => {
it('finds the shortest path on an open grid', () => {
const grid = createGrid(5, 5);
const start = grid[0][0];
const end = grid[4][4];
const { visitedNodesInOrder, nodesInShortestPathOrder } = service.dijkstra(grid, start, end);
expect(visitedNodesInOrder).toContain(end);
// Manhattan distance 8 => 9 nodes including both endpoints.
expect(nodesInShortestPathOrder.length).toBe(9);
expect(nodesInShortestPathOrder[0]).toBe(start);
expect(nodesInShortestPathOrder[nodesInShortestPathOrder.length - 1]).toBe(end);
});
it('returns an empty path when the end node is walled off', () => {
const grid = createGrid(3, 3);
const start = grid[0][0];
const end = grid[2][2];
grid[1][2].isWall = true;
grid[2][1].isWall = true;
const { nodesInShortestPathOrder } = service.dijkstra(grid, start, end);
expect(nodesInShortestPathOrder).toEqual([]);
});
});
describe('aStar', () => {
it('reaches the end and returns a connected path on an open grid', () => {
const grid = createGrid(5, 5);
const start = grid[0][0];
const end = grid[4][4];
const { visitedNodesInOrder, nodesInShortestPathOrder } = service.aStar(grid, start, end);
expect(visitedNodesInOrder).toContain(end);
expect(nodesInShortestPathOrder.length).toBeGreaterThan(0);
expect(nodesInShortestPathOrder[0]).toBe(start);
expect(nodesInShortestPathOrder[nodesInShortestPathOrder.length - 1]).toBe(end);
});
});
});

View File

@@ -25,7 +25,7 @@ export class PathfindingService {
let currentNode: Node | null = endNode; let currentNode: Node | null = endNode;
while (currentNode !== null) { while (currentNode !== null) {
shortestPathNodes.unshift(currentNode); shortestPathNodes.unshift(currentNode);
currentNode = currentNode.previousNode; currentNode = currentNode.linkedNode;
} }
return shortestPathNodes; return shortestPathNodes;
} }
@@ -33,7 +33,7 @@ export class PathfindingService {
// Dijkstra's Algorithm // Dijkstra's Algorithm
dijkstra(grid: Node[][], startNode: Node, endNode: Node): { visitedNodesInOrder: Node[], nodesInShortestPathOrder: Node[] } { dijkstra(grid: Node[][], startNode: Node, endNode: Node): { visitedNodesInOrder: Node[], nodesInShortestPathOrder: Node[] } {
const visitedNodesInOrder: Node[] = []; const visitedNodesInOrder: Node[] = [];
startNode.distance = 0; startNode.nodeData = 0;
const unvisitedNodes: Node[] = this.getAllNodes(grid); const unvisitedNodes: Node[] = this.getAllNodes(grid);
while (unvisitedNodes.length > 0) { while (unvisitedNodes.length > 0) {
@@ -44,7 +44,7 @@ export class PathfindingService {
continue; continue;
} }
const isTrapped = closestNode.distance === Infinity; const isTrapped = closestNode.nodeData === Infinity;
if (isTrapped) if (isTrapped)
{ {
return { visitedNodesInOrder, nodesInShortestPathOrder: [] }; return { visitedNodesInOrder, nodesInShortestPathOrder: [] };
@@ -65,24 +65,24 @@ export class PathfindingService {
} }
private sortNodesByDistance(unvisitedNodes: Node[]): void { private sortNodesByDistance(unvisitedNodes: Node[]): void {
unvisitedNodes.sort((nodeA, nodeB) => nodeA.distance - nodeB.distance); unvisitedNodes.sort((nodeA, nodeB) => nodeA.nodeData - nodeB.nodeData);
} }
private updateUnvisitedNeighbors(node: Node, grid: Node[][]): void { private updateUnvisitedNeighbors(node: Node, grid: Node[][]): void {
const unvisitedNeighbors = this.getUnvisitedNeighbors(node, grid); const unvisitedNeighbors = this.getUnvisitedNeighbors(node, grid);
for (const neighbor of unvisitedNeighbors) { for (const neighbor of unvisitedNeighbors) {
neighbor.distance = node.distance + 1; neighbor.nodeData = node.nodeData + 1;
neighbor.previousNode = node; neighbor.linkedNode = node;
} }
} }
// A* Search Algorithm // A* Search Algorithm
aStar(grid: Node[][], startNode: Node, endNode: Node): { visitedNodesInOrder: Node[], nodesInShortestPathOrder: Node[] } { aStar(grid: Node[][], startNode: Node, endNode: Node): { visitedNodesInOrder: Node[], nodesInShortestPathOrder: Node[] } {
const visitedNodesInOrder: Node[] = []; const visitedNodesInOrder: Node[] = [];
startNode.distance = 0; startNode.nodeData = 0;
startNode['hScore'] = this.calculateHeuristic(startNode, endNode); startNode['hScore'] = this.calculateHeuristic(startNode, endNode);
// fScore = gScore + hScore // fScore = gScore + hScore
startNode['fScore'] = startNode.distance + startNode['hScore']; startNode['fScore'] = startNode.nodeData + startNode['hScore'];
const openSet: Node[] = [startNode]; const openSet: Node[] = [startNode];
const allNodes = this.getAllNodes(grid); const allNodes = this.getAllNodes(grid);
@@ -97,7 +97,7 @@ export class PathfindingService {
continue; continue;
} }
const isTrapped = currentNode.distance === Infinity; const isTrapped = currentNode.nodeData === Infinity;
if (isTrapped) if (isTrapped)
{ {
return {visitedNodesInOrder, nodesInShortestPathOrder: []}; return {visitedNodesInOrder, nodesInShortestPathOrder: []};
@@ -114,9 +114,9 @@ export class PathfindingService {
const neighbors = this.getUnvisitedNeighbors(currentNode, grid); const neighbors = this.getUnvisitedNeighbors(currentNode, grid);
for (const neighbor of neighbors) { for (const neighbor of neighbors) {
const tentativeGScore = currentNode.distance + 1; // Distance from start to neighbor const tentativeGScore = currentNode.nodeData + 1; // Distance from start to neighbor
if (tentativeGScore < neighbor.distance) { if (tentativeGScore < neighbor.nodeData) {
this.updateNeighborNode(neighbor, currentNode, tentativeGScore, endNode, openSet); this.updateNeighborNode(neighbor, currentNode, tentativeGScore, endNode, openSet);
} }
} }
@@ -136,11 +136,11 @@ export class PathfindingService {
} }
private updateNeighborNode(neighbor: Node, currentNode: Node, tentativeGScore: number, endNode: Node, openSet: Node[]) { private updateNeighborNode(neighbor: Node, currentNode: Node, tentativeGScore: number, endNode: Node, openSet: Node[]) {
neighbor.previousNode = currentNode; neighbor.linkedNode = currentNode;
neighbor.distance = tentativeGScore; neighbor.nodeData = tentativeGScore;
neighbor['distance'] = this.calculateHeuristic(neighbor, endNode); neighbor['nodeData'] = this.calculateHeuristic(neighbor, endNode);
neighbor['hScore'] = this.calculateHeuristic(neighbor, endNode); neighbor['hScore'] = this.calculateHeuristic(neighbor, endNode);
neighbor['fScore'] = neighbor.distance + neighbor['hScore']; neighbor['fScore'] = neighbor.nodeData + neighbor['hScore'];
if (!openSet.includes(neighbor)) { if (!openSet.includes(neighbor)) {
openSet.push(neighbor); openSet.push(neighbor);
@@ -151,7 +151,7 @@ export class PathfindingService {
for (const node of allNodes) { for (const node of allNodes) {
if (node !== startNode) { if (node !== startNode) {
node['fScore'] = Infinity; node['fScore'] = Infinity;
node.distance = Infinity; // gScore node.nodeData = Infinity; // gScore
} }
} }
} }

View File

@@ -0,0 +1,134 @@
/**
* GLSL shaders for pendulum rendering on WebGL.
* Replicates the visual output of the WGSL pendulum compute+fragment shaders.
* Uses a feedback texture (ping-pong) for trail persistence.
*
* Coordinate note: WGSL pixel buffer has Y=0 at top (screen space).
* GLSL UVs have Y=0 at bottom. We flip Y via (1.0 - uv.y) to match.
*/
export const PENDULUM_VERTEX_SHADER_GLSL = `
precision highp float;
attribute vec3 position;
attribute vec2 uv;
varying vec2 vUV;
void main() {
vUV = uv;
gl_Position = vec4(position, 1.0);
}
`;
/**
* Shader for the feedback pass: renders pendulum state to a render target texture.
* The R channel stores trail1 intensity, G stores trail2 intensity.
*/
export const PENDULUM_FEEDBACK_FRAGMENT_GLSL = `
precision highp float;
uniform vec2 resolution;
uniform float theta1;
uniform float theta2;
uniform float l1;
uniform float l2;
uniform float trailDecay;
uniform sampler2D previousFrame;
varying vec2 vUV;
void main() {
// Flip Y to match WGSL screen-space (Y=0 at top)
vec2 uv = vec2(vUV.x, 1.0 - vUV.y);
float aspect = resolution.x / resolution.y;
vec2 uvCorrected = vec2(uv.x * aspect, uv.y);
vec4 prev = texture2D(previousFrame, vUV);
float trail1 = prev.r * trailDecay;
float trail2 = prev.g * trailDecay;
vec2 origin = vec2(0.5 * aspect, 0.3);
float displayScale = 0.15;
vec2 p1 = origin + vec2(sin(theta1), cos(theta1)) * l1 * displayScale;
vec2 p2 = p1 + vec2(sin(theta2), cos(theta2)) * l2 * displayScale;
float dMass1 = length(uvCorrected - p1);
float dMass2 = length(uvCorrected - p2);
if (dMass1 < 0.02) {
trail1 = 1.0;
}
if (dMass2 < 0.02) {
trail2 = 1.0;
}
gl_FragColor = vec4(trail1, trail2, 0.0, 1.0);
}
`;
/**
* Display shader: reads trail data from feedback texture and renders final colors.
*/
export const PENDULUM_DISPLAY_FRAGMENT_GLSL = `
precision highp float;
uniform vec2 resolution;
uniform float theta1;
uniform float theta2;
uniform float l1;
uniform float l2;
uniform sampler2D trailTexture;
varying vec2 vUV;
float sdSegment(vec2 p, vec2 a, vec2 b) {
vec2 pa = p - a;
vec2 ba = b - a;
float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0);
return length(pa - ba * h);
}
void main() {
// Flip Y to match WGSL screen-space (Y=0 at top)
vec2 uv = vec2(vUV.x, 1.0 - vUV.y);
float aspect = resolution.x / resolution.y;
vec2 uvCorrected = vec2(uv.x * aspect, uv.y);
vec4 trailData = texture2D(trailTexture, vUV);
float trail1 = trailData.r;
float trail2 = trailData.g;
vec2 origin = vec2(0.5 * aspect, 0.3);
float displayScale = 0.15;
vec2 p1 = origin + vec2(sin(theta1), cos(theta1)) * l1 * displayScale;
vec2 p2 = p1 + vec2(sin(theta2), cos(theta2)) * l2 * displayScale;
float dLine1 = sdSegment(uvCorrected, origin, p1);
float dLine2 = sdSegment(uvCorrected, p1, p2);
float dMass1 = length(uvCorrected - p1);
float dMass2 = length(uvCorrected - p2);
vec3 bgColor = vec3(0.1, 0.1, 0.15);
vec3 mass1Color = vec3(1.0, 0.0, 0.0);
vec3 mass2Color = vec3(0.0, 1.0, 0.0);
vec3 line1Color = vec3(1.0, 1.0, 0.0);
vec3 line2Color = vec3(1.0, 0.0, 1.0);
vec3 finalColor = bgColor;
finalColor = mix(finalColor, mass1Color, clamp(trail1, 0.0, 1.0));
finalColor = mix(finalColor, mass2Color, clamp(trail2, 0.0, 1.0));
if (dMass1 >= 0.02 && dMass2 >= 0.02) {
if (dLine1 < 0.003) {
finalColor = line1Color;
} else if (dLine2 < 0.003) {
finalColor = line2Color;
}
}
gl_FragColor = vec4(finalColor, 1.0);
}
`;

View File

@@ -0,0 +1,67 @@
<mat-card class="w-full max-w-[1920px] p-5">
<mat-card-header>
<mat-card-title>{{ 'PENDULUM.TITLE' | translate }}</mat-card-title>
</mat-card-header>
<mat-card-content>
<app-information [algorithmInformation]="algoInformation"/>
<div class="flex flex-col mb-4">
<div class="grid grid-cols-2 tablet:grid-cols-1 tablet:gap-4 mb-6">
<div class="flex items-center gap-4 mr-4">
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.TRAIL_DECAY_TIME' | translate }}</p>
<ngx-slider class="grow" [(value)]="simParams.trailDecay" [options]="trailDecayOptions" ></ngx-slider>
</div>
<div class="flex items-center gap-4 mr-4">
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.ATTRACTION' | translate }}</p>
<ngx-slider class="grow" [(value)]="simParams.g" [options]="gravityOptions" ></ngx-slider>
</div>
<div class="flex items-center gap-4 mr-4">
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.L1_LENGTH' | translate }}</p>
<ngx-slider class="grow" [(value)]="simParams.l1" [options]="lengthOptions" ></ngx-slider>
</div>
<div class="flex items-center gap-4 mr-4">
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.L2_LENGTH' | translate }}</p>
<ngx-slider class="grow" [(value)]="simParams.l2" [options]="lengthOptions" ></ngx-slider>
</div>
<div class="flex items-center gap-4 mr-4">
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.M1_MASS' | translate }}</p>
<ngx-slider class="grow" [(value)]="simParams.m1" [options]="massOptions" ></ngx-slider>
</div>
<div class="flex items-center gap-4 mr-4">
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.M2_MASS' | translate }}</p>
<ngx-slider class="grow" [(value)]="simParams.m2" [options]="massOptions" ></ngx-slider>
</div>
<div class="flex items-center gap-4 mr-4 col-span-full">
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.DAMPING' | translate }}</p>
<ngx-slider class="grow" [(value)]="simParams.damping" [options]="dampingOptions" ></ngx-slider>
</div>
</div>
<div class="flex flex-wrap gap-3 mb-4">
<button mat-raised-button color="primary" (click)="pushPendulum(true)">
{{ 'PENDULUM.POKE_M1' | translate }}
</button>
<button mat-raised-button color="primary" (click)="pushPendulum(false)">
{{ 'PENDULUM.POKE_M2' | translate }}
</button>
<button mat-raised-button color="primary" (click)="resetPendulum()">
{{ 'PENDULUM.RESET' | translate }}
</button>
</div>
<div class="flex flex-wrap gap-4 items-center text-[0.9em] mt-2.5">
<span><span class="legend-swatch bg-yellow-400"></span> L1</span>
<span><span class="legend-swatch bg-fuchsia-500"></span> L2</span>
<span><span class="legend-swatch bg-red-600"></span> M1</span>
<span><span class="legend-swatch bg-green-600"></span> M2</span>
</div>
</div>
<app-babylon-canvas
[config]="renderConfig"
(sceneReady)="onSceneReady($event)"
(sceneResized)="onSceneReady($event)"
/>
</mat-card-content>
</mat-card>

View File

@@ -0,0 +1,183 @@
import {Component} from '@angular/core';
import {BabylonCanvas, RenderConfig, SceneEventData} from '../../../shared/components/render-canvas/babylon-canvas.component';
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
import {FormsModule} from '@angular/forms';
import {NgxSliderModule, Options} from '@angular-slider/ngx-slider';
import {DEFAULT_DAMPING, DEFAULT_G, DEFAULT_L1_LENGTH, DEFAULT_M1_MASS, DEFAULT_L2_LENGTH, DEFAULT_M2_MASS, DEFAULT_TRAIL_DECAY, MAX_DAMPING, MAX_G, MAX_LENGTH, MAX_MASS, MAX_TRAIL_DECAY, MIN_DAMPING, MIN_G, MIN_LENGTH, MIN_MASS, MIN_TRAIL_DECAY, IMPULSE_M2, IMPULSE_M1} from './pendulum.model';
import {TranslatePipe} from '@ngx-translate/core';
import {MatButton} from '@angular/material/button';
import {Information} from '../information/information';
import {AlgorithmInformation} from '../information/information.models';
import {UrlConstants} from '../../../constants/UrlConstants';
import {PendulumSimulationStrategy} from './strategies/pendulum-simulation.strategy';
import {PendulumGpuStrategy} from './strategies/pendulum-gpu.strategy';
import {PendulumCpuStrategy} from './strategies/pendulum-cpu.strategy';
@Component({
selector: 'app-pendulum',
imports: [
BabylonCanvas,
MatCard,
MatCardContent,
MatCardHeader,
MatCardTitle,
FormsModule,
NgxSliderModule,
TranslatePipe,
MatButton,
Information,
],
templateUrl: './pendulum.component.html',
styleUrl: './pendulum.component.scss',
})
class PendulumComponent {
algoInformation: AlgorithmInformation = {
title: 'PENDULUM.EXPLANATION.TITLE',
entries: [
{
name: '',
description: 'PENDULUM.EXPLANATION.EXPLANATION',
link: UrlConstants.DOUBLE_PENDULUM_WIKI
}
],
disclaimer: 'PENDULUM.EXPLANATION.DISCLAIMER',
disclaimerBottom: 'PENDULUM.EXPLANATION.DISCLAIMER_BOTTOM',
disclaimerListEntry: ['PENDULUM.EXPLANATION.DISCLAIMER_1', 'PENDULUM.EXPLANATION.DISCLAIMER_2', 'PENDULUM.EXPLANATION.DISCLAIMER_3', 'PENDULUM.EXPLANATION.DISCLAIMER_4']
};
renderConfig: RenderConfig = {
mode: '2D',
initialViewSize: 2
};
trailDecayOptions: Options = {
floor: MIN_TRAIL_DECAY,
ceil: MAX_TRAIL_DECAY,
logScale: false,
step: 0.001,
showTicks: false,
hideLimitLabels: false,
hidePointerLabels: false
};
gravityOptions: Options = {
floor: MIN_G,
ceil: MAX_G,
logScale: false,
step: 0.01,
showTicks: false,
hideLimitLabels: false,
hidePointerLabels: false
};
dampingOptions: Options = {
floor: MAX_DAMPING,
ceil: MIN_DAMPING,
logScale: false,
step: 0.001,
showTicks: false,
hideLimitLabels: false,
hidePointerLabels: false
};
lengthOptions: Options = {
floor: MIN_LENGTH,
ceil: MAX_LENGTH,
logScale: false,
step: 0.1,
showTicks: false,
hideLimitLabels: false,
hidePointerLabels: false
};
massOptions: Options = {
floor: MIN_MASS,
ceil: MAX_MASS,
logScale: false,
step: 0.1,
showTicks: false,
hideLimitLabels: false,
hidePointerLabels: false
};
readonly simParams = {
time: 0,
dt: 0.015,
g: DEFAULT_G,
m1: DEFAULT_M1_MASS,
m2: DEFAULT_M2_MASS,
l1: DEFAULT_L1_LENGTH,
l2: DEFAULT_L2_LENGTH,
damping: DEFAULT_DAMPING,
trailDecay: DEFAULT_TRAIL_DECAY,
impulseM1: 0.0,
impulseM2: 0.0,
};
private currentSceneData: SceneEventData | null = null;
private strategy: PendulumSimulationStrategy | null = null;
onSceneReady(event: SceneEventData) {
this.currentSceneData = event;
this.createSimulation();
}
private createSimulation() {
if (!this.currentSceneData) {
return;
}
const {engine, scene, gpuTier} = this.currentSceneData;
if (this.strategy) {
this.strategy.dispose();
}
this.strategy = gpuTier === 'webgpu'
? new PendulumGpuStrategy()
: new PendulumCpuStrategy();
this.strategy.init(scene, engine);
this.startParamUpdateLoop(scene);
}
private startParamUpdateLoop(scene: any): void {
scene.onAfterRenderObservable.clear();
scene.onAfterRenderObservable.add(() => {
this.simParams.time += this.simParams.dt;
if (this.strategy) {
this.strategy.updateParams({...this.simParams});
}
this.resetImpulses();
});
}
private resetImpulses() {
if (this.simParams.impulseM1 !== 0.0) {
this.simParams.impulseM1 = 0;
}
if (this.simParams.impulseM2 !== 0.0) {
this.simParams.impulseM2 = 0;
}
}
pushPendulum(m1: boolean) {
if (m1) {
this.simParams.impulseM1 = IMPULSE_M1;
return;
}
this.simParams.impulseM2 = IMPULSE_M2;
}
resetPendulum() {
this.createSimulation();
}
}
export default PendulumComponent;

View File

@@ -0,0 +1,24 @@
export const DEFAULT_G = 9.81;
export const MIN_G = 2;
export const MAX_G = 15;
export const DEFAULT_DAMPING = 0.999;
export const MIN_DAMPING = 1;
export const MAX_DAMPING = 0.7;
export const DEFAULT_TRAIL_DECAY = 0.96;
export const MIN_TRAIL_DECAY = 0.2;
export const MAX_TRAIL_DECAY = 0.9999;
export const DEFAULT_L1_LENGTH = 1.5;
export const DEFAULT_L2_LENGTH = 1.2;
export const MIN_LENGTH = 0.2;
export const MAX_LENGTH = 3;
export const DEFAULT_M1_MASS = 2;
export const DEFAULT_M2_MASS = 1;
export const MIN_MASS = 0.1;
export const MAX_MASS = 5;
export const IMPULSE_M1 = 7;
export const IMPULSE_M2 = 15;

View File

@@ -0,0 +1,236 @@
//Simple Pass-Through Shader
export const PENDULUM_VERTEX_SHADER_WGSL = `
attribute position : vec3<f32>;
@vertex
fn main(input : VertexInputs) -> FragmentInputs {
var output : FragmentInputs;
output.position = vec4<f32>(input.position, 1.0);
return output;
}
`;
// --- SHARED DATA STRUCTURES ---
// These structs map exactly to the Float32Array in the TypeScript code.
const SHARED_STRUCTS = `
struct Params {
width: f32,
height: f32,
time: f32,
dt: f32,
g: f32,
m1: f32,
m2: f32,
l1: f32,
l2: f32,
damping: f32,
trailDecay: f32,
impulseM1: f32,
impulseM2: f32,
pad: f32 // <-- Padding for safe 16-byte memory alignment
};
struct State {
theta1: f32,
theta2: f32,
v1: f32,
v2: f32
};
`;
//Fragment Shader to display the pixel buffer
export const PENDULUM_FRAGMENT_SHADER_WGSL = SHARED_STRUCTS + `
var<storage, read> pixelBuffer : array<f32>;
var<storage, read> p : Params;
@fragment
fn main(input : FragmentInputs) -> FragmentOutputs {
let width = u32(p.width);
let height = u32(p.height);
if (width == 0u || height == 0u) {
fragmentOutputs.color = vec4<f32>(0.5, 0.0, 0.0, 1.0);
return fragmentOutputs;
}
let x = u32(input.position.x);
let y = u32(input.position.y);
if (x >= width || y >= height) {
fragmentOutputs.color = vec4<f32>(0.0, 0.0, 0.0, 1.0);
return fragmentOutputs;
}
let index = y * width + x;
// --- THE MAGIC DECODING ---
var val = pixelBuffer[index];
var isLine1 = false;
var isLine2 = false;
// 1. Check for overlays (Lines)
if (val >= 20.0) {
isLine2 = true;
val = val - 20.0;
} else if (val >= 10.0) {
isLine1 = true;
val = val - 10.0;
}
// 2. Check which trail it is
var isTrail2 = false;
if (val >= 2.0) {
isTrail2 = true;
val = val - 2.0;
}
// 3. What remains is purely the fading intensity (0.0 to 1.0)
let trailIntensity = val;
// --- COLORS ---
let bgColor = vec3<f32>(0.1, 0.1, 0.15);
let mass1Color = vec3<f32>(1.0, 0.0, 0.0); // Red
let mass2Color = vec3<f32>(0.0, 1.0, 0.0); // Green
let line1Color = vec3<f32>(1.0, 1.0, 0.0); // Yellow
let line2Color = vec3<f32>(1.0, 0.0, 1.0); // Magenta
var massColor = mass1Color;
if (isTrail2) {
massColor = mass2Color;
}
// Calculate background blending with the trail
var finalColor = mix(bgColor, massColor, clamp(trailIntensity, 0.0, 1.0));
// Overwrite with the line colors if necessary
if (isLine1) { finalColor = line1Color; }
if (isLine2) { finalColor = line2Color; }
fragmentOutputs.color = vec4<f32>(finalColor, 1.0);
return fragmentOutputs;
}
`;
//Math for the double pendulum
//https://en.wikipedia.org/wiki/Double_pendulum
export const PENDULUM_PHYSIC_COMPUTE_SHADER_WGSL = SHARED_STRUCTS + `
@group(0) @binding(0) var<storage, read_write> state : State;
@group(0) @binding(1) var<storage, read> p : Params;
@compute @workgroup_size(1)
fn main() {
let t1 = state.theta1;
let t2 = state.theta2;
let v1 = state.v1;
let v2 = state.v2;
let delta_t = t1 - t2;
let num1 = -p.g * (2.0 * p.m1 + p.m2) * sin(t1)
- p.m2 * p.g * sin(t1 - 2.0 * t2)
- 2.0 * sin(delta_t) * p.m2 * (v2 * v2 * p.l2 + v1 * v1 * p.l1 * cos(delta_t));
let den1 = p.l1 * (2.0 * p.m1 + p.m2 - p.m2 * cos(2.0 * delta_t));
let a1 = num1 / den1;
let num2 = 2.0 * sin(delta_t) * (v1 * v1 * p.l1 * (p.m1 + p.m2) + p.g * (p.m1 + p.m2) * cos(t1) + v2 * v2 * p.l2 * p.m2 * cos(delta_t));
let den2 = p.l2 * (2.0 * p.m1 + p.m2 - p.m2 * cos(2.0 * delta_t));
let a2 = num2 / den2;
let new_v1 = (v1 + a1 * p.dt) * p.damping + p.impulseM1;
let new_v2 = (v2 + a2 * p.dt) * p.damping + p.impulseM2;
state.v1 = new_v1;
state.v2 = new_v2;
state.theta1 = t1 + new_v1 * p.dt;
state.theta2 = t2 + new_v2 * p.dt;
}
`;
//Pixel data to visualize the pendulum
export const PENDULUM_RENDER_COMPUTE_SHADER_WGSL = SHARED_STRUCTS + `
@group(0) @binding(0) var<storage, read_write> pixelBuffer : array<f32>;
@group(0) @binding(1) var<storage, read> p : Params;
@group(0) @binding(2) var<storage, read> state : State;
fn sdSegment(point: vec2<f32>, a: vec2<f32>, b: vec2<f32>) -> f32 {
let pa = point - a;
let ba = b - a;
let h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0);
return length(pa - ba * h);
}
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) global_id : vec3<u32>) {
let index = global_id.x;
let width = u32(p.width);
let height = u32(p.height);
if (index >= width * height) { return; }
let x = f32(index % width);
let y = f32(index / width);
let uv = vec2<f32>(x / p.width, y / p.height);
let aspect = p.width / p.height;
let uv_corr = vec2<f32>(uv.x * aspect, uv.y);
// --- 1. EXTRACT & DECAY OLD MEMORY ---
var memory = pixelBuffer[index];
// Strip line overlays from the previous frame
if (memory >= 20.0) { memory = memory - 20.0; }
else if (memory >= 10.0) { memory = memory - 10.0; }
// Check if the memory belongs to Trail 2
var isTrail2 = false;
if (memory >= 2.0) {
isTrail2 = true;
memory = memory - 2.0;
}
// Apply decay to the pure intensity
memory = memory * p.trailDecay;
// --- 2. CALCULATE GEOMETRY ---
let origin = vec2<f32>(0.5 * aspect, 0.3);
let displayScale = 0.15;
let p1 = origin + vec2<f32>(sin(state.theta1), cos(state.theta1)) * p.l1 * displayScale;
let p2 = p1 + vec2<f32>(sin(state.theta2), cos(state.theta2)) * p.l2 * displayScale;
let dLine1 = sdSegment(uv_corr, origin, p1);
let dLine2 = sdSegment(uv_corr, p1, p2);
let dMass1 = length(uv_corr - p1);
let dMass2 = length(uv_corr - p2);
// --- 3. SMART LAYERING ---
var baseVal = 0.0;
// Base Layer (Masses & Trails)
if (dMass1 < 0.02) {
baseVal = 1.0; // Mass 1 = 1.0 (Trail 1 Max)
} else if (dMass2 < 0.02) {
baseVal = 3.0; // Mass 2 = 2.0 (Flag) + 1.0 (Trail 2 Max)
} else {
// Write fading memory back
if (isTrail2) {
baseVal = memory + 2.0;
} else {
baseVal = memory;
}
}
// Overlay Layer (Lines)
var overlay = 0.0;
// Don't draw lines over the masses (Clean Z-Index)
if (dMass1 < 0.02 || dMass2 < 0.02) {
overlay = 0.0;
} else if (dLine1 < 0.003) {
overlay = 10.0;
} else if (dLine2 < 0.003) {
overlay = 20.0;
}
pixelBuffer[index] = baseVal + overlay;
}
`;

View File

@@ -0,0 +1,50 @@
/**
* CPU-side double pendulum physics mirroring PENDULUM_PHYSIC_COMPUTE_SHADER_WGSL.
* Equations from: https://en.wikipedia.org/wiki/Double_pendulum
*/
export interface PendulumState {
theta1: number;
theta2: number;
v1: number;
v2: number;
}
export interface PendulumPhysicsParams {
dt: number;
g: number;
m1: number;
m2: number;
l1: number;
l2: number;
damping: number;
impulseM1: number;
impulseM2: number;
}
export function stepPendulumPhysics(state: PendulumState, params: PendulumPhysicsParams): void {
const t1 = state.theta1;
const t2 = state.theta2;
const v1 = state.v1;
const v2 = state.v2;
const deltaT = t1 - t2;
const num1 = -params.g * (2.0 * params.m1 + params.m2) * Math.sin(t1)
- params.m2 * params.g * Math.sin(t1 - 2.0 * t2)
- 2.0 * Math.sin(deltaT) * params.m2 * (v2 * v2 * params.l2 + v1 * v1 * params.l1 * Math.cos(deltaT));
const den1 = params.l1 * (2.0 * params.m1 + params.m2 - params.m2 * Math.cos(2.0 * deltaT));
const a1 = num1 / den1;
const num2 = 2.0 * Math.sin(deltaT) * (v1 * v1 * params.l1 * (params.m1 + params.m2) + params.g * (params.m1 + params.m2) * Math.cos(t1) + v2 * v2 * params.l2 * params.m2 * Math.cos(deltaT));
const den2 = params.l2 * (2.0 * params.m1 + params.m2 - params.m2 * Math.cos(2.0 * deltaT));
const a2 = num2 / den2;
const newV1 = (v1 + a1 * params.dt) * params.damping + params.impulseM1;
const newV2 = (v2 + a2 * params.dt) * params.damping + params.impulseM2;
state.v1 = newV1;
state.v2 = newV2;
state.theta1 = t1 + newV1 * params.dt;
state.theta2 = t2 + newV2 * params.dt;
}

View File

@@ -0,0 +1,134 @@
import {Constants, Engine, MeshBuilder, RenderTargetTexture, Scene, ShaderMaterial, Texture, Vector2, WebGPUEngine} from '@babylonjs/core';
import {PENDULUM_DISPLAY_FRAGMENT_GLSL, PENDULUM_FEEDBACK_FRAGMENT_GLSL, PENDULUM_VERTEX_SHADER_GLSL} from '../pendulum-glsl.shader';
import {PendulumState, stepPendulumPhysics} from './pendulum-cpu-physics';
import {PendulumSimParams, PendulumSimulationStrategy} from './pendulum-simulation.strategy';
export class PendulumCpuStrategy implements PendulumSimulationStrategy {
private scene: Scene | null = null;
private state: PendulumState = {theta1: Math.PI / 4, theta2: Math.PI / 2, v1: 0, v2: 0};
private params: PendulumSimParams | null = null;
private feedbackMaterial: ShaderMaterial | null = null;
private displayMaterial: ShaderMaterial | null = null;
private rttA: RenderTargetTexture | null = null;
private rttB: RenderTargetTexture | null = null;
private pingPong = false;
init(scene: Scene, engine: WebGPUEngine | Engine): void {
this.scene = scene;
const existingPlane = scene.getMeshByName("plane");
if (existingPlane) {
scene.removeMesh(existingPlane);
}
const width = engine.getRenderWidth();
const height = engine.getRenderHeight();
this.rttA = new RenderTargetTexture("rttA", {width, height}, scene, false, true, Constants.TEXTURETYPE_FLOAT);
this.rttB = new RenderTargetTexture("rttB", {width, height}, scene, false, true, Constants.TEXTURETYPE_FLOAT);
this.rttA.wrapU = Texture.CLAMP_ADDRESSMODE;
this.rttA.wrapV = Texture.CLAMP_ADDRESSMODE;
this.rttB.wrapU = Texture.CLAMP_ADDRESSMODE;
this.rttB.wrapV = Texture.CLAMP_ADDRESSMODE;
// Size 2 maps positions to -1..1 which fills clip space (vertex shader bypasses camera)
const feedbackPlane = MeshBuilder.CreatePlane("feedbackPlane", {size: 2}, scene);
feedbackPlane.alwaysSelectAsActiveMesh = true;
// Use layer mask to exclude from main scene rendering (only rendered via RTT)
feedbackPlane.layerMask = 0x10000000;
this.feedbackMaterial = new ShaderMaterial("feedbackMat", scene, {
vertexSource: PENDULUM_VERTEX_SHADER_GLSL,
fragmentSource: PENDULUM_FEEDBACK_FRAGMENT_GLSL
}, {
attributes: ["position", "uv"],
uniforms: ["resolution", "theta1", "theta2", "l1", "l2", "trailDecay"],
samplers: ["previousFrame"]
});
this.feedbackMaterial.backFaceCulling = false;
feedbackPlane.material = this.feedbackMaterial;
this.rttA.renderList!.push(feedbackPlane);
this.rttB.renderList!.push(feedbackPlane);
// Size 2 maps positions to -1..1 which fills clip space (vertex shader bypasses camera)
const displayPlane = MeshBuilder.CreatePlane("displayPlane", {size: 2}, scene);
displayPlane.alwaysSelectAsActiveMesh = true;
this.displayMaterial = new ShaderMaterial("displayMat", scene, {
vertexSource: PENDULUM_VERTEX_SHADER_GLSL,
fragmentSource: PENDULUM_DISPLAY_FRAGMENT_GLSL
}, {
attributes: ["position", "uv"],
uniforms: ["resolution", "theta1", "theta2", "l1", "l2"],
samplers: ["trailTexture"]
});
this.displayMaterial.backFaceCulling = false;
this.displayMaterial.disableDepthWrite = true;
displayPlane.material = this.displayMaterial;
// RTTs are rendered manually via writeTarget.render() -- do NOT add to customRenderTargets
this.startRenderLoop(scene, width, height);
}
updateParams(params: PendulumSimParams): void {
this.params = params;
}
dispose(): void {
if (this.scene) {
this.scene.onBeforeRenderObservable.clear();
}
this.rttA?.dispose();
this.rttB?.dispose();
this.feedbackMaterial?.dispose();
this.displayMaterial?.dispose();
this.scene = null;
}
private startRenderLoop(scene: Scene, width: number, height: number): void {
const resolution = new Vector2(width, height);
scene.onBeforeRenderObservable.clear();
scene.onBeforeRenderObservable.add(() => {
if (!this.params || !this.feedbackMaterial || !this.displayMaterial || !this.rttA || !this.rttB) {
return;
}
stepPendulumPhysics(this.state, {
dt: this.params.dt,
g: this.params.g,
m1: this.params.m1,
m2: this.params.m2,
l1: this.params.l1,
l2: this.params.l2,
damping: this.params.damping,
impulseM1: this.params.impulseM1,
impulseM2: this.params.impulseM2
});
const readTarget = this.pingPong ? this.rttA : this.rttB;
const writeTarget = this.pingPong ? this.rttB : this.rttA;
this.feedbackMaterial.setTexture("previousFrame", readTarget);
this.feedbackMaterial.setVector2("resolution", resolution);
this.feedbackMaterial.setFloat("theta1", this.state.theta1);
this.feedbackMaterial.setFloat("theta2", this.state.theta2);
this.feedbackMaterial.setFloat("l1", this.params.l1);
this.feedbackMaterial.setFloat("l2", this.params.l2);
this.feedbackMaterial.setFloat("trailDecay", this.params.trailDecay);
writeTarget.render();
this.displayMaterial.setTexture("trailTexture", writeTarget);
this.displayMaterial.setVector2("resolution", resolution);
this.displayMaterial.setFloat("theta1", this.state.theta1);
this.displayMaterial.setFloat("theta2", this.state.theta2);
this.displayMaterial.setFloat("l1", this.params.l1);
this.displayMaterial.setFloat("l2", this.params.l2);
this.pingPong = !this.pingPong;
});
}
}

View File

@@ -0,0 +1,112 @@
import {Camera, ComputeShader, Engine, MeshBuilder, Scene, ShaderLanguage, ShaderMaterial, StorageBuffer, WebGPUEngine} from '@babylonjs/core';
import {PENDULUM_FRAGMENT_SHADER_WGSL, PENDULUM_PHYSIC_COMPUTE_SHADER_WGSL, PENDULUM_RENDER_COMPUTE_SHADER_WGSL, PENDULUM_VERTEX_SHADER_WGSL} from '../pendulum.shader';
import {PendulumSimParams, PendulumSimulationStrategy} from './pendulum-simulation.strategy';
export class PendulumGpuStrategy implements PendulumSimulationStrategy {
private scene: Scene | null = null;
private paramsData = new Float32Array(14);
private paramsBuffer: StorageBuffer | null = null;
init(scene: Scene, engine: WebGPUEngine | Engine): void {
this.scene = scene;
const gpuEngine = engine as WebGPUEngine;
gpuEngine.resize();
const width = gpuEngine.getRenderWidth();
const height = gpuEngine.getRenderHeight();
const totalPixels = width * height;
const pixelBuffer = new StorageBuffer(gpuEngine, totalPixels * 4);
const stateBuffer = new StorageBuffer(gpuEngine, 4 * 4);
stateBuffer.update(new Float32Array([Math.PI / 4, Math.PI / 2, 0, 0]));
this.paramsBuffer = new StorageBuffer(gpuEngine, 14 * 4);
const csPhysics = new ComputeShader("physics", gpuEngine,
{computeSource: PENDULUM_PHYSIC_COMPUTE_SHADER_WGSL},
{bindingsMapping: {"state": {group: 0, binding: 0}, "p": {group: 0, binding: 1}}}
);
csPhysics.setStorageBuffer("state", stateBuffer);
csPhysics.setStorageBuffer("p", this.paramsBuffer);
const csRender = new ComputeShader("render", gpuEngine,
{computeSource: PENDULUM_RENDER_COMPUTE_SHADER_WGSL},
{bindingsMapping: {"pixelBuffer": {group: 0, binding: 0}, "p": {group: 0, binding: 1}, "state": {group: 0, binding: 2}}}
);
csRender.setStorageBuffer("pixelBuffer", pixelBuffer);
csRender.setStorageBuffer("p", this.paramsBuffer);
csRender.setStorageBuffer("state", stateBuffer);
// Create WGSL display plane and material
const displayMaterial = new ShaderMaterial("pendulumWgslMat", scene, {
vertexSource: PENDULUM_VERTEX_SHADER_WGSL,
fragmentSource: PENDULUM_FRAGMENT_SHADER_WGSL
}, {
attributes: ["position"],
uniforms: [],
storageBuffers: ["pixelBuffer", "p"],
shaderLanguage: ShaderLanguage.WGSL
});
displayMaterial.setStorageBuffer("pixelBuffer", pixelBuffer);
displayMaterial.setStorageBuffer("p", this.paramsBuffer);
displayMaterial.backFaceCulling = false;
displayMaterial.disableDepthWrite = true;
const plane = MeshBuilder.CreatePlane("pendulumPlane", {size: 100}, scene);
const camera = scene.activeCamera as Camera;
if (camera) {
plane.lookAt(camera.position);
}
plane.alwaysSelectAsActiveMesh = true;
plane.material = displayMaterial;
scene.onBeforeRenderObservable.clear();
scene.onBeforeRenderObservable.add(() => {
if (!this.paramsBuffer) {
return;
}
const currentWidth = gpuEngine.getRenderWidth();
const currentHeight = gpuEngine.getRenderHeight();
this.paramsData[0] = currentWidth;
this.paramsData[1] = currentHeight;
this.paramsBuffer.update(this.paramsData);
csPhysics.dispatch(1, 1, 1);
const dispatchCount = Math.ceil((currentWidth * currentHeight) / 64);
csRender.dispatch(dispatchCount, 1, 1);
});
}
updateParams(params: PendulumSimParams): void {
// paramsData[0] (width) and paramsData[1] (height) are set in the render loop
this.paramsData[2] = params.time;
this.paramsData[3] = params.dt;
this.paramsData[4] = params.g;
this.paramsData[5] = params.m1;
this.paramsData[6] = params.m2;
this.paramsData[7] = params.l1;
this.paramsData[8] = params.l2;
this.paramsData[9] = params.damping;
this.paramsData[10] = params.trailDecay;
this.paramsData[11] = params.impulseM1;
this.paramsData[12] = params.impulseM2;
this.paramsData[13] = 0;
}
dispose(): void {
if (this.scene) {
this.scene.onBeforeRenderObservable.clear();
const plane = this.scene.getMeshByName("pendulumPlane");
if (plane) {
this.scene.removeMesh(plane);
plane.dispose();
}
}
this.paramsBuffer = null;
this.scene = null;
}
}

View File

@@ -0,0 +1,21 @@
import {Engine, Scene, WebGPUEngine} from '@babylonjs/core';
export interface PendulumSimParams {
time: number;
dt: number;
g: number;
m1: number;
m2: number;
l1: number;
l2: number;
damping: number;
trailDecay: number;
impulseM1: number;
impulseM2: number;
}
export interface PendulumSimulationStrategy {
init(scene: Scene, engine: WebGPUEngine | Engine): void;
updateParams(params: PendulumSimParams): void;
dispose(): void;
}

View File

@@ -1,29 +0,0 @@
import { Injectable } from '@angular/core';
import { AlgorithmCategory } from '../models/algorithm-category';
import { Observable, of } from 'rxjs';
import {RouterConstants} from '../../../constants/RouterConstants';
@Injectable({
providedIn: 'root'
})
export class AlgorithmsService {
private readonly categories: AlgorithmCategory[] = [
{
id: 'pathfinding',
title: 'ALGORITHM.PATHFINDING.TITLE',
description: 'ALGORITHM.PATHFINDING.DESCRIPTION',
routerLink: RouterConstants.PATHFINDING.LINK
},
{
id: 'sorting',
title: 'ALGORITHM.SORTING.TITLE',
description: 'ALGORITHM.SORTING.DESCRIPTION',
routerLink: RouterConstants.SORTING.LINK
}
];
getCategories(): Observable<AlgorithmCategory[]> {
return of(this.categories);
}
}

View File

@@ -0,0 +1,80 @@
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class SortingAudioService {
private audioContext: AudioContext | null = null;
private ensureContext(): AudioContext {
this.audioContext ??= new AudioContext();
if (this.audioContext.state === 'suspended') {
this.audioContext.resume();
}
return this.audioContext;
}
// Call this on the user gesture (button click) so the AudioContext can be created/resumed.
initOnUserGesture(): void {
this.ensureContext();
}
playTone(value: number, maxValue: number, animationSpeedMs: number): void {
const ctx = this.ensureContext();
const frequency = this.valueToFrequency(value, maxValue);
// Keep tone duration slightly shorter than the animation frame to avoid overlap
const duration = Math.min(0.1, (animationSpeedMs * 0.75) / 1000);
const oscillator = ctx.createOscillator();
const gainNode = ctx.createGain();
oscillator.connect(gainNode);
gainNode.connect(ctx.destination);
oscillator.type = 'sawtooth';
oscillator.frequency.setValueAtTime(frequency, ctx.currentTime);
gainNode.gain.setValueAtTime(0.15, ctx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration);
oscillator.start(ctx.currentTime);
oscillator.stop(ctx.currentTime + duration);
}
playSortedSweep(sortedValues: number[], maxValue: number): void {
const ctx = this.ensureContext();
// Play a quick ascending sweep through all sorted bar values
const step = Math.ceil(sortedValues.length / 40);
sortedValues.forEach((value, i) => {
if (i % step !== 0) {
return;
}
const delayMs = (i / step) * 18;
setTimeout(() => {
const frequency = this.valueToFrequency(value, maxValue);
const oscillator = ctx.createOscillator();
const gainNode = ctx.createGain();
oscillator.connect(gainNode);
gainNode.connect(ctx.destination);
oscillator.type = 'sine';
oscillator.frequency.setValueAtTime(frequency, ctx.currentTime);
gainNode.gain.setValueAtTime(0.15, ctx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.06);
oscillator.start(ctx.currentTime);
oscillator.stop(ctx.currentTime + 0.06);
}, delayMs);
});
}
// Maps a bar value linearly to the frequency range 1801100 Hz (roughly 3 octaves)
private valueToFrequency(value: number, maxValue: number): number {
const minFreq = 400;
const maxFreq = 800;
return minFreq + (value / maxValue) * (maxFreq - minFreq);
}
}

View File

@@ -0,0 +1,57 @@
import { SortingService } from './sorting.service';
import { SortData, SortSnapshot } from '../sorting.models';
function toSortData(values: number[]): SortData[] {
return values.map((value) => ({ value, state: 'unsorted' }));
}
function finalValues(snapshots: SortSnapshot[]): number[] {
const lastSnapshot = snapshots[snapshots.length - 1];
return lastSnapshot.array.map((item) => item.value);
}
function isAscending(values: number[]): boolean {
for (let i = 1; i < values.length; i++) {
if (values[i - 1] > values[i]) {
return false;
}
}
return true;
}
describe('SortingService', () => {
let service: SortingService;
const unsorted = [5, 2, 9, 1, 5, 6, 3, 8, 7, 4];
const expectedSorted = [...unsorted].sort((a, b) => a - b);
beforeEach(() => {
service = new SortingService();
});
it('should be created', () => {
expect(service).toBeTruthy();
});
for (const algorithm of [
'bubbleSort',
'cocktailSort',
'quickSort',
'heapSort',
'timSort',
] as const) {
it(`${algorithm} should produce a fully sorted final snapshot`, () => {
const snapshots = service[algorithm](toSortData(unsorted));
const result = finalValues(snapshots);
expect(result).toEqual(expectedSorted);
expect(isAscending(result)).toBeTrue();
});
it(`${algorithm} should not mutate the input array`, () => {
const input = toSortData(unsorted);
service[algorithm](input);
expect(input.map((item) => item.value)).toEqual(unsorted);
});
}
});

View File

@@ -56,7 +56,7 @@ export class SortingService {
let start = -1; let start = -1;
let end = array.length-1; let end = array.length-1;
let changed = false; let changed: boolean;
do { do {
changed = false; changed = false;
start += 1; start += 1;
@@ -224,6 +224,103 @@ export class SortingService {
} }
} }
// --- TIM SORT ---
timSort(array: SortData[]): SortSnapshot[] {
const snapshots: SortSnapshot[] = [];
const arr = array.map(item => ({ ...item }));
const n = arr.length;
const RUN = 32;
snapshots.push(this.createSnapshot(arr));
// Step 1: Sort small runs with insertion sort
for (let i = 0; i < n; i += RUN) {
const end = Math.min(i + RUN - 1, n - 1);
this.insertionSortRange(arr, i, end, snapshots);
}
// Step 2: Merge the sorted runs
for (let size = RUN; size < n; size *= 2) {
for (let left = 0; left < n; left += 2 * size) {
const mid = Math.min(left + size - 1, n - 1);
const right = Math.min(left + 2 * size - 1, n - 1);
if (mid < right) {
this.mergeRanges(arr, left, mid, right, snapshots);
}
}
}
arr.forEach(item => item.state = 'sorted');
snapshots.push(this.createSnapshot(arr));
return snapshots;
}
private insertionSortRange(arr: SortData[], left: number, right: number, snapshots: SortSnapshot[]): void {
for (let i = left + 1; i <= right; i++) {
const tempValue = arr[i].value;
arr[i].state = 'comparing';
snapshots.push(this.createSnapshot(arr));
let j = i - 1;
while (j >= left && arr[j].value > tempValue) {
arr[j].state = 'comparing';
arr[j + 1].value = arr[j].value;
arr[j].state = 'unsorted';
snapshots.push(this.createSnapshot(arr));
j--;
}
arr[j + 1].value = tempValue;
arr[i].state = 'unsorted';
snapshots.push(this.createSnapshot(arr));
}
}
private mergeRanges(arr: SortData[], left: number, mid: number, right: number, snapshots: SortSnapshot[]): void {
const leftPart = arr.slice(left, mid + 1).map(item => ({ ...item }));
const rightPart = arr.slice(mid + 1, right + 1).map(item => ({ ...item }));
let i = 0;
let j = 0;
let k = left;
while (i < leftPart.length && j < rightPart.length) {
// Highlight the write target and the right-side source (arr[mid+1+j] is still
// untouched in the array since k never overtakes it during a merge)
const rightSourceIdx = mid + 1 + j;
arr[k].state = 'comparing';
arr[rightSourceIdx].state = 'comparing';
snapshots.push(this.createSnapshot(arr));
arr[rightSourceIdx].state = 'unsorted';
if (leftPart[i].value <= rightPart[j].value) {
arr[k].value = leftPart[i].value;
i++;
} else {
arr[k].value = rightPart[j].value;
j++;
}
arr[k].state = 'unsorted';
k++;
snapshots.push(this.createSnapshot(arr));
}
while (i < leftPart.length) {
arr[k].value = leftPart[i].value;
i++;
k++;
}
while (j < rightPart.length) {
arr[k].value = rightPart[j].value;
j++;
k++;
}
}
private swap(arr: SortData[], i: number, j: number) { private swap(arr: SortData[], i: number, j: number) {
const temp = arr[i].value; const temp = arr[i].value;
arr[i].value = arr[j].value; arr[i].value = arr[j].value;

View File

@@ -1,55 +1,20 @@
<div class="sorting-container"> <mat-card class="w-full max-w-[1920px] p-[clamp(10px,3vw,20px)]">
<mat-card class="sorting-card">
<mat-card-header> <mat-card-header>
<mat-card-title>{{ 'SORTING.TITLE' | translate }}</mat-card-title> <mat-card-title>{{ 'SORTING.TITLE' | translate }}</mat-card-title>
</mat-card-header> </mat-card-header>
<mat-card-content> <mat-card-content>
<div class="algo-info"> <app-information [algorithmInformation]="algoInformation"/>
<h3>{{ 'SORTING.EXPLANATION.TITLE' | translate }}</h3> <div class="flex gap-[clamp(5px,2vw,10px)] mb-[clamp(10px,3vw,20px)] items-center flex-wrap mt-2.5 text-[0.9em]">
<mat-form-field class="w-[clamp(150px,20vw,200px)]" appearance="fill">
<p>
<strong>Bubble Sort</strong> {{ 'SORTING.EXPLANATION.BUBBLE_SORT_EXPLANATION' | translate }}
<a href="{{UrlConstants.BUBBLE_SORT_WIKI}}" target="_blank" rel="noopener noreferrer">Wikipedia</a>
</p>
<p>
<strong>Cocktail Sort</strong> {{ 'SORTING.EXPLANATION.COCKTAIL_SORT_EXPLANATION' | translate}}
<a href="{{UrlConstants.SHAKE_SORT_WIKI}}" target="_blank" rel="noopener noreferrer">Wikipedia</a>
</p>
<p>
<strong>Quick Sort</strong> {{ 'SORTING.EXPLANATION.QUICK_SORT_EXPLANATION' | translate}}
<a href="{{UrlConstants.QUICK_SORT_WIKI}}" target="_blank" rel="noopener noreferrer">Wikipedia</a>
</p>
<p>
<strong>Heap Sort</strong> {{ 'SORTING.EXPLANATION.HEAP_SORT_EXPLANATION' | translate}}
<a href="{{UrlConstants.HEAP_SORT_WIKI}}" target="_blank" rel="noopener noreferrer">Wikipedia</a>
</p>
<p>
<strong>{{ 'SORTING.EXPLANATION.NOTE' | translate}}</strong> {{ 'SORTING.EXPLANATION.DISCLAIMER' | translate}}
</p>
<ul>
<li>{{ 'SORTING.EXPLANATION.DISCLAIMER_1' | translate}}</li>
<li>{{ 'SORTING.EXPLANATION.DISCLAIMER_2' | translate}}</li>
<li>{{ 'SORTING.EXPLANATION.DISCLAIMER_3' | translate}}</li>
</ul>
<p>
{{ 'SORTING.EXPLANATION.DISCLAIMER_4' | translate}}
</p>
</div>
<div class="controls-panel">
<mat-form-field appearance="fill">
<mat-label>{{ 'SORTING.ALGORITHM' | translate }}</mat-label> <mat-label>{{ 'SORTING.ALGORITHM' | translate }}</mat-label>
<mat-select [(ngModel)]="selectedAlgorithm"> <mat-select [(ngModel)]="selectedAlgorithm">
@for (algo of availableAlgorithms; track algo.value) { @for (algo of algoInformation.entries; track algo.name) {
<mat-option [value]="algo.value">{{ algo.name }}</mat-option> <mat-option [value]="algo.name">{{ algo.name }}</mat-option>
} }
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
<mat-form-field appearance="outline"> <mat-form-field class="w-[clamp(150px,20vw,200px)]" appearance="outline">
<mat-label>{{ 'SORTING.ARRAY_SIZE' | translate }}</mat-label> <mat-label>{{ 'SORTING.ARRAY_SIZE' | translate }}</mat-label>
<input <input
matInput matInput
@@ -62,7 +27,7 @@
/> />
</mat-form-field> </mat-form-field>
</div> </div>
<div class="controls-panel"> <div class="flex gap-[clamp(5px,2vw,10px)] mb-[clamp(10px,3vw,20px)] items-center flex-wrap">
<button mat-raised-button color="primary" (click)="startSorting()"> <button mat-raised-button color="primary" (click)="startSorting()">
<mat-icon>play_arrow</mat-icon> {{ 'SORTING.START' | translate }} <mat-icon>play_arrow</mat-icon> {{ 'SORTING.START' | translate }}
</button> </button>
@@ -72,22 +37,27 @@
<button mat-raised-button (click)="generateNewArray()"> <button mat-raised-button (click)="generateNewArray()">
<mat-icon>add_box</mat-icon> {{ 'SORTING.GENERATE_NEW_ARRAY' | translate }} <mat-icon>add_box</mat-icon> {{ 'SORTING.GENERATE_NEW_ARRAY' | translate }}
</button> </button>
<button mat-raised-button (click)="toggleSound()">
<mat-icon>{{ isSoundEnabled ? 'volume_up' : 'volume_off' }}</mat-icon>
{{ isSoundEnabled ? ('SORTING.SOUND_OFF' | translate) : ('SORTING.SOUND_ON' | translate) }}
</button>
</div> </div>
<div class="info-panel"> <div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
<p>{{ 'SORTING.EXECUTION_TIME' | translate }}: {{ executionTime }} ms</p> <p>{{ 'SORTING.EXECUTION_TIME' | translate }}: {{ executionTime }} ms</p>
</div> </div>
<div class="visualization-area"> <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) { @for (item of sortArray; track $index) {
<div <div
class="bar" class="grow w-2.5 min-w-px transition-all duration-50"
[style.height.px]="item.value * 3" [style.height.px]="item.value * 3"
[class.unsorted]="item.state === 'unsorted'" [ngClass]="{
[class.comparing]="item.state === 'comparing'" 'bg-[#424242]': item.state === 'unsorted',
[class.sorted]="item.state === 'sorted'" 'bg-[#ffeb3b]': item.state === 'comparing',
'bg-[#4caf50]': item.state === 'sorted'
}"
></div> ></div>
} }
</div> </div>
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
</div>

View File

@@ -1,62 +0,0 @@
.sorting-container {
display: flex;
justify-content: center;
align-items: flex-start;
padding: 20px;
height: 100%;
box-sizing: border-box;
.sorting-card {
width: 100%;
max-width: 1200px;
padding: 20px;
.controls-panel {
display: flex;
gap: 10px;
margin-bottom: 20px;
align-items: center;
flex-wrap: wrap;
mat-form-field {
width: 200px;
}
}
.visualization-area {
display: flex;
align-items: flex-end;
height: 300px; /* Max height for bars */
border-bottom: 1px solid #ccc;
margin-bottom: 20px;
gap: 1px;
background-color: #f0f0f0;
.bar {
flex-grow: 1;
background-color: #424242; /* Default unsorted color */
transition: height 0.05s ease-in-out, background-color 0.05s ease-in-out;
width: 10px; /* Default width, flex-grow will adjust */
min-width: 1px; /* Ensure bars are always visible */
&.unsorted {
background-color: #424242;
}
&.comparing {
background-color: #ffeb3b; /* Yellow for comparing */
}
&.sorted {
background-color: #4caf50; /* Green for sorted */
}
}
}
.info-panel {
margin-top: 10px;
font-size: 0.9em;
color: #FFFFFF;
}
}
}

View File

@@ -5,44 +5,77 @@ import {MatFormFieldModule} from "@angular/material/form-field";
import {MatSelectModule} from "@angular/material/select"; import {MatSelectModule} from "@angular/material/select";
import {MatButtonModule} from "@angular/material/button"; import {MatButtonModule} from "@angular/material/button";
import {MatIconModule} from "@angular/material/icon"; 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 { SortingService } from './service/sorting.service';
import { SortingAudioService } from './service/sorting-audio.service';
import {SortData, SortSnapshot} from './sorting.models'; import {SortData, SortSnapshot} from './sorting.models';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import {MatInput} from '@angular/material/input'; import {MatInput} from '@angular/material/input';
import {UrlConstants} from '../../../constants/UrlConstants'; import {UrlConstants} from '../../../constants/UrlConstants';
import {MIN} from '@angular/forms/signals'; import {AlgorithmInformation} from '../information/information.models';
import {Information} from '../information/information';
@Component({ @Component({
selector: 'app-sorting', selector: 'app-sorting',
standalone: true, imports: [CommonModule, MatCardModule, MatFormFieldModule, MatSelectModule, MatButtonModule, MatIconModule, TranslatePipe, FormsModule, MatInput, Information],
imports: [CommonModule, MatCardModule, MatFormFieldModule, MatSelectModule, MatButtonModule, MatIconModule, TranslateModule, FormsModule, MatInput],
templateUrl: './sorting.component.html', templateUrl: './sorting.component.html',
styleUrls: ['./sorting.component.scss'] styleUrl: './sorting.component.scss'
}) })
export class SortingComponent implements OnInit { export class SortingComponent implements OnInit {
private readonly sortingService: SortingService = inject(SortingService); private readonly sortingService: SortingService = inject(SortingService);
private readonly audioService: SortingAudioService = inject(SortingAudioService);
private readonly cdr: ChangeDetectorRef = inject(ChangeDetectorRef); private readonly cdr: ChangeDetectorRef = inject(ChangeDetectorRef);
readonly MAX_ARRAY_SIZE: number = 200; readonly MAX_ARRAY_SIZE: number = 200;
readonly MIN_ARRAY_SIZE: number = 20; readonly MIN_ARRAY_SIZE: number = 20;
algoInformation: AlgorithmInformation = {
title: 'SORTING.EXPLANATION.TITLE',
entries: [
{
name: 'Bubble Sort',
description: 'SORTING.EXPLANATION.BUBBLE_SORT_EXPLANATION',
link: UrlConstants.BUBBLE_SORT_WIKI
},
{
name: 'Cocktail Sort',
description: 'SORTING.EXPLANATION.COCKTAIL_SORT_EXPLANATION',
link: UrlConstants.SHAKE_SORT_WIKI
},
{
name: 'Quick Sort',
description: 'SORTING.EXPLANATION.QUICK_SORT_EXPLANATION',
link: UrlConstants.QUICK_SORT_WIKI
},
{
name: 'Heap Sort',
description: 'SORTING.EXPLANATION.HEAP_SORT_EXPLANATION',
link: UrlConstants.HEAP_SORT_WIKI
},
{
name: 'Tim Sort',
description: 'SORTING.EXPLANATION.TIMSORT_EXPLANATION',
link: UrlConstants.TIM_SORT_WIKI
}
],
disclaimer: 'SORTING.EXPLANATION.DISCLAIMER',
disclaimerBottom: 'SORTING.EXPLANATION.DISCLAIMER_4',
disclaimerListEntry: [
'SORTING.EXPLANATION.DISCLAIMER_1',
'SORTING.EXPLANATION.DISCLAIMER_2',
'SORTING.EXPLANATION.DISCLAIMER_3'
]
};
private timeoutIds: number[] = []; private timeoutIds: number[] = [];
sortArray: SortData[] = []; sortArray: SortData[] = [];
unsortedArrayCopy: SortData[] = []; unsortedArrayCopy: SortData[] = [];
arraySize = 50; arraySize = 50;
maxArrayValue = 100; maxArrayValue = 100;
animationSpeed = 50; // Milliseconds per step animationSpeed = 100; // Milliseconds per step
selectedAlgorithm: string = this.algoInformation.entries[0].name;
// Placeholder for available sorting algorithms
availableAlgorithms: { name: string; value: string }[] = [
{ name: 'Bubble Sort', value: 'bubbleSort' },
{ name: 'Cocktail Sort', value: 'cocktailSort' },
{ name: 'Quick Sort', value: 'quickSort' },
{ name: 'Heap Sort', value: 'heapSort' },
];
selectedAlgorithm: string = this.availableAlgorithms[0].value;
executionTime = 0; executionTime = 0;
isSoundEnabled = false;
ngOnInit(): void { ngOnInit(): void {
this.generateNewArray(); this.generateNewArray();
@@ -87,30 +120,38 @@ export class SortingComponent implements OnInit {
} }
} }
toggleSound(): void {
this.isSoundEnabled = !this.isSoundEnabled;
}
async startSorting(): Promise<void> { async startSorting(): Promise<void> {
this.resetSorting(); this.resetSorting();
// Init the AudioContext on this user gesture so the browser allows sound
this.audioService.initOnUserGesture();
const startTime = performance.now(); const startTime = performance.now();
let snapshots: SortSnapshot[] = []; let snapshots: SortSnapshot[] = [];
switch (this.selectedAlgorithm) { switch (this.selectedAlgorithm) {
case 'bubbleSort': case 'Bubble Sort':
snapshots = this.sortingService.bubbleSort(this.sortArray); snapshots = this.sortingService.bubbleSort(this.sortArray);
break; break;
case 'quickSort': case 'Quick Sort':
snapshots = this.sortingService.quickSort(this.sortArray); snapshots = this.sortingService.quickSort(this.sortArray);
break; break;
case 'heapSort': case 'Heap Sort':
snapshots = this.sortingService.heapSort(this.sortArray); snapshots = this.sortingService.heapSort(this.sortArray);
break; break;
case 'cocktailSort': case 'Cocktail Sort':
snapshots = this.sortingService.cocktailSort(this.sortArray); snapshots = this.sortingService.cocktailSort(this.sortArray);
break; break;
case 'Tim Sort':
snapshots = this.sortingService.timSort(this.sortArray);
break;
} }
const endTime = performance.now(); const endTime = performance.now();
this.executionTime = parseFloat((endTime - startTime).toFixed(4)); this.executionTime = Number.parseFloat((endTime - startTime).toFixed(4));
console.log(snapshots.length);
this.animateSorting(snapshots); this.animateSorting(snapshots);
} }
@@ -124,11 +165,22 @@ export class SortingComponent implements OnInit {
} }
} }
if (this.isSoundEnabled) {
// Play a tone for each comparing bar (max 2 at once to avoid noise)
snapshot.array
.filter(item => item.state === 'comparing')
.slice(0, 2)
.forEach(item => this.audioService.playTone(item.value, this.maxArrayValue, this.animationSpeed));
}
this.cdr.detectChanges(); this.cdr.detectChanges();
if (index === snapshots.length - 1) { if (index === snapshots.length - 1) {
this.sortArray.forEach(item => item.state = 'sorted'); this.sortArray.forEach(item => item.state = 'sorted');
this.cdr.detectChanges(); this.cdr.detectChanges();
if (this.isSoundEnabled) {
this.audioService.playSortedSweep(this.sortArray.map(item => item.value), this.maxArrayValue);
}
} }
}, index * this.animationSpeed); }, index * this.animationSpeed);
@@ -145,6 +197,4 @@ export class SortingComponent implements OnInit {
this.stopAnimations(); this.stopAnimations();
this.resetSortState(); this.resetSortState();
} }
protected readonly UrlConstants = UrlConstants;
} }

View File

@@ -1,20 +1,20 @@
<section class="imprint"> <section class="grid gap-fluid-md max-w-app mx-4 mt-auto">
<mat-card class="imprint-card"> <mat-card class="p-fluid-md">
<h2 class="imprint-title">{{ 'IMPRINT.TITLE' | translate }}</h2> <h2 class="m-0 mb-4 text-[clamp(1rem,3vw,1.2rem)] font-semibold">{{ 'IMPRINT.TITLE' | translate }}</h2>
<div class="imprint-section"> <div class="grid gap-1 mb-4">
<p class="imprint-label">{{ 'IMPRINT.PARAGRAPH' | translate }}</p> <p class="text-xs tracking-[0.04em] uppercase opacity-70 m-0">{{ 'IMPRINT.PARAGRAPH' | translate }}</p>
<p> <p>
Andreas Dahm<br /> Andreas Dahm<br />
Bgm.-Germeier-Str. 2<br /> Hauptstraße 37A<br />
85586 Poing<br /> 85614 Kirchseeon<br />
{{ 'IMPRINT.COUNTRY' | translate }} {{ 'IMPRINT.COUNTRY' | translate }}
</p> </p>
</div> </div>
<div class="imprint-section"> <div class="grid gap-1 mb-4">
<p class="imprint-label">{{ 'IMPRINT.CONTACT' | translate }}</p> <p class="text-xs tracking-[0.04em] uppercase opacity-70 m-0">{{ 'IMPRINT.CONTACT' | translate }}</p>
<p> <p>
E-Mail: E-Mail:
<a href="mailto:andreas&#46;dahm&#64;gmail&#46;com"> <a href="mailto:andreas&#46;dahm&#64;gmail&#46;com">

View File

@@ -1,40 +0,0 @@
.imprint {
display: grid;
gap: 1rem;
}
.imprint-card {
padding: 1.25rem 1.5rem;
}
.imprint-title {
margin: 0 0 1rem;
font-size: 1.1rem;
font-weight: 600;
}
.imprint-section {
display: grid;
gap: 0.25rem;
&:not(:last-child) {
margin-bottom: 1rem;
}
}
.imprint-label {
font-size: 0.75rem;
letter-spacing: 0.04em;
text-transform: uppercase;
opacity: 0.7;
margin: 0;
}
a {
color: var(--mat-primary);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}

View File

@@ -0,0 +1,10 @@
<section class="grid gap-fluid-md max-w-app mx-4 mt-auto">
<mat-card class="p-fluid-md text-center">
<h1 class="m-0 mb-2 text-[clamp(3rem,12vw,6rem)] font-bold opacity-80">404</h1>
<h2 class="m-0 mb-4 text-[clamp(1rem,3vw,1.2rem)] font-semibold">{{ 'NOT_FOUND.TITLE' | translate }}</h2>
<p class="opacity-80 mb-6">{{ 'NOT_FOUND.MESSAGE' | translate }}</p>
<a mat-flat-button color="primary" [routerLink]="RouterConstants.ABOUT.LINK">
{{ 'NOT_FOUND.BACK_HOME' | translate }}
</a>
</mat-card>
</section>

View File

@@ -0,0 +1,15 @@
import { Component } from '@angular/core';
import { RouterLink } from '@angular/router';
import { MatCard } from '@angular/material/card';
import { MatButton } from '@angular/material/button';
import { TranslatePipe } from '@ngx-translate/core';
import { RouterConstants } from '../../constants/RouterConstants';
@Component({
selector: 'app-not-found',
imports: [RouterLink, MatCard, MatButton, TranslatePipe],
templateUrl: './not-found.component.html',
})
export class NotFoundComponent {
protected readonly RouterConstants = RouterConstants;
}

View File

@@ -1,48 +1,73 @@
<h2 mat-dialog-title>{{ project.title | translate }}</h2> <h2 mat-dialog-title>{{ project.title | translate }}</h2>
<mat-dialog-content #dialogContent> <mat-dialog-content #dialogContent>
<p>{{ project.introduction | translate }}</p> <div class="flex flex-col gap-6">
<div>
<p class="text-lg leading-relaxed opacity-90 mb-4">{{ project.introduction | translate }}</p>
<ul> <div class="mb-6">
<ul class="pl-5">
@for(bullet of project.bulletPoints; track bullet) { @for(bullet of project.bulletPoints; track bullet) {
<li>{{ bullet | translate }}</li> <li class="mb-2">{{ bullet | translate }}</li>
} }
</ul> </ul>
</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/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>
<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">
@for(challenge of project.challenges; track challenge) {
<li class="mb-1">{{ challenge | translate }}</li>
}
</ul>
</div>
<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>
<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">
@for(learning of project.learnings; track learning) {
<li class="mb-1">{{ learning | translate }}</li>
}
</ul>
</div>
</div>
</div>
@if (project.images.length > 0) @if (project.images.length > 0)
{ {
<swiper-container <div class="my-4 rounded-xl overflow-hidden bg-black">
class="my-swiper" <swiper-container class="my-swiper rounded-xl" [attr.slides-per-view]="1" [attr.space-between]="12" [attr.navigation]="true"
[attr.slides-per-view]="1.2" [attr.pagination]="true" [attr.keyboard]="true" style="width: 100%;">
[attr.space-between]="12"
[attr.navigation]="true"
[attr.pagination]="true"
[attr.keyboard]="true"
style="width: 100%;"
>
@for (img of project.images; track img) { @for (img of project.images; track img) {
<swiper-slide> <swiper-slide class="rounded-xl overflow-hidden flex flex-col bg-[#222]">
<img <img class="w-full h-auto max-h-[clamp(300px,60vh,512px)]! object-contain block shrink-0" [src]="img.url" [alt]="project.title | translate" />
class="slide-img"
[src]="img.url"
[alt]="project.title | translate"
/>
@if (img.source) { @if (img.source) {
<div class="slide-source"> <div class="text-xs text-[#aaa] bg-[#2a2a2a] p-2 text-right border-t border-[#444]">
{{ img.source }} {{ img.source }}
</div> </div>
} }
</swiper-slide> </swiper-slide>
} }
</swiper-container> </swiper-container>
</div>
} }
<div class="flex flex-col gap-4 pt-4 border-t border-black/10 dark:border-white/10">
<div class="flex flex-wrap">
<mat-chip-set aria-label="Technologies"> <mat-chip-set aria-label="Technologies">
@for(tech of project.technologies; track tech) { @for(tech of project.technologies; track tech) {
<mat-chip>{{tech}}</mat-chip> <mat-chip>{{tech}}</mat-chip>
} }
</mat-chip-set> </mat-chip-set>
</div>
<div class="link-section"> <div class="flex flex-wrap gap-fluid-sm mt-6">
@for(link of project.links; track link) @for(link of project.links; track link)
{ {
<a mat-button href="{{link.url}}" target="_blank" rel="noopener noreferrer"> <a mat-button href="{{link.url}}" target="_blank" rel="noopener noreferrer">
@@ -59,6 +84,8 @@
</a> </a>
} }
</div> </div>
</div>
</div>
</mat-dialog-content> </mat-dialog-content>
<mat-dialog-actions> <mat-dialog-actions>

View File

@@ -1,81 +0,0 @@
.my-swiper::part(button-prev),
.my-swiper::part(button-next) {
width: 35px;
height: 35px;
padding: 5px;
border-radius: 999px;
background: rgba(0,0,0,.5);
color: white;
display: flex;
align-items: center;
justify-content: center;
}
.my-swiper::part(button-prev):hover,
.my-swiper::part(button-next):hover {
background: rgba(0,0,0,.75);
}
.my-swiper {
border-radius: 12px;
}
.my-swiper::part(pagination) {
bottom: 12px;
}
swiper-slide {
border-radius: 12px;
overflow: hidden;
display: flex;
flex-direction: column;
background-color: #222;
}
.slide-img {
width: 100%;
height: auto;
max-height: 512px !important;
object-fit: contain;
display: block;
flex-shrink: 0;
}
.slide-source {
font-size: 0.75rem;
color: #aaa;
background: #2a2a2a;
padding: 0.5rem;
text-align: right;
border-top: 1px solid #444;
}
ul {
padding-left: 20px;
margin-bottom: 1.5rem;
}
li {
margin-bottom: 0.5rem;
}
mat-chip-set {
margin-top: 1.5rem;
margin-bottom: 1.5rem;
}
.link-section {
display: flex;
gap: 1rem;
margin-top: 1.5rem;
flex-wrap: wrap;
a {
text-decoration: none;
}
}
mat-dialog-actions {
justify-content: flex-end;
}

View File

@@ -15,8 +15,7 @@ import {MatButton} from '@angular/material/button';
@Component({ @Component({
selector: 'app-project-dialog', selector: 'app-project-dialog',
templateUrl: './project-dialog.component.html', templateUrl: './project-dialog.component.html',
styleUrls: ['./project-dialog.component.scss'], styleUrl: './project-dialog.component.scss',
standalone: true,
imports: [ imports: [
MatDialogTitle, MatDialogTitle,
MatDialogContent, MatDialogContent,

Some files were not shown because too many files have changed in this diff Show More