diff --git a/ROADMAP.md b/ROADMAP.md index d2f4aff..af8eb54 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -23,5 +23,5 @@ Open ideas for the playground frontend. Tick a box when done. - [ ] Boids / flocking (GPU path like cloth) - [ ] Voronoi / Delaunay - [x] A* vs Dijkstra side by side (reuse pathfinding) - - [ ] Fourier epicycles + - [x] Fourier epicycles - [ ] Reaction-diffusion (Gray-Scott) shader (reuse the fractal shader setup) diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 3f1be31..db25c18 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -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' } }, ]; diff --git a/src/app/constants/RouterConstants.ts b/src/app/constants/RouterConstants.ts index 920a003..e44790b 100644 --- a/src/app/constants/RouterConstants.ts +++ b/src/app/constants/RouterConstants.ts @@ -60,6 +60,11 @@ LINK: '/algorithms/four_color', }; + static readonly FOURIER = { + PATH: 'algorithms/fourier', + LINK: '/algorithms/fourier', + }; + static readonly IMPRINT = { PATH: 'imprint', LINK: '/imprint', diff --git a/src/app/constants/UrlConstants.ts b/src/app/constants/UrlConstants.ts index b74480b..b5e4f14 100644 --- a/src/app/constants/UrlConstants.ts +++ b/src/app/constants/UrlConstants.ts @@ -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' } diff --git a/src/app/pages/algorithms/algorithms.service.ts b/src/app/pages/algorithms/algorithms.service.ts index ee21c24..bb90432 100644 --- a/src/app/pages/algorithms/algorithms.service.ts +++ b/src/app/pages/algorithms/algorithms.service.ts @@ -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' } ]; diff --git a/src/app/pages/algorithms/fourier/fourier.component.html b/src/app/pages/algorithms/fourier/fourier.component.html new file mode 100644 index 0000000..9d573a1 --- /dev/null +++ b/src/app/pages/algorithms/fourier/fourier.component.html @@ -0,0 +1,50 @@ + + + {{ 'FOURIER.TITLE' | translate }} + + + + +

{{ 'FOURIER.DRAW_HINT' | translate }}

+ +
+ + + + +
+ +
+ + {{ 'FOURIER.CIRCLES' | translate }}: {{ circleCount() }} + + + + + + {{ 'FOURIER.SPEED' | translate }}: {{ speed() }} + + + + +
+ +
+ +
+
+
diff --git a/src/app/pages/algorithms/fourier/fourier.component.spec.ts b/src/app/pages/algorithms/fourier/fourier.component.spec.ts new file mode 100644 index 0000000..b6c45f5 --- /dev/null +++ b/src/app/pages/algorithms/fourier/fourier.component.spec.ts @@ -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; + let component: FourierComponent; + let canvas: HTMLCanvasElement; + const originalGetContext = HTMLCanvasElement.prototype.getContext; + + let fakeContext: Record; + + 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(); + }); +}); diff --git a/src/app/pages/algorithms/fourier/fourier.component.ts b/src/app/pages/algorithms/fourier/fourier.component.ts new file mode 100644 index 0000000..d1fe8c4 --- /dev/null +++ b/src/app/pages/algorithms/fourier/fourier.component.ts @@ -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; + 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([]); + 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(); + } +} diff --git a/src/app/pages/algorithms/fourier/fourier.models.ts b/src/app/pages/algorithms/fourier/fourier.models.ts new file mode 100644 index 0000000..6ebb7cf --- /dev/null +++ b/src/app/pages/algorithms/fourier/fourier.models.ts @@ -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' }, +}; diff --git a/src/app/pages/algorithms/fourier/service/fourier.service.spec.ts b/src/app/pages/algorithms/fourier/service/fourier.service.spec.ts new file mode 100644 index 0000000..7d78beb --- /dev/null +++ b/src/app/pages/algorithms/fourier/service/fourier.service.spec.ts @@ -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); + }); +}); diff --git a/src/app/pages/algorithms/fourier/service/fourier.service.ts b/src/app/pages/algorithms/fourier/service/fourier.service.ts new file mode 100644 index 0000000..ea693e9 --- /dev/null +++ b/src/app/pages/algorithms/fourier/service/fourier.service.ts @@ -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}]; + } + } + } +} diff --git a/src/assets/i18n/de.json b/src/assets/i18n/de.json index 218b2ae..c8c8c96 100644 --- a/src/assets/i18n/de.json +++ b/src/assets/i18n/de.json @@ -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." diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 65f6f7c..bdb51f6 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -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."