Angular 19+ standalone library with 61 components, 15 directives, and 8 services for decorative visual effects, animations, and micro-interactions. Components include particle systems, flow fields, aurora effects, tilt/holographic/spotlight cards, text effects, dividers, scroll animations, generative art, celebrations, image treatments, morphing shapes, terminal output, and pattern textures. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
225 lines
6.8 KiB
TypeScript
225 lines
6.8 KiB
TypeScript
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: `<canvas #canvas></canvas>`,
|
|
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<ElementRef<HTMLCanvasElement>>('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<void>();
|
|
|
|
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;
|
|
}
|
|
}
|