feat: add @sda/flair-elements-ui library
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>
This commit is contained in:
38
.gitignore
vendored
Normal file
38
.gitignore
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
package-lock.json
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
out-tsc/
|
||||
*.tsbuildinfo
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Angular
|
||||
.angular/
|
||||
*.ngfactory.ts
|
||||
*.ngsummary.json
|
||||
*.metadata.json
|
||||
|
||||
# Testing
|
||||
coverage/
|
||||
.nyc_output/
|
||||
|
||||
# Misc
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.npm
|
||||
.eslintcache
|
||||
9
build-for-dev.sh
Executable file
9
build-for-dev.sh
Executable file
@@ -0,0 +1,9 @@
|
||||
#!/bin/bash
|
||||
# Build the library for development (used by demo)
|
||||
|
||||
set -e
|
||||
|
||||
echo "Building @sda/flair-elements-ui for development..."
|
||||
npm run build
|
||||
|
||||
echo "Build complete! Library available at dist/"
|
||||
12
ng-package.json
Normal file
12
ng-package.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "node_modules/ng-packagr/ng-package.schema.json",
|
||||
"lib": {
|
||||
"entryFile": "src/index.ts",
|
||||
"styleIncludePaths": ["src/styles"]
|
||||
},
|
||||
"dest": "dist",
|
||||
"deleteDestPath": true,
|
||||
"assets": [
|
||||
"src/styles/**/*.scss"
|
||||
]
|
||||
}
|
||||
39
package.json
Normal file
39
package.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "@sda/flair-elements-ui",
|
||||
"version": "0.1.0",
|
||||
"description": "Decorative and aesthetic visual effects library with animated backgrounds, text effects, generative art, celebration animations, scroll effects, and micro-interaction directives",
|
||||
"keywords": [
|
||||
"angular",
|
||||
"flair",
|
||||
"effects",
|
||||
"particles",
|
||||
"animations",
|
||||
"decorative",
|
||||
"components",
|
||||
"ui"
|
||||
],
|
||||
"license": "MIT",
|
||||
"sideEffects": false,
|
||||
"exports": {
|
||||
"./src/styles": "./src/styles/_index.scss"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "ng-packagr -p ng-package.json",
|
||||
"build:dev": "./build-for-dev.sh"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/common": "^19.1.0",
|
||||
"@angular/core": "^19.1.0",
|
||||
"@sda/base-ui": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular/common": "^19.1.0",
|
||||
"@angular/compiler": "^19.1.0",
|
||||
"@angular/compiler-cli": "^19.1.0",
|
||||
"@angular/core": "^19.1.0",
|
||||
"@angular/forms": "^19.1.0",
|
||||
"@sda/base-ui": "file:../base-ui/dist",
|
||||
"ng-packagr": "^19.1.0",
|
||||
"typescript": "~5.5.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
:host {
|
||||
display: block;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fl-ambient-light__layer-1,
|
||||
.fl-ambient-light__layer-2,
|
||||
.fl-ambient-light__layer-3 {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
will-change: background;
|
||||
}
|
||||
|
||||
.fl-ambient-light__layer-1 {
|
||||
opacity: 0.5;
|
||||
filter: blur(60px);
|
||||
}
|
||||
|
||||
.fl-ambient-light__layer-2 {
|
||||
opacity: 0.4;
|
||||
filter: blur(80px);
|
||||
}
|
||||
|
||||
.fl-ambient-light__layer-3 {
|
||||
opacity: 0.3;
|
||||
filter: blur(100px);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
Component, ChangeDetectionStrategy, input, inject,
|
||||
ElementRef, afterNextRender, DestroyRef, signal,
|
||||
} from '@angular/core';
|
||||
import { ReducedMotionService } from '../../services/reduced-motion.service';
|
||||
import { AnimationLoopService } from '../../services/animation-loop.service';
|
||||
|
||||
@Component({
|
||||
selector: 'fl-ambient-light',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="fl-ambient-light__layer-1" [style.background]="layer1()"></div>
|
||||
<div class="fl-ambient-light__layer-2" [style.background]="layer2()"></div>
|
||||
<div class="fl-ambient-light__layer-3" [style.background]="layer3()"></div>
|
||||
`,
|
||||
styleUrl: './fl-ambient-light.component.scss',
|
||||
host: {
|
||||
'class': 'fl-ambient-light',
|
||||
},
|
||||
})
|
||||
export class FlAmbientLightComponent {
|
||||
private el = inject(ElementRef);
|
||||
private reducedMotion = inject(ReducedMotionService);
|
||||
private animationLoop = inject(AnimationLoopService);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
// Inputs
|
||||
readonly colors = input<string[]>(['#3b82f6', '#8b5cf6', '#ec4899']);
|
||||
readonly speed = input(1);
|
||||
readonly blur = input(100);
|
||||
readonly opacity = input(0.5);
|
||||
|
||||
// Internal
|
||||
private elapsed = 0;
|
||||
private loopCleanup: (() => void) | null = null;
|
||||
|
||||
// Signals for layer backgrounds
|
||||
readonly layer1 = signal('');
|
||||
readonly layer2 = signal('');
|
||||
readonly layer3 = signal('');
|
||||
|
||||
constructor() {
|
||||
afterNextRender(() => {
|
||||
if (this.reducedMotion.reduced()) {
|
||||
this.setStaticGradients();
|
||||
return;
|
||||
}
|
||||
|
||||
const id = `ambient-light-${Math.random().toString(36).slice(2)}`;
|
||||
this.loopCleanup = this.animationLoop.register(id, (delta) => this.tick(delta));
|
||||
});
|
||||
|
||||
this.destroyRef.onDestroy(() => {
|
||||
this.loopCleanup?.();
|
||||
});
|
||||
}
|
||||
|
||||
private tick(delta: number): void {
|
||||
this.elapsed += delta * this.speed();
|
||||
this.updateGradients();
|
||||
}
|
||||
|
||||
private updateGradients(): void {
|
||||
const host = this.el.nativeElement as HTMLElement;
|
||||
const width = host.offsetWidth;
|
||||
const height = host.offsetHeight;
|
||||
const colors = this.colors();
|
||||
const blur = this.blur();
|
||||
const opacity = this.opacity();
|
||||
|
||||
// Layer 1
|
||||
const x1 = 50 + Math.sin(this.elapsed * 0.3) * 30;
|
||||
const y1 = 50 + Math.cos(this.elapsed * 0.2) * 30;
|
||||
const color1 = colors[0] || '#3b82f6';
|
||||
this.layer1.set(`radial-gradient(circle at ${x1}% ${y1}%, ${color1} 0%, transparent ${blur}%)`);
|
||||
|
||||
// Layer 2
|
||||
const x2 = 50 + Math.sin(this.elapsed * 0.4 + Math.PI) * 25;
|
||||
const y2 = 50 + Math.cos(this.elapsed * 0.3 + Math.PI) * 25;
|
||||
const color2 = colors[1] || '#8b5cf6';
|
||||
this.layer2.set(`radial-gradient(circle at ${x2}% ${y2}%, ${color2} 0%, transparent ${blur}%)`);
|
||||
|
||||
// Layer 3
|
||||
const x3 = 50 + Math.sin(this.elapsed * 0.5 + Math.PI * 0.5) * 20;
|
||||
const y3 = 50 + Math.cos(this.elapsed * 0.4 + Math.PI * 0.5) * 20;
|
||||
const color3 = colors[2] || '#ec4899';
|
||||
this.layer3.set(`radial-gradient(circle at ${x3}% ${y3}%, ${color3} 0%, transparent ${blur}%)`);
|
||||
}
|
||||
|
||||
private setStaticGradients(): void {
|
||||
const colors = this.colors();
|
||||
const blur = this.blur();
|
||||
|
||||
this.layer1.set(`radial-gradient(circle at 50% 50%, ${colors[0] || '#3b82f6'} 0%, transparent ${blur}%)`);
|
||||
this.layer2.set(`radial-gradient(circle at 30% 70%, ${colors[1] || '#8b5cf6'} 0%, transparent ${blur}%)`);
|
||||
this.layer3.set(`radial-gradient(circle at 70% 30%, ${colors[2] || '#ec4899'} 0%, transparent ${blur}%)`);
|
||||
}
|
||||
}
|
||||
1
src/components/fl-ambient-light/index.ts
Normal file
1
src/components/fl-ambient-light/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './fl-ambient-light.component';
|
||||
@@ -0,0 +1,46 @@
|
||||
.fl-animated-border {
|
||||
--fl-border-gradient: conic-gradient(from 0deg, #667eea, #764ba2, #f093fb, #4facfe, #667eea);
|
||||
--fl-border-width: 2px;
|
||||
--fl-border-speed: 3s;
|
||||
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
padding: var(--fl-border-width);
|
||||
background: var(--fl-border-gradient);
|
||||
background-size: 200% 200%;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
padding: var(--fl-border-width);
|
||||
background: var(--fl-border-gradient);
|
||||
-webkit-mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
&--animated::before {
|
||||
animation: fl-animated-border-rotate var(--fl-border-speed) linear infinite;
|
||||
}
|
||||
|
||||
&__content {
|
||||
position: relative;
|
||||
background: inherit;
|
||||
border-radius: inherit;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fl-animated-border-rotate {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
Component, ChangeDetectionStrategy, input, inject,
|
||||
ElementRef, afterNextRender, DestroyRef,
|
||||
} from '@angular/core';
|
||||
import { ReducedMotionService } from '../../services/reduced-motion.service';
|
||||
|
||||
@Component({
|
||||
selector: 'fl-animated-border',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="fl-animated-border__content">
|
||||
<ng-content />
|
||||
</div>
|
||||
`,
|
||||
styleUrl: './fl-animated-border.component.scss',
|
||||
host: { 'class': 'fl-animated-border' },
|
||||
})
|
||||
export class FlAnimatedBorderComponent {
|
||||
private el = inject(ElementRef);
|
||||
private reducedMotion = inject(ReducedMotionService);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
// Inputs
|
||||
readonly colors = input<string[]>(['#667eea', '#764ba2', '#f093fb', '#4facfe']);
|
||||
readonly width = input(2);
|
||||
readonly speed = input(3);
|
||||
readonly borderRadius = input('8px');
|
||||
|
||||
constructor() {
|
||||
afterNextRender(() => {
|
||||
this.updateStyles();
|
||||
});
|
||||
}
|
||||
|
||||
private updateStyles(): void {
|
||||
const host = this.el.nativeElement as HTMLElement;
|
||||
const colors = this.colors();
|
||||
const width = this.width();
|
||||
const speed = this.speed();
|
||||
const borderRadius = this.borderRadius();
|
||||
|
||||
const gradient = `conic-gradient(from 0deg, ${colors.join(', ')}, ${colors[0]})`;
|
||||
|
||||
host.style.setProperty('--fl-border-gradient', gradient);
|
||||
host.style.setProperty('--fl-border-width', `${width}px`);
|
||||
host.style.setProperty('--fl-border-speed', `${speed}s`);
|
||||
host.style.borderRadius = borderRadius;
|
||||
|
||||
if (this.reducedMotion.reduced()) {
|
||||
host.classList.remove('fl-animated-border--animated');
|
||||
} else {
|
||||
host.classList.add('fl-animated-border--animated');
|
||||
}
|
||||
}
|
||||
}
|
||||
1
src/components/fl-animated-border/index.ts
Normal file
1
src/components/fl-animated-border/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './fl-animated-border.component';
|
||||
15
src/components/fl-ascii-art/fl-ascii-art.component.scss
Normal file
15
src/components/fl-ascii-art/fl-ascii-art.component.scss
Normal file
@@ -0,0 +1,15 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.fl-ascii-art__pre {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.2;
|
||||
margin: 0;
|
||||
padding: 1rem;
|
||||
background: #000000;
|
||||
border-radius: 0.25rem;
|
||||
overflow-x: auto;
|
||||
white-space: pre;
|
||||
}
|
||||
66
src/components/fl-ascii-art/fl-ascii-art.component.ts
Normal file
66
src/components/fl-ascii-art/fl-ascii-art.component.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
Component, ChangeDetectionStrategy, input, inject,
|
||||
DestroyRef, afterNextRender, signal,
|
||||
} from '@angular/core';
|
||||
import { ReducedMotionService } from '../../services/reduced-motion.service';
|
||||
|
||||
@Component({
|
||||
selector: 'fl-ascii-art',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<pre
|
||||
class="fl-ascii-art__pre"
|
||||
[class.fl-ascii-art__pre--reduced-motion]="reducedMotion.reduced()"
|
||||
[style.color]="color()"
|
||||
>{{ displayText() }}</pre>
|
||||
`,
|
||||
styleUrl: './fl-ascii-art.component.scss',
|
||||
host: { 'class': 'fl-ascii-art' },
|
||||
})
|
||||
export class FlAsciiArtComponent {
|
||||
protected reducedMotion = inject(ReducedMotionService);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
// Inputs
|
||||
readonly text = input('');
|
||||
readonly color = input('#00ff00');
|
||||
readonly animated = input(true);
|
||||
readonly speed = input(20);
|
||||
|
||||
protected readonly displayText = signal('');
|
||||
private currentIndex = 0;
|
||||
private timerId: number | null = null;
|
||||
|
||||
constructor() {
|
||||
afterNextRender(() => {
|
||||
if (!this.animated() || this.reducedMotion.reduced()) {
|
||||
this.displayText.set(this.text());
|
||||
} else {
|
||||
this.startAnimation();
|
||||
}
|
||||
});
|
||||
|
||||
this.destroyRef.onDestroy(() => {
|
||||
if (this.timerId !== null) {
|
||||
clearInterval(this.timerId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private startAnimation(): void {
|
||||
const fullText = this.text();
|
||||
|
||||
this.timerId = setInterval(() => {
|
||||
if (this.currentIndex >= fullText.length) {
|
||||
if (this.timerId !== null) {
|
||||
clearInterval(this.timerId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.displayText.set(fullText.slice(0, this.currentIndex + 1));
|
||||
this.currentIndex++;
|
||||
}, this.speed()) as unknown as number;
|
||||
}
|
||||
}
|
||||
1
src/components/fl-ascii-art/index.ts
Normal file
1
src/components/fl-ascii-art/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './fl-ascii-art.component';
|
||||
20
src/components/fl-aurora/fl-aurora.component.scss
Normal file
20
src/components/fl-aurora/fl-aurora.component.scss
Normal file
@@ -0,0 +1,20 @@
|
||||
:host {
|
||||
display: block;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
svg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
::ng-deep > :not(svg) {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
157
src/components/fl-aurora/fl-aurora.component.ts
Normal file
157
src/components/fl-aurora/fl-aurora.component.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
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 { createSvgElement, generateWavePath } from '../../utils/svg.utils';
|
||||
|
||||
@Component({
|
||||
selector: 'fl-aurora',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<svg #svg xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="none">
|
||||
<defs>
|
||||
<filter id="aurora-blur" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="40" />
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
<ng-content />
|
||||
`,
|
||||
styleUrl: './fl-aurora.component.scss',
|
||||
host: { 'class': 'fl-aurora' },
|
||||
})
|
||||
export class FlAuroraComponent {
|
||||
private reducedMotion = inject(ReducedMotionService);
|
||||
private animationLoop = inject(AnimationLoopService);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
protected svgRef = viewChild.required<ElementRef<SVGElement>>('svg');
|
||||
|
||||
// Inputs
|
||||
readonly colors = input<string[]>(['#22c55e', '#06b6d4', '#8b5cf6', '#6366f1']);
|
||||
readonly layers = input(4);
|
||||
readonly speed = input(1);
|
||||
readonly blur = input(60);
|
||||
readonly opacity = input(0.6);
|
||||
readonly responsive = input(true);
|
||||
|
||||
// Outputs
|
||||
readonly ready = output<void>();
|
||||
|
||||
private paths: SVGPathElement[] = [];
|
||||
private width = 0;
|
||||
private height = 0;
|
||||
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 svg = this.svgRef().nativeElement;
|
||||
const parent = svg.parentElement;
|
||||
if (!parent) return;
|
||||
|
||||
this.resize();
|
||||
|
||||
if (this.responsive()) {
|
||||
this.resizeObserver = new ResizeObserver(() => this.resize());
|
||||
this.resizeObserver.observe(parent);
|
||||
}
|
||||
|
||||
// Update blur
|
||||
const blurFilter = svg.querySelector('feGaussianBlur');
|
||||
if (blurFilter) {
|
||||
blurFilter.setAttribute('stdDeviation', `${this.blur()}`);
|
||||
}
|
||||
|
||||
this.createLayers();
|
||||
|
||||
if (this.reducedMotion.reduced()) {
|
||||
this.renderStaticState();
|
||||
} else {
|
||||
const id = `aurora-${Math.random().toString(36).slice(2)}`;
|
||||
this.loopCleanup = this.animationLoop.register(id, (_, elapsed) => this.tick(elapsed));
|
||||
}
|
||||
|
||||
this.ready.emit();
|
||||
}
|
||||
|
||||
private resize(): void {
|
||||
const svg = this.svgRef().nativeElement;
|
||||
const parent = svg.parentElement;
|
||||
if (!parent) return;
|
||||
|
||||
this.width = parent.clientWidth;
|
||||
this.height = parent.clientHeight;
|
||||
svg.setAttribute('viewBox', `0 0 ${this.width} ${this.height}`);
|
||||
svg.setAttribute('width', `${this.width}`);
|
||||
svg.setAttribute('height', `${this.height}`);
|
||||
}
|
||||
|
||||
private createLayers(): void {
|
||||
const svg = this.svgRef().nativeElement;
|
||||
const colors = this.colors();
|
||||
const layerCount = this.layers();
|
||||
const opacity = this.opacity();
|
||||
|
||||
// Remove old paths
|
||||
for (const p of this.paths) p.remove();
|
||||
this.paths = [];
|
||||
|
||||
for (let i = 0; i < layerCount; i++) {
|
||||
const path = createSvgElement('path', {
|
||||
fill: colors[i % colors.length],
|
||||
opacity: `${opacity * (1 - i * 0.15)}`,
|
||||
filter: 'url(#aurora-blur)',
|
||||
});
|
||||
svg.appendChild(path);
|
||||
this.paths.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
private tick(elapsed: number): void {
|
||||
const w = this.width;
|
||||
const h = this.height;
|
||||
const speed = this.speed();
|
||||
const layerCount = this.layers();
|
||||
|
||||
for (let i = 0; i < this.paths.length; i++) {
|
||||
const layerOffset = i / layerCount;
|
||||
const amplitude = h * 0.15 * (1 + layerOffset * 0.5);
|
||||
const frequency = 1.5 + layerOffset;
|
||||
const phase = elapsed * speed * (0.3 + layerOffset * 0.2);
|
||||
const yOffset = h * (0.2 + layerOffset * 0.15);
|
||||
|
||||
const d = generateWavePath(w, h, amplitude, frequency, phase, yOffset);
|
||||
this.paths[i].setAttribute('d', d);
|
||||
}
|
||||
}
|
||||
|
||||
private renderStaticState(): void {
|
||||
const w = this.width;
|
||||
const h = this.height;
|
||||
const layerCount = this.layers();
|
||||
|
||||
for (let i = 0; i < this.paths.length; i++) {
|
||||
const layerOffset = i / layerCount;
|
||||
const amplitude = h * 0.15 * (1 + layerOffset * 0.5);
|
||||
const frequency = 1.5 + layerOffset;
|
||||
const yOffset = h * (0.2 + layerOffset * 0.15);
|
||||
|
||||
const d = generateWavePath(w, h, amplitude, frequency, 0, yOffset);
|
||||
this.paths[i].setAttribute('d', d);
|
||||
}
|
||||
}
|
||||
}
|
||||
1
src/components/fl-aurora/index.ts
Normal file
1
src/components/fl-aurora/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './fl-aurora.component';
|
||||
9
src/components/fl-blob/fl-blob.component.scss
Normal file
9
src/components/fl-blob/fl-blob.component.scss
Normal file
@@ -0,0 +1,9 @@
|
||||
:host {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.fl-blob__svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
95
src/components/fl-blob/fl-blob.component.ts
Normal file
95
src/components/fl-blob/fl-blob.component.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import {
|
||||
Component, ChangeDetectionStrategy, input, inject,
|
||||
ElementRef, afterNextRender, DestroyRef, signal,
|
||||
} from '@angular/core';
|
||||
import { ReducedMotionService } from '../../services/reduced-motion.service';
|
||||
import { AnimationLoopService } from '../../services/animation-loop.service';
|
||||
import { generateBlobPath } from '../../utils/svg.utils';
|
||||
|
||||
@Component({
|
||||
selector: 'fl-blob',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<svg
|
||||
class="fl-blob__svg"
|
||||
[attr.viewBox]="viewBox()"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="blob-gradient-{{id}}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
@for (color of colors(); track $index) {
|
||||
<stop
|
||||
[attr.offset]="($index / (colors().length - 1)) * 100 + '%'"
|
||||
[attr.stop-color]="color"
|
||||
/>
|
||||
}
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
[attr.d]="blobPath()"
|
||||
[attr.fill]="'url(#blob-gradient-' + id + ')'"
|
||||
/>
|
||||
</svg>
|
||||
`,
|
||||
styleUrl: './fl-blob.component.scss',
|
||||
host: {
|
||||
'class': 'fl-blob',
|
||||
},
|
||||
})
|
||||
export class FlBlobComponent {
|
||||
private el = inject(ElementRef);
|
||||
private reducedMotion = inject(ReducedMotionService);
|
||||
private animationLoop = inject(AnimationLoopService);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
// Inputs
|
||||
readonly colors = input<string[]>(['#3b82f6', '#8b5cf6']);
|
||||
readonly speed = input(1);
|
||||
readonly complexity = input(8);
|
||||
readonly size = input(200);
|
||||
|
||||
// Internal
|
||||
private elapsed = 0;
|
||||
private loopCleanup: (() => void) | null = null;
|
||||
protected readonly id = `blob-${Math.random().toString(36).slice(2)}`;
|
||||
|
||||
// Signals
|
||||
readonly blobPath = signal('');
|
||||
readonly viewBox = signal('0 0 200 200');
|
||||
|
||||
constructor() {
|
||||
afterNextRender(() => {
|
||||
this.viewBox.set(`0 0 ${this.size()} ${this.size()}`);
|
||||
|
||||
if (this.reducedMotion.reduced()) {
|
||||
this.updateBlob(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const id = `blob-${Math.random().toString(36).slice(2)}`;
|
||||
this.loopCleanup = this.animationLoop.register(id, (delta) => this.tick(delta));
|
||||
});
|
||||
|
||||
this.destroyRef.onDestroy(() => {
|
||||
this.loopCleanup?.();
|
||||
});
|
||||
}
|
||||
|
||||
private tick(delta: number): void {
|
||||
this.elapsed += delta * this.speed();
|
||||
this.updateBlob(this.elapsed);
|
||||
}
|
||||
|
||||
private updateBlob(phase: number): void {
|
||||
const size = this.size();
|
||||
const cx = size / 2;
|
||||
const cy = size / 2;
|
||||
const radius = size * 0.35;
|
||||
const variance = size * 0.1;
|
||||
const points = this.complexity();
|
||||
|
||||
const path = generateBlobPath(cx, cy, radius, points, variance, phase);
|
||||
this.blobPath.set(path);
|
||||
}
|
||||
}
|
||||
1
src/components/fl-blob/index.ts
Normal file
1
src/components/fl-blob/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './fl-blob.component';
|
||||
@@ -0,0 +1,12 @@
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #0a1628;
|
||||
}
|
||||
|
||||
.fl-blueprint-grid__svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
104
src/components/fl-blueprint-grid/fl-blueprint-grid.component.ts
Normal file
104
src/components/fl-blueprint-grid/fl-blueprint-grid.component.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import {
|
||||
Component, ChangeDetectionStrategy, input, inject,
|
||||
DestroyRef,
|
||||
} from '@angular/core';
|
||||
import { ReducedMotionService } from '../../services/reduced-motion.service';
|
||||
|
||||
@Component({
|
||||
selector: 'fl-blueprint-grid',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<svg
|
||||
class="fl-blueprint-grid__svg"
|
||||
[attr.width]="'100%'"
|
||||
[attr.height]="'100%'"
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
>
|
||||
<defs>
|
||||
<pattern
|
||||
[id]="patternId"
|
||||
[attr.width]="spacing()"
|
||||
[attr.height]="spacing()"
|
||||
patternUnits="userSpaceOnUse"
|
||||
>
|
||||
<!-- Minor grid lines -->
|
||||
<line
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
[attr.y2]="spacing()"
|
||||
[attr.stroke]="color()"
|
||||
stroke-width="0.5"
|
||||
/>
|
||||
<line
|
||||
x1="0"
|
||||
y1="0"
|
||||
[attr.x2]="spacing()"
|
||||
y2="0"
|
||||
[attr.stroke]="color()"
|
||||
stroke-width="0.5"
|
||||
/>
|
||||
</pattern>
|
||||
|
||||
<pattern
|
||||
[id]="majorPatternId"
|
||||
[attr.width]="spacing() * majorInterval()"
|
||||
[attr.height]="spacing() * majorInterval()"
|
||||
patternUnits="userSpaceOnUse"
|
||||
>
|
||||
<!-- Major grid lines -->
|
||||
<rect
|
||||
width="100%"
|
||||
height="100%"
|
||||
[attr.fill]="minorPatternFill"
|
||||
/>
|
||||
<line
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
[attr.y2]="spacing() * majorInterval()"
|
||||
[attr.stroke]="majorColor()"
|
||||
stroke-width="1"
|
||||
/>
|
||||
<line
|
||||
x1="0"
|
||||
y1="0"
|
||||
[attr.x2]="spacing() * majorInterval()"
|
||||
y2="0"
|
||||
[attr.stroke]="majorColor()"
|
||||
stroke-width="1"
|
||||
/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect
|
||||
width="100%"
|
||||
height="100%"
|
||||
[attr.fill]="majorPatternFill"
|
||||
/>
|
||||
</svg>
|
||||
`,
|
||||
styleUrl: './fl-blueprint-grid.component.scss',
|
||||
host: { 'class': 'fl-blueprint-grid' },
|
||||
})
|
||||
export class FlBlueprintGridComponent {
|
||||
private reducedMotion = inject(ReducedMotionService);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
// Inputs
|
||||
readonly spacing = input(20);
|
||||
readonly color = input('rgba(100, 150, 200, 0.3)');
|
||||
readonly majorInterval = input(5);
|
||||
readonly majorColor = input('rgba(100, 150, 200, 0.6)');
|
||||
|
||||
protected readonly patternId = `blueprint-grid-${Math.random().toString(36).slice(2)}`;
|
||||
protected readonly majorPatternId = `blueprint-major-${Math.random().toString(36).slice(2)}`;
|
||||
|
||||
protected get minorPatternFill(): string {
|
||||
return `url(#${this.patternId})`;
|
||||
}
|
||||
|
||||
protected get majorPatternFill(): string {
|
||||
return `url(#${this.majorPatternId})`;
|
||||
}
|
||||
}
|
||||
1
src/components/fl-blueprint-grid/index.ts
Normal file
1
src/components/fl-blueprint-grid/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './fl-blueprint-grid.component';
|
||||
@@ -0,0 +1,28 @@
|
||||
:host {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.fl-breathing-dot__inner {
|
||||
border-radius: 50%;
|
||||
animation: fl-breathing-dot-pulse 2s ease-in-out infinite;
|
||||
box-shadow: 0 0 10px currentColor;
|
||||
will-change: transform, box-shadow;
|
||||
}
|
||||
|
||||
@keyframes fl-breathing-dot-pulse {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 10px currentColor;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.2);
|
||||
box-shadow: 0 0 20px currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
:host.fl-breathing-dot--reduced-motion {
|
||||
.fl-breathing-dot__inner {
|
||||
animation: none;
|
||||
box-shadow: 0 0 10px currentColor;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
Component, ChangeDetectionStrategy, input, inject, computed,
|
||||
DestroyRef,
|
||||
} from '@angular/core';
|
||||
import { ReducedMotionService } from '../../services/reduced-motion.service';
|
||||
|
||||
@Component({
|
||||
selector: 'fl-breathing-dot',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div
|
||||
class="fl-breathing-dot__inner"
|
||||
[style.width.px]="size()"
|
||||
[style.height.px]="size()"
|
||||
[style.background-color]="color()"
|
||||
[style.animation-duration.s]="animationDuration()"
|
||||
></div>
|
||||
`,
|
||||
styleUrl: './fl-breathing-dot.component.scss',
|
||||
host: {
|
||||
'class': 'fl-breathing-dot',
|
||||
'[class.fl-breathing-dot--reduced-motion]': 'reducedMotion.reduced()',
|
||||
},
|
||||
})
|
||||
export class FlBreathingDotComponent {
|
||||
private reducedMotion = inject(ReducedMotionService);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
// Inputs
|
||||
readonly color = input('#3b82f6');
|
||||
readonly size = input(12);
|
||||
readonly speed = input(1);
|
||||
|
||||
// Computed
|
||||
readonly animationDuration = computed(() => 2 / this.speed());
|
||||
}
|
||||
1
src/components/fl-breathing-dot/index.ts
Normal file
1
src/components/fl-breathing-dot/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './fl-breathing-dot.component';
|
||||
30
src/components/fl-card-stack/fl-card-stack.component.scss
Normal file
30
src/components/fl-card-stack/fl-card-stack.component.scss
Normal file
@@ -0,0 +1,30 @@
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.fl-card-stack__container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
> * {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
:host.fl-card-stack--hover-expand {
|
||||
.fl-card-stack__container > *:hover {
|
||||
transform: translate(-50%, -50%) rotate(0deg) scale(1.05) !important;
|
||||
z-index: 1000 !important;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
:host.fl-card-stack--reduced-motion {
|
||||
.fl-card-stack__container > * {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
62
src/components/fl-card-stack/fl-card-stack.component.ts
Normal file
62
src/components/fl-card-stack/fl-card-stack.component.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
Component, ChangeDetectionStrategy, input, inject,
|
||||
DestroyRef, ElementRef, afterNextRender,
|
||||
} from '@angular/core';
|
||||
import { ReducedMotionService } from '../../services/reduced-motion.service';
|
||||
|
||||
@Component({
|
||||
selector: 'fl-card-stack',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="fl-card-stack__container">
|
||||
<ng-content />
|
||||
</div>
|
||||
`,
|
||||
styleUrl: './fl-card-stack.component.scss',
|
||||
host: {
|
||||
'class': 'fl-card-stack',
|
||||
'[class.fl-card-stack--reduced-motion]': 'reducedMotion.reduced()',
|
||||
'[class.fl-card-stack--hover-expand]': 'hoverExpand()',
|
||||
},
|
||||
})
|
||||
export class FlCardStackComponent {
|
||||
protected reducedMotion = inject(ReducedMotionService);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
private el = inject(ElementRef);
|
||||
|
||||
// Inputs
|
||||
readonly spread = input(20);
|
||||
readonly rotation = input(3);
|
||||
readonly hoverExpand = input(true);
|
||||
|
||||
constructor() {
|
||||
afterNextRender(() => {
|
||||
this.applyCardStyles();
|
||||
});
|
||||
}
|
||||
|
||||
private applyCardStyles(): void {
|
||||
const container = this.el.nativeElement.querySelector('.fl-card-stack__container') as HTMLElement;
|
||||
if (!container) return;
|
||||
|
||||
const children = Array.from(container.children) as HTMLElement[];
|
||||
const total = children.length;
|
||||
const spreadPx = this.spread();
|
||||
const rotationDeg = this.rotation();
|
||||
|
||||
children.forEach((child, index) => {
|
||||
const offset = (index - (total - 1) / 2);
|
||||
const translateY = offset * spreadPx;
|
||||
const rotate = offset * rotationDeg;
|
||||
const zIndex = total - Math.abs(index - Math.floor(total / 2));
|
||||
|
||||
child.style.position = 'absolute';
|
||||
child.style.top = '50%';
|
||||
child.style.left = '50%';
|
||||
child.style.transform = `translate(-50%, calc(-50% + ${translateY}px)) rotate(${rotate}deg)`;
|
||||
child.style.zIndex = `${zIndex}`;
|
||||
child.style.transition = 'transform 0.3s ease';
|
||||
});
|
||||
}
|
||||
}
|
||||
1
src/components/fl-card-stack/index.ts
Normal file
1
src/components/fl-card-stack/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './fl-card-stack.component';
|
||||
@@ -0,0 +1,36 @@
|
||||
:host {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.fl-checkmark-flourish__svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.fl-checkmark-flourish__circle,
|
||||
.fl-checkmark-flourish__checkmark {
|
||||
transition: stroke-dashoffset 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.fl-checkmark-flourish__sparkle {
|
||||
position: absolute;
|
||||
font-size: 1rem;
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
animation: fl-checkmark-sparkle 1s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes fl-checkmark-sparkle {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%) scale(0);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1.2);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%) scale(0.8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import {
|
||||
Component, ChangeDetectionStrategy, input, inject,
|
||||
ElementRef, afterNextRender, DestroyRef, effect, signal,
|
||||
} from '@angular/core';
|
||||
import { ReducedMotionService } from '../../services/reduced-motion.service';
|
||||
import { AnimationLoopService } from '../../services/animation-loop.service';
|
||||
|
||||
@Component({
|
||||
selector: 'fl-checkmark-flourish',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<svg
|
||||
class="fl-checkmark-flourish__svg"
|
||||
[attr.width]="size()"
|
||||
[attr.height]="size()"
|
||||
[attr.viewBox]="'0 0 ' + size() + ' ' + size()"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle
|
||||
[attr.cx]="size() / 2"
|
||||
[attr.cy]="size() / 2"
|
||||
[attr.r]="size() / 2 - 2"
|
||||
[attr.stroke]="color()"
|
||||
stroke-width="2"
|
||||
fill="none"
|
||||
[attr.stroke-dasharray]="circleCircumference()"
|
||||
[attr.stroke-dashoffset]="circleOffset()"
|
||||
class="fl-checkmark-flourish__circle"
|
||||
/>
|
||||
<path
|
||||
[attr.d]="checkmarkPath()"
|
||||
[attr.stroke]="color()"
|
||||
stroke-width="3"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
[attr.stroke-dasharray]="checkmarkLength()"
|
||||
[attr.stroke-dashoffset]="checkmarkOffset()"
|
||||
class="fl-checkmark-flourish__checkmark"
|
||||
/>
|
||||
</svg>
|
||||
@if (showSparkles()) {
|
||||
@for (sparkle of sparkles(); track $index) {
|
||||
<div
|
||||
class="fl-checkmark-flourish__sparkle"
|
||||
[style.left]="sparkle.x + '%'"
|
||||
[style.top]="sparkle.y + '%'"
|
||||
[style.opacity]="sparkle.opacity"
|
||||
>✨</div>
|
||||
}
|
||||
}
|
||||
`,
|
||||
styleUrl: './fl-checkmark-flourish.component.scss',
|
||||
host: {
|
||||
'class': 'fl-checkmark-flourish',
|
||||
'[class.fl-checkmark-flourish--active]': 'active()',
|
||||
},
|
||||
})
|
||||
export class FlCheckmarkFlourishComponent {
|
||||
private el = inject(ElementRef);
|
||||
private reducedMotion = inject(ReducedMotionService);
|
||||
private animationLoop = inject(AnimationLoopService);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
// Inputs
|
||||
readonly active = input(false);
|
||||
readonly color = input('#10b981');
|
||||
readonly size = input(64);
|
||||
readonly duration = input(600);
|
||||
|
||||
// Internal
|
||||
private progress = 0;
|
||||
private loopCleanup: (() => void) | null = null;
|
||||
private isAnimating = false;
|
||||
|
||||
// Signals
|
||||
readonly circleOffset = signal(0);
|
||||
readonly checkmarkOffset = signal(0);
|
||||
readonly showSparkles = signal(false);
|
||||
readonly sparkles = signal<Array<{ x: number; y: number; opacity: number }>>([]);
|
||||
|
||||
readonly circleCircumference = signal(0);
|
||||
readonly checkmarkLength = signal(70);
|
||||
readonly checkmarkPath = signal('');
|
||||
|
||||
constructor() {
|
||||
afterNextRender(() => {
|
||||
const size = this.size();
|
||||
const radius = size / 2 - 2;
|
||||
this.circleCircumference.set(2 * Math.PI * radius);
|
||||
this.circleOffset.set(this.circleCircumference());
|
||||
|
||||
// Checkmark path
|
||||
const scale = size / 64;
|
||||
this.checkmarkPath.set(
|
||||
`M ${18 * scale} ${32 * scale} L ${28 * scale} ${42 * scale} L ${46 * scale} ${24 * scale}`
|
||||
);
|
||||
|
||||
this.checkmarkOffset.set(this.checkmarkLength());
|
||||
|
||||
// Watch for active changes
|
||||
effect(() => {
|
||||
const isActive = this.active();
|
||||
if (isActive && !this.isAnimating) {
|
||||
this.trigger();
|
||||
} else if (!isActive) {
|
||||
this.reset();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
this.destroyRef.onDestroy(() => {
|
||||
this.loopCleanup?.();
|
||||
});
|
||||
}
|
||||
|
||||
private trigger(): void {
|
||||
if (this.reducedMotion.reduced()) {
|
||||
this.circleOffset.set(0);
|
||||
this.checkmarkOffset.set(0);
|
||||
return;
|
||||
}
|
||||
|
||||
this.progress = 0;
|
||||
this.isAnimating = true;
|
||||
|
||||
const id = `checkmark-${Math.random().toString(36).slice(2)}`;
|
||||
this.loopCleanup = this.animationLoop.register(id, (delta) => this.tick(delta));
|
||||
}
|
||||
|
||||
private tick(delta: number): void {
|
||||
const durationSec = this.duration() / 1000;
|
||||
this.progress += delta / durationSec;
|
||||
|
||||
if (this.progress >= 1) {
|
||||
this.progress = 1;
|
||||
this.isAnimating = false;
|
||||
this.loopCleanup?.();
|
||||
this.loopCleanup = null;
|
||||
|
||||
// Show sparkles
|
||||
this.showSparkles.set(true);
|
||||
this.createSparkles();
|
||||
setTimeout(() => this.showSparkles.set(false), 1000);
|
||||
}
|
||||
|
||||
// Animate circle (first half)
|
||||
const circleProgress = Math.min(this.progress * 2, 1);
|
||||
this.circleOffset.set(this.circleCircumference() * (1 - circleProgress));
|
||||
|
||||
// Animate checkmark (second half)
|
||||
const checkmarkProgress = Math.max(0, (this.progress - 0.5) * 2);
|
||||
this.checkmarkOffset.set(this.checkmarkLength() * (1 - checkmarkProgress));
|
||||
}
|
||||
|
||||
private createSparkles(): void {
|
||||
const sparkles = [];
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const angle = (Math.PI * 2 * i) / 6;
|
||||
const distance = 60;
|
||||
sparkles.push({
|
||||
x: 50 + Math.cos(angle) * distance,
|
||||
y: 50 + Math.sin(angle) * distance,
|
||||
opacity: 1,
|
||||
});
|
||||
}
|
||||
this.sparkles.set(sparkles);
|
||||
}
|
||||
|
||||
private reset(): void {
|
||||
this.progress = 0;
|
||||
this.circleOffset.set(this.circleCircumference());
|
||||
this.checkmarkOffset.set(this.checkmarkLength());
|
||||
this.showSparkles.set(false);
|
||||
this.sparkles.set([]);
|
||||
this.loopCleanup?.();
|
||||
this.loopCleanup = null;
|
||||
this.isAnimating = false;
|
||||
}
|
||||
}
|
||||
1
src/components/fl-checkmark-flourish/index.ts
Normal file
1
src/components/fl-checkmark-flourish/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './fl-checkmark-flourish.component';
|
||||
@@ -0,0 +1,12 @@
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #0a0a0a;
|
||||
}
|
||||
|
||||
.fl-circuit-board__svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
157
src/components/fl-circuit-board/fl-circuit-board.component.ts
Normal file
157
src/components/fl-circuit-board/fl-circuit-board.component.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import {
|
||||
Component, ChangeDetectionStrategy, input, inject,
|
||||
DestroyRef, afterNextRender, signal,
|
||||
} from '@angular/core';
|
||||
import { ReducedMotionService } from '../../services/reduced-motion.service';
|
||||
import { AnimationLoopService } from '../../services/animation-loop.service';
|
||||
|
||||
interface CircuitPath {
|
||||
d: string;
|
||||
pulseOffset: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'fl-circuit-board',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<svg
|
||||
class="fl-circuit-board__svg"
|
||||
viewBox="0 0 100 100"
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient [id]="pulseGradientId">
|
||||
<stop offset="0%" [attr.stop-color]="color()" stop-opacity="0" />
|
||||
<stop offset="50%" [attr.stop-color]="pulseColor()" stop-opacity="1" />
|
||||
<stop offset="100%" [attr.stop-color]="color()" stop-opacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Circuit traces -->
|
||||
@for (circuit of circuits(); track $index) {
|
||||
<path
|
||||
[attr.d]="circuit.d"
|
||||
fill="none"
|
||||
[attr.stroke]="color()"
|
||||
stroke-width="0.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
}
|
||||
|
||||
<!-- Animated pulses -->
|
||||
@if (!reducedMotion.reduced()) {
|
||||
@for (circuit of circuits(); track $index) {
|
||||
<path
|
||||
[attr.d]="circuit.d"
|
||||
fill="none"
|
||||
[attr.stroke]="pulseGradientFill"
|
||||
stroke-width="1"
|
||||
stroke-linecap="round"
|
||||
[style.stroke-dasharray]="10"
|
||||
[style.stroke-dashoffset]="getPulseOffset($index, circuit.pulseOffset)"
|
||||
/>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- Connection nodes -->
|
||||
@for (node of nodes(); track $index) {
|
||||
<circle
|
||||
[attr.cx]="node.x"
|
||||
[attr.cy]="node.y"
|
||||
r="0.8"
|
||||
[attr.fill]="pulseColor()"
|
||||
/>
|
||||
}
|
||||
</svg>
|
||||
`,
|
||||
styleUrl: './fl-circuit-board.component.scss',
|
||||
host: { 'class': 'fl-circuit-board' },
|
||||
})
|
||||
export class FlCircuitBoardComponent {
|
||||
protected reducedMotion = inject(ReducedMotionService);
|
||||
private animationLoop = inject(AnimationLoopService);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
// Inputs
|
||||
readonly color = input('#00ff88');
|
||||
readonly pulseColor = input('#00ffff');
|
||||
readonly speed = input(1);
|
||||
readonly density = input(0.5);
|
||||
|
||||
protected readonly circuits = signal<CircuitPath[]>([]);
|
||||
protected readonly nodes = signal<{ x: number; y: number }[]>([]);
|
||||
protected readonly pulseGradientId = `circuit-pulse-${Math.random().toString(36).slice(2)}`;
|
||||
|
||||
private loopCleanup: (() => void) | null = null;
|
||||
private time = 0;
|
||||
|
||||
protected get pulseGradientFill(): string {
|
||||
return `url(#${this.pulseGradientId})`;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
afterNextRender(() => {
|
||||
this.generateCircuits();
|
||||
|
||||
if (!this.reducedMotion.reduced()) {
|
||||
const id = `circuit-board-${Math.random().toString(36).slice(2)}`;
|
||||
this.loopCleanup = this.animationLoop.register(id, (delta) => this.tick(delta));
|
||||
}
|
||||
});
|
||||
|
||||
this.destroyRef.onDestroy(() => {
|
||||
this.loopCleanup?.();
|
||||
});
|
||||
}
|
||||
|
||||
private tick(delta: number): void {
|
||||
this.time += delta * this.speed();
|
||||
}
|
||||
|
||||
protected getPulseOffset(index: number, baseOffset: number): number {
|
||||
return (this.time * 50 + baseOffset) % 100;
|
||||
}
|
||||
|
||||
private generateCircuits(): void {
|
||||
const circuitPaths: CircuitPath[] = [];
|
||||
const nodeList: { x: number; y: number }[] = [];
|
||||
const pathCount = Math.floor(10 * this.density());
|
||||
|
||||
// Generate grid-based circuit paths
|
||||
for (let i = 0; i < pathCount; i++) {
|
||||
const startX = Math.random() * 100;
|
||||
const startY = Math.random() * 100;
|
||||
|
||||
const path: string[] = [`M ${startX} ${startY}`];
|
||||
let currentX = startX;
|
||||
let currentY = startY;
|
||||
|
||||
const segments = 3 + Math.floor(Math.random() * 4);
|
||||
|
||||
for (let j = 0; j < segments; j++) {
|
||||
const horizontal = Math.random() > 0.5;
|
||||
const distance = 10 + Math.random() * 20;
|
||||
|
||||
if (horizontal) {
|
||||
currentX += (Math.random() > 0.5 ? 1 : -1) * distance;
|
||||
currentX = Math.max(0, Math.min(100, currentX));
|
||||
} else {
|
||||
currentY += (Math.random() > 0.5 ? 1 : -1) * distance;
|
||||
currentY = Math.max(0, Math.min(100, currentY));
|
||||
}
|
||||
|
||||
path.push(`L ${currentX} ${currentY}`);
|
||||
nodeList.push({ x: currentX, y: currentY });
|
||||
}
|
||||
|
||||
circuitPaths.push({
|
||||
d: path.join(' '),
|
||||
pulseOffset: Math.random() * 100,
|
||||
});
|
||||
}
|
||||
|
||||
this.circuits.set(circuitPaths);
|
||||
this.nodes.set(nodeList);
|
||||
}
|
||||
}
|
||||
1
src/components/fl-circuit-board/index.ts
Normal file
1
src/components/fl-circuit-board/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './fl-circuit-board.component';
|
||||
@@ -0,0 +1,24 @@
|
||||
:host {
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.fl-color-splash__content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
filter: grayscale(100%);
|
||||
transition: filter 0.4s ease;
|
||||
|
||||
// Child elements with .fl-color-splash-keep class will keep their color
|
||||
:global(.fl-color-splash-keep) {
|
||||
filter: grayscale(0%);
|
||||
}
|
||||
|
||||
&--active {
|
||||
filter: grayscale(0%);
|
||||
}
|
||||
|
||||
&--reduced-motion {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
29
src/components/fl-color-splash/fl-color-splash.component.ts
Normal file
29
src/components/fl-color-splash/fl-color-splash.component.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
Component, ChangeDetectionStrategy, input, inject,
|
||||
DestroyRef,
|
||||
} from '@angular/core';
|
||||
import { ReducedMotionService } from '../../services/reduced-motion.service';
|
||||
|
||||
@Component({
|
||||
selector: 'fl-color-splash',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div
|
||||
class="fl-color-splash__content"
|
||||
[class.fl-color-splash__content--active]="active()"
|
||||
[class.fl-color-splash__content--reduced-motion]="reducedMotion.reduced()"
|
||||
>
|
||||
<ng-content />
|
||||
</div>
|
||||
`,
|
||||
styleUrl: './fl-color-splash.component.scss',
|
||||
host: { 'class': 'fl-color-splash' },
|
||||
})
|
||||
export class FlColorSplashComponent {
|
||||
protected reducedMotion = inject(ReducedMotionService);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
// Inputs
|
||||
readonly active = input(false);
|
||||
}
|
||||
1
src/components/fl-color-splash/index.ts
Normal file
1
src/components/fl-color-splash/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './fl-color-splash.component';
|
||||
14
src/components/fl-confetti/fl-confetti.component.scss
Normal file
14
src/components/fl-confetti/fl-confetti.component.scss
Normal file
@@ -0,0 +1,14 @@
|
||||
:host {
|
||||
display: block;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.fl-confetti__canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
153
src/components/fl-confetti/fl-confetti.component.ts
Normal file
153
src/components/fl-confetti/fl-confetti.component.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import {
|
||||
Component, ChangeDetectionStrategy, input, inject,
|
||||
ElementRef, afterNextRender, DestroyRef, effect,
|
||||
} from '@angular/core';
|
||||
import { ReducedMotionService } from '../../services/reduced-motion.service';
|
||||
import { AnimationLoopService } from '../../services/animation-loop.service';
|
||||
|
||||
interface ConfettiParticle {
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
rotation: number;
|
||||
rotationSpeed: number;
|
||||
color: string;
|
||||
width: number;
|
||||
height: number;
|
||||
opacity: number;
|
||||
life: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'fl-confetti',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<canvas #canvas class="fl-confetti__canvas"></canvas>
|
||||
`,
|
||||
styleUrl: './fl-confetti.component.scss',
|
||||
host: {
|
||||
'class': 'fl-confetti',
|
||||
},
|
||||
})
|
||||
export class FlConfettiComponent {
|
||||
private el = inject(ElementRef);
|
||||
private reducedMotion = inject(ReducedMotionService);
|
||||
private animationLoop = inject(AnimationLoopService);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
// Inputs
|
||||
readonly active = input(false);
|
||||
readonly count = input(100);
|
||||
readonly colors = input<string[]>(['#3b82f6', '#8b5cf6', '#ec4899', '#f59e0b', '#10b981']);
|
||||
readonly duration = input(3000);
|
||||
|
||||
// Internal
|
||||
private canvas: HTMLCanvasElement | null = null;
|
||||
private ctx: CanvasRenderingContext2D | null = null;
|
||||
private particles: ConfettiParticle[] = [];
|
||||
private loopCleanup: (() => void) | null = null;
|
||||
private isAnimating = false;
|
||||
|
||||
constructor() {
|
||||
afterNextRender(() => {
|
||||
this.canvas = this.el.nativeElement.querySelector('canvas');
|
||||
if (!this.canvas) return;
|
||||
|
||||
this.ctx = this.canvas.getContext('2d');
|
||||
this.resizeCanvas();
|
||||
|
||||
window.addEventListener('resize', () => this.resizeCanvas());
|
||||
});
|
||||
|
||||
// Watch for active changes
|
||||
effect(() => {
|
||||
const isActive = this.active();
|
||||
if (isActive && !this.isAnimating) {
|
||||
this.trigger();
|
||||
}
|
||||
});
|
||||
|
||||
this.destroyRef.onDestroy(() => {
|
||||
this.loopCleanup?.();
|
||||
});
|
||||
}
|
||||
|
||||
private resizeCanvas(): void {
|
||||
if (!this.canvas) return;
|
||||
const host = this.el.nativeElement as HTMLElement;
|
||||
this.canvas.width = host.offsetWidth;
|
||||
this.canvas.height = host.offsetHeight;
|
||||
}
|
||||
|
||||
private trigger(): void {
|
||||
if (this.reducedMotion.reduced() || !this.canvas) return;
|
||||
|
||||
this.particles = [];
|
||||
const count = this.count();
|
||||
const colors = this.colors();
|
||||
const width = this.canvas.width;
|
||||
const height = this.canvas.height;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
this.particles.push({
|
||||
x: width / 2 + (Math.random() - 0.5) * 100,
|
||||
y: height / 2,
|
||||
vx: (Math.random() - 0.5) * 10,
|
||||
vy: (Math.random() - 0.5) * 15 - 5,
|
||||
rotation: Math.random() * Math.PI * 2,
|
||||
rotationSpeed: (Math.random() - 0.5) * 0.3,
|
||||
color: colors[Math.floor(Math.random() * colors.length)],
|
||||
width: 8 + Math.random() * 6,
|
||||
height: 6 + Math.random() * 4,
|
||||
opacity: 1,
|
||||
life: this.duration() / 1000,
|
||||
});
|
||||
}
|
||||
|
||||
this.isAnimating = true;
|
||||
const id = `confetti-${Math.random().toString(36).slice(2)}`;
|
||||
this.loopCleanup = this.animationLoop.register(id, (delta) => this.tick(delta));
|
||||
}
|
||||
|
||||
private tick(delta: number): void {
|
||||
if (!this.ctx || !this.canvas) return;
|
||||
|
||||
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
|
||||
let activeCount = 0;
|
||||
|
||||
for (const particle of this.particles) {
|
||||
particle.x += particle.vx;
|
||||
particle.y += particle.vy;
|
||||
particle.vy += 0.3; // gravity
|
||||
particle.rotation += particle.rotationSpeed;
|
||||
particle.life -= delta;
|
||||
particle.opacity = Math.max(0, particle.life / (this.duration() / 1000));
|
||||
|
||||
if (particle.life > 0) {
|
||||
activeCount++;
|
||||
this.drawParticle(particle);
|
||||
}
|
||||
}
|
||||
|
||||
if (activeCount === 0) {
|
||||
this.isAnimating = false;
|
||||
this.loopCleanup?.();
|
||||
this.loopCleanup = null;
|
||||
}
|
||||
}
|
||||
|
||||
private drawParticle(particle: ConfettiParticle): void {
|
||||
if (!this.ctx) return;
|
||||
|
||||
this.ctx.save();
|
||||
this.ctx.translate(particle.x, particle.y);
|
||||
this.ctx.rotate(particle.rotation);
|
||||
this.ctx.globalAlpha = particle.opacity;
|
||||
this.ctx.fillStyle = particle.color;
|
||||
this.ctx.fillRect(-particle.width / 2, -particle.height / 2, particle.width, particle.height);
|
||||
this.ctx.restore();
|
||||
}
|
||||
}
|
||||
1
src/components/fl-confetti/index.ts
Normal file
1
src/components/fl-confetti/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './fl-confetti.component';
|
||||
@@ -0,0 +1,4 @@
|
||||
.fl-counter-rollup {
|
||||
display: inline-block;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
Component, ChangeDetectionStrategy, input, inject,
|
||||
afterNextRender, DestroyRef, signal, effect,
|
||||
} from '@angular/core';
|
||||
import { ReducedMotionService } from '../../services/reduced-motion.service';
|
||||
|
||||
@Component({
|
||||
selector: 'fl-counter-rollup',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `{{ prefix() }}{{ formattedValue() }}{{ suffix() }}`,
|
||||
styleUrl: './fl-counter-rollup.component.scss',
|
||||
host: { 'class': 'fl-counter-rollup' },
|
||||
})
|
||||
export class FlCounterRollupComponent {
|
||||
private reducedMotion = inject(ReducedMotionService);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
// Inputs
|
||||
readonly value = input.required<number>();
|
||||
readonly duration = input(2000);
|
||||
readonly prefix = input('');
|
||||
readonly suffix = input('');
|
||||
readonly decimals = input(0);
|
||||
readonly separator = input(',');
|
||||
|
||||
// Internal
|
||||
readonly formattedValue = signal('0');
|
||||
|
||||
private currentValue = 0;
|
||||
private startValue = 0;
|
||||
private targetValue = 0;
|
||||
private startTime = 0;
|
||||
private animationFrame: number | null = null;
|
||||
|
||||
constructor() {
|
||||
afterNextRender(() => {
|
||||
this.startAnimation();
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
// React to value changes
|
||||
const newValue = this.value();
|
||||
this.startValue = this.currentValue;
|
||||
this.targetValue = newValue;
|
||||
this.startAnimation();
|
||||
});
|
||||
|
||||
this.destroyRef.onDestroy(() => {
|
||||
if (this.animationFrame !== null) {
|
||||
cancelAnimationFrame(this.animationFrame);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private startAnimation(): void {
|
||||
if (this.reducedMotion.reduced()) {
|
||||
this.currentValue = this.targetValue;
|
||||
this.formattedValue.set(this.formatNumber(this.targetValue));
|
||||
return;
|
||||
}
|
||||
|
||||
this.startTime = performance.now();
|
||||
this.animate();
|
||||
}
|
||||
|
||||
private animate = (): void => {
|
||||
const now = performance.now();
|
||||
const elapsed = now - this.startTime;
|
||||
const duration = this.duration();
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
|
||||
// Ease out cubic
|
||||
const eased = 1 - Math.pow(1 - progress, 3);
|
||||
|
||||
this.currentValue = this.startValue + (this.targetValue - this.startValue) * eased;
|
||||
this.formattedValue.set(this.formatNumber(this.currentValue));
|
||||
|
||||
if (progress < 1) {
|
||||
this.animationFrame = requestAnimationFrame(this.animate);
|
||||
} else {
|
||||
this.currentValue = this.targetValue;
|
||||
this.formattedValue.set(this.formatNumber(this.targetValue));
|
||||
}
|
||||
};
|
||||
|
||||
private formatNumber(value: number): string {
|
||||
const decimals = this.decimals();
|
||||
const separator = this.separator();
|
||||
|
||||
const fixed = value.toFixed(decimals);
|
||||
const parts = fixed.split('.');
|
||||
|
||||
// Add thousands separator
|
||||
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, separator);
|
||||
|
||||
return parts.join('.');
|
||||
}
|
||||
}
|
||||
1
src/components/fl-counter-rollup/index.ts
Normal file
1
src/components/fl-counter-rollup/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './fl-counter-rollup.component';
|
||||
@@ -0,0 +1,4 @@
|
||||
.fl-counter-trigger {
|
||||
display: inline-block;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
Component, ChangeDetectionStrategy, input, inject,
|
||||
ElementRef, afterNextRender, DestroyRef, signal,
|
||||
} from '@angular/core';
|
||||
import { ReducedMotionService } from '../../services/reduced-motion.service';
|
||||
import { ScrollObserverService } from '../../services/scroll-observer.service';
|
||||
|
||||
@Component({
|
||||
selector: 'fl-counter-trigger',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `{{ prefix() }}{{ formattedValue() }}{{ suffix() }}`,
|
||||
styleUrl: './fl-counter-trigger.component.scss',
|
||||
host: { 'class': 'fl-counter-trigger' },
|
||||
})
|
||||
export class FlCounterTriggerComponent {
|
||||
private el = inject(ElementRef);
|
||||
private reducedMotion = inject(ReducedMotionService);
|
||||
private scrollObserver = inject(ScrollObserverService);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
// Inputs
|
||||
readonly value = input.required<number>();
|
||||
readonly duration = input(2000);
|
||||
readonly prefix = input('');
|
||||
readonly suffix = input('');
|
||||
|
||||
// Internal
|
||||
readonly formattedValue = signal('0');
|
||||
|
||||
private currentValue = 0;
|
||||
private startValue = 0;
|
||||
private targetValue = 0;
|
||||
private startTime = 0;
|
||||
private animationFrame: number | null = null;
|
||||
private hasTriggered = false;
|
||||
private observerCleanup: (() => void) | null = null;
|
||||
|
||||
constructor() {
|
||||
afterNextRender(() => {
|
||||
const host = this.el.nativeElement as HTMLElement;
|
||||
|
||||
this.observerCleanup = this.scrollObserver.observe(
|
||||
host,
|
||||
(entry) => {
|
||||
if (entry.isIntersecting && !this.hasTriggered) {
|
||||
this.hasTriggered = true;
|
||||
this.startAnimation();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.5 }
|
||||
);
|
||||
});
|
||||
|
||||
this.destroyRef.onDestroy(() => {
|
||||
this.observerCleanup?.();
|
||||
if (this.animationFrame !== null) {
|
||||
cancelAnimationFrame(this.animationFrame);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private startAnimation(): void {
|
||||
this.startValue = 0;
|
||||
this.targetValue = this.value();
|
||||
|
||||
if (this.reducedMotion.reduced()) {
|
||||
this.currentValue = this.targetValue;
|
||||
this.formattedValue.set(this.formatNumber(this.targetValue));
|
||||
return;
|
||||
}
|
||||
|
||||
this.startTime = performance.now();
|
||||
this.animate();
|
||||
}
|
||||
|
||||
private animate = (): void => {
|
||||
const now = performance.now();
|
||||
const elapsed = now - this.startTime;
|
||||
const duration = this.duration();
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
|
||||
// Ease out cubic
|
||||
const eased = 1 - Math.pow(1 - progress, 3);
|
||||
|
||||
this.currentValue = this.startValue + (this.targetValue - this.startValue) * eased;
|
||||
this.formattedValue.set(this.formatNumber(this.currentValue));
|
||||
|
||||
if (progress < 1) {
|
||||
this.animationFrame = requestAnimationFrame(this.animate);
|
||||
} else {
|
||||
this.currentValue = this.targetValue;
|
||||
this.formattedValue.set(this.formatNumber(this.targetValue));
|
||||
}
|
||||
};
|
||||
|
||||
private formatNumber(value: number): string {
|
||||
const fixed = Math.round(value).toString();
|
||||
return fixed.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
}
|
||||
}
|
||||
1
src/components/fl-counter-trigger/index.ts
Normal file
1
src/components/fl-counter-trigger/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './fl-counter-trigger.component';
|
||||
11
src/components/fl-dot-grid/fl-dot-grid.component.scss
Normal file
11
src/components/fl-dot-grid/fl-dot-grid.component.scss
Normal file
@@ -0,0 +1,11 @@
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.fl-dot-grid__svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
85
src/components/fl-dot-grid/fl-dot-grid.component.ts
Normal file
85
src/components/fl-dot-grid/fl-dot-grid.component.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
Component, ChangeDetectionStrategy, input, inject,
|
||||
DestroyRef, afterNextRender, signal,
|
||||
} from '@angular/core';
|
||||
import { ReducedMotionService } from '../../services/reduced-motion.service';
|
||||
import { AnimationLoopService } from '../../services/animation-loop.service';
|
||||
|
||||
@Component({
|
||||
selector: 'fl-dot-grid',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<svg
|
||||
class="fl-dot-grid__svg"
|
||||
[attr.width]="'100%'"
|
||||
[attr.height]="'100%'"
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
>
|
||||
<defs>
|
||||
<pattern
|
||||
[id]="patternId"
|
||||
[attr.width]="spacing()"
|
||||
[attr.height]="spacing()"
|
||||
patternUnits="userSpaceOnUse"
|
||||
>
|
||||
<circle
|
||||
[attr.cx]="spacing() / 2"
|
||||
[attr.cy]="spacing() / 2"
|
||||
[attr.r]="currentDotSize()"
|
||||
[attr.fill]="color()"
|
||||
/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect
|
||||
width="100%"
|
||||
height="100%"
|
||||
[attr.fill]="patternFill"
|
||||
/>
|
||||
</svg>
|
||||
`,
|
||||
styleUrl: './fl-dot-grid.component.scss',
|
||||
host: { 'class': 'fl-dot-grid' },
|
||||
})
|
||||
export class FlDotGridComponent {
|
||||
private reducedMotion = inject(ReducedMotionService);
|
||||
private animationLoop = inject(AnimationLoopService);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
// Inputs
|
||||
readonly dotSize = input(2);
|
||||
readonly spacing = input(20);
|
||||
readonly color = input('#cccccc');
|
||||
readonly animated = input(false);
|
||||
|
||||
protected readonly currentDotSize = signal(2);
|
||||
protected readonly patternId = `dot-grid-${Math.random().toString(36).slice(2)}`;
|
||||
|
||||
private loopCleanup: (() => void) | null = null;
|
||||
private time = 0;
|
||||
|
||||
protected get patternFill(): string {
|
||||
return `url(#${this.patternId})`;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
afterNextRender(() => {
|
||||
this.currentDotSize.set(this.dotSize());
|
||||
|
||||
if (this.animated() && !this.reducedMotion.reduced()) {
|
||||
const id = `dot-grid-${Math.random().toString(36).slice(2)}`;
|
||||
this.loopCleanup = this.animationLoop.register(id, (delta) => this.tick(delta));
|
||||
}
|
||||
});
|
||||
|
||||
this.destroyRef.onDestroy(() => {
|
||||
this.loopCleanup?.();
|
||||
});
|
||||
}
|
||||
|
||||
private tick(delta: number): void {
|
||||
this.time += delta;
|
||||
const pulse = Math.sin(this.time * 2) * 0.5 + 0.5;
|
||||
this.currentDotSize.set(this.dotSize() * (0.8 + pulse * 0.4));
|
||||
}
|
||||
}
|
||||
1
src/components/fl-dot-grid/index.ts
Normal file
1
src/components/fl-dot-grid/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './fl-dot-grid.component';
|
||||
@@ -0,0 +1,16 @@
|
||||
:host {
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.fl-duotone-filter__svg {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.fl-duotone-filter__content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
Component, ChangeDetectionStrategy, input, inject,
|
||||
DestroyRef,
|
||||
} from '@angular/core';
|
||||
import { ReducedMotionService } from '../../services/reduced-motion.service';
|
||||
|
||||
@Component({
|
||||
selector: 'fl-duotone-filter',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<svg class="fl-duotone-filter__svg" width="0" height="0">
|
||||
<defs>
|
||||
<filter [id]="filterId">
|
||||
<feColorMatrix
|
||||
type="matrix"
|
||||
[attr.values]="colorMatrix"
|
||||
/>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
<div class="fl-duotone-filter__content" [style.filter]="filterValue">
|
||||
<ng-content />
|
||||
</div>
|
||||
`,
|
||||
styleUrl: './fl-duotone-filter.component.scss',
|
||||
host: { 'class': 'fl-duotone-filter' },
|
||||
})
|
||||
export class FlDuotoneFilterComponent {
|
||||
private reducedMotion = inject(ReducedMotionService);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
// Inputs
|
||||
readonly colorLight = input('#ff6b6b');
|
||||
readonly colorDark = input('#1a1a2e');
|
||||
readonly intensity = input(1);
|
||||
|
||||
protected readonly filterId = `duotone-${Math.random().toString(36).slice(2)}`;
|
||||
|
||||
protected get colorMatrix(): string {
|
||||
const light = this.hexToRgb(this.colorLight());
|
||||
const dark = this.hexToRgb(this.colorDark());
|
||||
const i = this.intensity();
|
||||
|
||||
// Create duotone effect by mapping luminance to two colors
|
||||
const lr = (light.r - dark.r) / 255 * i;
|
||||
const lg = (light.g - dark.g) / 255 * i;
|
||||
const lb = (light.b - dark.b) / 255 * i;
|
||||
|
||||
return `
|
||||
${lr} ${lr} ${lr} 0 ${dark.r / 255}
|
||||
${lg} ${lg} ${lg} 0 ${dark.g / 255}
|
||||
${lb} ${lb} ${lb} 0 ${dark.b / 255}
|
||||
0 0 0 1 0
|
||||
`.trim().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
protected get filterValue(): string {
|
||||
return `url(#${this.filterId})`;
|
||||
}
|
||||
|
||||
private hexToRgb(hex: string): { r: number; g: number; b: number } {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
return result
|
||||
? {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16),
|
||||
}
|
||||
: { r: 0, g: 0, b: 0 };
|
||||
}
|
||||
}
|
||||
1
src/components/fl-duotone-filter/index.ts
Normal file
1
src/components/fl-duotone-filter/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './fl-duotone-filter.component';
|
||||
15
src/components/fl-emoji-rain/fl-emoji-rain.component.scss
Normal file
15
src/components/fl-emoji-rain/fl-emoji-rain.component.scss
Normal file
@@ -0,0 +1,15 @@
|
||||
:host {
|
||||
display: block;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.fl-emoji-rain__drop {
|
||||
position: absolute;
|
||||
font-size: 2rem;
|
||||
will-change: transform, opacity;
|
||||
pointer-events: none;
|
||||
}
|
||||
139
src/components/fl-emoji-rain/fl-emoji-rain.component.ts
Normal file
139
src/components/fl-emoji-rain/fl-emoji-rain.component.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import {
|
||||
Component, ChangeDetectionStrategy, input, inject,
|
||||
ElementRef, afterNextRender, DestroyRef, effect, signal,
|
||||
} from '@angular/core';
|
||||
import { ReducedMotionService } from '../../services/reduced-motion.service';
|
||||
import { AnimationLoopService } from '../../services/animation-loop.service';
|
||||
|
||||
interface EmojiDrop {
|
||||
id: string;
|
||||
emoji: string;
|
||||
x: number;
|
||||
y: number;
|
||||
vy: number;
|
||||
rotation: number;
|
||||
rotationSpeed: number;
|
||||
scale: number;
|
||||
opacity: number;
|
||||
life: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'fl-emoji-rain',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
@for (drop of drops(); track drop.id) {
|
||||
<div
|
||||
class="fl-emoji-rain__drop"
|
||||
[style.left.%]="drop.x"
|
||||
[style.top.%]="drop.y"
|
||||
[style.transform]="'translate(-50%, -50%) rotate(' + drop.rotation + 'deg) scale(' + drop.scale + ')'"
|
||||
[style.opacity]="drop.opacity"
|
||||
>{{ drop.emoji }}</div>
|
||||
}
|
||||
`,
|
||||
styleUrl: './fl-emoji-rain.component.scss',
|
||||
host: {
|
||||
'class': 'fl-emoji-rain',
|
||||
},
|
||||
})
|
||||
export class FlEmojiRainComponent {
|
||||
private el = inject(ElementRef);
|
||||
private reducedMotion = inject(ReducedMotionService);
|
||||
private animationLoop = inject(AnimationLoopService);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
// Inputs
|
||||
readonly active = input(false);
|
||||
readonly emojis = input<string[]>(['🎉', '🎊', '🎈', '⭐', '✨']);
|
||||
readonly count = input(30);
|
||||
readonly duration = input(3000);
|
||||
|
||||
// Internal
|
||||
private dropsData: EmojiDrop[] = [];
|
||||
private loopCleanup: (() => void) | null = null;
|
||||
private isAnimating = false;
|
||||
|
||||
// Signals
|
||||
readonly drops = signal<EmojiDrop[]>([]);
|
||||
|
||||
constructor() {
|
||||
afterNextRender(() => {
|
||||
// Watch for active changes
|
||||
effect(() => {
|
||||
const isActive = this.active();
|
||||
if (isActive && !this.isAnimating) {
|
||||
this.trigger();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
this.destroyRef.onDestroy(() => {
|
||||
this.loopCleanup?.();
|
||||
});
|
||||
}
|
||||
|
||||
private trigger(): void {
|
||||
if (this.reducedMotion.reduced()) return;
|
||||
|
||||
this.dropsData = [];
|
||||
const count = this.count();
|
||||
const emojis = this.emojis();
|
||||
const durationSec = this.duration() / 1000;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const delay = (Math.random() * durationSec * 0.3);
|
||||
setTimeout(() => {
|
||||
this.dropsData.push({
|
||||
id: `emoji-${Date.now()}-${i}`,
|
||||
emoji: emojis[Math.floor(Math.random() * emojis.length)],
|
||||
x: Math.random() * 100,
|
||||
y: -10,
|
||||
vy: 15 + Math.random() * 10,
|
||||
rotation: Math.random() * 360,
|
||||
rotationSpeed: (Math.random() - 0.5) * 180,
|
||||
scale: 0.5 + Math.random() * 0.5,
|
||||
opacity: 1,
|
||||
life: durationSec,
|
||||
});
|
||||
}, delay * 1000);
|
||||
}
|
||||
|
||||
this.isAnimating = true;
|
||||
const id = `emoji-rain-${Math.random().toString(36).slice(2)}`;
|
||||
this.loopCleanup = this.animationLoop.register(id, (delta) => this.tick(delta));
|
||||
}
|
||||
|
||||
private tick(delta: number): void {
|
||||
let activeCount = 0;
|
||||
|
||||
for (const drop of this.dropsData) {
|
||||
drop.y += drop.vy * delta;
|
||||
drop.rotation += drop.rotationSpeed * delta;
|
||||
drop.life -= delta;
|
||||
|
||||
// Fade out at the end
|
||||
if (drop.life < 0.5) {
|
||||
drop.opacity = drop.life * 2;
|
||||
}
|
||||
|
||||
// Remove if below viewport or life ended
|
||||
if (drop.y < 110 && drop.life > 0) {
|
||||
activeCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Update display
|
||||
this.drops.set(
|
||||
this.dropsData.filter(d => d.y < 110 && d.life > 0)
|
||||
);
|
||||
|
||||
if (activeCount === 0 && this.dropsData.length >= this.count()) {
|
||||
this.isAnimating = false;
|
||||
this.drops.set([]);
|
||||
this.loopCleanup?.();
|
||||
this.loopCleanup = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
1
src/components/fl-emoji-rain/index.ts
Normal file
1
src/components/fl-emoji-rain/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './fl-emoji-rain.component';
|
||||
14
src/components/fl-fireworks/fl-fireworks.component.scss
Normal file
14
src/components/fl-fireworks/fl-fireworks.component.scss
Normal file
@@ -0,0 +1,14 @@
|
||||
:host {
|
||||
display: block;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.fl-fireworks__canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
193
src/components/fl-fireworks/fl-fireworks.component.ts
Normal file
193
src/components/fl-fireworks/fl-fireworks.component.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import {
|
||||
Component, ChangeDetectionStrategy, input, inject,
|
||||
ElementRef, afterNextRender, DestroyRef, effect,
|
||||
} from '@angular/core';
|
||||
import { ReducedMotionService } from '../../services/reduced-motion.service';
|
||||
import { AnimationLoopService } from '../../services/animation-loop.service';
|
||||
|
||||
interface FireworkParticle {
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
color: string;
|
||||
opacity: number;
|
||||
life: number;
|
||||
maxLife: number;
|
||||
}
|
||||
|
||||
interface Firework {
|
||||
x: number;
|
||||
y: number;
|
||||
targetY: number;
|
||||
vy: number;
|
||||
color: string;
|
||||
exploded: boolean;
|
||||
particles: FireworkParticle[];
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'fl-fireworks',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<canvas #canvas class="fl-fireworks__canvas"></canvas>
|
||||
`,
|
||||
styleUrl: './fl-fireworks.component.scss',
|
||||
host: {
|
||||
'class': 'fl-fireworks',
|
||||
},
|
||||
})
|
||||
export class FlFireworksComponent {
|
||||
private el = inject(ElementRef);
|
||||
private reducedMotion = inject(ReducedMotionService);
|
||||
private animationLoop = inject(AnimationLoopService);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
// Inputs
|
||||
readonly active = input(false);
|
||||
readonly count = input(5);
|
||||
readonly colors = input<string[]>(['#3b82f6', '#8b5cf6', '#ec4899', '#f59e0b', '#10b981']);
|
||||
readonly duration = input(3000);
|
||||
|
||||
// Internal
|
||||
private canvas: HTMLCanvasElement | null = null;
|
||||
private ctx: CanvasRenderingContext2D | null = null;
|
||||
private fireworks: Firework[] = [];
|
||||
private loopCleanup: (() => void) | null = null;
|
||||
private isAnimating = false;
|
||||
|
||||
constructor() {
|
||||
afterNextRender(() => {
|
||||
this.canvas = this.el.nativeElement.querySelector('canvas');
|
||||
if (!this.canvas) return;
|
||||
|
||||
this.ctx = this.canvas.getContext('2d');
|
||||
this.resizeCanvas();
|
||||
|
||||
window.addEventListener('resize', () => this.resizeCanvas());
|
||||
});
|
||||
|
||||
// Watch for active changes
|
||||
effect(() => {
|
||||
const isActive = this.active();
|
||||
if (isActive && !this.isAnimating) {
|
||||
this.trigger();
|
||||
}
|
||||
});
|
||||
|
||||
this.destroyRef.onDestroy(() => {
|
||||
this.loopCleanup?.();
|
||||
});
|
||||
}
|
||||
|
||||
private resizeCanvas(): void {
|
||||
if (!this.canvas) return;
|
||||
const host = this.el.nativeElement as HTMLElement;
|
||||
this.canvas.width = host.offsetWidth;
|
||||
this.canvas.height = host.offsetHeight;
|
||||
}
|
||||
|
||||
private trigger(): void {
|
||||
if (this.reducedMotion.reduced() || !this.canvas) return;
|
||||
|
||||
this.fireworks = [];
|
||||
const count = this.count();
|
||||
const colors = this.colors();
|
||||
const width = this.canvas.width;
|
||||
const height = this.canvas.height;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const delay = i * 300;
|
||||
setTimeout(() => {
|
||||
this.fireworks.push({
|
||||
x: Math.random() * width,
|
||||
y: height,
|
||||
targetY: height * 0.2 + Math.random() * height * 0.3,
|
||||
vy: -8 - Math.random() * 4,
|
||||
color: colors[Math.floor(Math.random() * colors.length)],
|
||||
exploded: false,
|
||||
particles: [],
|
||||
});
|
||||
}, delay);
|
||||
}
|
||||
|
||||
this.isAnimating = true;
|
||||
const id = `fireworks-${Math.random().toString(36).slice(2)}`;
|
||||
this.loopCleanup = this.animationLoop.register(id, (delta) => this.tick(delta));
|
||||
}
|
||||
|
||||
private tick(delta: number): void {
|
||||
if (!this.ctx || !this.canvas) return;
|
||||
|
||||
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
|
||||
let activeCount = 0;
|
||||
|
||||
for (const firework of this.fireworks) {
|
||||
if (!firework.exploded) {
|
||||
firework.y += firework.vy;
|
||||
firework.vy += 0.2; // gravity
|
||||
|
||||
// Draw rocket
|
||||
this.ctx.fillStyle = firework.color;
|
||||
this.ctx.fillRect(firework.x - 2, firework.y - 8, 4, 8);
|
||||
|
||||
if (firework.y <= firework.targetY) {
|
||||
this.explode(firework);
|
||||
}
|
||||
|
||||
activeCount++;
|
||||
} else {
|
||||
// Update and draw particles
|
||||
let particleCount = 0;
|
||||
for (const particle of firework.particles) {
|
||||
particle.x += particle.vx;
|
||||
particle.y += particle.vy;
|
||||
particle.vy += 0.15; // gravity
|
||||
particle.life -= delta;
|
||||
particle.opacity = Math.max(0, particle.life / particle.maxLife);
|
||||
|
||||
if (particle.life > 0) {
|
||||
particleCount++;
|
||||
this.ctx.globalAlpha = particle.opacity;
|
||||
this.ctx.fillStyle = particle.color;
|
||||
this.ctx.fillRect(particle.x - 2, particle.y - 2, 4, 4);
|
||||
}
|
||||
}
|
||||
|
||||
if (particleCount > 0) {
|
||||
activeCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.ctx.globalAlpha = 1;
|
||||
|
||||
if (activeCount === 0) {
|
||||
this.isAnimating = false;
|
||||
this.loopCleanup?.();
|
||||
this.loopCleanup = null;
|
||||
}
|
||||
}
|
||||
|
||||
private explode(firework: Firework): void {
|
||||
firework.exploded = true;
|
||||
const particleCount = 50;
|
||||
|
||||
for (let i = 0; i < particleCount; i++) {
|
||||
const angle = (Math.PI * 2 * i) / particleCount;
|
||||
const speed = 2 + Math.random() * 3;
|
||||
firework.particles.push({
|
||||
x: firework.x,
|
||||
y: firework.y,
|
||||
vx: Math.cos(angle) * speed,
|
||||
vy: Math.sin(angle) * speed,
|
||||
color: firework.color,
|
||||
opacity: 1,
|
||||
life: 1.5,
|
||||
maxLife: 1.5,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
1
src/components/fl-fireworks/index.ts
Normal file
1
src/components/fl-fireworks/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './fl-fireworks.component';
|
||||
@@ -0,0 +1,17 @@
|
||||
:host {
|
||||
display: block;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
> ::ng-deep * {
|
||||
will-change: transform;
|
||||
transition: transform 50ms linear;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
> ::ng-deep * {
|
||||
transform: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
Component, ChangeDetectionStrategy, input, inject,
|
||||
ElementRef, afterNextRender, DestroyRef, contentChildren,
|
||||
} from '@angular/core';
|
||||
import { ReducedMotionService } from '../../services/reduced-motion.service';
|
||||
import { CursorTrackerService } from '../../services/cursor-tracker.service';
|
||||
import { AnimationLoopService } from '../../services/animation-loop.service';
|
||||
import { lerp, mapRange, clamp } from '../../utils/math.utils';
|
||||
|
||||
@Component({
|
||||
selector: 'fl-floating-layers',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `<ng-content />`,
|
||||
styleUrl: './fl-floating-layers.component.scss',
|
||||
host: { 'class': 'fl-floating-layers' },
|
||||
})
|
||||
export class FlFloatingLayersComponent {
|
||||
private el = inject(ElementRef);
|
||||
private reducedMotion = inject(ReducedMotionService);
|
||||
private cursorTracker = inject(CursorTrackerService);
|
||||
private animationLoop = inject(AnimationLoopService);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
// Inputs
|
||||
readonly maxOffset = input(30);
|
||||
readonly ease = input(0.05);
|
||||
readonly inverted = input(false);
|
||||
|
||||
private currentX = 0;
|
||||
private currentY = 0;
|
||||
private cursorCleanup: (() => void) | null = null;
|
||||
private loopCleanup: (() => void) | null = null;
|
||||
|
||||
constructor() {
|
||||
afterNextRender(() => {
|
||||
if (this.reducedMotion.reduced()) return;
|
||||
this.cursorCleanup = this.cursorTracker.subscribe();
|
||||
const id = `floating-layers-${Math.random().toString(36).slice(2)}`;
|
||||
this.loopCleanup = this.animationLoop.register(id, () => this.tick());
|
||||
});
|
||||
|
||||
this.destroyRef.onDestroy(() => {
|
||||
this.cursorCleanup?.();
|
||||
this.loopCleanup?.();
|
||||
});
|
||||
}
|
||||
|
||||
private tick(): void {
|
||||
const host = this.el.nativeElement as HTMLElement;
|
||||
const rect = host.getBoundingClientRect();
|
||||
const pos = this.cursorTracker.position();
|
||||
|
||||
const normX = mapRange(
|
||||
clamp(pos.x, rect.left, rect.right),
|
||||
rect.left, rect.right, -1, 1,
|
||||
);
|
||||
const normY = mapRange(
|
||||
clamp(pos.y, rect.top, rect.bottom),
|
||||
rect.top, rect.bottom, -1, 1,
|
||||
);
|
||||
|
||||
const easeVal = this.ease();
|
||||
this.currentX = lerp(this.currentX, normX, easeVal);
|
||||
this.currentY = lerp(this.currentY, normY, easeVal);
|
||||
|
||||
const children = host.children;
|
||||
const max = this.maxOffset();
|
||||
const inv = this.inverted() ? -1 : 1;
|
||||
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
const child = children[i] as HTMLElement;
|
||||
const depth = parseFloat(child.getAttribute('data-depth') ?? `${(i + 1) / children.length}`);
|
||||
const offsetX = this.currentX * max * depth * inv;
|
||||
const offsetY = this.currentY * max * depth * inv;
|
||||
child.style.transform = `translate(${offsetX}px, ${offsetY}px)`;
|
||||
}
|
||||
}
|
||||
}
|
||||
1
src/components/fl-floating-layers/index.ts
Normal file
1
src/components/fl-floating-layers/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './fl-floating-layers.component';
|
||||
14
src/components/fl-flow-field/fl-flow-field.component.scss
Normal file
14
src/components/fl-flow-field/fl-flow-field.component.scss
Normal file
@@ -0,0 +1,14 @@
|
||||
:host {
|
||||
display: block;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #000;
|
||||
|
||||
canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
196
src/components/fl-flow-field/fl-flow-field.component.ts
Normal file
196
src/components/fl-flow-field/fl-flow-field.component.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
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 { noise, randomInRange } from '../../utils/math.utils';
|
||||
|
||||
interface FlowParticle {
|
||||
x: number;
|
||||
y: number;
|
||||
speed: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'fl-flow-field',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `<canvas #canvas></canvas>`,
|
||||
styleUrl: './fl-flow-field.component.scss',
|
||||
host: { 'class': 'fl-flow-field' },
|
||||
})
|
||||
export class FlFlowFieldComponent {
|
||||
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 particleCount = input(1000);
|
||||
readonly speed = input(2);
|
||||
readonly noiseScale = input(0.005);
|
||||
readonly noiseSpeed = input(0.0005);
|
||||
readonly fadeOpacity = input(0.03);
|
||||
readonly colors = input<string[]>(['#6366f1', '#8b5cf6', '#a78bfa', '#c4b5fd']);
|
||||
readonly lineWidth = input(1);
|
||||
readonly responsive = input(true);
|
||||
|
||||
// Outputs
|
||||
readonly ready = output<void>();
|
||||
|
||||
private particles: FlowParticle[] = [];
|
||||
private ctx: CanvasRenderingContext2D | null = null;
|
||||
private width = 0;
|
||||
private height = 0;
|
||||
private pixelRatio = 1;
|
||||
private elapsed = 0;
|
||||
private loopCleanup: (() => void) | null = null;
|
||||
private resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
constructor() {
|
||||
afterNextRender(() => {
|
||||
if (this.reducedMotion.reduced()) {
|
||||
this.renderStaticFrame();
|
||||
return;
|
||||
}
|
||||
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.createParticles();
|
||||
});
|
||||
this.resizeObserver.observe(canvas.parentElement!);
|
||||
}
|
||||
|
||||
this.createParticles();
|
||||
|
||||
// Fill background
|
||||
this.ctx.fillStyle = '#000';
|
||||
this.ctx.fillRect(0, 0, this.width, this.height);
|
||||
|
||||
const id = `flow-field-${Math.random().toString(36).slice(2)}`;
|
||||
this.loopCleanup = this.animationLoop.register(id, (delta) => this.tick(delta));
|
||||
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);
|
||||
}
|
||||
|
||||
private createParticles(): void {
|
||||
const count = this.particleCount();
|
||||
const cols = this.colors();
|
||||
this.particles = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
this.particles.push({
|
||||
x: randomInRange(0, this.width),
|
||||
y: randomInRange(0, this.height),
|
||||
speed: randomInRange(0.5, 1.5),
|
||||
color: cols[i % cols.length],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private tick(delta: number): void {
|
||||
if (!this.ctx) return;
|
||||
|
||||
const w = this.width;
|
||||
const h = this.height;
|
||||
const ctx = this.ctx;
|
||||
const scale = this.noiseScale();
|
||||
const spd = this.speed();
|
||||
const lw = this.lineWidth();
|
||||
|
||||
this.elapsed += delta;
|
||||
|
||||
// Fade previous frame
|
||||
ctx.fillStyle = `rgba(0, 0, 0, ${this.fadeOpacity()})`;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
const timeOffset = this.elapsed * this.noiseSpeed() * 1000;
|
||||
|
||||
for (const p of this.particles) {
|
||||
const angle = noise(p.x * scale, p.y * scale + timeOffset) * Math.PI * 4;
|
||||
const prevX = p.x;
|
||||
const prevY = p.y;
|
||||
|
||||
p.x += Math.cos(angle) * spd * p.speed * delta * 60;
|
||||
p.y += Math.sin(angle) * spd * p.speed * delta * 60;
|
||||
|
||||
ctx.strokeStyle = p.color;
|
||||
ctx.lineWidth = lw;
|
||||
ctx.globalAlpha = 0.5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(prevX, prevY);
|
||||
ctx.lineTo(p.x, p.y);
|
||||
ctx.stroke();
|
||||
|
||||
// Wrap around
|
||||
if (p.x < 0 || p.x > w || p.y < 0 || p.y > h) {
|
||||
p.x = randomInRange(0, w);
|
||||
p.y = randomInRange(0, h);
|
||||
}
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
private renderStaticFrame(): void {
|
||||
const canvas = this.canvasRef().nativeElement;
|
||||
this.ctx = canvas.getContext('2d');
|
||||
if (!this.ctx) return;
|
||||
|
||||
this.pixelRatio = getPixelRatio(this.config.maxPixelRatio);
|
||||
this.resize();
|
||||
this.createParticles();
|
||||
|
||||
this.ctx.fillStyle = '#000';
|
||||
this.ctx.fillRect(0, 0, this.width, this.height);
|
||||
|
||||
// Draw static flow lines
|
||||
const scale = this.noiseScale();
|
||||
for (const p of this.particles) {
|
||||
const angle = noise(p.x * scale, p.y * scale) * Math.PI * 4;
|
||||
this.ctx.strokeStyle = p.color;
|
||||
this.ctx.lineWidth = this.lineWidth();
|
||||
this.ctx.globalAlpha = 0.3;
|
||||
this.ctx.beginPath();
|
||||
this.ctx.moveTo(p.x, p.y);
|
||||
this.ctx.lineTo(p.x + Math.cos(angle) * 10, p.y + Math.sin(angle) * 10);
|
||||
this.ctx.stroke();
|
||||
}
|
||||
this.ctx.globalAlpha = 1;
|
||||
this.ready.emit();
|
||||
}
|
||||
}
|
||||
1
src/components/fl-flow-field/index.ts
Normal file
1
src/components/fl-flow-field/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './fl-flow-field.component';
|
||||
@@ -0,0 +1,19 @@
|
||||
:host {
|
||||
display: block;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.fl-fluid-container__svg {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.fl-fluid-container__content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import {
|
||||
Component, ChangeDetectionStrategy, input, inject,
|
||||
DestroyRef, afterNextRender, signal,
|
||||
} from '@angular/core';
|
||||
import { ReducedMotionService } from '../../services/reduced-motion.service';
|
||||
import { AnimationLoopService } from '../../services/animation-loop.service';
|
||||
|
||||
@Component({
|
||||
selector: 'fl-fluid-container',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<svg
|
||||
class="fl-fluid-container__svg"
|
||||
preserveAspectRatio="none"
|
||||
[attr.viewBox]="viewBox"
|
||||
>
|
||||
<defs>
|
||||
<clipPath [id]="clipId">
|
||||
<path [attr.d]="clipPath()" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
<div
|
||||
class="fl-fluid-container__content"
|
||||
[style.clip-path]="clipPathValue"
|
||||
[style.border]="borderStyle"
|
||||
>
|
||||
<ng-content />
|
||||
</div>
|
||||
`,
|
||||
styleUrl: './fl-fluid-container.component.scss',
|
||||
host: { 'class': 'fl-fluid-container' },
|
||||
})
|
||||
export class FlFluidContainerComponent {
|
||||
private reducedMotion = inject(ReducedMotionService);
|
||||
private animationLoop = inject(AnimationLoopService);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
// Inputs
|
||||
readonly borderColor = input('#ff6b6b');
|
||||
readonly speed = input(1);
|
||||
readonly amplitude = input(10);
|
||||
readonly borderWidth = input(2);
|
||||
|
||||
protected readonly clipPath = signal('');
|
||||
protected readonly clipId = `fluid-${Math.random().toString(36).slice(2)}`;
|
||||
protected readonly viewBox = '0 0 100 100';
|
||||
|
||||
private loopCleanup: (() => void) | null = null;
|
||||
private time = 0;
|
||||
|
||||
protected get clipPathValue(): string {
|
||||
return this.reducedMotion.reduced() ? 'none' : `url(#${this.clipId})`;
|
||||
}
|
||||
|
||||
protected get borderStyle(): string {
|
||||
return `${this.borderWidth()}px solid ${this.borderColor()}`;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
afterNextRender(() => {
|
||||
this.updatePath();
|
||||
|
||||
if (!this.reducedMotion.reduced()) {
|
||||
const id = `fluid-container-${Math.random().toString(36).slice(2)}`;
|
||||
this.loopCleanup = this.animationLoop.register(id, (delta) => this.tick(delta));
|
||||
}
|
||||
});
|
||||
|
||||
this.destroyRef.onDestroy(() => {
|
||||
this.loopCleanup?.();
|
||||
});
|
||||
}
|
||||
|
||||
private tick(delta: number): void {
|
||||
this.time += delta * this.speed();
|
||||
this.updatePath();
|
||||
}
|
||||
|
||||
private updatePath(): void {
|
||||
const amp = this.amplitude() / 10;
|
||||
const t = this.time;
|
||||
|
||||
// Create undulating border effect
|
||||
const topWave = this.createWave(0, 100, t, amp, 0);
|
||||
const rightWave = this.createWave(100, 100, t, amp, Math.PI / 2, true);
|
||||
const bottomWave = this.createWave(100, 0, t, amp, Math.PI);
|
||||
const leftWave = this.createWave(0, 0, t, amp, 3 * Math.PI / 2, true);
|
||||
|
||||
this.clipPath.set(`
|
||||
M ${topWave}
|
||||
L ${rightWave}
|
||||
L ${bottomWave}
|
||||
L ${leftWave}
|
||||
Z
|
||||
`);
|
||||
}
|
||||
|
||||
private createWave(
|
||||
start: number,
|
||||
end: number,
|
||||
time: number,
|
||||
amplitude: number,
|
||||
phase: number,
|
||||
vertical = false
|
||||
): string {
|
||||
const points: string[] = [];
|
||||
const steps = 20;
|
||||
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const t = i / steps;
|
||||
const pos = start + (end - start) * t;
|
||||
const wave = Math.sin(t * Math.PI * 4 + time + phase) * amplitude;
|
||||
|
||||
if (vertical) {
|
||||
points.push(`${pos + wave},${start + (end - start) * t}`);
|
||||
} else {
|
||||
points.push(`${start + (end - start) * t},${pos + wave}`);
|
||||
}
|
||||
}
|
||||
|
||||
return points.join(' L ');
|
||||
}
|
||||
}
|
||||
1
src/components/fl-fluid-container/index.ts
Normal file
1
src/components/fl-fluid-container/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './fl-fluid-container.component';
|
||||
@@ -0,0 +1,14 @@
|
||||
:host {
|
||||
display: block;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #0a0a0a;
|
||||
|
||||
canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
1
src/components/fl-fractal-landscape/index.ts
Normal file
1
src/components/fl-fractal-landscape/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './fl-fractal-landscape.component';
|
||||
@@ -0,0 +1,33 @@
|
||||
:host {
|
||||
display: inline-block;
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.fl-frequency-rings__svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
circle {
|
||||
animation: fl-frequency-rings-pulse 2s ease-in-out infinite;
|
||||
transform-origin: center;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fl-frequency-rings-pulse {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
opacity: 0.8;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.15);
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
:host.fl-frequency-rings--reduced-motion {
|
||||
.fl-frequency-rings__svg circle {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
Component, ChangeDetectionStrategy, input, inject, computed,
|
||||
DestroyRef,
|
||||
} from '@angular/core';
|
||||
import { ReducedMotionService } from '../../services/reduced-motion.service';
|
||||
|
||||
@Component({
|
||||
selector: 'fl-frequency-rings',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<svg class="fl-frequency-rings__svg" viewBox="0 0 200 200">
|
||||
@for (ring of ringData(); track ring.index) {
|
||||
<circle
|
||||
cx="100"
|
||||
cy="100"
|
||||
[attr.r]="ring.radius"
|
||||
fill="none"
|
||||
[attr.stroke]="color()"
|
||||
[attr.stroke-width]="ring.strokeWidth"
|
||||
[attr.opacity]="ring.opacity"
|
||||
[style.animation-duration.s]="ring.duration"
|
||||
[style.animation-delay.s]="ring.delay"
|
||||
/>
|
||||
}
|
||||
</svg>
|
||||
`,
|
||||
styleUrl: './fl-frequency-rings.component.scss',
|
||||
host: {
|
||||
'class': 'fl-frequency-rings',
|
||||
'[class.fl-frequency-rings--reduced-motion]': 'reducedMotion.reduced()',
|
||||
},
|
||||
})
|
||||
export class FlFrequencyRingsComponent {
|
||||
private reducedMotion = inject(ReducedMotionService);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
// Inputs
|
||||
readonly rings = input(5);
|
||||
readonly color = input('#3b82f6');
|
||||
readonly speed = input(1);
|
||||
readonly baseRadius = input(20);
|
||||
|
||||
// Computed
|
||||
readonly ringData = computed(() => {
|
||||
const count = this.rings();
|
||||
const base = this.baseRadius();
|
||||
const spd = this.speed();
|
||||
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const radius = base + i * 15;
|
||||
const opacity = 1 - (i / count) * 0.7;
|
||||
const strokeWidth = 2 - (i / count) * 1;
|
||||
const duration = (2 + i * 0.3) / spd;
|
||||
const delay = -i * 0.2;
|
||||
|
||||
return { index: i, radius, opacity, strokeWidth, duration, delay };
|
||||
});
|
||||
});
|
||||
}
|
||||
1
src/components/fl-frequency-rings/index.ts
Normal file
1
src/components/fl-frequency-rings/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './fl-frequency-rings.component';
|
||||
@@ -0,0 +1,11 @@
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.fl-generative-pattern__svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import {
|
||||
Component, ChangeDetectionStrategy, input, inject,
|
||||
ElementRef, afterNextRender, DestroyRef, signal, computed,
|
||||
} from '@angular/core';
|
||||
import { ReducedMotionService } from '../../services/reduced-motion.service';
|
||||
import { AnimationLoopService } from '../../services/animation-loop.service';
|
||||
|
||||
@Component({
|
||||
selector: 'fl-generative-pattern',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<svg
|
||||
class="fl-generative-pattern__svg"
|
||||
[attr.viewBox]="viewBox()"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<defs>
|
||||
<pattern
|
||||
[attr.id]="patternId"
|
||||
[attr.width]="size()"
|
||||
[attr.height]="size()"
|
||||
patternUnits="userSpaceOnUse"
|
||||
>
|
||||
@switch (pattern()) {
|
||||
@case ('triangles') {
|
||||
@for (item of triangles(); track $index) {
|
||||
<polygon
|
||||
[attr.points]="item.points"
|
||||
[attr.fill]="item.color"
|
||||
[attr.opacity]="item.opacity"
|
||||
/>
|
||||
}
|
||||
}
|
||||
@case ('hexagons') {
|
||||
@for (item of hexagons(); track $index) {
|
||||
<polygon
|
||||
[attr.points]="item.points"
|
||||
[attr.fill]="item.color"
|
||||
[attr.opacity]="item.opacity"
|
||||
/>
|
||||
}
|
||||
}
|
||||
@case ('circles') {
|
||||
@for (item of circles(); track $index) {
|
||||
<circle
|
||||
[attr.cx]="item.cx"
|
||||
[attr.cy]="item.cy"
|
||||
[attr.r]="item.r"
|
||||
[attr.fill]="item.color"
|
||||
[attr.opacity]="item.opacity"
|
||||
/>
|
||||
}
|
||||
}
|
||||
@case ('squares') {
|
||||
@for (item of squares(); track $index) {
|
||||
<rect
|
||||
[attr.x]="item.x"
|
||||
[attr.y]="item.y"
|
||||
[attr.width]="item.width"
|
||||
[attr.height]="item.height"
|
||||
[attr.fill]="item.color"
|
||||
[attr.opacity]="item.opacity"
|
||||
/>
|
||||
}
|
||||
}
|
||||
}
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" [attr.fill]="'url(#' + patternId + ')'" />
|
||||
</svg>
|
||||
`,
|
||||
styleUrl: './fl-generative-pattern.component.scss',
|
||||
host: {
|
||||
'class': 'fl-generative-pattern',
|
||||
},
|
||||
})
|
||||
export class FlGenerativePatternComponent {
|
||||
private el = inject(ElementRef);
|
||||
private reducedMotion = inject(ReducedMotionService);
|
||||
private animationLoop = inject(AnimationLoopService);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
// Inputs
|
||||
readonly pattern = input<'triangles' | 'hexagons' | 'circles' | 'squares'>('triangles');
|
||||
readonly size = input(50);
|
||||
readonly colors = input<string[]>(['#3b82f6', '#8b5cf6', '#ec4899']);
|
||||
readonly animated = input(true);
|
||||
|
||||
// Internal
|
||||
private elapsed = 0;
|
||||
private loopCleanup: (() => void) | null = null;
|
||||
protected readonly patternId = `pattern-${Math.random().toString(36).slice(2)}`;
|
||||
|
||||
// Signals
|
||||
readonly viewBox = signal('0 0 100 100');
|
||||
readonly triangles = signal<Array<{ points: string; color: string; opacity: number }>>([]);
|
||||
readonly hexagons = signal<Array<{ points: string; color: string; opacity: number }>>([]);
|
||||
readonly circles = signal<Array<{ cx: number; cy: number; r: number; color: string; opacity: number }>>([]);
|
||||
readonly squares = signal<Array<{ x: number; y: number; width: number; height: number; color: string; opacity: number }>>([]);
|
||||
|
||||
constructor() {
|
||||
afterNextRender(() => {
|
||||
this.generatePattern(0);
|
||||
|
||||
if (this.reducedMotion.reduced() || !this.animated()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = `pattern-${Math.random().toString(36).slice(2)}`;
|
||||
this.loopCleanup = this.animationLoop.register(id, (delta) => this.tick(delta));
|
||||
});
|
||||
|
||||
this.destroyRef.onDestroy(() => {
|
||||
this.loopCleanup?.();
|
||||
});
|
||||
}
|
||||
|
||||
private tick(delta: number): void {
|
||||
this.elapsed += delta * 0.5;
|
||||
this.generatePattern(this.elapsed);
|
||||
}
|
||||
|
||||
private generatePattern(phase: number): void {
|
||||
const size = this.size();
|
||||
const colors = this.colors();
|
||||
|
||||
switch (this.pattern()) {
|
||||
case 'triangles':
|
||||
this.generateTriangles(size, colors, phase);
|
||||
break;
|
||||
case 'hexagons':
|
||||
this.generateHexagons(size, colors, phase);
|
||||
break;
|
||||
case 'circles':
|
||||
this.generateCircles(size, colors, phase);
|
||||
break;
|
||||
case 'squares':
|
||||
this.generateSquares(size, colors, phase);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private generateTriangles(size: number, colors: string[], phase: number): void {
|
||||
const items = [];
|
||||
const half = size / 2;
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const color = colors[i % colors.length];
|
||||
const opacity = 0.5 + Math.sin(phase + i) * 0.3;
|
||||
items.push({
|
||||
points: `${i * half},0 ${(i + 1) * half},${half} ${i * half},${half}`,
|
||||
color,
|
||||
opacity,
|
||||
});
|
||||
}
|
||||
this.triangles.set(items);
|
||||
}
|
||||
|
||||
private generateHexagons(size: number, colors: string[], phase: number): void {
|
||||
const items = [];
|
||||
const cx = size / 2;
|
||||
const cy = size / 2;
|
||||
const r = size * 0.4;
|
||||
const points = [];
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const angle = (Math.PI / 3) * i;
|
||||
const x = cx + r * Math.cos(angle);
|
||||
const y = cy + r * Math.sin(angle);
|
||||
points.push(`${x},${y}`);
|
||||
}
|
||||
const color = colors[0];
|
||||
const opacity = 0.5 + Math.sin(phase) * 0.3;
|
||||
items.push({ points: points.join(' '), color, opacity });
|
||||
this.hexagons.set(items);
|
||||
}
|
||||
|
||||
private generateCircles(size: number, colors: string[], phase: number): void {
|
||||
const items = [];
|
||||
const half = size / 2;
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const color = colors[i % colors.length];
|
||||
const opacity = 0.5 + Math.sin(phase + i * 0.5) * 0.3;
|
||||
items.push({
|
||||
cx: (i % 2) * half + half / 2,
|
||||
cy: Math.floor(i / 2) * half + half / 2,
|
||||
r: size * 0.2,
|
||||
color,
|
||||
opacity,
|
||||
});
|
||||
}
|
||||
this.circles.set(items);
|
||||
}
|
||||
|
||||
private generateSquares(size: number, colors: string[], phase: number): void {
|
||||
const items = [];
|
||||
const half = size / 2;
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const color = colors[i % colors.length];
|
||||
const opacity = 0.5 + Math.sin(phase + i * 0.5) * 0.3;
|
||||
items.push({
|
||||
x: (i % 2) * half,
|
||||
y: Math.floor(i / 2) * half,
|
||||
width: half * 0.8,
|
||||
height: half * 0.8,
|
||||
color,
|
||||
opacity,
|
||||
});
|
||||
}
|
||||
this.squares.set(items);
|
||||
}
|
||||
}
|
||||
1
src/components/fl-generative-pattern/index.ts
Normal file
1
src/components/fl-generative-pattern/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './fl-generative-pattern.component';
|
||||
101
src/components/fl-glitch-image/fl-glitch-image.component.scss
Normal file
101
src/components/fl-glitch-image/fl-glitch-image.component.scss
Normal file
@@ -0,0 +1,101 @@
|
||||
:host {
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.fl-glitch-image__content {
|
||||
--fl-glitch-intensity: 5;
|
||||
--fl-glitch-speed: 2;
|
||||
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
&--active::before,
|
||||
&--active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: inherit;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&--active::before {
|
||||
left: calc(var(--fl-glitch-intensity) * 1px);
|
||||
animation: glitch-channel-1 calc(var(--fl-glitch-speed) * 1s) infinite linear alternate-reverse;
|
||||
mix-blend-mode: screen;
|
||||
filter: hue-rotate(0deg) brightness(1.2);
|
||||
clip-path: inset(0);
|
||||
}
|
||||
|
||||
&--active::after {
|
||||
left: calc(var(--fl-glitch-intensity) * -1px);
|
||||
animation: glitch-channel-2 calc(var(--fl-glitch-speed) * 1s) infinite linear alternate-reverse;
|
||||
mix-blend-mode: screen;
|
||||
filter: hue-rotate(180deg) brightness(1.2);
|
||||
clip-path: inset(0);
|
||||
}
|
||||
|
||||
&--reduced-motion::before,
|
||||
&--reduced-motion::after {
|
||||
animation: none !important;
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes glitch-channel-1 {
|
||||
0% {
|
||||
transform: translate(0);
|
||||
clip-path: inset(20% 0 60% 0);
|
||||
}
|
||||
20% {
|
||||
transform: translate(calc(var(--fl-glitch-intensity) * -1px), calc(var(--fl-glitch-intensity) * 1px));
|
||||
clip-path: inset(40% 0 40% 0);
|
||||
}
|
||||
40% {
|
||||
transform: translate(calc(var(--fl-glitch-intensity) * 1px), calc(var(--fl-glitch-intensity) * -1px));
|
||||
clip-path: inset(10% 0 70% 0);
|
||||
}
|
||||
60% {
|
||||
transform: translate(calc(var(--fl-glitch-intensity) * 1px), calc(var(--fl-glitch-intensity) * 1px));
|
||||
clip-path: inset(60% 0 20% 0);
|
||||
}
|
||||
80% {
|
||||
transform: translate(calc(var(--fl-glitch-intensity) * -1px), calc(var(--fl-glitch-intensity) * -1px));
|
||||
clip-path: inset(30% 0 50% 0);
|
||||
}
|
||||
100% {
|
||||
transform: translate(0);
|
||||
clip-path: inset(50% 0 30% 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes glitch-channel-2 {
|
||||
0% {
|
||||
transform: translate(0);
|
||||
clip-path: inset(60% 0 20% 0);
|
||||
}
|
||||
20% {
|
||||
transform: translate(calc(var(--fl-glitch-intensity) * 1px), calc(var(--fl-glitch-intensity) * -1px));
|
||||
clip-path: inset(10% 0 70% 0);
|
||||
}
|
||||
40% {
|
||||
transform: translate(calc(var(--fl-glitch-intensity) * -1px), calc(var(--fl-glitch-intensity) * 1px));
|
||||
clip-path: inset(50% 0 30% 0);
|
||||
}
|
||||
60% {
|
||||
transform: translate(calc(var(--fl-glitch-intensity) * -1px), calc(var(--fl-glitch-intensity) * -1px));
|
||||
clip-path: inset(20% 0 60% 0);
|
||||
}
|
||||
80% {
|
||||
transform: translate(calc(var(--fl-glitch-intensity) * 1px), calc(var(--fl-glitch-intensity) * 1px));
|
||||
clip-path: inset(40% 0 40% 0);
|
||||
}
|
||||
100% {
|
||||
transform: translate(0);
|
||||
clip-path: inset(30% 0 50% 0);
|
||||
}
|
||||
}
|
||||
33
src/components/fl-glitch-image/fl-glitch-image.component.ts
Normal file
33
src/components/fl-glitch-image/fl-glitch-image.component.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
Component, ChangeDetectionStrategy, input, inject,
|
||||
DestroyRef,
|
||||
} from '@angular/core';
|
||||
import { ReducedMotionService } from '../../services/reduced-motion.service';
|
||||
|
||||
@Component({
|
||||
selector: 'fl-glitch-image',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div
|
||||
class="fl-glitch-image__content"
|
||||
[class.fl-glitch-image__content--active]="active()"
|
||||
[class.fl-glitch-image__content--reduced-motion]="reducedMotion.reduced()"
|
||||
[style.--fl-glitch-intensity]="intensity()"
|
||||
[style.--fl-glitch-speed.s]="speed()"
|
||||
>
|
||||
<ng-content />
|
||||
</div>
|
||||
`,
|
||||
styleUrl: './fl-glitch-image.component.scss',
|
||||
host: { 'class': 'fl-glitch-image' },
|
||||
})
|
||||
export class FlGlitchImageComponent {
|
||||
protected reducedMotion = inject(ReducedMotionService);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
// Inputs
|
||||
readonly intensity = input(5);
|
||||
readonly speed = input(2);
|
||||
readonly active = input(true);
|
||||
}
|
||||
1
src/components/fl-glitch-image/index.ts
Normal file
1
src/components/fl-glitch-image/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './fl-glitch-image.component';
|
||||
75
src/components/fl-glitch-text/fl-glitch-text.component.scss
Normal file
75
src/components/fl-glitch-text/fl-glitch-text.component.scss
Normal file
@@ -0,0 +1,75 @@
|
||||
.fl-glitch-text {
|
||||
--fl-glitch-intensity: 5px;
|
||||
--fl-glitch-speed: 1s;
|
||||
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
|
||||
&__content {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
&--active &__content {
|
||||
&::before,
|
||||
&::after {
|
||||
content: attr(data-text);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&::before {
|
||||
color: #ff00ff;
|
||||
animation: fl-glitch-before var(--fl-glitch-speed) infinite;
|
||||
clip-path: polygon(0 0, 100% 0, 100% 45%, 0 45%);
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
&::after {
|
||||
color: #00ffff;
|
||||
animation: fl-glitch-after var(--fl-glitch-speed) infinite reverse;
|
||||
clip-path: polygon(0 55%, 100% 55%, 100% 100%, 0 100%);
|
||||
z-index: -2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fl-glitch-before {
|
||||
0%, 100% {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
20% {
|
||||
transform: translate(calc(var(--fl-glitch-intensity) * -1), var(--fl-glitch-intensity));
|
||||
}
|
||||
40% {
|
||||
transform: translate(var(--fl-glitch-intensity), calc(var(--fl-glitch-intensity) * -1));
|
||||
}
|
||||
60% {
|
||||
transform: translate(calc(var(--fl-glitch-intensity) * -1), var(--fl-glitch-intensity));
|
||||
}
|
||||
80% {
|
||||
transform: translate(var(--fl-glitch-intensity), calc(var(--fl-glitch-intensity) * -1));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fl-glitch-after {
|
||||
0%, 100% {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
20% {
|
||||
transform: translate(var(--fl-glitch-intensity), calc(var(--fl-glitch-intensity) * -1));
|
||||
}
|
||||
40% {
|
||||
transform: translate(calc(var(--fl-glitch-intensity) * -1), var(--fl-glitch-intensity));
|
||||
}
|
||||
60% {
|
||||
transform: translate(var(--fl-glitch-intensity), calc(var(--fl-glitch-intensity) * -1));
|
||||
}
|
||||
80% {
|
||||
transform: translate(calc(var(--fl-glitch-intensity) * -1), var(--fl-glitch-intensity));
|
||||
}
|
||||
}
|
||||
53
src/components/fl-glitch-text/fl-glitch-text.component.ts
Normal file
53
src/components/fl-glitch-text/fl-glitch-text.component.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
Component, ChangeDetectionStrategy, input, inject,
|
||||
ElementRef, afterNextRender, DestroyRef, effect,
|
||||
} from '@angular/core';
|
||||
import { ReducedMotionService } from '../../services/reduced-motion.service';
|
||||
|
||||
@Component({
|
||||
selector: 'fl-glitch-text',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<span class="fl-glitch-text__content" [attr.data-text]="text()">{{ text() }}</span>
|
||||
`,
|
||||
styleUrl: './fl-glitch-text.component.scss',
|
||||
host: { 'class': 'fl-glitch-text' },
|
||||
})
|
||||
export class FlGlitchTextComponent {
|
||||
private el = inject(ElementRef);
|
||||
private reducedMotion = inject(ReducedMotionService);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
// Inputs
|
||||
readonly text = input.required<string>();
|
||||
readonly intensity = input(5);
|
||||
readonly speed = input(1);
|
||||
readonly active = input(true);
|
||||
|
||||
constructor() {
|
||||
afterNextRender(() => {
|
||||
this.updateStyles();
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
this.updateStyles();
|
||||
});
|
||||
}
|
||||
|
||||
private updateStyles(): void {
|
||||
const host = this.el.nativeElement as HTMLElement;
|
||||
const intensity = this.intensity();
|
||||
const speed = this.speed();
|
||||
const active = this.active();
|
||||
|
||||
if (this.reducedMotion.reduced() || !active) {
|
||||
host.classList.remove('fl-glitch-text--active');
|
||||
return;
|
||||
}
|
||||
|
||||
host.classList.add('fl-glitch-text--active');
|
||||
host.style.setProperty('--fl-glitch-intensity', `${intensity}px`);
|
||||
host.style.setProperty('--fl-glitch-speed', `${1 / speed}s`);
|
||||
}
|
||||
}
|
||||
1
src/components/fl-glitch-text/index.ts
Normal file
1
src/components/fl-glitch-text/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './fl-glitch-text.component';
|
||||
35
src/components/fl-glow-badge/fl-glow-badge.component.scss
Normal file
35
src/components/fl-glow-badge/fl-glow-badge.component.scss
Normal file
@@ -0,0 +1,35 @@
|
||||
:host {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.fl-glow-badge__inner {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
background: currentColor;
|
||||
color: white;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
will-change: box-shadow;
|
||||
|
||||
&--pulse {
|
||||
animation: fl-glow-badge-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fl-glow-badge-pulse {
|
||||
0%, 100% {
|
||||
filter: brightness(1);
|
||||
}
|
||||
50% {
|
||||
filter: brightness(1.3);
|
||||
}
|
||||
}
|
||||
|
||||
:host.fl-glow-badge--reduced-motion {
|
||||
.fl-glow-badge__inner--pulse {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
42
src/components/fl-glow-badge/fl-glow-badge.component.ts
Normal file
42
src/components/fl-glow-badge/fl-glow-badge.component.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
Component, ChangeDetectionStrategy, input, inject, computed,
|
||||
DestroyRef,
|
||||
} from '@angular/core';
|
||||
import { ReducedMotionService } from '../../services/reduced-motion.service';
|
||||
|
||||
@Component({
|
||||
selector: 'fl-glow-badge',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div
|
||||
class="fl-glow-badge__inner"
|
||||
[style.color]="color()"
|
||||
[style.box-shadow]="boxShadow()"
|
||||
[class.fl-glow-badge__inner--pulse]="pulse()"
|
||||
>
|
||||
<ng-content />
|
||||
</div>
|
||||
`,
|
||||
styleUrl: './fl-glow-badge.component.scss',
|
||||
host: {
|
||||
'class': 'fl-glow-badge',
|
||||
'[class.fl-glow-badge--reduced-motion]': 'reducedMotion.reduced()',
|
||||
},
|
||||
})
|
||||
export class FlGlowBadgeComponent {
|
||||
private reducedMotion = inject(ReducedMotionService);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
// Inputs
|
||||
readonly color = input('#3b82f6');
|
||||
readonly glowSize = input(10);
|
||||
readonly pulse = input(false);
|
||||
|
||||
// Computed
|
||||
readonly boxShadow = computed(() => {
|
||||
const size = this.glowSize();
|
||||
const c = this.color();
|
||||
return `0 0 ${size}px ${c}, 0 0 ${size * 2}px ${c}`;
|
||||
});
|
||||
}
|
||||
1
src/components/fl-glow-badge/index.ts
Normal file
1
src/components/fl-glow-badge/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './fl-glow-badge.component';
|
||||
@@ -0,0 +1,31 @@
|
||||
.fl-glow-divider {
|
||||
--fl-glow-color: #00ffff;
|
||||
--fl-glow-size: 8px;
|
||||
|
||||
display: block;
|
||||
width: 100%;
|
||||
|
||||
&__line {
|
||||
width: 100%;
|
||||
border-radius: 999px;
|
||||
|
||||
&--pulse {
|
||||
animation: fl-glow-divider-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fl-glow-divider-pulse {
|
||||
0%, 100% {
|
||||
box-shadow:
|
||||
0 0 var(--fl-glow-size) var(--fl-glow-color),
|
||||
0 0 calc(var(--fl-glow-size) * 2) var(--fl-glow-color),
|
||||
0 0 calc(var(--fl-glow-size) * 3) var(--fl-glow-color);
|
||||
}
|
||||
50% {
|
||||
box-shadow:
|
||||
0 0 calc(var(--fl-glow-size) * 1.5) var(--fl-glow-color),
|
||||
0 0 calc(var(--fl-glow-size) * 3) var(--fl-glow-color),
|
||||
0 0 calc(var(--fl-glow-size) * 4) var(--fl-glow-color);
|
||||
}
|
||||
}
|
||||
57
src/components/fl-glow-divider/fl-glow-divider.component.ts
Normal file
57
src/components/fl-glow-divider/fl-glow-divider.component.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
Component, ChangeDetectionStrategy, input, inject,
|
||||
ElementRef, afterNextRender, DestroyRef,
|
||||
} from '@angular/core';
|
||||
import { ReducedMotionService } from '../../services/reduced-motion.service';
|
||||
|
||||
@Component({
|
||||
selector: 'fl-glow-divider',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `<div class="fl-glow-divider__line"></div>`,
|
||||
styleUrl: './fl-glow-divider.component.scss',
|
||||
host: { 'class': 'fl-glow-divider' },
|
||||
})
|
||||
export class FlGlowDividerComponent {
|
||||
private el = inject(ElementRef);
|
||||
private reducedMotion = inject(ReducedMotionService);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
// Inputs
|
||||
readonly color = input('#00ffff');
|
||||
readonly width = input(2);
|
||||
readonly glowSize = input(8);
|
||||
readonly pulse = input(true);
|
||||
|
||||
constructor() {
|
||||
afterNextRender(() => {
|
||||
this.updateStyles();
|
||||
});
|
||||
}
|
||||
|
||||
private updateStyles(): void {
|
||||
const host = this.el.nativeElement as HTMLElement;
|
||||
const line = host.querySelector('.fl-glow-divider__line') as HTMLElement;
|
||||
|
||||
if (!line) return;
|
||||
|
||||
const color = this.color();
|
||||
const width = this.width();
|
||||
const glowSize = this.glowSize();
|
||||
const pulse = this.pulse();
|
||||
|
||||
line.style.backgroundColor = color;
|
||||
line.style.height = `${width}px`;
|
||||
line.style.boxShadow = `
|
||||
0 0 ${glowSize}px ${color},
|
||||
0 0 ${glowSize * 2}px ${color},
|
||||
0 0 ${glowSize * 3}px ${color}
|
||||
`;
|
||||
|
||||
if (pulse && !this.reducedMotion.reduced()) {
|
||||
line.classList.add('fl-glow-divider__line--pulse');
|
||||
line.style.setProperty('--fl-glow-color', color);
|
||||
line.style.setProperty('--fl-glow-size', `${glowSize}px`);
|
||||
}
|
||||
}
|
||||
}
|
||||
1
src/components/fl-glow-divider/index.ts
Normal file
1
src/components/fl-glow-divider/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './fl-glow-divider.component';
|
||||
@@ -0,0 +1,22 @@
|
||||
.fl-gradient-divider {
|
||||
display: block;
|
||||
width: 100%;
|
||||
|
||||
&__line {
|
||||
width: 100%;
|
||||
border-radius: 999px;
|
||||
|
||||
&--animated {
|
||||
animation: fl-gradient-divider-slide 3s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fl-gradient-divider-slide {
|
||||
0%, 100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
Component, ChangeDetectionStrategy, input, inject,
|
||||
ElementRef, afterNextRender, DestroyRef,
|
||||
} from '@angular/core';
|
||||
import { ReducedMotionService } from '../../services/reduced-motion.service';
|
||||
|
||||
@Component({
|
||||
selector: 'fl-gradient-divider',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `<div class="fl-gradient-divider__line"></div>`,
|
||||
styleUrl: './fl-gradient-divider.component.scss',
|
||||
host: { 'class': 'fl-gradient-divider' },
|
||||
})
|
||||
export class FlGradientDividerComponent {
|
||||
private el = inject(ElementRef);
|
||||
private reducedMotion = inject(ReducedMotionService);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
// Inputs
|
||||
readonly colors = input<string[]>(['#667eea', '#764ba2', '#f093fb']);
|
||||
readonly height = input(2);
|
||||
readonly animated = input(true);
|
||||
|
||||
constructor() {
|
||||
afterNextRender(() => {
|
||||
this.updateStyles();
|
||||
});
|
||||
}
|
||||
|
||||
private updateStyles(): void {
|
||||
const host = this.el.nativeElement as HTMLElement;
|
||||
const line = host.querySelector('.fl-gradient-divider__line') as HTMLElement;
|
||||
|
||||
if (!line) return;
|
||||
|
||||
const colors = this.colors();
|
||||
const height = this.height();
|
||||
const animated = this.animated();
|
||||
|
||||
const gradient = `linear-gradient(90deg, ${colors.join(', ')})`;
|
||||
line.style.background = gradient;
|
||||
line.style.height = `${height}px`;
|
||||
|
||||
if (animated && !this.reducedMotion.reduced()) {
|
||||
line.style.backgroundSize = '200% 100%';
|
||||
line.classList.add('fl-gradient-divider__line--animated');
|
||||
}
|
||||
}
|
||||
}
|
||||
1
src/components/fl-gradient-divider/index.ts
Normal file
1
src/components/fl-gradient-divider/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './fl-gradient-divider.component';
|
||||
@@ -0,0 +1,19 @@
|
||||
:host {
|
||||
display: block;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.fl-gradient-mesh__blob {
|
||||
position: absolute;
|
||||
width: 40%;
|
||||
height: 40%;
|
||||
border-radius: 50%;
|
||||
opacity: 0.6;
|
||||
transform: translate(-50%, -50%);
|
||||
will-change: left, top;
|
||||
pointer-events: none;
|
||||
}
|
||||
121
src/components/fl-gradient-mesh/fl-gradient-mesh.component.ts
Normal file
121
src/components/fl-gradient-mesh/fl-gradient-mesh.component.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import {
|
||||
Component, ChangeDetectionStrategy, input, inject,
|
||||
ElementRef, afterNextRender, DestroyRef, signal,
|
||||
} from '@angular/core';
|
||||
import { ReducedMotionService } from '../../services/reduced-motion.service';
|
||||
import { AnimationLoopService } from '../../services/animation-loop.service';
|
||||
|
||||
interface MeshPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'fl-gradient-mesh',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
@for (point of meshPoints(); track $index) {
|
||||
<div
|
||||
class="fl-gradient-mesh__blob"
|
||||
[style.left.%]="point.x"
|
||||
[style.top.%]="point.y"
|
||||
[style.background]="point.color"
|
||||
[style.filter]="'blur(' + blur() + 'px)'"
|
||||
></div>
|
||||
}
|
||||
`,
|
||||
styleUrl: './fl-gradient-mesh.component.scss',
|
||||
host: {
|
||||
'class': 'fl-gradient-mesh',
|
||||
},
|
||||
})
|
||||
export class FlGradientMeshComponent {
|
||||
private el = inject(ElementRef);
|
||||
private reducedMotion = inject(ReducedMotionService);
|
||||
private animationLoop = inject(AnimationLoopService);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
// Inputs
|
||||
readonly points = input(5);
|
||||
readonly colors = input<string[]>(['#3b82f6', '#8b5cf6', '#ec4899', '#f59e0b']);
|
||||
readonly speed = input(1);
|
||||
readonly blur = input(80);
|
||||
|
||||
// Internal
|
||||
private meshPointsData: MeshPoint[] = [];
|
||||
private loopCleanup: (() => void) | null = null;
|
||||
|
||||
// Signals
|
||||
readonly meshPoints = signal<Array<{ x: number; y: number; color: string }>>([]);
|
||||
|
||||
constructor() {
|
||||
afterNextRender(() => {
|
||||
this.initializeMesh();
|
||||
|
||||
if (this.reducedMotion.reduced()) {
|
||||
this.updateDisplay();
|
||||
return;
|
||||
}
|
||||
|
||||
const id = `gradient-mesh-${Math.random().toString(36).slice(2)}`;
|
||||
this.loopCleanup = this.animationLoop.register(id, (delta) => this.tick(delta));
|
||||
});
|
||||
|
||||
this.destroyRef.onDestroy(() => {
|
||||
this.loopCleanup?.();
|
||||
});
|
||||
}
|
||||
|
||||
private initializeMesh(): void {
|
||||
const count = this.points();
|
||||
const colors = this.colors();
|
||||
this.meshPointsData = [];
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
this.meshPointsData.push({
|
||||
x: Math.random() * 100,
|
||||
y: Math.random() * 100,
|
||||
vx: (Math.random() - 0.5) * 10,
|
||||
vy: (Math.random() - 0.5) * 10,
|
||||
color: colors[i % colors.length],
|
||||
});
|
||||
}
|
||||
|
||||
this.updateDisplay();
|
||||
}
|
||||
|
||||
private tick(delta: number): void {
|
||||
const speed = this.speed() * delta * 10;
|
||||
|
||||
for (const point of this.meshPointsData) {
|
||||
point.x += point.vx * speed;
|
||||
point.y += point.vy * speed;
|
||||
|
||||
// Bounce off edges
|
||||
if (point.x < 0 || point.x > 100) {
|
||||
point.vx *= -1;
|
||||
point.x = Math.max(0, Math.min(100, point.x));
|
||||
}
|
||||
if (point.y < 0 || point.y > 100) {
|
||||
point.vy *= -1;
|
||||
point.y = Math.max(0, Math.min(100, point.y));
|
||||
}
|
||||
}
|
||||
|
||||
this.updateDisplay();
|
||||
}
|
||||
|
||||
private updateDisplay(): void {
|
||||
this.meshPoints.set(
|
||||
this.meshPointsData.map(p => ({
|
||||
x: p.x,
|
||||
y: p.y,
|
||||
color: p.color,
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
1
src/components/fl-gradient-mesh/index.ts
Normal file
1
src/components/fl-gradient-mesh/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './fl-gradient-mesh.component';
|
||||
@@ -0,0 +1,8 @@
|
||||
.fl-gradient-text {
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
color: transparent;
|
||||
display: inline-block;
|
||||
transition: background-position 0.3s ease;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
Component, ChangeDetectionStrategy, input, inject,
|
||||
ElementRef, afterNextRender, DestroyRef,
|
||||
} from '@angular/core';
|
||||
import { ReducedMotionService } from '../../services/reduced-motion.service';
|
||||
import { AnimationLoopService } from '../../services/animation-loop.service';
|
||||
|
||||
@Component({
|
||||
selector: 'fl-gradient-text',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `<ng-content />`,
|
||||
styleUrl: './fl-gradient-text.component.scss',
|
||||
host: { 'class': 'fl-gradient-text' },
|
||||
})
|
||||
export class FlGradientTextComponent {
|
||||
private el = inject(ElementRef);
|
||||
private reducedMotion = inject(ReducedMotionService);
|
||||
private animationLoop = inject(AnimationLoopService);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
// Inputs
|
||||
readonly colors = input<string[]>(['#ff6b6b', '#4ecdc4', '#45b7d1', '#f7b731']);
|
||||
readonly speed = input(1);
|
||||
readonly angle = input(45);
|
||||
|
||||
private loopCleanup: (() => void) | null = null;
|
||||
private position = 0;
|
||||
|
||||
constructor() {
|
||||
afterNextRender(() => {
|
||||
this.updateGradient();
|
||||
|
||||
if (!this.reducedMotion.reduced()) {
|
||||
const id = `gradient-text-${Math.random().toString(36).slice(2)}`;
|
||||
this.loopCleanup = this.animationLoop.register(id, (delta) => this.tick(delta));
|
||||
}
|
||||
});
|
||||
|
||||
this.destroyRef.onDestroy(() => {
|
||||
this.loopCleanup?.();
|
||||
});
|
||||
}
|
||||
|
||||
private tick(delta: number): void {
|
||||
this.position += this.speed() * delta * 50;
|
||||
this.updateGradient();
|
||||
}
|
||||
|
||||
private updateGradient(): void {
|
||||
const host = this.el.nativeElement as HTMLElement;
|
||||
const colorStops = this.colors().join(', ');
|
||||
const angle = this.angle();
|
||||
const pos = this.position % 200;
|
||||
|
||||
host.style.backgroundImage = `linear-gradient(${angle}deg, ${colorStops})`;
|
||||
host.style.backgroundSize = '200% 200%';
|
||||
host.style.backgroundPosition = `${pos}% ${pos}%`;
|
||||
}
|
||||
}
|
||||
1
src/components/fl-gradient-text/index.ts
Normal file
1
src/components/fl-gradient-text/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './fl-gradient-text.component';
|
||||
44
src/components/fl-grid-matrix/fl-grid-matrix.component.scss
Normal file
44
src/components/fl-grid-matrix/fl-grid-matrix.component.scss
Normal file
@@ -0,0 +1,44 @@
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
perspective: 1000px;
|
||||
}
|
||||
|
||||
.fl-grid-matrix__grid {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
gap: 8px;
|
||||
padding: 20px;
|
||||
transform: rotateX(45deg) rotateZ(45deg);
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
|
||||
.fl-grid-matrix__dot {
|
||||
border-radius: 50%;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.3s ease;
|
||||
|
||||
&.fl-grid-matrix__dot--animated {
|
||||
animation: fl-grid-matrix-pulse 3s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fl-grid-matrix-pulse {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
opacity: 0.4;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.5);
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
:host.fl-grid-matrix--reduced-motion {
|
||||
.fl-grid-matrix__dot {
|
||||
animation: none !important;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
63
src/components/fl-grid-matrix/fl-grid-matrix.component.ts
Normal file
63
src/components/fl-grid-matrix/fl-grid-matrix.component.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
Component, ChangeDetectionStrategy, input, inject, computed,
|
||||
DestroyRef,
|
||||
} from '@angular/core';
|
||||
import { ReducedMotionService } from '../../services/reduced-motion.service';
|
||||
|
||||
@Component({
|
||||
selector: 'fl-grid-matrix',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div
|
||||
class="fl-grid-matrix__grid"
|
||||
[style.grid-template-columns]="'repeat(' + cols() + ', 1fr)'"
|
||||
[style.grid-template-rows]="'repeat(' + rows() + ', 1fr)'"
|
||||
>
|
||||
@for (dot of dots(); track dot.index) {
|
||||
<div
|
||||
class="fl-grid-matrix__dot"
|
||||
[class.fl-grid-matrix__dot--animated]="animated()"
|
||||
[style.width.px]="dotSize()"
|
||||
[style.height.px]="dotSize()"
|
||||
[style.background-color]="color()"
|
||||
[style.animation-delay.s]="dot.delay"
|
||||
></div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styleUrl: './fl-grid-matrix.component.scss',
|
||||
host: {
|
||||
'class': 'fl-grid-matrix',
|
||||
'[class.fl-grid-matrix--reduced-motion]': 'reducedMotion.reduced()',
|
||||
},
|
||||
})
|
||||
export class FlGridMatrixComponent {
|
||||
private reducedMotion = inject(ReducedMotionService);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
// Inputs
|
||||
readonly rows = input(10);
|
||||
readonly cols = input(10);
|
||||
readonly dotSize = input(4);
|
||||
readonly color = input('#3b82f6');
|
||||
readonly animated = input(true);
|
||||
|
||||
// Computed
|
||||
readonly dots = computed(() => {
|
||||
const r = this.rows();
|
||||
const c = this.cols();
|
||||
const total = r * c;
|
||||
|
||||
return Array.from({ length: total }, (_, i) => {
|
||||
const row = Math.floor(i / c);
|
||||
const col = i % c;
|
||||
const distanceFromCenter = Math.sqrt(
|
||||
Math.pow(row - r / 2, 2) + Math.pow(col - c / 2, 2)
|
||||
);
|
||||
const delay = distanceFromCenter * 0.05;
|
||||
|
||||
return { index: i, delay };
|
||||
});
|
||||
});
|
||||
}
|
||||
1
src/components/fl-grid-matrix/index.ts
Normal file
1
src/components/fl-grid-matrix/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './fl-grid-matrix.component';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user