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; } }