Add fractal visualization feature
All checks were successful
Build, Test & Push Frontend / quality-check (pull_request) Successful in 55s
Build, Test & Push Frontend / docker (pull_request) Has been skipped

Introduce a new Fractal visualization: adds FractalComponent (template, styles, TS), FractalService (rendering, palettes, Mandelbrot/Julia/Burning Ship/Newton implementations), and Fractal model/types. Wire up routing and router constants (route and component import), add wiki links to UrlConstants, and expose the new algorithm in AlgorithmsService. Also add i18n entries (en/de) for UI labels and explanations. Component supports canvas zoom/drag, color schemes and iteration controls.
This commit is contained in:
2026-02-10 14:49:17 +01:00
parent dab7c51b90
commit 5d162b57ab
11 changed files with 597 additions and 1 deletions

View File

@@ -0,0 +1,55 @@
<mat-card class="container">
<mat-card-header>
<mat-card-title>{{ 'FRACTAL.TITLE' | translate }}</mat-card-title>
</mat-card-header>
<mat-card-content>
<app-information [algorithmInformation]="algoInformation"/>
<div class="controls-panel">
<mat-form-field appearance="fill">
<mat-label>{{ 'FRACTAL.ALGORITHM' | translate }}</mat-label>
<mat-select [(ngModel)]="selectedAlgorithm"
(selectionChange)="onAlgorithmChange()">
@for (algo of algoInformation.entries; track algo.name) {
<mat-option [value]="algo.name">{{ algo.name }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'FRACTAL.COLOR_SCHEME' | translate }}</mat-label>
<mat-select [(ngModel)]="selectedColorScheme"
(selectionChange)="onColorChanged()"
[disabled]="selectedAlgorithm === 'Newton'">
@for (name of FRACTAL_COLOR_SCHEMES; track name) {
<mat-option [value]="name">{{ name }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>{{ 'FRACTAL.MAX_ITERATION' | translate }}</mat-label>
<input
matInput
type="number"
[disabled]="selectedAlgorithm === 'Newton'"
[min]="MIN_ITERATION"
[max]="MAX_ITERATION"
[(ngModel)]="currentIteration"
(blur)="onIterationChanged()"
(keyup.enter)="onIterationChanged()"
/>
</mat-form-field>
</div>
<div class="canvas-container">
<canvas #fractalCanvas
width="1000"
height="1000"
(mousedown)="onMouseDown($event)"
(mousemove)="onMouseMove($event)"
(mouseup)="onMouseUp()"
(mouseleave)="onMouseUp()"
(wheel)="onWheel($event)">
</canvas>
</div>
</mat-card-content>
</mat-card>

View File

@@ -0,0 +1,189 @@
import {AfterViewInit, Component, ElementRef, inject, ViewChild} from '@angular/core';
import {Information} from '../information/information';
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
import {TranslatePipe} from '@ngx-translate/core';
import {MatFormField, MatInput, MatLabel} from '@angular/material/input';
import {MatOption} from '@angular/material/core';
import {MatSelect} from '@angular/material/select';
import {AlgorithmInformation} from '../information/information.models';
import {UrlConstants} from '../../../constants/UrlConstants';
import {FormsModule} from '@angular/forms';
import {FractalService} from './service/fractal.service';
import {DEFAULT_ITERATION, FRACTAL_COLOR_SCHEMES, FractalConfig, MAX_ITERATION, MIN_ITERATION} from './fractal.model';
@Component({
selector: 'app-fractal',
imports: [
Information,
MatCard,
MatCardContent,
MatCardHeader,
MatCardTitle,
TranslatePipe,
MatFormField,
MatLabel,
MatOption,
MatSelect,
FormsModule,
MatInput
],
templateUrl: './fractal.component.html',
styleUrl: './fractal.component.scss',
})
export class FractalComponent implements AfterViewInit {
algoInformation: AlgorithmInformation = {
title: 'FRACTAL.EXPLANATION.TITLE',
entries: [
{
name: 'Mandelbrot',
description: 'FRACTAL.EXPLANATION.MANDELBROT_EXPLANATION',
link: UrlConstants.MANDELBROT_WIKI
},
{
name: 'Julia',
description: 'FRACTAL.EXPLANATION.JULIA_EXPLANATION',
link: UrlConstants.JULIA_WIKI
},
{
name: 'Burning Ship',
description: 'FRACTAL.EXPLANATION.BURNING_SHIP_EXPLANATION',
link: UrlConstants.BURNING_SHIP_WIKI
},
{
name: 'Newton',
description: 'FRACTAL.EXPLANATION.NEWTON_EXPLANATION',
link: UrlConstants.NEWTON_FRACTAL_WIKI
}
],
disclaimer: 'FRACTAL.EXPLANATION.DISCLAIMER',
disclaimerBottom: '',
disclaimerListEntry: [
'FRACTAL.EXPLANATION.DISCLAIMER_1',
'FRACTAL.EXPLANATION.DISCLAIMER_2',
'FRACTAL.EXPLANATION.DISCLAIMER_3',
'FRACTAL.EXPLANATION.DISCLAIMER_4'
]
};
private readonly fractalService = inject(FractalService);
config: FractalConfig = {
algorithm: 'Mandelbrot',
width: 1000,
height: 1000,
maxIterations: DEFAULT_ITERATION,
zoom: 1,
offsetX: -0.5,
offsetY: 0,
colorScheme: FRACTAL_COLOR_SCHEMES[0]
};
private isDragging = false;
private dragStartX = 0;
private dragStartY = 0;
@ViewChild('fractalCanvas') canvasRef!: ElementRef<HTMLCanvasElement>;
selectedAlgorithm: string = this.config.algorithm;
currentIteration: number = this.config.maxIterations;
selectedColorScheme: string = 'Blue-Gold';
ngAfterViewInit(): void {
this.draw();
}
resetView(): void{
this.isDragging = false;
this.dragStartX = 0;
this.dragStartY = 0;
this.config.offsetX = -0.5;
this.config.offsetY = 0;
this.config.zoom = 1;
}
onAlgorithmChange(): void {
this.config.algorithm = this.selectedAlgorithm as any;
this.resetView();
this.draw();
}
onColorChanged(): void {
this.config.colorScheme = this.selectedColorScheme as any;
this.draw();
}
onIterationChanged(): void {
this.config.maxIterations = Math.max(Math.min(this.currentIteration, MAX_ITERATION), MIN_ITERATION );
this.draw();
}
private draw(): void {
const canvas = this.canvasRef.nativeElement;
const ctx = canvas.getContext('2d');
if (ctx) {
this.fractalService.draw(ctx, this.config);
}
}
//movement
onMouseDown(event: MouseEvent): void {
this.isDragging = true;
this.dragStartX = event.clientX;
this.dragStartY = event.clientY;
}
onMouseUp(): void {
this.isDragging = false;
}
onMouseMove(event: MouseEvent): void {
if (!this.isDragging) return;
const deltaX = event.clientX - this.dragStartX;
const deltaY = event.clientY - this.dragStartY;
const reScale = 4 / (this.config.width * this.config.zoom);
const imScale = 4 / (this.config.height * this.config.zoom);
this.config.offsetX -= deltaX * reScale;
this.config.offsetY -= deltaY * imScale;
this.dragStartX = event.clientX;
this.dragStartY = event.clientY;
this.draw();
}
onWheel(event: WheelEvent): void {
event.preventDefault();
const zoomFactor = 1.1;
const zoomIn = event.deltaY < 0;
const rect = this.canvasRef.nativeElement.getBoundingClientRect();
const mouseX = event.clientX - rect.left;
const mouseY = event.clientY - rect.top;
const reScale = 4 / (this.config.width * this.config.zoom);
const imScale = 4 / (this.config.height * this.config.zoom);
const mouseRe = (mouseX - this.config.width / 2) * reScale + this.config.offsetX;
const mouseIm = (mouseY - this.config.height / 2) * imScale + this.config.offsetY;
if (zoomIn) {
this.config.zoom *= zoomFactor;
} else {
this.config.zoom /= zoomFactor;
}
const newReScale = 4 / (this.config.width * this.config.zoom);
const newImScale = 4 / (this.config.height * this.config.zoom);
this.config.offsetX = mouseRe - (mouseX - this.config.width / 2) * newReScale;
this.config.offsetY = mouseIm - (mouseY - this.config.height / 2) * newImScale;
this.draw();
}
protected readonly MIN_ITERATION = MIN_ITERATION;
protected readonly MAX_ITERATION = MAX_ITERATION;
protected readonly FRACTAL_COLOR_SCHEMES = FRACTAL_COLOR_SCHEMES;
}

View File

@@ -0,0 +1,33 @@
export interface FractalConfig {
algorithm: 'Mandelbrot' | 'Julia' | 'Burning Ship' | 'Newton';
width: number;
height: number;
maxIterations: number;
zoom: number;
offsetX: number;
offsetY: number;
cReal?: number;
cImag?: number;
colorScheme: FractalColorScheme;
}
export const FRACTAL_COLOR_SCHEMES = [
'Blue-Gold',
'Fire',
'Rainbow',
'Greyscale',
] as const;
export type FractalColorScheme = typeof FRACTAL_COLOR_SCHEMES[number];
export class ComplexNumber {
constructor(public re: number, public im: number) {}
add(other: ComplexNumber): ComplexNumber {
return new ComplexNumber(this.re + other.re, this.im + other.im);
}
// Für Newton brauchen wir später auch Multiplikation und Division
}
export const DEFAULT_ITERATION = 100;
export const MIN_ITERATION = 20;
export const MAX_ITERATION = 1000;

View File

@@ -0,0 +1,256 @@
import { Injectable } from '@angular/core';
import {FractalColorScheme, FractalConfig} from '../fractal.model';
@Injectable({
providedIn: 'root'
})
export class FractalService {
private currentPalette: Uint8ClampedArray = new Uint8ClampedArray(0);
private lastScheme: FractalColorScheme | null = null;
private lastMaxIter: number = 0;
draw(ctx: CanvasRenderingContext2D, config: FractalConfig): void {
const width = config.width;
const height = config.height;
this.updateColorPalette(config);
const imageData = ctx.createImageData(width, height);
const data = imageData.data;
for (let x = 0; x < width; x++) {
for (let y = 0; y < height; y++) {
const re = (x - width / 2) * (4 / width / config.zoom) + config.offsetX;
const im = (y - height / 2) * (4 / height / config.zoom) + config.offsetY;
let iterations = 0;
const pixelIndex = (y * width + x) * 4;
switch (config.algorithm) {
case 'Mandelbrot':
iterations = this.calculateMandelbrot(re, im, config.maxIterations);
this.colorizePixel(data, pixelIndex, iterations);
break;
case 'Julia':
{ const cRe = config.cReal ?? -0.7;
const cIm = config.cImag ?? 0.27015;
iterations = this.calculateJulia(re, im, cRe, cIm, config.maxIterations);
this.colorizePixel(data, pixelIndex, iterations);
break; }
case 'Burning Ship':
iterations = this.calculateBurningShip(re, im, config.maxIterations);
this.colorizePixel(data, pixelIndex, iterations);
break;
case 'Newton':
this.handleNewtonFractal(re, im, pixelIndex, config, data);
break;
}
}
}
ctx.putImageData(imageData, 0, 0);
}
private handleNewtonFractal(re: number, im: number, pixelIndex:number, config: FractalConfig, data: Uint8ClampedArray<ArrayBuffer>) {
const result = this.calculateNewton(re, im, config.maxIterations);
if (result === config.maxIterations) {
data[pixelIndex] = 0;
data[pixelIndex + 1] = 0;
data[pixelIndex + 2] = 0;
} else if (result >= 2000) {
const light = 255 - (result - 2000) * 10;
data[pixelIndex] = 0;
data[pixelIndex + 1] = 0;
data[pixelIndex + 2] = Math.max(0, light);
} else if (result >= 1000) {
const light = 255 - (result - 1000) * 10;
data[pixelIndex] = 0;
data[pixelIndex + 1] = Math.max(0, light);
data[pixelIndex + 2] = 0;
} else {
const light = 255 - result * 10;
data[pixelIndex] = Math.max(0, light);
data[pixelIndex + 1] = 0;
data[pixelIndex + 2] = 0;
}
data[pixelIndex + 3] = 255;
}
private updateColorPalette(config: FractalConfig) {
if (this.lastScheme !== config.colorScheme || this.lastMaxIter !== config.maxIterations) {
this.currentPalette = this.generatePalette(config.colorScheme, config.maxIterations);
this.lastScheme = config.colorScheme;
this.lastMaxIter = config.maxIterations;
}
}
private calculateMandelbrot(cRe: number, cIm: number, maxIter: number): number {
let zRe = 0;
let zIm = 0;
let n = 0;
while (zRe * zRe + zIm * zIm <= 4 && n < maxIter) {
const zReNew = zRe * zRe - zIm * zIm + cRe;
zIm = 2 * zRe * zIm + cIm;
zRe = zReNew;
n++;
}
return n;
}
private calculateJulia(zRe: number, zIm: number, cRe: number, cIm: number, maxIter: number): number {
let n = 0;
while (zRe * zRe + zIm * zIm <= 4 && n < maxIter) {
const zReNew = zRe * zRe - zIm * zIm + cRe;
zIm = 2 * zRe * zIm + cIm;
zRe = zReNew;
n++;
}
return n;
}
private calculateBurningShip(cRe: number, cIm: number, maxIter: number): number {
let zRe = 0;
let zIm = 0;
let n = 0;
while (zRe * zRe + zIm * zIm <= 4 && n < maxIter) {
const zReAbs = Math.abs(zRe);
const zImAbs = Math.abs(zIm);
const zReNew = zReAbs * zReAbs - zImAbs * zImAbs + cRe;
zIm = 2 * zReAbs * zImAbs + cIm;
zRe = zReNew;
n++;
}
return n;
}
private colorizePixel(data: Uint8ClampedArray, pixelIndex: number, iterations: number): void {
const paletteIndex = iterations * 4;
data[pixelIndex] = this.currentPalette[paletteIndex]; // R
data[pixelIndex + 1] = this.currentPalette[paletteIndex + 1]; // G
data[pixelIndex + 2] = this.currentPalette[paletteIndex + 2]; // B
data[pixelIndex + 3] = 255;
}
// z^3 - 1
// 1. 1 + 0i (right)
// 2. -0.5 + 0.866i (upper left)
// 3. -0.5 - 0.866i (lower left)
private calculateNewton(x0: number, y0: number, maxIter: number): number {
let x = x0;
let y = y0;
const tolerance = 0.000001;
for (let i = 0; i < maxIter; i++) {
const x2 = x * x;
const y2 = y * y;
if (x2 + y2 < 0.0000001) return maxIter;
const oldX = x;
const oldY = y;
const z3Re = x*x*x - 3*x*y*y;
const z3Im = 3*x*x*y - y*y*y;
const fRe = z3Re - 1;
const fIm = z3Im;
const fPrimeRe = 3 * (x2 - y2);
const fPrimeIm = 3 * (2 * x * y);
const divDenom = fPrimeRe * fPrimeRe + fPrimeIm * fPrimeIm;
if (divDenom === 0) return maxIter;
const divRe = (fRe * fPrimeRe + fIm * fPrimeIm) / divDenom;
const divIm = (fIm * fPrimeRe - fRe * fPrimeIm) / divDenom;
x = oldX - divRe;
y = oldY - divIm;
if ((x - 1)*(x - 1) + y*y < tolerance) return i;
if ((x + 0.5)*(x + 0.5) + (y - 0.866)*(y - 0.866) < tolerance) return i + 1000;
if ((x + 0.5)*(x + 0.5) + (y + 0.866)*(y + 0.866) < tolerance) return i + 2000;
}
return maxIter;
}
// --- Paletten-Generator ---
private generatePalette(scheme: FractalColorScheme, maxIter: number): Uint8ClampedArray {
const palette = new Uint8ClampedArray((maxIter + 1) * 4);
for (let i = 0; i <= maxIter; i++) {
let r = 0, g = 0, b = 0;
if (i === maxIter) {
r = 0; g = 0; b = 0;
} else {
const t = i / maxIter;
switch (scheme) {
case 'Greyscale':
r = g = b = t * 255;
break;
case 'Blue-Gold':
r = 9 * (1 - t) * t * t * t * 255;
g = 15 * (1 - t) * (1 - t) * t * t * 255;
b = 8.5 * (1 - t) * (1 - t) * (1 - t) * t * 255;
r = Math.min(255, r * 2);
g = Math.min(255, g * 2);
b = Math.min(255, b * 4) + 40;
break;
case 'Fire':
if (i === maxIter) {
r = 0; g = 0; b = 0;
} else {
const t = Math.sqrt(i / maxIter);
r = t * 255 * 2;
g = (t - 0.3) * 255 * 3;
b = (t - 0.6) * 255 * 6;
}
break;
case 'Rainbow':
{ const hue = (i * 5) % 360;
const rgb = this.hsvToRgb(hue, 1, 1);
r = rgb[0]; g = rgb[1]; b = rgb[2];
break; }
}
}
const index = i * 4;
palette[index] = Math.min(255, Math.max(0, r));
palette[index + 1] = Math.min(255, Math.max(0, g));
palette[index + 2] = Math.min(255, Math.max(0, b));
palette[index + 3] = 255; // Alpha
}
return palette;
}
private hsvToRgb(h: number, s: number, v: number): [number, number, number] {
let r = 0, g = 0, b = 0;
const c = v * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = v - c;
if (h >= 0 && h < 60) { r = c; g = x; b = 0; }
else if (h >= 60 && h < 120) { r = x; g = c; b = 0; }
else if (h >= 120 && h < 180) { r = 0; g = c; b = x; }
else if (h >= 180 && h < 240) { r = 0; g = x; b = c; }
else if (h >= 240 && h < 300) { r = x; g = 0; b = c; }
else if (h >= 300 && h < 360) { r = c; g = 0; b = x; }
return [(r + m) * 255, (g + m) * 255, (b + m) * 255];
}
}

View File

@@ -32,6 +32,12 @@ export class AlgorithmsService {
title: 'ALGORITHM.LABYRINTH.TITLE',
description: 'ALGORITHM.LABYRINTH.DESCRIPTION',
routerLink: RouterConstants.LABYRINTH.LINK
},
{
id: 'fractal',
title: 'ALGORITHM.FRACTAL.TITLE',
description: 'ALGORITHM.FRACTAL.DESCRIPTION',
routerLink: RouterConstants.FRACTAL.LINK
}
];