feat(fourier): add epicycles algorithm demo
Build, Test & Push Frontend / quality-check (push) Successful in 1m25s
Build, Test & Push Frontend / docker (push) Successful in 53s

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.
This commit is contained in:
lobo committed 2026-10-14 16:05:54 +02:00
1 parent dc23023b32
commit 52375e5ab1
13 files changed
+689 -1

No files matched your search

+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}];
}
}
}
}
+28
View File
@@ -618,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)."
@@ -669,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",
@@ -705,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."
+28
View File
@@ -618,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)."
@@ -669,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",
@@ -705,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."