Compare commits
4
Commits
331d653ae6
...
52375e5ab1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52375e5ab1 | ||
|
|
dc23023b32 | ||
|
|
718bc28bd0 | ||
|
|
ce7db0f376 |
No files matched your search
+27
@@ -0,0 +1,27 @@
|
||||
# Roadmap
|
||||
|
||||
Open ideas for the playground frontend. Tick a box when done.
|
||||
|
||||
## Quick wins
|
||||
|
||||
- [ ] **Labyrinth grid size crash**: `labyrinth.component.html` still binds `[gridRows]`; typed sizes above the max crash the page. Same fix as pathfinding (clamp in code, drop the binding).
|
||||
- [ ] **Lighthouse gate**: add an `assert` block to `lighthouserc.json` (e.g. performance, accessibility, SEO >= 0.9). Today CI runs the audit but never fails.
|
||||
Note: the `@lhci/cli` → lighthouse 13 override in `package.json` is only safe without `assert`. Check it again before you add one.
|
||||
- [ ] **robots.txt + sitemap.xml**: static files in `public/`, listing all routes from `app.routes.ts`.
|
||||
- [ ] **SEO per algorithm page**: all algorithm routes use `data: { seo: 'ALGORITHMS' }`. Give each one its own key and add `SEO.<KEY>.TITLE` / `DESCRIPTION` to `en.json` and `de.json`.
|
||||
- [ ] **nginx security headers + gzip**: no CSP, `X-Content-Type-Options`, `Referrer-Policy`, `frame-ancestors` and no `gzip` in `nginx.conf`. First check with `curl -I https://app.andreas-dahm.eu/` if the reverse proxy already sets them.
|
||||
|
||||
## Tests
|
||||
|
||||
- [ ] `seo.service.ts`: spec for title/meta update on route change and language change.
|
||||
- [ ] Pure logic without specs: `pathfinding-animator.ts`, cloth and pendulum GPU strategies (the math around them, not the shaders), `fractal3d`.
|
||||
|
||||
## Features
|
||||
|
||||
- [ ] **Shareable demo state**: keep demo settings in query params (fractal zoom/center, sorting algorithm, seeds). Opening the link restores the view.
|
||||
- [ ] **New algorithm demos** (pattern: route + `RouterConstants` + `AlgorithmsService` entry + i18n + information panel):
|
||||
- [ ] Boids / flocking (GPU path like cloth)
|
||||
- [ ] Voronoi / Delaunay
|
||||
- [x] A* vs Dijkstra side by side (reuse pathfinding)
|
||||
- [x] Fourier epicycles
|
||||
- [ ] Reaction-diffusion (Gray-Scott) shader (reuse the fractal shader setup)
|
||||
@@ -16,6 +16,7 @@ export const routes: Routes = [
|
||||
{ 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.FOURIER.PATH, loadComponent: () => import('./pages/algorithms/fourier/fourier.component').then(m => m.FourierComponent), data: { seo: 'FOURIER' } },
|
||||
{ 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' } },
|
||||
];
|
||||
@@ -60,6 +60,11 @@
|
||||
LINK: '/algorithms/four_color',
|
||||
};
|
||||
|
||||
static readonly FOURIER = {
|
||||
PATH: 'algorithms/fourier',
|
||||
LINK: '/algorithms/fourier',
|
||||
};
|
||||
|
||||
static readonly IMPRINT = {
|
||||
PATH: 'imprint',
|
||||
LINK: '/imprint',
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
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'
|
||||
static readonly FOURIER_SERIES_WIKI = 'https://de.wikipedia.org/wiki/Fourierreihe'
|
||||
static readonly GITEA = 'https://git.andreas-dahm.eu'
|
||||
|
||||
}
|
||||
@@ -70,6 +70,13 @@ export class AlgorithmsService {
|
||||
description: 'ALGORITHM.FOUR_COLOR.DESCRIPTION',
|
||||
routerLink: RouterConstants.FOUR_COLOR.LINK,
|
||||
icon: 'palette'
|
||||
},
|
||||
{
|
||||
id: 'fourier',
|
||||
title: 'ALGORITHM.FOURIER.TITLE',
|
||||
description: 'ALGORITHM.FOURIER.DESCRIPTION',
|
||||
routerLink: RouterConstants.FOURIER.LINK,
|
||||
icon: 'track_changes'
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<mat-card class="w-full max-w-[1920px] p-5">
|
||||
<mat-card-header>
|
||||
<mat-card-title>{{ 'FOURIER.TITLE' | translate }}</mat-card-title>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<app-information [algorithmInformation]="algoInformation"/>
|
||||
|
||||
<p class="mt-2.5">{{ 'FOURIER.DRAW_HINT' | translate }}</p>
|
||||
|
||||
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
|
||||
<button mat-raised-button (click)="applyPreset(Preset.HEART)">
|
||||
<mat-icon>favorite</mat-icon>{{ 'FOURIER.PRESET_HEART' | translate }}
|
||||
</button>
|
||||
<button mat-raised-button (click)="applyPreset(Preset.STAR)">
|
||||
<mat-icon>star</mat-icon>{{ 'FOURIER.PRESET_STAR' | translate }}
|
||||
</button>
|
||||
<button mat-raised-button (click)="applyPreset(Preset.SQUARE)">
|
||||
<mat-icon>crop_square</mat-icon>{{ 'FOURIER.PRESET_SQUARE' | translate }}
|
||||
</button>
|
||||
<button mat-raised-button (click)="toggleCircles()">
|
||||
{{ (showCircles() ? 'FOURIER.HIDE_CIRCLES' : 'FOURIER.SHOW_CIRCLES') | translate }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2.5 flex-wrap">
|
||||
<span class="flex items-center gap-2.5">
|
||||
{{ 'FOURIER.CIRCLES' | translate }}: {{ circleCount() }}
|
||||
<mat-slider [min]="1" [max]="maxCircles()" step="1" discrete>
|
||||
<input matSliderThumb [(ngModel)]="circleCount">
|
||||
</mat-slider>
|
||||
</span>
|
||||
<span class="flex items-center gap-2.5">
|
||||
{{ 'FOURIER.SPEED' | translate }}: {{ speed() }}
|
||||
<mat-slider [min]="MIN_SPEED" [max]="MAX_SPEED" step="0.02">
|
||||
<input matSliderThumb [(ngModel)]="speed">
|
||||
</mat-slider>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="w-full max-w-[800px] mx-auto">
|
||||
<canvas #fourierCanvas
|
||||
class="touch-none cursor-crosshair max-w-full"
|
||||
(pointerdown)="onPointerDown($event)"
|
||||
(pointermove)="onPointerMove($event)"
|
||||
(pointerup)="onPointerUp()"
|
||||
(pointercancel)="onPointerUp()"
|
||||
></canvas>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
@@ -0,0 +1,147 @@
|
||||
import {ComponentFixture, TestBed} from '@angular/core/testing';
|
||||
import {provideZonelessChangeDetection} from '@angular/core';
|
||||
import {provideNoopAnimations} from '@angular/platform-browser/animations';
|
||||
import {provideTranslateService} from '@ngx-translate/core';
|
||||
import {FourierComponent} from './fourier.component';
|
||||
import {Preset, SAMPLE_COUNT} from './fourier.models';
|
||||
|
||||
describe('FourierComponent', () => {
|
||||
let fixture: ComponentFixture<FourierComponent>;
|
||||
let component: FourierComponent;
|
||||
let canvas: HTMLCanvasElement;
|
||||
const originalGetContext = HTMLCanvasElement.prototype.getContext;
|
||||
|
||||
let fakeContext: Record<string, any>;
|
||||
|
||||
const runFrame = (now: number) => (component as any).frame(now);
|
||||
const pointer = (x: number, y: number, isPrimary = true) => new PointerEvent('pointermove', {clientX: x, clientY: y, isPrimary});
|
||||
const down = (x: number, y: number, isPrimary = true) => new PointerEvent('pointerdown', {clientX: x, clientY: y, isPrimary});
|
||||
|
||||
beforeEach(async () => {
|
||||
// jsdom has no 2D context.
|
||||
fakeContext = {
|
||||
clearRect: vi.fn(), beginPath: vi.fn(), moveTo: vi.fn(), lineTo: vi.fn(), stroke: vi.fn(), arc: vi.fn(), closePath: vi.fn(),
|
||||
strokeStyle: '', lineWidth: 1,
|
||||
};
|
||||
Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', {configurable: true, value: () => fakeContext});
|
||||
vi.stubGlobal('requestAnimationFrame', vi.fn(() => 1));
|
||||
vi.stubGlobal('cancelAnimationFrame', vi.fn());
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [FourierComponent],
|
||||
providers: [
|
||||
provideZonelessChangeDetection(),
|
||||
provideNoopAnimations(),
|
||||
provideTranslateService({lang: 'en', fallbackLang: 'en'}),
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(FourierComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
canvas = component.canvasRef.nativeElement;
|
||||
canvas.setPointerCapture = vi.fn();
|
||||
canvas.getBoundingClientRect = () => ({left: 0, top: 0, width: 400, height: 400}) as DOMRect;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', {configurable: true, value: originalGetContext});
|
||||
});
|
||||
|
||||
it('starts with the heart preset', () => {
|
||||
expect(component.terms().length).toBe(SAMPLE_COUNT);
|
||||
expect(component.circleCount()).toBe(SAMPLE_COUNT);
|
||||
});
|
||||
|
||||
it('replaces the terms when another preset is applied', () => {
|
||||
const heartFirst = component.terms()[0].amplitude;
|
||||
component.circleCount.set(5);
|
||||
|
||||
component.applyPreset(Preset.STAR);
|
||||
|
||||
expect(component.terms()[0].amplitude).not.toBeCloseTo(heartFirst);
|
||||
expect(component.circleCount()).toBe(component.terms().length);
|
||||
});
|
||||
|
||||
it('limits the used terms to the slider value', () => {
|
||||
component.circleCount.set(3);
|
||||
|
||||
expect(component.usedTerms().length).toBe(3);
|
||||
});
|
||||
|
||||
it('builds new terms from a drawn shape', () => {
|
||||
component.applyPreset(Preset.SQUARE);
|
||||
const before = component.terms();
|
||||
|
||||
component.onPointerDown(down(100, 100));
|
||||
component.onPointerMove(pointer(300, 100));
|
||||
component.onPointerMove(pointer(200, 300));
|
||||
component.onPointerUp();
|
||||
|
||||
expect(component.terms()).not.toBe(before);
|
||||
expect(component.terms().length).toBe(SAMPLE_COUNT);
|
||||
});
|
||||
|
||||
it('keeps the shape when fewer than three points are drawn', () => {
|
||||
const before = component.terms();
|
||||
|
||||
component.onPointerDown(down(100, 100));
|
||||
component.onPointerMove(pointer(300, 100));
|
||||
component.onPointerUp();
|
||||
|
||||
expect(component.terms()).toBe(before);
|
||||
});
|
||||
|
||||
it('keeps the shape when the stroke is a tap with jitter', () => {
|
||||
const before = component.terms();
|
||||
|
||||
component.onPointerDown(down(100, 100));
|
||||
component.onPointerMove(pointer(101, 100));
|
||||
component.onPointerMove(pointer(100, 101));
|
||||
component.onPointerUp();
|
||||
|
||||
expect(component.terms()).toBe(before);
|
||||
});
|
||||
|
||||
it('ignores a second touch while drawing', () => {
|
||||
const before = component.terms();
|
||||
|
||||
component.onPointerDown(down(100, 100));
|
||||
component.onPointerDown(down(300, 300, false));
|
||||
component.onPointerMove(pointer(300, 300, false));
|
||||
component.onPointerMove(pointer(300, 100));
|
||||
component.onPointerMove(pointer(200, 300));
|
||||
component.onPointerUp();
|
||||
|
||||
expect(component.terms()).not.toBe(before);
|
||||
expect((component as any).drawnPoints.length).toBe(3);
|
||||
});
|
||||
|
||||
it('draws one circle per used term on each frame', () => {
|
||||
component.circleCount.set(5);
|
||||
|
||||
runFrame(1);
|
||||
runFrame(17);
|
||||
|
||||
expect(fakeContext['arc']).toHaveBeenCalledTimes(10);
|
||||
expect(fakeContext['stroke']).toHaveBeenCalled();
|
||||
expect((component as any).trace.length).toBe(2);
|
||||
});
|
||||
|
||||
it('wraps time into 0..1 after a long frame gap', () => {
|
||||
component.speed.set(0.5);
|
||||
|
||||
runFrame(1);
|
||||
runFrame(10501);
|
||||
|
||||
expect((component as any).time).toBeCloseTo(0.25);
|
||||
expect((component as any).trace.length).toBe(1);
|
||||
});
|
||||
|
||||
it('stops the animation loop on destroy', () => {
|
||||
fixture.destroy();
|
||||
|
||||
expect(cancelAnimationFrame).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
import {AfterViewInit, Component, computed, ElementRef, HostListener, inject, NgZone, OnDestroy, signal, ViewChild} from '@angular/core';
|
||||
import {FormsModule} from '@angular/forms';
|
||||
import {MatButton} from '@angular/material/button';
|
||||
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
|
||||
import {MatIcon} from '@angular/material/icon';
|
||||
import {MatSliderModule} from '@angular/material/slider';
|
||||
import {TranslatePipe} from '@ngx-translate/core';
|
||||
import {Information} from '../information/information';
|
||||
import {AlgorithmInformation} from '../information/information.models';
|
||||
import {UrlConstants} from '../../../constants/UrlConstants';
|
||||
import {ThemeService} from '../../../service/theme.service';
|
||||
import {FourierService} from './service/fourier.service';
|
||||
import {
|
||||
DEFAULT_SPEED, EpicycleColors, EpicycleTerm, MAX_CANVAS_PX, MAX_SPEED, MIN_DRAW_LENGTH, MIN_DRAW_POINTS, MIN_SPEED, Point, Preset, SAMPLE_COUNT, THEME_COLORS
|
||||
} from './fourier.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-fourier',
|
||||
imports: [
|
||||
MatCard,
|
||||
MatCardContent,
|
||||
MatCardHeader,
|
||||
MatCardTitle,
|
||||
TranslatePipe,
|
||||
Information,
|
||||
MatButton,
|
||||
MatIcon,
|
||||
MatSliderModule,
|
||||
FormsModule
|
||||
],
|
||||
templateUrl: './fourier.component.html'
|
||||
})
|
||||
export class FourierComponent implements AfterViewInit, OnDestroy {
|
||||
private readonly fourierService = inject(FourierService);
|
||||
private readonly themeService = inject(ThemeService);
|
||||
private readonly ngZone = inject(NgZone);
|
||||
|
||||
@ViewChild('fourierCanvas', {static: true}) canvasRef!: ElementRef<HTMLCanvasElement>;
|
||||
private ctx!: CanvasRenderingContext2D;
|
||||
private animationFrameId = 0;
|
||||
private lastFrameTime = 0;
|
||||
private time = 0; // fraction of the period, 0..1
|
||||
private trace: Point[] = []; // normalized tip positions of the current period
|
||||
private drawnPoints: Point[] = []; // normalized pointer samples while drawing
|
||||
private drawing = false;
|
||||
|
||||
algoInformation: AlgorithmInformation = {
|
||||
title: 'FOURIER.EXPLANATION.TITLE',
|
||||
entries: [
|
||||
{
|
||||
name: 'FOURIER.TITLE',
|
||||
translateName: true,
|
||||
description: 'FOURIER.EXPLANATION.EXPLANATION',
|
||||
link: UrlConstants.FOURIER_SERIES_WIKI
|
||||
}
|
||||
],
|
||||
disclaimer: 'FOURIER.EXPLANATION.DISCLAIMER',
|
||||
disclaimerBottom: 'FOURIER.EXPLANATION.DISCLAIMER_BOTTOM',
|
||||
disclaimerListEntry: [
|
||||
'FOURIER.EXPLANATION.DISCLAIMER_1',
|
||||
'FOURIER.EXPLANATION.DISCLAIMER_2',
|
||||
'FOURIER.EXPLANATION.DISCLAIMER_3'
|
||||
]
|
||||
};
|
||||
|
||||
readonly terms = signal<EpicycleTerm[]>([]);
|
||||
readonly circleCount = signal(1);
|
||||
readonly speed = signal(DEFAULT_SPEED);
|
||||
readonly showCircles = signal(true);
|
||||
readonly maxCircles = computed(() => Math.max(this.terms().length, 1));
|
||||
readonly usedTerms = computed(() => this.terms().slice(0, this.circleCount()));
|
||||
|
||||
protected readonly Preset = Preset;
|
||||
protected readonly MIN_SPEED = MIN_SPEED;
|
||||
protected readonly MAX_SPEED = MAX_SPEED;
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.ctx = this.canvasRef.nativeElement.getContext('2d')!;
|
||||
this.resizeCanvas();
|
||||
this.applyPreset(Preset.HEART);
|
||||
this.ngZone.runOutsideAngular(() => {
|
||||
this.animationFrameId = requestAnimationFrame(this.frame);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
cancelAnimationFrame(this.animationFrameId);
|
||||
}
|
||||
|
||||
@HostListener('window:resize')
|
||||
resizeCanvas(): void {
|
||||
const canvas = this.canvasRef.nativeElement;
|
||||
canvas.width = canvas.height = Math.min(canvas.parentElement!.clientWidth, MAX_CANVAS_PX);
|
||||
}
|
||||
|
||||
applyPreset(preset: Preset): void {
|
||||
this.setShape(this.fourierService.presetPoints(preset));
|
||||
}
|
||||
|
||||
toggleCircles(): void {
|
||||
this.showCircles.update(v => !v);
|
||||
}
|
||||
|
||||
onPointerDown(event: PointerEvent): void {
|
||||
if (!event.isPrimary) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
this.canvasRef.nativeElement.setPointerCapture(event.pointerId);
|
||||
this.drawing = true;
|
||||
this.drawnPoints = [this.toNormalized(event)];
|
||||
}
|
||||
|
||||
onPointerMove(event: PointerEvent): void {
|
||||
if (this.drawing && event.isPrimary) {
|
||||
this.drawnPoints.push(this.toNormalized(event));
|
||||
}
|
||||
}
|
||||
|
||||
onPointerUp(): void {
|
||||
this.drawing = false;
|
||||
if (this.drawnPoints.length >= MIN_DRAW_POINTS && this.pathLength(this.drawnPoints) >= MIN_DRAW_LENGTH) {
|
||||
this.setShape(this.drawnPoints);
|
||||
}
|
||||
}
|
||||
|
||||
private pathLength(points: Point[]): number {
|
||||
let length = 0;
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
length += Math.hypot(points[i].x - points[i - 1].x, points[i].y - points[i - 1].y);
|
||||
}
|
||||
return length;
|
||||
}
|
||||
|
||||
private setShape(points: Point[]): void {
|
||||
const samples = this.fourierService.resample(points, SAMPLE_COUNT);
|
||||
this.terms.set(this.fourierService.dft(samples));
|
||||
this.circleCount.set(this.terms().length);
|
||||
this.time = 0;
|
||||
this.trace = [];
|
||||
}
|
||||
|
||||
private toNormalized(event: PointerEvent): Point {
|
||||
const rect = this.canvasRef.nativeElement.getBoundingClientRect();
|
||||
const half = rect.width / 2;
|
||||
return {x: (event.clientX - rect.left - half) / half, y: (event.clientY - rect.top - half) / half};
|
||||
}
|
||||
|
||||
private toCanvas(p: Point): Point {
|
||||
const half = this.canvasRef.nativeElement.width / 2;
|
||||
return {x: half + p.x * half, y: half + p.y * half};
|
||||
}
|
||||
|
||||
private readonly frame = (now: number): void => {
|
||||
const dt = this.lastFrameTime ? (now - this.lastFrameTime) / 1000 : 0;
|
||||
this.lastFrameTime = now;
|
||||
const positions = this.advance(dt);
|
||||
this.draw(positions);
|
||||
this.animationFrameId = requestAnimationFrame(this.frame);
|
||||
};
|
||||
|
||||
private advance(dt: number): Point[] {
|
||||
if (this.drawing || this.terms().length === 0) {
|
||||
return [];
|
||||
}
|
||||
this.time += this.speed() * dt;
|
||||
if (this.time >= 1) {
|
||||
this.time %= 1;
|
||||
this.trace = [];
|
||||
}
|
||||
const used = this.usedTerms();
|
||||
const positions = this.fourierService.epicyclePositions(used, used.length, this.time);
|
||||
this.trace.push(positions.at(-1)!);
|
||||
return positions;
|
||||
}
|
||||
|
||||
private draw(positions: Point[]): void {
|
||||
const size = this.canvasRef.nativeElement.width;
|
||||
const colors = THEME_COLORS[this.themeService.theme()];
|
||||
this.ctx.clearRect(0, 0, size, size);
|
||||
this.ctx.lineWidth = 2;
|
||||
if (this.drawing) {
|
||||
this.strokePolyline(this.drawnPoints, colors.drawing);
|
||||
return;
|
||||
}
|
||||
this.strokePolyline(this.trace, colors.trace);
|
||||
if (this.showCircles()) {
|
||||
this.drawCircles(positions, colors);
|
||||
}
|
||||
}
|
||||
|
||||
private drawCircles(positions: Point[], colors: EpicycleColors): void {
|
||||
const ctx = this.ctx;
|
||||
const half = this.canvasRef.nativeElement.width / 2;
|
||||
const used = this.usedTerms();
|
||||
for (let i = 0; i < positions.length - 1; i++) {
|
||||
const center = this.toCanvas(positions[i]);
|
||||
const next = this.toCanvas(positions[i + 1]);
|
||||
ctx.strokeStyle = colors.circle;
|
||||
ctx.beginPath();
|
||||
ctx.arc(center.x, center.y, used[i].amplitude * half, 0, 2 * Math.PI);
|
||||
ctx.stroke();
|
||||
ctx.strokeStyle = colors.path;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(center.x, center.y);
|
||||
ctx.lineTo(next.x, next.y);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
private strokePolyline(points: Point[], color: string): void {
|
||||
if (points.length < 2) {
|
||||
return;
|
||||
}
|
||||
const ctx = this.ctx;
|
||||
ctx.strokeStyle = color;
|
||||
ctx.beginPath();
|
||||
points.forEach((p, i) => {
|
||||
const c = this.toCanvas(p);
|
||||
if (i === 0) {
|
||||
ctx.moveTo(c.x, c.y);
|
||||
} else {
|
||||
ctx.lineTo(c.x, c.y);
|
||||
}
|
||||
});
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export interface Point { x: number; y: number; }
|
||||
|
||||
/** One rotating circle. frequency is signed (turns per period), phase in radians. */
|
||||
export interface EpicycleTerm { frequency: number; amplitude: number; phase: number; }
|
||||
|
||||
export enum Preset { HEART = 0, STAR = 1, SQUARE = 2 }
|
||||
|
||||
export const SAMPLE_COUNT = 256;
|
||||
export const MIN_DRAW_POINTS = 3;
|
||||
export const MIN_DRAW_LENGTH = 0.1; // normalized units; shorter strokes count as a tap
|
||||
export const MIN_SPEED = 0.02; // periods per second
|
||||
export const MAX_SPEED = 0.5;
|
||||
export const DEFAULT_SPEED = 0.1;
|
||||
export const MAX_CANVAS_PX = 800;
|
||||
export const PRESET_RADIUS = 0.8; // presets stay inside the canvas
|
||||
|
||||
export interface EpicycleColors { circle: string; path: string; trace: string; drawing: string; }
|
||||
|
||||
export const THEME_COLORS: Record<'light' | 'dark', EpicycleColors> = {
|
||||
light: { circle: 'rgba(0, 0, 0, 0.25)', path: '#0f4f8a', trace: '#d32f2f', drawing: '#555' },
|
||||
dark: { circle: 'rgba(255, 255, 255, 0.3)', path: '#6dbcff', trace: '#ff7961', drawing: '#bbb' },
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
import {FourierService} from './fourier.service';
|
||||
import {Point, Preset, SAMPLE_COUNT} from '../fourier.models';
|
||||
|
||||
describe('FourierService', () => {
|
||||
const service = new FourierService();
|
||||
|
||||
it('resamples a polygon to equally spaced points', () => {
|
||||
const square: Point[] = [{x: 0, y: 0}, {x: 1, y: 0}, {x: 1, y: 1}, {x: 0, y: 1}];
|
||||
|
||||
const samples = service.resample(square, 8);
|
||||
|
||||
expect(samples.length).toBe(8);
|
||||
samples.forEach((p, i) => {
|
||||
const next = samples[(i + 1) % 8];
|
||||
expect(Math.hypot(next.x - p.x, next.y - p.y)).toBeCloseTo(0.5);
|
||||
});
|
||||
});
|
||||
|
||||
it('resamples a single point to copies without NaN', () => {
|
||||
const samples = service.resample([{x: 0.3, y: -0.2}], 5);
|
||||
|
||||
expect(samples).toEqual(Array(5).fill({x: 0.3, y: -0.2}));
|
||||
});
|
||||
|
||||
it('turns a unit circle into one circle of radius 1', () => {
|
||||
const circle = Array.from({length: 64}, (_, n) => ({x: Math.cos(2 * Math.PI * n / 64), y: Math.sin(2 * Math.PI * n / 64)}));
|
||||
|
||||
const terms = service.dft(circle);
|
||||
|
||||
expect(terms[0].frequency).toBe(1);
|
||||
expect(terms[0].amplitude).toBeCloseTo(1);
|
||||
terms.slice(1).forEach(t => expect(t.amplitude).toBeLessThan(1e-9));
|
||||
});
|
||||
|
||||
it('sorts terms by amplitude descending', () => {
|
||||
const terms = service.dft(service.resample(service.presetPoints(Preset.STAR), 64));
|
||||
|
||||
for (let i = 0; i < terms.length - 1; i++) {
|
||||
expect(terms[i].amplitude).toBeGreaterThanOrEqual(terms[i + 1].amplitude);
|
||||
}
|
||||
});
|
||||
|
||||
it('reconstructs the input points from all terms', () => {
|
||||
const samples = service.resample(service.presetPoints(Preset.SQUARE), 32);
|
||||
const terms = service.dft(samples);
|
||||
|
||||
for (const n of [0, 7, 19]) {
|
||||
const tip = service.epicyclePositions(terms, terms.length, n / 32).at(-1)!;
|
||||
expect(tip.x).toBeCloseTo(samples[n].x, 6);
|
||||
expect(tip.y).toBeCloseTo(samples[n].y, 6);
|
||||
}
|
||||
});
|
||||
|
||||
it('truncates to the requested circle count', () => {
|
||||
const terms = service.dft(service.resample(service.presetPoints(Preset.STAR), 32));
|
||||
|
||||
expect(service.epicyclePositions(terms, 3, 0.25).length).toBe(4);
|
||||
expect(service.epicyclePositions(terms, 0, 0)).toEqual([{x: 0, y: 0}]);
|
||||
});
|
||||
|
||||
it.each([Preset.HEART, Preset.STAR, Preset.SQUARE])('keeps preset %s inside the canvas', preset => {
|
||||
const points = service.presetPoints(preset);
|
||||
|
||||
expect(points.length).toBeGreaterThanOrEqual(3);
|
||||
points.forEach(p => {
|
||||
expect(Math.abs(p.x)).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(p.y)).toBeLessThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('samples the heart preset SAMPLE_COUNT times', () => {
|
||||
expect(service.presetPoints(Preset.HEART).length).toBe(SAMPLE_COUNT);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import {Injectable} from '@angular/core';
|
||||
import {EpicycleTerm, Point, PRESET_RADIUS, Preset, SAMPLE_COUNT} from '../fourier.models';
|
||||
|
||||
@Injectable({providedIn: 'root'})
|
||||
export class FourierService {
|
||||
/** Treats points as a closed polygon and returns count points at equal arc-length spacing. */
|
||||
resample(points: Point[], count: number): Point[] {
|
||||
const n = points.length;
|
||||
const lengths = points.map((p, i) => {
|
||||
const next = points[(i + 1) % n];
|
||||
return Math.hypot(next.x - p.x, next.y - p.y);
|
||||
});
|
||||
const perimeter = lengths.reduce((a, b) => a + b, 0);
|
||||
if (n < 2 || perimeter === 0) {
|
||||
return Array.from({length: count}, () => ({...points[0]}));
|
||||
}
|
||||
|
||||
const result: Point[] = [];
|
||||
const step = perimeter / count;
|
||||
let segment = 0;
|
||||
let segmentStart = 0;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const distance = i * step;
|
||||
while (segment < n - 1 && segmentStart + lengths[segment] <= distance) {
|
||||
segmentStart += lengths[segment];
|
||||
segment++;
|
||||
}
|
||||
const from = points[segment];
|
||||
const to = points[(segment + 1) % n];
|
||||
const t = lengths[segment] === 0 ? 0 : (distance - segmentStart) / lengths[segment];
|
||||
result.push({x: from.x + (to.x - from.x) * t, y: from.y + (to.y - from.y) * t});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Direct O(N^2) DFT. Terms are sorted by amplitude, biggest first. */
|
||||
dft(points: Point[]): EpicycleTerm[] {
|
||||
const n = points.length;
|
||||
const terms: EpicycleTerm[] = [];
|
||||
for (let k = 0; k < n; k++) {
|
||||
let re = 0;
|
||||
let im = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const angle = 2 * Math.PI * k * i / n;
|
||||
const cos = Math.cos(angle);
|
||||
const sin = Math.sin(angle);
|
||||
re += points[i].x * cos + points[i].y * sin;
|
||||
im += points[i].y * cos - points[i].x * sin;
|
||||
}
|
||||
re /= n;
|
||||
im /= n;
|
||||
terms.push({
|
||||
frequency: k <= n / 2 ? k : k - n,
|
||||
amplitude: Math.hypot(re, im),
|
||||
phase: Math.atan2(im, re),
|
||||
});
|
||||
}
|
||||
return terms.sort((a, b) => b.amplitude - a.amplitude);
|
||||
}
|
||||
|
||||
/** Circle centers, then the pen tip last. time is the fraction of the period in [0, 1). */
|
||||
epicyclePositions(terms: EpicycleTerm[], count: number, time: number): Point[] {
|
||||
const positions: Point[] = [{x: 0, y: 0}];
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
for (const term of terms.slice(0, count)) {
|
||||
const angle = 2 * Math.PI * term.frequency * time + term.phase;
|
||||
x += term.amplitude * Math.cos(angle);
|
||||
y += term.amplitude * Math.sin(angle);
|
||||
positions.push({x, y});
|
||||
}
|
||||
return positions;
|
||||
}
|
||||
|
||||
presetPoints(preset: Preset): Point[] {
|
||||
switch (preset) {
|
||||
case Preset.HEART:
|
||||
return Array.from({length: SAMPLE_COUNT}, (_, i) => {
|
||||
const t = 2 * Math.PI * i / SAMPLE_COUNT;
|
||||
return {
|
||||
x: 16 * Math.sin(t) ** 3 / 17 * PRESET_RADIUS,
|
||||
y: -(13 * Math.cos(t) - 5 * Math.cos(2 * t) - 2 * Math.cos(3 * t) - Math.cos(4 * t)) / 17 * PRESET_RADIUS,
|
||||
};
|
||||
});
|
||||
case Preset.STAR:
|
||||
return Array.from({length: 10}, (_, i) => {
|
||||
const angle = -Math.PI / 2 + i * Math.PI / 5;
|
||||
const radius = i % 2 === 0 ? PRESET_RADIUS : PRESET_RADIUS * 0.45;
|
||||
return {x: radius * Math.cos(angle), y: radius * Math.sin(angle)};
|
||||
});
|
||||
case Preset.SQUARE: {
|
||||
const r = PRESET_RADIUS * 0.85;
|
||||
return [{x: -r, y: -r}, {x: r, y: -r}, {x: r, y: r}, {x: -r, y: r}];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
|
||||
<button matButton="filled" (click)="visualize('dijkstra')">{{ 'PATHFINDING.DIJKSTRA' | translate }}</button>
|
||||
<button matButton="filled" (click)="visualize('astar')">{{ 'PATHFINDING.ASTAR' | translate }}</button>
|
||||
<button matButton="filled" (click)="visualizeCompare()">{{ 'PATHFINDING.COMPARE' | translate }}</button>
|
||||
</div>
|
||||
<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>
|
||||
@@ -58,15 +59,21 @@
|
||||
<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>
|
||||
@if (!compareMode()) {
|
||||
<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>
|
||||
|
||||
<div class="flex flex-col lg:flex-row gap-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
@if (compareMode()) {
|
||||
<p class="text-[0.9em] mb-2">A* · {{ 'PATHFINDING.VISITED_NODES' | translate }}: {{ astarStats()?.visitedCount }} · {{ 'PATHFINDING.PATH_LENGTH' | translate }}: {{ astarStats()?.pathLength }}</p>
|
||||
}
|
||||
<app-generic-grid
|
||||
[gridRows]="gridRows()"
|
||||
[gridCols]="gridCols()"
|
||||
#primaryGrid
|
||||
[minGridSize]="MIN_GRID_SIZE"
|
||||
[maxGridSize]="MAX_GRID_SIZE"
|
||||
[maxGridPx]="MAX_GRID_PX"
|
||||
@@ -76,5 +83,20 @@
|
||||
[backgroundColor]="'lightgray'"
|
||||
(gridChange)="grid = $event"
|
||||
></app-generic-grid>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0" [class.hidden]="!compareMode()">
|
||||
<p class="text-[0.9em] mb-2">Dijkstra · {{ 'PATHFINDING.VISITED_NODES' | translate }}: {{ dijkstraStats()?.visitedCount }} · {{ 'PATHFINDING.PATH_LENGTH' | translate }}: {{ dijkstraStats()?.pathLength }}</p>
|
||||
<app-generic-grid
|
||||
#compareGrid
|
||||
[minGridSize]="MIN_GRID_SIZE"
|
||||
[maxGridSize]="MAX_GRID_SIZE"
|
||||
[maxGridPx]="MAX_GRID_PX"
|
||||
[createNodeFn]="createPathfindingNode"
|
||||
[getNodeColorFn]="getPathfindingNodeColor"
|
||||
[applySelectionFn]="applyCompareSelection"
|
||||
[backgroundColor]="'lightgray'"
|
||||
></app-generic-grid>
|
||||
</div>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
@@ -0,0 +1,171 @@
|
||||
import {ComponentFixture, TestBed} from '@angular/core/testing';
|
||||
import {provideZonelessChangeDetection} from '@angular/core';
|
||||
import {provideNoopAnimations} from '@angular/platform-browser/animations';
|
||||
import {provideTranslateService} from '@ngx-translate/core';
|
||||
import {PathfindingComponent} from './pathfinding.component';
|
||||
import {Node} from './pathfinding.models';
|
||||
|
||||
function countWalls(grid: Node[][]): number {
|
||||
return grid.flat().filter(node => node.isWall).length;
|
||||
}
|
||||
|
||||
describe('PathfindingComponent compare mode', () => {
|
||||
let fixture: ComponentFixture<PathfindingComponent>;
|
||||
let component: PathfindingComponent;
|
||||
const originalGetContext = HTMLCanvasElement.prototype.getContext;
|
||||
|
||||
const compareGrid = (): Node[][] => component.compareGridComponent.grid as Node[][];
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.useFakeTimers({toFake: ['setTimeout', 'clearTimeout']});
|
||||
// jsdom has no 2D context.
|
||||
const fakeContext = {
|
||||
fillRect: vi.fn(), strokeRect: vi.fn(), beginPath: vi.fn(), moveTo: vi.fn(), lineTo: vi.fn(), stroke: vi.fn(),
|
||||
fillStyle: '', strokeStyle: '', lineWidth: 1,
|
||||
};
|
||||
Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', {
|
||||
configurable: true,
|
||||
value: () => fakeContext,
|
||||
});
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [PathfindingComponent],
|
||||
providers: [
|
||||
provideZonelessChangeDetection(),
|
||||
provideNoopAnimations(),
|
||||
provideTranslateService({lang: 'en', fallbackLang: 'en'}),
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(PathfindingComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', {configurable: true, value: originalGetContext});
|
||||
});
|
||||
|
||||
it('runs both algorithms and reports stats', () => {
|
||||
component.visualizeCompare();
|
||||
vi.runAllTimers();
|
||||
|
||||
const astar = component.astarStats()!;
|
||||
const dijkstra = component.dijkstraStats()!;
|
||||
expect(component.compareMode()).toBe(true);
|
||||
expect(astar).not.toBeNull();
|
||||
expect(dijkstra).not.toBeNull();
|
||||
expect(astar.pathLength).toBe(dijkstra.pathLength);
|
||||
expect(astar.pathLength).not.toBe('∞');
|
||||
expect(astar.visitedCount).toBeLessThanOrEqual(dijkstra.visitedCount);
|
||||
});
|
||||
|
||||
it('mirrors an edit from the primary canvas', () => {
|
||||
component.visualizeCompare();
|
||||
vi.runAllTimers();
|
||||
component.selectedNodeType.set(component.NodeType.Wall);
|
||||
|
||||
component.applyPathfindingSelection({row: 0, col: 0}, component.grid);
|
||||
|
||||
expect(component.grid[0][0].isWall).toBe(true);
|
||||
expect(compareGrid()[0][0].isWall).toBe(true);
|
||||
});
|
||||
|
||||
it('mirrors an edit from the compare canvas', () => {
|
||||
component.visualizeCompare();
|
||||
vi.runAllTimers();
|
||||
component.selectedNodeType.set(component.NodeType.Wall);
|
||||
|
||||
component.applyCompareSelection({row: 1, col: 1});
|
||||
|
||||
expect(component.grid[1][1].isWall).toBe(true);
|
||||
expect(compareGrid()[1][1].isWall).toBe(true);
|
||||
});
|
||||
|
||||
it('moves the start on both grids', () => {
|
||||
component.visualizeCompare();
|
||||
vi.runAllTimers();
|
||||
const oldStart = component.startNode!;
|
||||
const {row, col} = oldStart;
|
||||
component.selectedNodeType.set(component.NodeType.Start);
|
||||
|
||||
component.applyCompareSelection({row: 2, col: 2});
|
||||
|
||||
expect(component.grid[row][col].isStart).toBe(false);
|
||||
expect(compareGrid()[row][col].isStart).toBe(false);
|
||||
expect(component.grid[2][2].isStart).toBe(true);
|
||||
expect(compareGrid()[2][2].isStart).toBe(true);
|
||||
});
|
||||
|
||||
it('syncs the compare grid when a test case is created', () => {
|
||||
component.visualizeCompare();
|
||||
vi.runAllTimers();
|
||||
|
||||
component.createCase({withWalls: false, scenario: 'edge'});
|
||||
|
||||
const grid = compareGrid();
|
||||
expect(grid[0][0].isStart).toBe(true);
|
||||
expect(grid[49][49].isEnd).toBe(true);
|
||||
expect(countWalls(grid)).toBe(0);
|
||||
});
|
||||
|
||||
it('leaves compare mode on a single run and keeps the primary grid', () => {
|
||||
component.visualizeCompare();
|
||||
vi.runAllTimers();
|
||||
const {row, col} = component.startNode!;
|
||||
|
||||
component.visualize('astar');
|
||||
vi.runAllTimers();
|
||||
|
||||
expect(component.compareMode()).toBe(false);
|
||||
expect(countWalls(component.grid)).toBeGreaterThan(0);
|
||||
expect(component.startNode).toBe(component.grid[row][col]);
|
||||
});
|
||||
|
||||
it('stops the compare animation when the grid is resized', () => {
|
||||
component.visualizeCompare();
|
||||
const drawn: Node[] = [];
|
||||
vi.spyOn(component.compareGridComponent, 'drawNode').mockImplementation(node => {
|
||||
drawn.push(node);
|
||||
});
|
||||
|
||||
component['applyGridRows'](60);
|
||||
vi.runAllTimers();
|
||||
|
||||
const mirror = compareGrid();
|
||||
const stale = drawn.filter(node => mirror[node.row]?.[node.col] !== node);
|
||||
expect(stale).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('clamps a typed grid size above the maximum and keeps the grids usable', async () => {
|
||||
component.gridRows.set(200);
|
||||
component['applyGridRows'](200);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(component.gridRows()).toBe(component.MAX_GRID_SIZE);
|
||||
expect(component.genericGridComponent.gridRows).toBe(component.MAX_GRID_SIZE);
|
||||
expect(() => component.createCase({withWalls: true, scenario: 'random'})).not.toThrow();
|
||||
expect(() => component.visualizeCompare()).not.toThrow();
|
||||
expect(compareGrid()).toHaveLength(component.MAX_GRID_SIZE);
|
||||
});
|
||||
|
||||
it('shows the clamped height in the input after a second overflow', async () => {
|
||||
const input = fixture.nativeElement.querySelector('input[type=number]') as HTMLInputElement;
|
||||
const type = async (value: string) => {
|
||||
input.value = value;
|
||||
input.dispatchEvent(new Event('input', {bubbles: true}));
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
};
|
||||
|
||||
await type('200');
|
||||
expect(input.value).toBe('150');
|
||||
|
||||
await type('1500');
|
||||
|
||||
expect(component.genericGridComponent.gridRows).toBe(150);
|
||||
expect(input.value).toBe('150');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import {AfterViewInit, Component, inject, signal, ViewChild} from '@angular/core';
|
||||
import {afterNextRender, AfterViewInit, Component, inject, Injector, signal, ViewChild} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {FormsModule} from '@angular/forms';
|
||||
|
||||
@@ -10,7 +10,7 @@ import {MatSnackBar} from '@angular/material/snack-bar';
|
||||
|
||||
import {TranslatePipe, TranslateService} from '@ngx-translate/core';
|
||||
|
||||
import {createPathfindingNode, DEFAULT_GRID_COLS, DEFAULT_GRID_ROWS, MAX_GRID_PX, MAX_GRID_SIZE, MIN_GRID_SIZE, Node, PathfindingAlgorithm, PathfindingScenario} from './pathfinding.models';
|
||||
import {createPathfindingNode, DEFAULT_GRID_COLS, DEFAULT_GRID_ROWS, MAX_GRID_PX, MAX_GRID_SIZE, MIN_GRID_SIZE, Node, PathfindingAlgorithm, PathfindingResult, PathfindingRunStats, PathfindingScenario} from './pathfinding.models';
|
||||
import {PathfindingService} from './service/pathfinding.service';
|
||||
import {PathfindingGridService} from './service/pathfinding-grid.service';
|
||||
import {PathfindingAnimator} from './pathfinding-animator';
|
||||
@@ -51,6 +51,7 @@ export class PathfindingComponent implements AfterViewInit {
|
||||
private readonly gridService = inject(PathfindingGridService);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly injector = inject(Injector);
|
||||
|
||||
readonly NodeType = NodeType;
|
||||
readonly MIN_GRID_SIZE = MIN_GRID_SIZE;
|
||||
@@ -90,12 +91,22 @@ export class PathfindingComponent implements AfterViewInit {
|
||||
readonly pathLength = signal("0");
|
||||
readonly executionTime = signal(0);
|
||||
|
||||
readonly compareMode = signal(false);
|
||||
readonly astarStats = signal<PathfindingRunStats | null>(null);
|
||||
readonly dijkstraStats = signal<PathfindingRunStats | null>(null);
|
||||
|
||||
private readonly animator = new PathfindingAnimator(
|
||||
node => this.genericGridComponent?.drawNode(node),
|
||||
() => this.animationSpeed
|
||||
);
|
||||
|
||||
@ViewChild(GenericGridComponent) genericGridComponent!: GenericGridComponent;
|
||||
private readonly compareAnimator = new PathfindingAnimator(
|
||||
node => this.compareGridComponent?.drawNode(node),
|
||||
() => this.animationSpeed
|
||||
);
|
||||
|
||||
@ViewChild('primaryGrid') genericGridComponent!: GenericGridComponent;
|
||||
@ViewChild('compareGrid') compareGridComponent!: GenericGridComponent;
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
// Canvas logic is now handled by GenericGridComponent
|
||||
@@ -112,17 +123,37 @@ export class PathfindingComponent implements AfterViewInit {
|
||||
this.genericGridComponent.maxGridPx = MAX_GRID_PX;
|
||||
this.genericGridComponent.applyGridSize(); // Trigger initial grid setup
|
||||
}
|
||||
if (this.compareGridComponent) {
|
||||
this.compareGridComponent.initializationFn = this.initializeCompareGrid;
|
||||
}
|
||||
this.createCase({withWalls: true, scenario: "normal"});
|
||||
}
|
||||
|
||||
protected applyGridRows(rows: number): void {
|
||||
this.stopAnimations();
|
||||
this.genericGridComponent.gridRows = rows;
|
||||
this.genericGridComponent.applyGridSize();
|
||||
this.applyClampedGridSize();
|
||||
}
|
||||
|
||||
protected applyGridCols(cols: number): void {
|
||||
this.stopAnimations();
|
||||
this.genericGridComponent.gridCols = cols;
|
||||
this.applyClampedGridSize();
|
||||
}
|
||||
|
||||
private applyClampedGridSize(): void {
|
||||
this.genericGridComponent.applyGridSize();
|
||||
// Only clamp down while typing; raising to the minimum would fight the user typing "100".
|
||||
// After the next render: ngModel needs to see the typed value once, else a repeated overflow never rewrites the input.
|
||||
afterNextRender(() => {
|
||||
this.gridRows.update(rows => this.clampDown(rows, this.genericGridComponent.gridRows));
|
||||
this.gridCols.update(cols => this.clampDown(cols, this.genericGridComponent.gridCols));
|
||||
}, {injector: this.injector});
|
||||
this.syncCompareGridIfActive();
|
||||
}
|
||||
|
||||
private clampDown(value: number, max: number): number {
|
||||
return Number.isFinite(value) && value > max ? max : value;
|
||||
}
|
||||
|
||||
// --- Callbacks for GenericGridComponent ---
|
||||
@@ -139,18 +170,27 @@ export class PathfindingComponent implements AfterViewInit {
|
||||
|
||||
applyPathfindingSelection = (pos: GridPos, grid: Node[][]): void => {
|
||||
this.grid = grid; // Keep internal grid in sync
|
||||
const node = grid[pos.row][pos.col];
|
||||
this.applySelection(pos, this.genericGridComponent);
|
||||
};
|
||||
|
||||
applyCompareSelection = (pos: GridPos): void => {
|
||||
this.applySelection(pos, this.compareGridComponent);
|
||||
};
|
||||
|
||||
private applySelection(pos: GridPos, source: GenericGridComponent): void {
|
||||
const node = this.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) {
|
||||
if (this.selectedNodeType() === NodeType.Wall && source.isDrawing && source['lastCell'] === null) {
|
||||
this.shouldAddWall = !node.isWall;
|
||||
}
|
||||
|
||||
const changed: Node[] = [node];
|
||||
switch (this.selectedNodeType()) {
|
||||
case NodeType.Start:
|
||||
if (this.gridService.placeStart(node, this.startNode)) {
|
||||
if (this.startNode) {
|
||||
this.genericGridComponent.drawNode(this.startNode); // Redraw old start node
|
||||
changed.push(this.startNode);
|
||||
}
|
||||
this.startNode = node;
|
||||
}
|
||||
@@ -159,7 +199,7 @@ export class PathfindingComponent implements AfterViewInit {
|
||||
case NodeType.End:
|
||||
if (this.gridService.placeEnd(node, this.endNode)) {
|
||||
if (this.endNode) {
|
||||
this.genericGridComponent.drawNode(this.endNode); // Redraw old end node
|
||||
changed.push(this.endNode);
|
||||
}
|
||||
this.endNode = node;
|
||||
}
|
||||
@@ -179,14 +219,29 @@ export class PathfindingComponent implements AfterViewInit {
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
for (const changedNode of changed) {
|
||||
this.redrawEverywhere(changedNode);
|
||||
}
|
||||
}
|
||||
|
||||
private redrawEverywhere(node: Node): void {
|
||||
this.genericGridComponent.drawNode(node);
|
||||
if (!this.compareMode()) {
|
||||
return;
|
||||
}
|
||||
const mirror = this.compareNodeOf(node);
|
||||
this.gridService.copyNodeStructure(node, mirror);
|
||||
this.compareGridComponent.drawNode(mirror);
|
||||
}
|
||||
|
||||
visualize(algorithm: PathfindingAlgorithm): void {
|
||||
if (!this.ensureStartAndEnd()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.animator.stop();
|
||||
this.stopAnimations();
|
||||
this.compareMode.set(false);
|
||||
this.clearPath();
|
||||
|
||||
const startTime = performance.now();
|
||||
@@ -199,15 +254,69 @@ export class PathfindingComponent implements AfterViewInit {
|
||||
this.animator.animate(result);
|
||||
}
|
||||
|
||||
visualizeCompare(): void {
|
||||
if (!this.ensureStartAndEnd()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.stopAnimations();
|
||||
this.compareMode.set(true);
|
||||
this.clearPath();
|
||||
this.syncCompareGrid();
|
||||
const compareGrid = this.compareGridComponent.grid as Node[][];
|
||||
const compareStart = this.compareNodeOf(this.startNode!);
|
||||
const compareEnd = this.compareNodeOf(this.endNode!);
|
||||
|
||||
const astar = this.pathfindingService.aStar(this.grid, this.startNode!, this.endNode!);
|
||||
const dijkstra = this.pathfindingService.dijkstra(compareGrid, compareStart, compareEnd);
|
||||
|
||||
this.astarStats.set(this.toRunStats(astar));
|
||||
this.dijkstraStats.set(this.toRunStats(dijkstra));
|
||||
this.animator.animate(astar);
|
||||
this.compareAnimator.animate(dijkstra);
|
||||
}
|
||||
|
||||
initializePathfindingGrid = (grid: Node[][]): void => {
|
||||
this.setupGrid(grid, 'normal', true);
|
||||
};
|
||||
|
||||
initializeCompareGrid = (grid: Node[][]): void => {
|
||||
this.gridService.copyGridStructure(this.grid, grid);
|
||||
};
|
||||
|
||||
createCase({withWalls, scenario}: { withWalls: boolean, scenario: PathfindingScenario }): void {
|
||||
this.animator.stop();
|
||||
this.stopAnimations();
|
||||
// Reinitialize grid through the generic component
|
||||
this.genericGridComponent.initializationFn = (grid) => this.setupGrid(grid, scenario, withWalls);
|
||||
this.genericGridComponent.initializeGrid(); // Trigger re-initialization and redraw
|
||||
this.syncCompareGridIfActive();
|
||||
}
|
||||
|
||||
private syncCompareGrid(): void {
|
||||
const compare = this.compareGridComponent;
|
||||
compare.gridRows = this.genericGridComponent.gridRows; // clamped values, not the raw typed signals
|
||||
compare.gridCols = this.genericGridComponent.gridCols;
|
||||
compare.applyGridSize(); // resizes the canvas; re-inits only when the size changed
|
||||
compare.initializeGrid(); // always re-init: fresh nodes = no stale search state
|
||||
}
|
||||
|
||||
private compareNodeOf(node: Node): Node {
|
||||
return this.compareGridComponent.grid[node.row][node.col] as Node;
|
||||
}
|
||||
|
||||
private syncCompareGridIfActive(): void {
|
||||
if (this.compareMode()) {
|
||||
this.syncCompareGrid();
|
||||
}
|
||||
}
|
||||
|
||||
private stopAnimations(): void {
|
||||
this.animator.stop();
|
||||
this.compareAnimator.stop();
|
||||
}
|
||||
|
||||
private toRunStats(result: PathfindingResult): PathfindingRunStats {
|
||||
return {visitedCount: result.visitedNodesInOrder.length, pathLength: PathfindingAnimator.pathLengthLabel(result)};
|
||||
}
|
||||
|
||||
private setupGrid(grid: Node[][], scenario: PathfindingScenario, withWalls: boolean): void {
|
||||
|
||||
@@ -45,3 +45,8 @@ export const MAX_GRID_SIZE = 150;
|
||||
// Canvas max size (px)
|
||||
export const MAX_GRID_PX = 1000;
|
||||
export const MAX_RANDOM_WALLS_FACTORS = 0.3;
|
||||
|
||||
export interface PathfindingRunStats {
|
||||
visitedCount: number;
|
||||
pathLength: string;
|
||||
}
|
||||
@@ -17,6 +17,35 @@ describe('PathfindingGridService', () => {
|
||||
service = new PathfindingGridService();
|
||||
});
|
||||
|
||||
describe('copyGridStructure', () => {
|
||||
it('copies start, end and walls to the same positions', () => {
|
||||
const source = createGrid(5, 5);
|
||||
const target = createGrid(5, 5);
|
||||
source[0][0].isStart = true;
|
||||
source[4][4].isEnd = true;
|
||||
source[2][3].isWall = true;
|
||||
|
||||
service.copyGridStructure(source, target);
|
||||
|
||||
expect(target[0][0].isStart).toBe(true);
|
||||
expect(target[4][4].isEnd).toBe(true);
|
||||
expect(target[2][3].isWall).toBe(true);
|
||||
expect(countWalls(target)).toBe(1);
|
||||
});
|
||||
|
||||
it('does not copy search state', () => {
|
||||
const source = createGrid(3, 3);
|
||||
const target = createGrid(3, 3);
|
||||
source[1][1].isVisited = true;
|
||||
source[1][2].isPath = true;
|
||||
|
||||
service.copyGridStructure(source, target);
|
||||
|
||||
expect(target[1][1].isVisited).toBe(false);
|
||||
expect(target[1][2].isPath).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setupScenario', () => {
|
||||
it('places start and end mid-left and mid-right with a vertical wall for "normal"', () => {
|
||||
const grid = createGrid(20, 20);
|
||||
|
||||
@@ -63,6 +63,22 @@ export class PathfindingGridService {
|
||||
}
|
||||
}
|
||||
|
||||
/** Copies the flags the user edits. Search state (isVisited, isPath, ...) is not copied. */
|
||||
copyNodeStructure(source: Node, target: Node): void {
|
||||
target.isStart = source.isStart;
|
||||
target.isEnd = source.isEnd;
|
||||
target.isWall = source.isWall;
|
||||
}
|
||||
|
||||
/** Both grids must have the same size. */
|
||||
copyGridStructure(source: Node[][], target: Node[][]): void {
|
||||
for (let row = 0; row < source.length; row++) {
|
||||
for (let col = 0; col < source[row].length; col++) {
|
||||
this.copyNodeStructure(source[row][col], target[row][col]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getScenarioStartEnd(rows: number, cols: number, scenario: PathfindingScenario): { start: GridPos; end: GridPos } {
|
||||
if (scenario === 'edge') {
|
||||
return {
|
||||
|
||||
@@ -117,6 +117,30 @@ describe('PathfindingService', () => {
|
||||
expect(nodesInShortestPathOrder[0]).toBe(start);
|
||||
expect(nodesInShortestPathOrder[nodesInShortestPathOrder.length - 1]).toBe(end);
|
||||
});
|
||||
|
||||
it('finds a path as short as Dijkstra on random grids', () => {
|
||||
let seed = 12345;
|
||||
const random = (): number => (seed = (seed * 1664525 + 1013904223) % 4294967296) / 4294967296;
|
||||
for (let run = 0; run < 20; run++) {
|
||||
const build = (): Node[][] => {
|
||||
seed = 1000 + run;
|
||||
const grid = createGrid(20, 20);
|
||||
for (const node of grid.flat()) {
|
||||
node.isWall = random() < 0.3;
|
||||
}
|
||||
grid[0][0].isWall = false;
|
||||
grid[19][19].isWall = false;
|
||||
return grid;
|
||||
};
|
||||
const astarGrid = build();
|
||||
const dijkstraGrid = build();
|
||||
|
||||
const astar = service.aStar(astarGrid, astarGrid[0][0], astarGrid[19][19]);
|
||||
const dijkstra = service.dijkstra(dijkstraGrid, dijkstraGrid[0][0], dijkstraGrid[19][19]);
|
||||
|
||||
expect(astar.nodesInShortestPathOrder.length).toBe(dijkstra.nodesInShortestPathOrder.length);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('findPath', () => {
|
||||
|
||||
@@ -155,7 +155,6 @@ export class PathfindingService {
|
||||
private updateNeighborNode(neighbor: Node, currentNode: Node, tentativeGScore: number, endNode: Node, openSet: Node[]) {
|
||||
neighbor.linkedNode = currentNode;
|
||||
neighbor.nodeData = tentativeGScore;
|
||||
neighbor['nodeData'] = this.calculateHeuristic(neighbor, endNode);
|
||||
neighbor['hScore'] = this.calculateHeuristic(neighbor, endNode);
|
||||
neighbor['fScore'] = neighbor.nodeData + neighbor['hScore'];
|
||||
|
||||
|
||||
@@ -430,6 +430,8 @@
|
||||
"CLEAR_BOARD": "Leeres Gitter",
|
||||
"VISITED": "Besucht",
|
||||
"PATH": "Pfad",
|
||||
"COMPARE": "Vergleich (A* vs Dijkstra)",
|
||||
"VISITED_NODES": "Besuchte Knoten",
|
||||
"PATH_LENGTH": "Pfadlänge",
|
||||
"EXECUTION_TIME": "Ausführungszeit",
|
||||
"EXPLANATION": {
|
||||
@@ -616,6 +618,10 @@
|
||||
"TITLE": "Vier-Farben-Satz",
|
||||
"DESCRIPTION": "Der Vier-Farben-Satz besagt, dass jede Landkarte in der Ebene mit maximal vier Farben so eingefärbt werden kann, dass keine zwei aneinandergrenzenden Gebiete dieselbe Farbe besitzen."
|
||||
},
|
||||
"FOURIER": {
|
||||
"TITLE": "Fourier-Epizykel",
|
||||
"DESCRIPTION": "Jede geschlossene Form wird von einer Kette rotierender Kreise gezeichnet. Zeichne eine eigene Form und sieh zu, wie die Kreise sie mit einer diskreten Fourier-Transformation nachbauen."
|
||||
},
|
||||
"FRACTAL": {
|
||||
"TITLE": "Fraktale",
|
||||
"DESCRIPTION": "Visualisierung von komplexe, geometrische Mustern, die sich selbst in immer kleineren Maßstäben ähneln (Selbstähnlichkeit)."
|
||||
@@ -667,6 +673,26 @@
|
||||
"NO_SOLUTION": "Keine Lösung gefunden (das sollte bei einer planaren Karte nicht passieren!)."
|
||||
}
|
||||
},
|
||||
"FOURIER": {
|
||||
"TITLE": "Fourier-Epizykel",
|
||||
"DRAW_HINT": "Zeichne eine geschlossene Form auf die Fläche oder wähle eine Vorlage",
|
||||
"PRESET_HEART": "Herz",
|
||||
"PRESET_STAR": "Stern",
|
||||
"PRESET_SQUARE": "Quadrat",
|
||||
"CIRCLES": "Kreise",
|
||||
"SPEED": "Geschwindigkeit",
|
||||
"SHOW_CIRCLES": "Kreise anzeigen",
|
||||
"HIDE_CIRCLES": "Kreise ausblenden",
|
||||
"EXPLANATION": {
|
||||
"TITLE": "Fourierreihe",
|
||||
"EXPLANATION": "Eine Fourierreihe besagt, dass jede periodische Kurve die Summe rotierender Kreise ist. Jeder Kreis hat einen eigenen Radius, eine eigene Geschwindigkeit und einen eigenen Startwinkel.",
|
||||
"DISCLAIMER": "Diese Demo geht in drei Schritten vor.",
|
||||
"DISCLAIMER_1": "Deine Form wird auf 256 gleichmäßig verteilte Punkte neu abgetastet.",
|
||||
"DISCLAIMER_2": "Jeder Punkt ist eine komplexe Zahl x + iy. Eine direkte diskrete Fourier-Transformation liefert einen Kreis pro Frequenz.",
|
||||
"DISCLAIMER_3": "Die Kreise werden nach Radius sortiert. Der Regler behält nur die größten N Kreise.",
|
||||
"DISCLAIMER_BOTTOM": "Weniger Kreise geben eine glattere Näherung. Mit allen Kreisen läuft die Kurve exakt durch alle 256 Punkte."
|
||||
}
|
||||
},
|
||||
"STOPWATCH": {
|
||||
"TITLE": "Stoppuhr",
|
||||
"START": "Start",
|
||||
@@ -703,6 +729,10 @@
|
||||
"TITLE": "Algorithmen – Andreas Dahm",
|
||||
"DESCRIPTION": "Interaktive Visualisierungen von Algorithmen und Simulationen: Pathfinding, Sortierung, Fraktale, Stoff- und Pendelphysik – direkt im Browser."
|
||||
},
|
||||
"FOURIER": {
|
||||
"TITLE": "Fourier-Epizykel – Andreas Dahm",
|
||||
"DESCRIPTION": "Interaktive Fourier-Epizykel: Zeichne eine geschlossene Form und sieh zu, wie eine Kette rotierender Kreise sie mit einer diskreten Fourier-Transformation nachzeichnet."
|
||||
},
|
||||
"IMPRINT": {
|
||||
"TITLE": "Impressum – Andreas Dahm",
|
||||
"DESCRIPTION": "Rechtliche Informationen und Kontaktdaten zur Website Andreas Dahm Playground."
|
||||
|
||||
@@ -430,6 +430,8 @@
|
||||
"CLEAR_BOARD": "Empty Board",
|
||||
"VISITED": "Visited",
|
||||
"PATH": "Path",
|
||||
"COMPARE": "Compare (A* vs Dijkstra)",
|
||||
"VISITED_NODES": "Visited nodes",
|
||||
"PATH_LENGTH": "Path length",
|
||||
"EXECUTION_TIME": "Execution Time",
|
||||
"EXPLANATION": {
|
||||
@@ -616,6 +618,10 @@
|
||||
"TITLE": "Four Color Theorem",
|
||||
"DESCRIPTION": "The four color theorem states that any map in a plane can be colored using at most four colors in such a way that regions sharing a common boundary (other than a single point) do not share the same color."
|
||||
},
|
||||
"FOURIER": {
|
||||
"TITLE": "Fourier Epicycles",
|
||||
"DESCRIPTION": "Any closed shape is drawn by a chain of rotating circles. Draw your own shape and watch the circles rebuild it with a discrete Fourier transform."
|
||||
},
|
||||
"FRACTAL": {
|
||||
"TITLE": "Fractals",
|
||||
"DESCRIPTION": "Visualisation of complex geometric patterns that resemble each other on increasingly smaller scales (self-similarity)."
|
||||
@@ -667,6 +673,26 @@
|
||||
"NO_SOLUTION": "No solution found (this should not happen for a planar map!)."
|
||||
}
|
||||
},
|
||||
"FOURIER": {
|
||||
"TITLE": "Fourier Epicycles",
|
||||
"DRAW_HINT": "Draw a closed shape on the canvas or pick a preset",
|
||||
"PRESET_HEART": "Heart",
|
||||
"PRESET_STAR": "Star",
|
||||
"PRESET_SQUARE": "Square",
|
||||
"CIRCLES": "Circles",
|
||||
"SPEED": "Speed",
|
||||
"SHOW_CIRCLES": "Show circles",
|
||||
"HIDE_CIRCLES": "Hide circles",
|
||||
"EXPLANATION": {
|
||||
"TITLE": "Fourier Series",
|
||||
"EXPLANATION": "A Fourier series says that any periodic curve is a sum of rotating circles. Each circle has its own radius, speed and start angle.",
|
||||
"DISCLAIMER": "This demo does it in three steps.",
|
||||
"DISCLAIMER_1": "Your shape is resampled to 256 equally spaced points.",
|
||||
"DISCLAIMER_2": "Each point is a complex number x + iy. A direct discrete Fourier transform gives one circle per frequency.",
|
||||
"DISCLAIMER_3": "The circles are sorted by radius. The slider keeps only the biggest N circles.",
|
||||
"DISCLAIMER_BOTTOM": "Fewer circles give a smoother approximation. With all circles the curve passes exactly through all 256 points."
|
||||
}
|
||||
},
|
||||
"STOPWATCH": {
|
||||
"TITLE": "Stopwatch",
|
||||
"START": "Start",
|
||||
@@ -703,6 +729,10 @@
|
||||
"TITLE": "Algorithms – Andreas Dahm",
|
||||
"DESCRIPTION": "Interactive visualizations of algorithms and simulations: pathfinding, sorting, fractals, cloth and pendulum physics, running in the browser."
|
||||
},
|
||||
"FOURIER": {
|
||||
"TITLE": "Fourier Epicycles – Andreas Dahm",
|
||||
"DESCRIPTION": "Interactive Fourier epicycles: draw a closed shape and watch a chain of rotating circles trace it with a discrete Fourier transform."
|
||||
},
|
||||
"IMPRINT": {
|
||||
"TITLE": "Imprint – Andreas Dahm",
|
||||
"DESCRIPTION": "Legal information and contact details for the Andreas Dahm Playground website."
|
||||
|
||||
Reference in new issue
Block a user