Compare commits

..
4 Commits
Author SHA1 Message Date
lobo 52375e5ab1 feat(fourier): add epicycles algorithm demo
Build, Test & Push Frontend / docker (push) Successful in 53s
Build, Test & Push Frontend / quality-check (push) Successful in 1m25s
Draw a shape or pick a preset, then rebuild it with a direct DFT.
Each frequency becomes one circle. Circles are sorted by radius, and
the circles slider keeps the biggest N. Also tick the ROADMAP item and
add the FOURIER SEO key.
2026-10-14 16:05:54 +02:00
lobo dc23023b32 docs: add ROADMAP.md 2026-10-14 15:40:20 +02:00
lobo 718bc28bd0 feat(pathfinding): add A* vs Dijkstra compare mode
Show A* and Dijkstra side by side on the same grid with visited count and path length. Also clamp typed grid sizes above the max instead of crashing.
2026-10-14 15:40:20 +02:00
lobo ce7db0f376 fix(pathfinding): use real cost in A* instead of greedy heuristic
updateNeighborNode overwrote gScore with the heuristic, so A* behaved like greedy best-first search and found longer paths than Dijkstra.
2026-10-14 15:40:20 +02:00
21 changed files with 1122 additions and 28 deletions

No files matched your search

+27
View File
@@ -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)
+1
View File
@@ -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' } },
];
+5
View File
@@ -60,6 +60,11 @@
LINK: '/algorithms/four_color',
};
static readonly FOURIER = {
PATH: 'algorithms/fourier',
LINK: '/algorithms/fourier',
};
static readonly IMPRINT = {
PATH: 'imprint',
LINK: '/imprint',
+1
View File
@@ -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,23 +59,44 @@
<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>
@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>
<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>
<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
#primaryGrid
[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>
</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'];
+30
View File
@@ -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."
+30
View File
@@ -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."