import {
Component, ChangeDetectionStrategy, input, output, inject,
viewChild, ElementRef, afterNextRender, DestroyRef,
} from '@angular/core';
import { ReducedMotionService } from '../../services/reduced-motion.service';
import { AnimationLoopService } from '../../services/animation-loop.service';
import { FL_CONFIG } from '../../providers/flair-config.provider';
import { getPixelRatio } from '../../utils/canvas.utils';
import { randomInRange } from '../../utils/math.utils';
@Component({
selector: 'fl-fractal-landscape',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: ``,
styleUrl: './fl-fractal-landscape.component.scss',
host: { 'class': 'fl-fractal-landscape' },
})
export class FlFractalLandscapeComponent {
private config = inject(FL_CONFIG);
private reducedMotion = inject(ReducedMotionService);
private animationLoop = inject(AnimationLoopService);
private destroyRef = inject(DestroyRef);
protected canvasRef = viewChild.required>('canvas');
// Inputs
readonly gridSize = input(64);
readonly roughness = input(0.6);
readonly speed = input(0.3);
readonly wireColor = input('#6366f1');
readonly wireOpacity = input(0.6);
readonly perspective = input(400);
readonly elevation = input(0.4);
readonly rotationSpeed = input(0.1);
readonly responsive = input(true);
// Outputs
readonly ready = output();
private heightmap: number[][] = [];
private ctx: CanvasRenderingContext2D | null = null;
private width = 0;
private height = 0;
private pixelRatio = 1;
private loopCleanup: (() => void) | null = null;
private resizeObserver: ResizeObserver | null = null;
constructor() {
afterNextRender(() => {
this.initialize();
});
this.destroyRef.onDestroy(() => {
this.loopCleanup?.();
this.resizeObserver?.disconnect();
});
}
private initialize(): void {
const canvas = this.canvasRef().nativeElement;
this.ctx = canvas.getContext('2d');
if (!this.ctx) return;
this.pixelRatio = getPixelRatio(this.config.maxPixelRatio);
this.resize();
if (this.responsive()) {
this.resizeObserver = new ResizeObserver(() => this.resize());
this.resizeObserver.observe(canvas.parentElement!);
}
this.generateHeightmap();
if (this.reducedMotion.reduced()) {
this.renderFrame(0);
} else {
const id = `fractal-${Math.random().toString(36).slice(2)}`;
this.loopCleanup = this.animationLoop.register(id, (_, elapsed) => this.renderFrame(elapsed));
}
this.ready.emit();
}
private resize(): void {
const canvas = this.canvasRef().nativeElement;
const parent = canvas.parentElement;
if (!parent) return;
this.width = parent.clientWidth;
this.height = parent.clientHeight;
canvas.width = this.width * this.pixelRatio;
canvas.height = this.height * this.pixelRatio;
canvas.style.width = `${this.width}px`;
canvas.style.height = `${this.height}px`;
this.ctx?.scale(this.pixelRatio, this.pixelRatio);
}
/** Diamond-square heightmap generation */
private generateHeightmap(): void {
const size = this.gridSize() + 1;
const roughness = this.roughness();
this.heightmap = Array.from({ length: size }, () => new Array(size).fill(0));
// Seed corners
this.heightmap[0][0] = randomInRange(-1, 1);
this.heightmap[0][size - 1] = randomInRange(-1, 1);
this.heightmap[size - 1][0] = randomInRange(-1, 1);
this.heightmap[size - 1][size - 1] = randomInRange(-1, 1);
let step = size - 1;
let scale = roughness;
while (step > 1) {
const half = step >> 1;
// Diamond step
for (let y = 0; y < size - 1; y += step) {
for (let x = 0; x < size - 1; x += step) {
const avg = (
this.heightmap[y][x] +
this.heightmap[y][x + step] +
this.heightmap[y + step][x] +
this.heightmap[y + step][x + step]
) / 4;
this.heightmap[y + half][x + half] = avg + randomInRange(-scale, scale);
}
}
// Square step
for (let y = 0; y < size; y += half) {
for (let x = (y + half) % step; x < size; x += step) {
let sum = 0;
let count = 0;
if (y - half >= 0) { sum += this.heightmap[y - half][x]; count++; }
if (y + half < size) { sum += this.heightmap[y + half][x]; count++; }
if (x - half >= 0) { sum += this.heightmap[y][x - half]; count++; }
if (x + half < size) { sum += this.heightmap[y][x + half]; count++; }
this.heightmap[y][x] = sum / count + randomInRange(-scale, scale);
}
}
step = half;
scale *= 0.5;
}
}
private renderFrame(elapsed: number): void {
if (!this.ctx) return;
const ctx = this.ctx;
const w = this.width;
const h = this.height;
const size = this.gridSize() + 1;
const perspective = this.perspective();
const elevation = this.elevation();
const rotation = elapsed * this.rotationSpeed();
const color = this.wireColor();
const opacity = this.wireOpacity();
ctx.clearRect(0, 0, w, h);
ctx.strokeStyle = color;
ctx.globalAlpha = opacity;
ctx.lineWidth = 0.5;
const cx = w / 2;
const cy = h * 0.65;
const cosR = Math.cos(rotation);
const sinR = Math.sin(rotation);
const project = (gx: number, gy: number, gz: number): { x: number; y: number; z: number } => {
// Rotate around Y axis
const rx = gx * cosR - gz * sinR;
const rz = gx * sinR + gz * cosR;
const ry = gy;
// Perspective projection
const scale = perspective / (perspective + rz + size * 0.5);
return {
x: cx + rx * scale,
y: cy + ry * scale,
z: rz,
};
};
// Draw wireframe grid
for (let y = 0; y < size - 1; y++) {
for (let x = 0; x < size - 1; x++) {
const gx = (x - size / 2) * 4;
const gz = (y - size / 2) * 4;
const gy = -this.heightmap[y][x] * size * elevation;
const gx1 = ((x + 1) - size / 2) * 4;
const gz1 = ((y + 1) - size / 2) * 4;
const gy1x = -this.heightmap[y][x + 1] * size * elevation;
const gy1y = -this.heightmap[y + 1][x] * size * elevation;
const p0 = project(gx, gy, gz);
const p1 = project(gx1, gy1x, gz);
const p2 = project(gx, gy1y, gz1);
// Depth-based opacity
const depthAlpha = Math.max(0.1, 1 - (p0.z + size) / (size * 2));
ctx.globalAlpha = opacity * depthAlpha;
// Horizontal line
ctx.beginPath();
ctx.moveTo(p0.x, p0.y);
ctx.lineTo(p1.x, p1.y);
ctx.stroke();
// Vertical line
ctx.beginPath();
ctx.moveTo(p0.x, p0.y);
ctx.lineTo(p2.x, p2.y);
ctx.stroke();
}
}
ctx.globalAlpha = 1;
}
}