feat(fourier): add epicycles algorithm demo
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:
1 parent
dc23023b32
commit
52375e5ab1
13 files changed
+689
-1
No files matched your search
@@ -16,6 +16,7 @@ export const routes: Routes = [
|
||||
{ path: RouterConstants.PENDULUM.PATH, loadComponent: () => import('./pages/algorithms/pendulum/pendulum.component').then(m => m.default), data: { seo: 'ALGORITHMS' } },
|
||||
{ path: RouterConstants.CLOTH.PATH, loadComponent: () => import('./pages/algorithms/cloth/cloth.component').then(m => m.ClothComponent), data: { seo: 'ALGORITHMS' } },
|
||||
{ path: RouterConstants.FOUR_COLOR.PATH, loadComponent: () => import('./pages/algorithms/four-color/four-color.component').then(m => m.FourColorComponent), data: { seo: 'ALGORITHMS' } },
|
||||
{ path: RouterConstants.FOURIER.PATH, loadComponent: () => import('./pages/algorithms/fourier/fourier.component').then(m => m.FourierComponent), data: { seo: 'FOURIER' } },
|
||||
{ path: RouterConstants.STOPWATCH.PATH, loadComponent: () => import('./pages/stopwatch/stopwatch.component').then(m => m.StopwatchComponent), data: { seo: 'STOPWATCH' } },
|
||||
{ path: '**', loadComponent: () => import('./pages/not-found/not-found.component').then(m => m.NotFoundComponent), data: { seo: 'NOT_FOUND' } },
|
||||
];
|
||||
@@ -60,6 +60,11 @@
|
||||
LINK: '/algorithms/four_color',
|
||||
};
|
||||
|
||||
static readonly FOURIER = {
|
||||
PATH: 'algorithms/fourier',
|
||||
LINK: '/algorithms/fourier',
|
||||
};
|
||||
|
||||
static readonly IMPRINT = {
|
||||
PATH: 'imprint',
|
||||
LINK: '/imprint',
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
static readonly GPU_COMPUTING_WIKI = 'https://en.wikipedia.org/wiki/General-purpose_computing_on_graphics_processing_units'
|
||||
static readonly DATA_STRUCTURE_WIKI = 'https://de.wikipedia.org/wiki/Datenstruktur'
|
||||
static readonly FOUR_COLOR_THEOREM = 'https://de.wikipedia.org/wiki/Vier-Farben-Satz'
|
||||
static readonly FOURIER_SERIES_WIKI = 'https://de.wikipedia.org/wiki/Fourierreihe'
|
||||
static readonly GITEA = 'https://git.andreas-dahm.eu'
|
||||
|
||||
}
|
||||
@@ -70,6 +70,13 @@ export class AlgorithmsService {
|
||||
description: 'ALGORITHM.FOUR_COLOR.DESCRIPTION',
|
||||
routerLink: RouterConstants.FOUR_COLOR.LINK,
|
||||
icon: 'palette'
|
||||
},
|
||||
{
|
||||
id: 'fourier',
|
||||
title: 'ALGORITHM.FOURIER.TITLE',
|
||||
description: 'ALGORITHM.FOURIER.DESCRIPTION',
|
||||
routerLink: RouterConstants.FOURIER.LINK,
|
||||
icon: 'track_changes'
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<mat-card class="w-full max-w-[1920px] p-5">
|
||||
<mat-card-header>
|
||||
<mat-card-title>{{ 'FOURIER.TITLE' | translate }}</mat-card-title>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<app-information [algorithmInformation]="algoInformation"/>
|
||||
|
||||
<p class="mt-2.5">{{ 'FOURIER.DRAW_HINT' | translate }}</p>
|
||||
|
||||
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
|
||||
<button mat-raised-button (click)="applyPreset(Preset.HEART)">
|
||||
<mat-icon>favorite</mat-icon>{{ 'FOURIER.PRESET_HEART' | translate }}
|
||||
</button>
|
||||
<button mat-raised-button (click)="applyPreset(Preset.STAR)">
|
||||
<mat-icon>star</mat-icon>{{ 'FOURIER.PRESET_STAR' | translate }}
|
||||
</button>
|
||||
<button mat-raised-button (click)="applyPreset(Preset.SQUARE)">
|
||||
<mat-icon>crop_square</mat-icon>{{ 'FOURIER.PRESET_SQUARE' | translate }}
|
||||
</button>
|
||||
<button mat-raised-button (click)="toggleCircles()">
|
||||
{{ (showCircles() ? 'FOURIER.HIDE_CIRCLES' : 'FOURIER.SHOW_CIRCLES') | translate }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2.5 flex-wrap">
|
||||
<span class="flex items-center gap-2.5">
|
||||
{{ 'FOURIER.CIRCLES' | translate }}: {{ circleCount() }}
|
||||
<mat-slider [min]="1" [max]="maxCircles()" step="1" discrete>
|
||||
<input matSliderThumb [(ngModel)]="circleCount">
|
||||
</mat-slider>
|
||||
</span>
|
||||
<span class="flex items-center gap-2.5">
|
||||
{{ 'FOURIER.SPEED' | translate }}: {{ speed() }}
|
||||
<mat-slider [min]="MIN_SPEED" [max]="MAX_SPEED" step="0.02">
|
||||
<input matSliderThumb [(ngModel)]="speed">
|
||||
</mat-slider>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="w-full max-w-[800px] mx-auto">
|
||||
<canvas #fourierCanvas
|
||||
class="touch-none cursor-crosshair max-w-full"
|
||||
(pointerdown)="onPointerDown($event)"
|
||||
(pointermove)="onPointerMove($event)"
|
||||
(pointerup)="onPointerUp()"
|
||||
(pointercancel)="onPointerUp()"
|
||||
></canvas>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
@@ -0,0 +1,147 @@
|
||||
import {ComponentFixture, TestBed} from '@angular/core/testing';
|
||||
import {provideZonelessChangeDetection} from '@angular/core';
|
||||
import {provideNoopAnimations} from '@angular/platform-browser/animations';
|
||||
import {provideTranslateService} from '@ngx-translate/core';
|
||||
import {FourierComponent} from './fourier.component';
|
||||
import {Preset, SAMPLE_COUNT} from './fourier.models';
|
||||
|
||||
describe('FourierComponent', () => {
|
||||
let fixture: ComponentFixture<FourierComponent>;
|
||||
let component: FourierComponent;
|
||||
let canvas: HTMLCanvasElement;
|
||||
const originalGetContext = HTMLCanvasElement.prototype.getContext;
|
||||
|
||||
let fakeContext: Record<string, any>;
|
||||
|
||||
const runFrame = (now: number) => (component as any).frame(now);
|
||||
const pointer = (x: number, y: number, isPrimary = true) => new PointerEvent('pointermove', {clientX: x, clientY: y, isPrimary});
|
||||
const down = (x: number, y: number, isPrimary = true) => new PointerEvent('pointerdown', {clientX: x, clientY: y, isPrimary});
|
||||
|
||||
beforeEach(async () => {
|
||||
// jsdom has no 2D context.
|
||||
fakeContext = {
|
||||
clearRect: vi.fn(), beginPath: vi.fn(), moveTo: vi.fn(), lineTo: vi.fn(), stroke: vi.fn(), arc: vi.fn(), closePath: vi.fn(),
|
||||
strokeStyle: '', lineWidth: 1,
|
||||
};
|
||||
Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', {configurable: true, value: () => fakeContext});
|
||||
vi.stubGlobal('requestAnimationFrame', vi.fn(() => 1));
|
||||
vi.stubGlobal('cancelAnimationFrame', vi.fn());
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [FourierComponent],
|
||||
providers: [
|
||||
provideZonelessChangeDetection(),
|
||||
provideNoopAnimations(),
|
||||
provideTranslateService({lang: 'en', fallbackLang: 'en'}),
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(FourierComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
canvas = component.canvasRef.nativeElement;
|
||||
canvas.setPointerCapture = vi.fn();
|
||||
canvas.getBoundingClientRect = () => ({left: 0, top: 0, width: 400, height: 400}) as DOMRect;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', {configurable: true, value: originalGetContext});
|
||||
});
|
||||
|
||||
it('starts with the heart preset', () => {
|
||||
expect(component.terms().length).toBe(SAMPLE_COUNT);
|
||||
expect(component.circleCount()).toBe(SAMPLE_COUNT);
|
||||
});
|
||||
|
||||
it('replaces the terms when another preset is applied', () => {
|
||||
const heartFirst = component.terms()[0].amplitude;
|
||||
component.circleCount.set(5);
|
||||
|
||||
component.applyPreset(Preset.STAR);
|
||||
|
||||
expect(component.terms()[0].amplitude).not.toBeCloseTo(heartFirst);
|
||||
expect(component.circleCount()).toBe(component.terms().length);
|
||||
});
|
||||
|
||||
it('limits the used terms to the slider value', () => {
|
||||
component.circleCount.set(3);
|
||||
|
||||
expect(component.usedTerms().length).toBe(3);
|
||||
});
|
||||
|
||||
it('builds new terms from a drawn shape', () => {
|
||||
component.applyPreset(Preset.SQUARE);
|
||||
const before = component.terms();
|
||||
|
||||
component.onPointerDown(down(100, 100));
|
||||
component.onPointerMove(pointer(300, 100));
|
||||
component.onPointerMove(pointer(200, 300));
|
||||
component.onPointerUp();
|
||||
|
||||
expect(component.terms()).not.toBe(before);
|
||||
expect(component.terms().length).toBe(SAMPLE_COUNT);
|
||||
});
|
||||
|
||||
it('keeps the shape when fewer than three points are drawn', () => {
|
||||
const before = component.terms();
|
||||
|
||||
component.onPointerDown(down(100, 100));
|
||||
component.onPointerMove(pointer(300, 100));
|
||||
component.onPointerUp();
|
||||
|
||||
expect(component.terms()).toBe(before);
|
||||
});
|
||||
|
||||
it('keeps the shape when the stroke is a tap with jitter', () => {
|
||||
const before = component.terms();
|
||||
|
||||
component.onPointerDown(down(100, 100));
|
||||
component.onPointerMove(pointer(101, 100));
|
||||
component.onPointerMove(pointer(100, 101));
|
||||
component.onPointerUp();
|
||||
|
||||
expect(component.terms()).toBe(before);
|
||||
});
|
||||
|
||||
it('ignores a second touch while drawing', () => {
|
||||
const before = component.terms();
|
||||
|
||||
component.onPointerDown(down(100, 100));
|
||||
component.onPointerDown(down(300, 300, false));
|
||||
component.onPointerMove(pointer(300, 300, false));
|
||||
component.onPointerMove(pointer(300, 100));
|
||||
component.onPointerMove(pointer(200, 300));
|
||||
component.onPointerUp();
|
||||
|
||||
expect(component.terms()).not.toBe(before);
|
||||
expect((component as any).drawnPoints.length).toBe(3);
|
||||
});
|
||||
|
||||
it('draws one circle per used term on each frame', () => {
|
||||
component.circleCount.set(5);
|
||||
|
||||
runFrame(1);
|
||||
runFrame(17);
|
||||
|
||||
expect(fakeContext['arc']).toHaveBeenCalledTimes(10);
|
||||
expect(fakeContext['stroke']).toHaveBeenCalled();
|
||||
expect((component as any).trace.length).toBe(2);
|
||||
});
|
||||
|
||||
it('wraps time into 0..1 after a long frame gap', () => {
|
||||
component.speed.set(0.5);
|
||||
|
||||
runFrame(1);
|
||||
runFrame(10501);
|
||||
|
||||
expect((component as any).time).toBeCloseTo(0.25);
|
||||
expect((component as any).trace.length).toBe(1);
|
||||
});
|
||||
|
||||
it('stops the animation loop on destroy', () => {
|
||||
fixture.destroy();
|
||||
|
||||
expect(cancelAnimationFrame).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
import {AfterViewInit, Component, computed, ElementRef, HostListener, inject, NgZone, OnDestroy, signal, ViewChild} from '@angular/core';
|
||||
import {FormsModule} from '@angular/forms';
|
||||
import {MatButton} from '@angular/material/button';
|
||||
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
|
||||
import {MatIcon} from '@angular/material/icon';
|
||||
import {MatSliderModule} from '@angular/material/slider';
|
||||
import {TranslatePipe} from '@ngx-translate/core';
|
||||
import {Information} from '../information/information';
|
||||
import {AlgorithmInformation} from '../information/information.models';
|
||||
import {UrlConstants} from '../../../constants/UrlConstants';
|
||||
import {ThemeService} from '../../../service/theme.service';
|
||||
import {FourierService} from './service/fourier.service';
|
||||
import {
|
||||
DEFAULT_SPEED, EpicycleColors, EpicycleTerm, MAX_CANVAS_PX, MAX_SPEED, MIN_DRAW_LENGTH, MIN_DRAW_POINTS, MIN_SPEED, Point, Preset, SAMPLE_COUNT, THEME_COLORS
|
||||
} from './fourier.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-fourier',
|
||||
imports: [
|
||||
MatCard,
|
||||
MatCardContent,
|
||||
MatCardHeader,
|
||||
MatCardTitle,
|
||||
TranslatePipe,
|
||||
Information,
|
||||
MatButton,
|
||||
MatIcon,
|
||||
MatSliderModule,
|
||||
FormsModule
|
||||
],
|
||||
templateUrl: './fourier.component.html'
|
||||
})
|
||||
export class FourierComponent implements AfterViewInit, OnDestroy {
|
||||
private readonly fourierService = inject(FourierService);
|
||||
private readonly themeService = inject(ThemeService);
|
||||
private readonly ngZone = inject(NgZone);
|
||||
|
||||
@ViewChild('fourierCanvas', {static: true}) canvasRef!: ElementRef<HTMLCanvasElement>;
|
||||
private ctx!: CanvasRenderingContext2D;
|
||||
private animationFrameId = 0;
|
||||
private lastFrameTime = 0;
|
||||
private time = 0; // fraction of the period, 0..1
|
||||
private trace: Point[] = []; // normalized tip positions of the current period
|
||||
private drawnPoints: Point[] = []; // normalized pointer samples while drawing
|
||||
private drawing = false;
|
||||
|
||||
algoInformation: AlgorithmInformation = {
|
||||
title: 'FOURIER.EXPLANATION.TITLE',
|
||||
entries: [
|
||||
{
|
||||
name: 'FOURIER.TITLE',
|
||||
translateName: true,
|
||||
description: 'FOURIER.EXPLANATION.EXPLANATION',
|
||||
link: UrlConstants.FOURIER_SERIES_WIKI
|
||||
}
|
||||
],
|
||||
disclaimer: 'FOURIER.EXPLANATION.DISCLAIMER',
|
||||
disclaimerBottom: 'FOURIER.EXPLANATION.DISCLAIMER_BOTTOM',
|
||||
disclaimerListEntry: [
|
||||
'FOURIER.EXPLANATION.DISCLAIMER_1',
|
||||
'FOURIER.EXPLANATION.DISCLAIMER_2',
|
||||
'FOURIER.EXPLANATION.DISCLAIMER_3'
|
||||
]
|
||||
};
|
||||
|
||||
readonly terms = signal<EpicycleTerm[]>([]);
|
||||
readonly circleCount = signal(1);
|
||||
readonly speed = signal(DEFAULT_SPEED);
|
||||
readonly showCircles = signal(true);
|
||||
readonly maxCircles = computed(() => Math.max(this.terms().length, 1));
|
||||
readonly usedTerms = computed(() => this.terms().slice(0, this.circleCount()));
|
||||
|
||||
protected readonly Preset = Preset;
|
||||
protected readonly MIN_SPEED = MIN_SPEED;
|
||||
protected readonly MAX_SPEED = MAX_SPEED;
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.ctx = this.canvasRef.nativeElement.getContext('2d')!;
|
||||
this.resizeCanvas();
|
||||
this.applyPreset(Preset.HEART);
|
||||
this.ngZone.runOutsideAngular(() => {
|
||||
this.animationFrameId = requestAnimationFrame(this.frame);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
cancelAnimationFrame(this.animationFrameId);
|
||||
}
|
||||
|
||||
@HostListener('window:resize')
|
||||
resizeCanvas(): void {
|
||||
const canvas = this.canvasRef.nativeElement;
|
||||
canvas.width = canvas.height = Math.min(canvas.parentElement!.clientWidth, MAX_CANVAS_PX);
|
||||
}
|
||||
|
||||
applyPreset(preset: Preset): void {
|
||||
this.setShape(this.fourierService.presetPoints(preset));
|
||||
}
|
||||
|
||||
toggleCircles(): void {
|
||||
this.showCircles.update(v => !v);
|
||||
}
|
||||
|
||||
onPointerDown(event: PointerEvent): void {
|
||||
if (!event.isPrimary) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
this.canvasRef.nativeElement.setPointerCapture(event.pointerId);
|
||||
this.drawing = true;
|
||||
this.drawnPoints = [this.toNormalized(event)];
|
||||
}
|
||||
|
||||
onPointerMove(event: PointerEvent): void {
|
||||
if (this.drawing && event.isPrimary) {
|
||||
this.drawnPoints.push(this.toNormalized(event));
|
||||
}
|
||||
}
|
||||
|
||||
onPointerUp(): void {
|
||||
this.drawing = false;
|
||||
if (this.drawnPoints.length >= MIN_DRAW_POINTS && this.pathLength(this.drawnPoints) >= MIN_DRAW_LENGTH) {
|
||||
this.setShape(this.drawnPoints);
|
||||
}
|
||||
}
|
||||
|
||||
private pathLength(points: Point[]): number {
|
||||
let length = 0;
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
length += Math.hypot(points[i].x - points[i - 1].x, points[i].y - points[i - 1].y);
|
||||
}
|
||||
return length;
|
||||
}
|
||||
|
||||
private setShape(points: Point[]): void {
|
||||
const samples = this.fourierService.resample(points, SAMPLE_COUNT);
|
||||
this.terms.set(this.fourierService.dft(samples));
|
||||
this.circleCount.set(this.terms().length);
|
||||
this.time = 0;
|
||||
this.trace = [];
|
||||
}
|
||||
|
||||
private toNormalized(event: PointerEvent): Point {
|
||||
const rect = this.canvasRef.nativeElement.getBoundingClientRect();
|
||||
const half = rect.width / 2;
|
||||
return {x: (event.clientX - rect.left - half) / half, y: (event.clientY - rect.top - half) / half};
|
||||
}
|
||||
|
||||
private toCanvas(p: Point): Point {
|
||||
const half = this.canvasRef.nativeElement.width / 2;
|
||||
return {x: half + p.x * half, y: half + p.y * half};
|
||||
}
|
||||
|
||||
private readonly frame = (now: number): void => {
|
||||
const dt = this.lastFrameTime ? (now - this.lastFrameTime) / 1000 : 0;
|
||||
this.lastFrameTime = now;
|
||||
const positions = this.advance(dt);
|
||||
this.draw(positions);
|
||||
this.animationFrameId = requestAnimationFrame(this.frame);
|
||||
};
|
||||
|
||||
private advance(dt: number): Point[] {
|
||||
if (this.drawing || this.terms().length === 0) {
|
||||
return [];
|
||||
}
|
||||
this.time += this.speed() * dt;
|
||||
if (this.time >= 1) {
|
||||
this.time %= 1;
|
||||
this.trace = [];
|
||||
}
|
||||
const used = this.usedTerms();
|
||||
const positions = this.fourierService.epicyclePositions(used, used.length, this.time);
|
||||
this.trace.push(positions.at(-1)!);
|
||||
return positions;
|
||||
}
|
||||
|
||||
private draw(positions: Point[]): void {
|
||||
const size = this.canvasRef.nativeElement.width;
|
||||
const colors = THEME_COLORS[this.themeService.theme()];
|
||||
this.ctx.clearRect(0, 0, size, size);
|
||||
this.ctx.lineWidth = 2;
|
||||
if (this.drawing) {
|
||||
this.strokePolyline(this.drawnPoints, colors.drawing);
|
||||
return;
|
||||
}
|
||||
this.strokePolyline(this.trace, colors.trace);
|
||||
if (this.showCircles()) {
|
||||
this.drawCircles(positions, colors);
|
||||
}
|
||||
}
|
||||
|
||||
private drawCircles(positions: Point[], colors: EpicycleColors): void {
|
||||
const ctx = this.ctx;
|
||||
const half = this.canvasRef.nativeElement.width / 2;
|
||||
const used = this.usedTerms();
|
||||
for (let i = 0; i < positions.length - 1; i++) {
|
||||
const center = this.toCanvas(positions[i]);
|
||||
const next = this.toCanvas(positions[i + 1]);
|
||||
ctx.strokeStyle = colors.circle;
|
||||
ctx.beginPath();
|
||||
ctx.arc(center.x, center.y, used[i].amplitude * half, 0, 2 * Math.PI);
|
||||
ctx.stroke();
|
||||
ctx.strokeStyle = colors.path;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(center.x, center.y);
|
||||
ctx.lineTo(next.x, next.y);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
private strokePolyline(points: Point[], color: string): void {
|
||||
if (points.length < 2) {
|
||||
return;
|
||||
}
|
||||
const ctx = this.ctx;
|
||||
ctx.strokeStyle = color;
|
||||
ctx.beginPath();
|
||||
points.forEach((p, i) => {
|
||||
const c = this.toCanvas(p);
|
||||
if (i === 0) {
|
||||
ctx.moveTo(c.x, c.y);
|
||||
} else {
|
||||
ctx.lineTo(c.x, c.y);
|
||||
}
|
||||
});
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export interface Point { x: number; y: number; }
|
||||
|
||||
/** One rotating circle. frequency is signed (turns per period), phase in radians. */
|
||||
export interface EpicycleTerm { frequency: number; amplitude: number; phase: number; }
|
||||
|
||||
export enum Preset { HEART = 0, STAR = 1, SQUARE = 2 }
|
||||
|
||||
export const SAMPLE_COUNT = 256;
|
||||
export const MIN_DRAW_POINTS = 3;
|
||||
export const MIN_DRAW_LENGTH = 0.1; // normalized units; shorter strokes count as a tap
|
||||
export const MIN_SPEED = 0.02; // periods per second
|
||||
export const MAX_SPEED = 0.5;
|
||||
export const DEFAULT_SPEED = 0.1;
|
||||
export const MAX_CANVAS_PX = 800;
|
||||
export const PRESET_RADIUS = 0.8; // presets stay inside the canvas
|
||||
|
||||
export interface EpicycleColors { circle: string; path: string; trace: string; drawing: string; }
|
||||
|
||||
export const THEME_COLORS: Record<'light' | 'dark', EpicycleColors> = {
|
||||
light: { circle: 'rgba(0, 0, 0, 0.25)', path: '#0f4f8a', trace: '#d32f2f', drawing: '#555' },
|
||||
dark: { circle: 'rgba(255, 255, 255, 0.3)', path: '#6dbcff', trace: '#ff7961', drawing: '#bbb' },
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
import {FourierService} from './fourier.service';
|
||||
import {Point, Preset, SAMPLE_COUNT} from '../fourier.models';
|
||||
|
||||
describe('FourierService', () => {
|
||||
const service = new FourierService();
|
||||
|
||||
it('resamples a polygon to equally spaced points', () => {
|
||||
const square: Point[] = [{x: 0, y: 0}, {x: 1, y: 0}, {x: 1, y: 1}, {x: 0, y: 1}];
|
||||
|
||||
const samples = service.resample(square, 8);
|
||||
|
||||
expect(samples.length).toBe(8);
|
||||
samples.forEach((p, i) => {
|
||||
const next = samples[(i + 1) % 8];
|
||||
expect(Math.hypot(next.x - p.x, next.y - p.y)).toBeCloseTo(0.5);
|
||||
});
|
||||
});
|
||||
|
||||
it('resamples a single point to copies without NaN', () => {
|
||||
const samples = service.resample([{x: 0.3, y: -0.2}], 5);
|
||||
|
||||
expect(samples).toEqual(Array(5).fill({x: 0.3, y: -0.2}));
|
||||
});
|
||||
|
||||
it('turns a unit circle into one circle of radius 1', () => {
|
||||
const circle = Array.from({length: 64}, (_, n) => ({x: Math.cos(2 * Math.PI * n / 64), y: Math.sin(2 * Math.PI * n / 64)}));
|
||||
|
||||
const terms = service.dft(circle);
|
||||
|
||||
expect(terms[0].frequency).toBe(1);
|
||||
expect(terms[0].amplitude).toBeCloseTo(1);
|
||||
terms.slice(1).forEach(t => expect(t.amplitude).toBeLessThan(1e-9));
|
||||
});
|
||||
|
||||
it('sorts terms by amplitude descending', () => {
|
||||
const terms = service.dft(service.resample(service.presetPoints(Preset.STAR), 64));
|
||||
|
||||
for (let i = 0; i < terms.length - 1; i++) {
|
||||
expect(terms[i].amplitude).toBeGreaterThanOrEqual(terms[i + 1].amplitude);
|
||||
}
|
||||
});
|
||||
|
||||
it('reconstructs the input points from all terms', () => {
|
||||
const samples = service.resample(service.presetPoints(Preset.SQUARE), 32);
|
||||
const terms = service.dft(samples);
|
||||
|
||||
for (const n of [0, 7, 19]) {
|
||||
const tip = service.epicyclePositions(terms, terms.length, n / 32).at(-1)!;
|
||||
expect(tip.x).toBeCloseTo(samples[n].x, 6);
|
||||
expect(tip.y).toBeCloseTo(samples[n].y, 6);
|
||||
}
|
||||
});
|
||||
|
||||
it('truncates to the requested circle count', () => {
|
||||
const terms = service.dft(service.resample(service.presetPoints(Preset.STAR), 32));
|
||||
|
||||
expect(service.epicyclePositions(terms, 3, 0.25).length).toBe(4);
|
||||
expect(service.epicyclePositions(terms, 0, 0)).toEqual([{x: 0, y: 0}]);
|
||||
});
|
||||
|
||||
it.each([Preset.HEART, Preset.STAR, Preset.SQUARE])('keeps preset %s inside the canvas', preset => {
|
||||
const points = service.presetPoints(preset);
|
||||
|
||||
expect(points.length).toBeGreaterThanOrEqual(3);
|
||||
points.forEach(p => {
|
||||
expect(Math.abs(p.x)).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(p.y)).toBeLessThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('samples the heart preset SAMPLE_COUNT times', () => {
|
||||
expect(service.presetPoints(Preset.HEART).length).toBe(SAMPLE_COUNT);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import {Injectable} from '@angular/core';
|
||||
import {EpicycleTerm, Point, PRESET_RADIUS, Preset, SAMPLE_COUNT} from '../fourier.models';
|
||||
|
||||
@Injectable({providedIn: 'root'})
|
||||
export class FourierService {
|
||||
/** Treats points as a closed polygon and returns count points at equal arc-length spacing. */
|
||||
resample(points: Point[], count: number): Point[] {
|
||||
const n = points.length;
|
||||
const lengths = points.map((p, i) => {
|
||||
const next = points[(i + 1) % n];
|
||||
return Math.hypot(next.x - p.x, next.y - p.y);
|
||||
});
|
||||
const perimeter = lengths.reduce((a, b) => a + b, 0);
|
||||
if (n < 2 || perimeter === 0) {
|
||||
return Array.from({length: count}, () => ({...points[0]}));
|
||||
}
|
||||
|
||||
const result: Point[] = [];
|
||||
const step = perimeter / count;
|
||||
let segment = 0;
|
||||
let segmentStart = 0;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const distance = i * step;
|
||||
while (segment < n - 1 && segmentStart + lengths[segment] <= distance) {
|
||||
segmentStart += lengths[segment];
|
||||
segment++;
|
||||
}
|
||||
const from = points[segment];
|
||||
const to = points[(segment + 1) % n];
|
||||
const t = lengths[segment] === 0 ? 0 : (distance - segmentStart) / lengths[segment];
|
||||
result.push({x: from.x + (to.x - from.x) * t, y: from.y + (to.y - from.y) * t});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Direct O(N^2) DFT. Terms are sorted by amplitude, biggest first. */
|
||||
dft(points: Point[]): EpicycleTerm[] {
|
||||
const n = points.length;
|
||||
const terms: EpicycleTerm[] = [];
|
||||
for (let k = 0; k < n; k++) {
|
||||
let re = 0;
|
||||
let im = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const angle = 2 * Math.PI * k * i / n;
|
||||
const cos = Math.cos(angle);
|
||||
const sin = Math.sin(angle);
|
||||
re += points[i].x * cos + points[i].y * sin;
|
||||
im += points[i].y * cos - points[i].x * sin;
|
||||
}
|
||||
re /= n;
|
||||
im /= n;
|
||||
terms.push({
|
||||
frequency: k <= n / 2 ? k : k - n,
|
||||
amplitude: Math.hypot(re, im),
|
||||
phase: Math.atan2(im, re),
|
||||
});
|
||||
}
|
||||
return terms.sort((a, b) => b.amplitude - a.amplitude);
|
||||
}
|
||||
|
||||
/** Circle centers, then the pen tip last. time is the fraction of the period in [0, 1). */
|
||||
epicyclePositions(terms: EpicycleTerm[], count: number, time: number): Point[] {
|
||||
const positions: Point[] = [{x: 0, y: 0}];
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
for (const term of terms.slice(0, count)) {
|
||||
const angle = 2 * Math.PI * term.frequency * time + term.phase;
|
||||
x += term.amplitude * Math.cos(angle);
|
||||
y += term.amplitude * Math.sin(angle);
|
||||
positions.push({x, y});
|
||||
}
|
||||
return positions;
|
||||
}
|
||||
|
||||
presetPoints(preset: Preset): Point[] {
|
||||
switch (preset) {
|
||||
case Preset.HEART:
|
||||
return Array.from({length: SAMPLE_COUNT}, (_, i) => {
|
||||
const t = 2 * Math.PI * i / SAMPLE_COUNT;
|
||||
return {
|
||||
x: 16 * Math.sin(t) ** 3 / 17 * PRESET_RADIUS,
|
||||
y: -(13 * Math.cos(t) - 5 * Math.cos(2 * t) - 2 * Math.cos(3 * t) - Math.cos(4 * t)) / 17 * PRESET_RADIUS,
|
||||
};
|
||||
});
|
||||
case Preset.STAR:
|
||||
return Array.from({length: 10}, (_, i) => {
|
||||
const angle = -Math.PI / 2 + i * Math.PI / 5;
|
||||
const radius = i % 2 === 0 ? PRESET_RADIUS : PRESET_RADIUS * 0.45;
|
||||
return {x: radius * Math.cos(angle), y: radius * Math.sin(angle)};
|
||||
});
|
||||
case Preset.SQUARE: {
|
||||
const r = PRESET_RADIUS * 0.85;
|
||||
return [{x: -r, y: -r}, {x: r, y: -r}, {x: r, y: r}, {x: -r, y: r}];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user