fix: improve demo performance and fix broken component animations

- Add IntersectionObserver-based visibility pausing to AnimationLoopService
  (registerWithElement) so canvas components auto-pause when off-screen
- Fix voronoi-mesh: increase step size, add cell cache with invalidation
- Fix particle-field: replace O(n²) connections with spatial grid partitioning
- Fix ambient-light: remove GPU-killing blur filters, use inset -20% instead
- Tune demo: reduce particle/seed counts, remove always-on floating-layers
- Fix celebration components: move effect() inside afterNextRender so canvas
  is ready, pass [active]="true", render as fixed overlays instead of inline
- Fix one-shot text effects (typewriter, scramble-reveal, counter-rollup):
  defer animation until element is visible via IntersectionObserver

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Giuliano Silvestro
2026-03-09 14:21:13 +10:00
parent daf6182e94
commit 033cb7e10e
25 changed files with 1230 additions and 89 deletions

34
demo/angular.json Normal file
View File

@@ -0,0 +1,34 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"demo": {
"projectType": "application",
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
"outputPath": "dist",
"index": "src/index.html",
"browser": "src/main.ts",
"polyfills": ["zone.js"],
"tsConfig": "tsconfig.app.json",
"styles": ["src/styles.scss"],
"scripts": [],
"preserveSymlinks": true
}
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"buildTarget": "demo:build"
}
}
}
}
}
}

25
demo/package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "flair-elements-ui-demo",
"version": "0.1.0",
"private": true,
"scripts": {
"start": "ng serve --port 4203",
"build": "ng build"
},
"dependencies": {
"@angular/animations": "^19.1.0",
"@angular/common": "^19.1.0",
"@angular/compiler": "^19.1.0",
"@angular/core": "^19.1.0",
"@angular/platform-browser": "^19.1.0",
"@angular/platform-browser-dynamic": "^19.1.0",
"@sda/flair-elements-ui": "file:../dist",
"@sda/base-ui": "file:../../base-ui/dist"
},
"devDependencies": {
"@angular-devkit/build-angular": "^19.1.0",
"@angular/cli": "^19.1.0",
"@angular/compiler-cli": "^19.1.0",
"typescript": "~5.5.0"
}
}

833
demo/src/app.component.ts Normal file
View File

@@ -0,0 +1,833 @@
import { Component } from '@angular/core';
import {
// Scroll & Progress
FlScrollProgressComponent,
// Backgrounds & Fields
FlParticleFieldComponent,
FlFlowFieldComponent,
FlMatrixRainComponent,
FlAuroraComponent,
FlVoronoiMeshComponent,
FlFractalLandscapeComponent,
FlAmbientLightComponent,
FlFloatingLayersComponent,
// Text Effects
FlGradientTextComponent,
FlTypewriterComponent,
FlTextShimmerComponent,
FlGlitchTextComponent,
FlScrambleRevealComponent,
FlCounterRollupComponent,
// Cards
FlTiltCardComponent,
FlHolographicCardComponent,
FlSpotlightCardComponent,
// Generative Art
FlBlobComponent,
FlGenerativePatternComponent,
FlGradientMeshComponent,
// Dividers
FlWaveDividerComponent,
FlGradientDividerComponent,
FlGlowDividerComponent,
// Celebrations
FlConfettiComponent,
FlFireworksComponent,
FlSparkleComponent,
FlEmojiRainComponent,
FlCheckmarkFlourishComponent,
// Patterns & Textures
FlDotGridComponent,
FlBlueprintGridComponent,
FlTopographicLinesComponent,
FlCircuitBoardComponent,
// Status Indicators
FlBreathingDotComponent,
FlLiveIndicatorComponent,
FlSignalStrengthComponent,
FlHeartbeatLineComponent,
// Directives
FlHoverLiftDirective,
FlMagneticDirective,
FlRippleDirective,
FlNeonGlowDirective,
FlCursorGlowDirective,
FlRevealOnScrollDirective,
FlPressSquishDirective,
FlTextGradientDirective,
} from '@sda/flair-elements-ui';
@Component({
selector: 'app-root',
standalone: true,
imports: [
// Scroll & Progress
FlScrollProgressComponent,
// Backgrounds & Fields
FlParticleFieldComponent,
FlFlowFieldComponent,
FlMatrixRainComponent,
FlAuroraComponent,
FlVoronoiMeshComponent,
FlFractalLandscapeComponent,
FlAmbientLightComponent,
// Text Effects
FlGradientTextComponent,
FlTypewriterComponent,
FlTextShimmerComponent,
FlGlitchTextComponent,
FlScrambleRevealComponent,
FlCounterRollupComponent,
// Cards
FlTiltCardComponent,
FlHolographicCardComponent,
FlSpotlightCardComponent,
// Generative Art
FlBlobComponent,
FlGenerativePatternComponent,
FlGradientMeshComponent,
// Dividers
FlWaveDividerComponent,
FlGradientDividerComponent,
FlGlowDividerComponent,
// Celebrations
FlConfettiComponent,
FlFireworksComponent,
FlSparkleComponent,
FlEmojiRainComponent,
FlCheckmarkFlourishComponent,
// Patterns & Textures
FlDotGridComponent,
FlBlueprintGridComponent,
FlTopographicLinesComponent,
FlCircuitBoardComponent,
// Status Indicators
FlBreathingDotComponent,
FlLiveIndicatorComponent,
FlSignalStrengthComponent,
FlHeartbeatLineComponent,
// Directives
FlHoverLiftDirective,
FlMagneticDirective,
FlRippleDirective,
FlNeonGlowDirective,
FlCursorGlowDirective,
FlRevealOnScrollDirective,
FlPressSquishDirective,
FlTextGradientDirective,
],
template: `
<div class="showcase">
<!-- Ambient Background Effects -->
<fl-ambient-light class="ambient-bg" [opacity]="0.15" [blur]="60"></fl-ambient-light>
<!-- 1. HERO SECTION -->
<fl-scroll-progress class="scroll-progress"></fl-scroll-progress>
<section class="hero">
<fl-particle-field class="hero-particles" [count]="50"></fl-particle-field>
<div class="hero-content">
<fl-gradient-text
[colors]="['#ff0080', '#7928ca', '#0070f3']"
class="hero-title">
Flair Elements UI
</fl-gradient-text>
<fl-typewriter
[text]="'Stunning visual effects and animations for modern web applications'"
[speed]="50"
class="hero-subtitle">
</fl-typewriter>
</div>
</section>
<fl-wave-divider></fl-wave-divider>
<!-- 2. INTERACTIVE CARDS -->
<section class="section">
<h2 class="section-title" flRevealOnScroll>Interactive Cards</h2>
<div class="cards-grid">
<fl-tilt-card>
<div class="card-content">
<h3>Tilt Card</h3>
<p>3D tilt effect that follows your cursor</p>
</div>
</fl-tilt-card>
<fl-holographic-card>
<div class="card-content">
<h3>Holographic Card</h3>
<p>Iridescent holographic shimmer effect</p>
</div>
</fl-holographic-card>
<fl-spotlight-card>
<div class="card-content">
<h3>Spotlight Card</h3>
<p>Dynamic spotlight that follows your mouse</p>
</div>
</fl-spotlight-card>
</div>
</section>
<fl-gradient-divider [colors]="['#ff0080', '#7928ca']"></fl-gradient-divider>
<!-- 3. TEXT EFFECTS -->
<section class="section">
<h2 class="section-title" flRevealOnScroll>Text Effects</h2>
<div class="text-effects">
<div class="text-effect-item">
<label>Gradient Text:</label>
<fl-gradient-text
[colors]="['#f093fb', '#f5576c']">
Beautiful Gradients
</fl-gradient-text>
</div>
<div class="text-effect-item">
<label>Shimmer:</label>
<fl-text-shimmer>Shimmering Effect</fl-text-shimmer>
</div>
<div class="text-effect-item">
<label>Glitch:</label>
<fl-glitch-text [text]="'Glitch Effect'"></fl-glitch-text>
</div>
<div class="text-effect-item">
<label>Scramble Reveal:</label>
<fl-scramble-reveal [text]="'Decoded Message'"></fl-scramble-reveal>
</div>
<div class="text-effect-item">
<label>Counter:</label>
<fl-counter-rollup
[value]="9999"
[duration]="2000">
</fl-counter-rollup>
</div>
</div>
</section>
<fl-glow-divider [color]="'#0070f3'"></fl-glow-divider>
<!-- 4. CANVAS BACKGROUNDS -->
<section class="section">
<h2 class="section-title" flRevealOnScroll>Canvas Backgrounds</h2>
<div class="canvas-grid">
<div class="canvas-box">
<fl-flow-field [particleCount]="400"></fl-flow-field>
<span class="canvas-label">Flow Field</span>
</div>
<div class="canvas-box">
<fl-matrix-rain></fl-matrix-rain>
<span class="canvas-label">Matrix Rain</span>
</div>
<div class="canvas-box">
<fl-aurora></fl-aurora>
<span class="canvas-label">Aurora</span>
</div>
<div class="canvas-box">
<fl-voronoi-mesh [seedCount]="15"></fl-voronoi-mesh>
<span class="canvas-label">Voronoi Mesh</span>
</div>
<div class="canvas-box">
<fl-fractal-landscape></fl-fractal-landscape>
<span class="canvas-label">Fractal Landscape</span>
</div>
</div>
</section>
<fl-wave-divider></fl-wave-divider>
<!-- 5. GENERATIVE ART -->
<section class="section">
<h2 class="section-title" flRevealOnScroll>Generative Art</h2>
<div class="generative-grid">
<div class="generative-box">
<fl-blob></fl-blob>
<span class="canvas-label">Blob</span>
</div>
<div class="generative-box">
<fl-generative-pattern></fl-generative-pattern>
<span class="canvas-label">Generative Pattern</span>
</div>
<div class="generative-box">
<fl-gradient-mesh [points]="3" [blur]="40"></fl-gradient-mesh>
<span class="canvas-label">Gradient Mesh</span>
</div>
</div>
</section>
<fl-gradient-divider [colors]="['#0070f3', '#00dfd8']"></fl-gradient-divider>
<!-- 7. CELEBRATIONS -->
<section class="section celebrations-section">
<h2 class="section-title" flRevealOnScroll>Celebrations</h2>
<div class="celebrations">
<button (click)="triggerConfetti = true" flPressSquish class="celebration-btn">
Confetti
</button>
<button (click)="triggerFireworks = true" flPressSquish class="celebration-btn">
Fireworks
</button>
<button (click)="triggerSparkle = true" flPressSquish class="celebration-btn">
Sparkle
</button>
<button (click)="triggerEmoji = true" flPressSquish class="celebration-btn">
Emoji Rain
</button>
<button (click)="triggerCheckmark = true" flPressSquish class="celebration-btn">
Checkmark
</button>
</div>
@if (triggerConfetti) {
<fl-confetti class="celebration-overlay" [active]="true"></fl-confetti>
}
@if (triggerFireworks) {
<fl-fireworks class="celebration-overlay" [active]="true"></fl-fireworks>
}
@if (triggerSparkle) {
<fl-sparkle class="celebration-overlay" [active]="true"></fl-sparkle>
}
@if (triggerEmoji) {
<fl-emoji-rain class="celebration-overlay" [active]="true" [emojis]="['🎉', '🎊', '⭐', '💫']"></fl-emoji-rain>
}
@if (triggerCheckmark) {
<fl-checkmark-flourish class="celebration-overlay" [active]="true"></fl-checkmark-flourish>
}
</section>
<fl-glow-divider [color]="'#7928ca'"></fl-glow-divider>
<!-- 8. DIRECTIVES -->
<section class="section">
<h2 class="section-title" flRevealOnScroll>Interactive Directives</h2>
<div class="directives-grid">
<div class="directive-card" flHoverLift>
<h4>Hover Lift</h4>
<p>Elevates on hover</p>
</div>
<div class="directive-card" flMagnetic>
<h4>Magnetic</h4>
<p>Attracted to cursor</p>
</div>
<div class="directive-card" flRipple>
<h4>Ripple</h4>
<p>Click for ripple effect</p>
</div>
<div class="directive-card" flNeonGlow>
<h4>Neon Glow</h4>
<p>Glowing neon border</p>
</div>
<div class="directive-card" flCursorGlow>
<h4>Cursor Glow</h4>
<p>Glow follows cursor</p>
</div>
<div class="directive-card" flRevealOnScroll>
<h4>Reveal on Scroll</h4>
<p>Animates into view</p>
</div>
<div class="directive-card" flPressSquish>
<h4>Press Squish</h4>
<p>Click to squish</p>
</div>
<div class="directive-card">
<h4 flTextGradient>Text Gradient</h4>
<p>Gradient text directive</p>
</div>
</div>
</section>
<fl-wave-divider></fl-wave-divider>
<!-- 9. PATTERNS & TEXTURES -->
<section class="section">
<h2 class="section-title" flRevealOnScroll>Patterns & Textures</h2>
<div class="patterns-grid">
<div class="pattern-box">
<fl-dot-grid></fl-dot-grid>
<span class="canvas-label">Dot Grid</span>
</div>
<div class="pattern-box">
<fl-blueprint-grid></fl-blueprint-grid>
<span class="canvas-label">Blueprint Grid</span>
</div>
<div class="pattern-box">
<fl-topographic-lines></fl-topographic-lines>
<span class="canvas-label">Topographic Lines</span>
</div>
<div class="pattern-box">
<fl-circuit-board></fl-circuit-board>
<span class="canvas-label">Circuit Board</span>
</div>
</div>
</section>
<fl-gradient-divider [colors]="['#ff0080', '#00dfd8']"></fl-gradient-divider>
<!-- 10. STATUS INDICATORS -->
<section class="section">
<h2 class="section-title" flRevealOnScroll>Status Indicators</h2>
<div class="status-grid">
<div class="status-item">
<fl-breathing-dot [color]="'#00ff00'"></fl-breathing-dot>
<span>Breathing Dot</span>
</div>
<div class="status-item">
<fl-live-indicator></fl-live-indicator>
<span>Live Indicator</span>
</div>
<div class="status-item">
<fl-signal-strength [level]="4"></fl-signal-strength>
<span>Signal Strength</span>
</div>
<div class="status-item">
<fl-heartbeat-line [color]="'#ff0080'"></fl-heartbeat-line>
<span>Heartbeat Line</span>
</div>
</div>
</section>
<fl-glow-divider [color]="'#ff0080'"></fl-glow-divider>
<!-- 11. AMBIENT -->
<section class="section final-section">
<h2 class="section-title" flRevealOnScroll>Ambient Effects</h2>
<p class="ambient-note">
Ambient Light is active throughout this entire showcase,
creating subtle atmospheric effects in the background.
</p>
</section>
<footer class="footer">
<fl-gradient-text
[colors]="['#ff0080', '#7928ca', '#0070f3']">
Built with Flair Elements UI
</fl-gradient-text>
</footer>
</div>
`,
styles: [`
:host {
display: block;
background: #0a0a0f;
color: #ffffff;
min-height: 100vh;
}
.showcase {
position: relative;
overflow-x: hidden;
}
/* Ambient Backgrounds */
.ambient-bg {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
pointer-events: none;
opacity: 0.3;
}
/* Scroll Progress */
.scroll-progress {
position: fixed;
top: 0;
left: 0;
width: 100%;
z-index: 1000;
}
/* Hero Section */
.hero {
position: relative;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.hero-particles {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
}
.hero-content {
position: relative;
z-index: 2;
text-align: center;
padding: 2rem;
max-width: 1200px;
}
.hero-title {
font-size: 5rem;
font-weight: 900;
margin-bottom: 2rem;
line-height: 1.2;
}
.hero-subtitle {
font-size: 1.5rem;
opacity: 0.9;
max-width: 800px;
margin: 0 auto;
}
/* Section Styles */
.section {
position: relative;
z-index: 1;
padding: 6rem 2rem;
max-width: 1400px;
margin: 0 auto;
}
.section-title {
font-size: 3rem;
font-weight: 800;
text-align: center;
margin-bottom: 4rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Cards Grid */
.cards-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 2rem;
margin-bottom: 2rem;
}
.card-content {
padding: 3rem 2rem;
text-align: center;
background: rgba(255, 255, 255, 0.05);
border-radius: 1rem;
backdrop-filter: blur(10px);
}
.card-content h3 {
font-size: 1.8rem;
margin-bottom: 1rem;
color: #fff;
}
.card-content p {
font-size: 1rem;
opacity: 0.8;
line-height: 1.6;
}
/* Text Effects */
.text-effects {
display: flex;
flex-direction: column;
gap: 2rem;
max-width: 800px;
margin: 0 auto;
}
.text-effect-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 2rem;
background: rgba(255, 255, 255, 0.03);
border-radius: 1rem;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.text-effect-item label {
font-weight: 600;
font-size: 1.1rem;
opacity: 0.7;
min-width: 150px;
}
/* Canvas Grid */
.canvas-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 2rem;
}
.canvas-box {
position: relative;
height: 300px;
border-radius: 1rem;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(0, 0, 0, 0.5);
}
.canvas-label {
position: absolute;
bottom: 1rem;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.8);
padding: 0.5rem 1.5rem;
border-radius: 2rem;
font-size: 0.9rem;
font-weight: 600;
z-index: 10;
backdrop-filter: blur(10px);
}
/* Generative Grid */
.generative-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 2rem;
}
.generative-box {
position: relative;
height: 350px;
border-radius: 1rem;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(0, 0, 0, 0.5);
}
/* Celebrations */
.celebrations-section {
position: relative;
}
.celebrations {
display: flex;
flex-wrap: wrap;
gap: 1.5rem;
justify-content: center;
align-items: center;
padding: 2rem;
}
.celebration-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 100;
}
.celebration-btn {
padding: 1rem 2rem;
font-size: 1.1rem;
font-weight: 600;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 0.75rem;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
}
.celebration-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.6);
}
/* Directives Grid */
.directives-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 2rem;
}
.directive-card {
padding: 2.5rem 2rem;
background: rgba(255, 255, 255, 0.05);
border-radius: 1rem;
border: 1px solid rgba(255, 255, 255, 0.1);
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
}
.directive-card h4 {
font-size: 1.3rem;
margin-bottom: 0.75rem;
color: #fff;
}
.directive-card p {
font-size: 0.95rem;
opacity: 0.7;
line-height: 1.5;
}
.directive-card:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.2);
}
/* Patterns Grid */
.patterns-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 2rem;
}
.pattern-box {
position: relative;
height: 300px;
border-radius: 1rem;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(0, 0, 0, 0.5);
}
/* Status Grid */
.status-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 3rem;
padding: 2rem;
}
.status-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
padding: 2rem;
background: rgba(255, 255, 255, 0.03);
border-radius: 1rem;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.status-item span {
font-size: 1rem;
font-weight: 600;
opacity: 0.8;
}
/* Final Section */
.final-section {
min-height: 50vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.ambient-note {
max-width: 700px;
text-align: center;
font-size: 1.2rem;
line-height: 1.8;
opacity: 0.8;
padding: 2rem;
background: rgba(255, 255, 255, 0.05);
border-radius: 1rem;
border: 1px solid rgba(255, 255, 255, 0.1);
}
/* Footer */
.footer {
text-align: center;
padding: 4rem 2rem;
font-size: 1.5rem;
position: relative;
z-index: 1;
}
/* Responsive Design */
@media (max-width: 768px) {
.hero-title {
font-size: 3rem;
}
.hero-subtitle {
font-size: 1.2rem;
}
.section-title {
font-size: 2rem;
}
.cards-grid,
.canvas-grid,
.generative-grid,
.directives-grid,
.patterns-grid {
grid-template-columns: 1fr;
}
.text-effect-item {
flex-direction: column;
gap: 1rem;
text-align: center;
}
.text-effect-item label {
min-width: auto;
}
}
`]
})
export class AppComponent {
triggerConfetti = false;
triggerFireworks = false;
triggerSparkle = false;
triggerEmoji = false;
triggerCheckmark = false;
}

13
demo/src/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Flair Elements UI Demo</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="data:,">
</head>
<body>
<app-root></app-root>
</body>
</html>

6
demo/src/main.ts Normal file
View File

@@ -0,0 +1,6 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app.component';
bootstrapApplication(AppComponent, {
providers: []
}).catch((err) => console.error(err));

32
demo/src/styles.scss Normal file
View File

@@ -0,0 +1,32 @@
@use '@sda/flair-elements-ui/src/styles' as flair;
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: #0a0a0f;
color: #ffffff;
overflow-x: hidden;
}
h1, h2, h3, h4, h5, h6 {
margin: 0;
}
a {
color: #0ea5e9;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
button {
cursor: pointer;
font-family: inherit;
}

13
demo/tsconfig.app.json Normal file
View File

@@ -0,0 +1,13 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"files": [
"src/main.ts"
],
"include": [
"src/**/*.d.ts"
]
}

31
demo/tsconfig.json Normal file
View File

@@ -0,0 +1,31 @@
{
"compileOnSave": false,
"compilerOptions": {
"outDir": "./dist/out-tsc",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"esModuleInterop": true,
"sourceMap": true,
"declaration": false,
"experimentalDecorators": true,
"moduleResolution": "bundler",
"importHelpers": true,
"target": "ES2022",
"module": "ES2022",
"lib": [
"ES2022",
"dom"
]
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}

View File

@@ -10,22 +10,18 @@
.fl-ambient-light__layer-2, .fl-ambient-light__layer-2,
.fl-ambient-light__layer-3 { .fl-ambient-light__layer-3 {
position: absolute; position: absolute;
inset: 0; inset: -20%;
pointer-events: none; pointer-events: none;
will-change: background;
} }
.fl-ambient-light__layer-1 { .fl-ambient-light__layer-1 {
opacity: 0.5; opacity: 0.5;
filter: blur(60px);
} }
.fl-ambient-light__layer-2 { .fl-ambient-light__layer-2 {
opacity: 0.4; opacity: 0.4;
filter: blur(80px);
} }
.fl-ambient-light__layer-3 { .fl-ambient-light__layer-3 {
opacity: 0.3; opacity: 0.3;
filter: blur(100px);
} }

View File

@@ -48,7 +48,7 @@ export class FlAmbientLightComponent {
} }
const id = `ambient-light-${Math.random().toString(36).slice(2)}`; const id = `ambient-light-${Math.random().toString(36).slice(2)}`;
this.loopCleanup = this.animationLoop.register(id, (delta) => this.tick(delta)); this.loopCleanup = this.animationLoop.registerWithElement(id, this.el.nativeElement, (delta) => this.tick(delta));
}); });
this.destroyRef.onDestroy(() => { this.destroyRef.onDestroy(() => {

View File

@@ -82,7 +82,7 @@ export class FlAuroraComponent {
this.renderStaticState(); this.renderStaticState();
} else { } else {
const id = `aurora-${Math.random().toString(36).slice(2)}`; const id = `aurora-${Math.random().toString(36).slice(2)}`;
this.loopCleanup = this.animationLoop.register(id, (_, elapsed) => this.tick(elapsed)); this.loopCleanup = this.animationLoop.registerWithElement(id, svg, (_, elapsed) => this.tick(elapsed));
} }
this.ready.emit(); this.ready.emit();

View File

@@ -59,14 +59,14 @@ export class FlConfettiComponent {
this.resizeCanvas(); this.resizeCanvas();
window.addEventListener('resize', () => this.resizeCanvas()); window.addEventListener('resize', () => this.resizeCanvas());
});
// Watch for active changes // Watch for active changes (must be after canvas is ready)
effect(() => { effect(() => {
const isActive = this.active(); const isActive = this.active();
if (isActive && !this.isAnimating) { if (isActive && !this.isAnimating) {
this.trigger(); this.trigger();
} }
});
}); });
this.destroyRef.onDestroy(() => { this.destroyRef.onDestroy(() => {

View File

@@ -1,6 +1,6 @@
import { import {
Component, ChangeDetectionStrategy, input, inject, Component, ChangeDetectionStrategy, input, inject,
afterNextRender, DestroyRef, signal, effect, ElementRef, afterNextRender, DestroyRef, signal, effect,
} from '@angular/core'; } from '@angular/core';
import { ReducedMotionService } from '../../services/reduced-motion.service'; import { ReducedMotionService } from '../../services/reduced-motion.service';
@@ -13,6 +13,7 @@ import { ReducedMotionService } from '../../services/reduced-motion.service';
host: { 'class': 'fl-counter-rollup' }, host: { 'class': 'fl-counter-rollup' },
}) })
export class FlCounterRollupComponent { export class FlCounterRollupComponent {
private el = inject(ElementRef);
private reducedMotion = inject(ReducedMotionService); private reducedMotion = inject(ReducedMotionService);
private destroyRef = inject(DestroyRef); private destroyRef = inject(DestroyRef);
@@ -32,24 +33,48 @@ export class FlCounterRollupComponent {
private targetValue = 0; private targetValue = 0;
private startTime = 0; private startTime = 0;
private animationFrame: number | null = null; private animationFrame: number | null = null;
private observer: IntersectionObserver | null = null;
private hasAnimated = false;
constructor() { constructor() {
afterNextRender(() => { afterNextRender(() => {
this.startAnimation(); if (this.reducedMotion.reduced()) {
this.targetValue = this.value();
this.currentValue = this.targetValue;
this.formattedValue.set(this.formatNumber(this.targetValue));
return;
}
this.targetValue = this.value();
this.observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting && !this.hasAnimated) {
this.observer?.disconnect();
this.observer = null;
this.hasAnimated = true;
this.startAnimation();
}
},
{ threshold: 0.1 }
);
this.observer.observe(this.el.nativeElement);
}); });
effect(() => { effect(() => {
// React to value changes // React to value changes after initial animation
const newValue = this.value(); const newValue = this.value();
this.startValue = this.currentValue; this.startValue = this.currentValue;
this.targetValue = newValue; this.targetValue = newValue;
this.startAnimation(); if (this.hasAnimated) {
this.startAnimation();
}
}); });
this.destroyRef.onDestroy(() => { this.destroyRef.onDestroy(() => {
if (this.animationFrame !== null) { if (this.animationFrame !== null) {
cancelAnimationFrame(this.animationFrame); cancelAnimationFrame(this.animationFrame);
} }
this.observer?.disconnect();
}); });
} }

View File

@@ -66,14 +66,14 @@ export class FlFireworksComponent {
this.resizeCanvas(); this.resizeCanvas();
window.addEventListener('resize', () => this.resizeCanvas()); window.addEventListener('resize', () => this.resizeCanvas());
});
// Watch for active changes // Watch for active changes (must be after canvas is ready)
effect(() => { effect(() => {
const isActive = this.active(); const isActive = this.active();
if (isActive && !this.isAnimating) { if (isActive && !this.isAnimating) {
this.trigger(); this.trigger();
} }
});
}); });
this.destroyRef.onDestroy(() => { this.destroyRef.onDestroy(() => {

View File

@@ -37,7 +37,7 @@ export class FlFloatingLayersComponent {
if (this.reducedMotion.reduced()) return; if (this.reducedMotion.reduced()) return;
this.cursorCleanup = this.cursorTracker.subscribe(); this.cursorCleanup = this.cursorTracker.subscribe();
const id = `floating-layers-${Math.random().toString(36).slice(2)}`; const id = `floating-layers-${Math.random().toString(36).slice(2)}`;
this.loopCleanup = this.animationLoop.register(id, () => this.tick()); this.loopCleanup = this.animationLoop.registerWithElement(id, this.el.nativeElement, () => this.tick());
}); });
this.destroyRef.onDestroy(() => { this.destroyRef.onDestroy(() => {

View File

@@ -91,7 +91,7 @@ export class FlFlowFieldComponent {
this.ctx.fillRect(0, 0, this.width, this.height); this.ctx.fillRect(0, 0, this.width, this.height);
const id = `flow-field-${Math.random().toString(36).slice(2)}`; const id = `flow-field-${Math.random().toString(36).slice(2)}`;
this.loopCleanup = this.animationLoop.register(id, (delta) => this.tick(delta)); this.loopCleanup = this.animationLoop.registerWithElement(id, canvas, (delta) => this.tick(delta));
this.ready.emit(); this.ready.emit();
} }

View File

@@ -76,7 +76,7 @@ export class FlFractalLandscapeComponent {
this.renderFrame(0); this.renderFrame(0);
} else { } else {
const id = `fractal-${Math.random().toString(36).slice(2)}`; const id = `fractal-${Math.random().toString(36).slice(2)}`;
this.loopCleanup = this.animationLoop.register(id, (_, elapsed) => this.renderFrame(elapsed)); this.loopCleanup = this.animationLoop.registerWithElement(id, canvas, (_, elapsed) => this.renderFrame(elapsed));
} }
this.ready.emit(); this.ready.emit();

View File

@@ -62,7 +62,7 @@ export class FlGradientMeshComponent {
} }
const id = `gradient-mesh-${Math.random().toString(36).slice(2)}`; const id = `gradient-mesh-${Math.random().toString(36).slice(2)}`;
this.loopCleanup = this.animationLoop.register(id, (delta) => this.tick(delta)); this.loopCleanup = this.animationLoop.registerWithElement(id, this.el.nativeElement, (delta) => this.tick(delta));
}); });
this.destroyRef.onDestroy(() => { this.destroyRef.onDestroy(() => {

View File

@@ -81,7 +81,7 @@ export class FlMatrixRainComponent {
this.initColumns(); this.initColumns();
const id = `matrix-rain-${Math.random().toString(36).slice(2)}`; const id = `matrix-rain-${Math.random().toString(36).slice(2)}`;
this.loopCleanup = this.animationLoop.register(id, (delta) => this.tick(delta)); this.loopCleanup = this.animationLoop.registerWithElement(id, canvas, (delta) => this.tick(delta));
this.ready.emit(); this.ready.emit();
} }

View File

@@ -108,7 +108,7 @@ export class FlParticleFieldComponent {
} }
const id = `particle-field-${Math.random().toString(36).slice(2)}`; const id = `particle-field-${Math.random().toString(36).slice(2)}`;
this.loopCleanup = this.animationLoop.register(id, (delta) => this.tick(delta)); this.loopCleanup = this.animationLoop.registerWithElement(id, canvas, (delta) => this.tick(delta));
this.ready.emit(); this.ready.emit();
} }
@@ -192,21 +192,68 @@ export class FlParticleFieldComponent {
if (p.y > h + p.size) p.y = -p.size; if (p.y > h + p.size) p.y = -p.size;
} }
// Draw connections // Draw connections using spatial grid
if (useConnections) { if (useConnections) {
const cellSize = connDist;
const gridCols = Math.ceil(w / cellSize) + 1;
const gridRows = Math.ceil(h / cellSize) + 1;
const grid = new Array(gridCols * gridRows);
for (let i = 0; i < grid.length; i++) grid[i] = [];
// Assign particles to grid cells
for (let i = 0; i < this.particles.length; i++) { for (let i = 0; i < this.particles.length; i++) {
for (let j = i + 1; j < this.particles.length; j++) { const p = this.particles[i];
const a = this.particles[i]; const col = Math.floor(p.x / cellSize);
const b = this.particles[j]; const row = Math.floor(p.y / cellSize);
const d = distance2D(a.x, a.y, b.x, b.y); if (col >= 0 && col < gridCols && row >= 0 && row < gridRows) {
if (d < connDist) { grid[col * gridRows + row].push(i);
const alpha = (1 - d / connDist) * connOpacity; }
ctx.strokeStyle = `rgba(255, 255, 255, ${alpha})`; }
ctx.lineWidth = 0.5;
ctx.beginPath(); const maxConnsPerParticle = 5;
ctx.moveTo(a.x, a.y); const connCounts = new Uint8Array(this.particles.length);
ctx.lineTo(b.x, b.y); const drawn = new Set<string>();
ctx.stroke();
ctx.lineWidth = 0.5;
for (let col = 0; col < gridCols; col++) {
for (let row = 0; row < gridRows; row++) {
const cell = grid[col * gridRows + row];
if (cell.length === 0) continue;
// Check this cell and neighboring cells
for (let dc = -1; dc <= 1; dc++) {
for (let dr = -1; dr <= 1; dr++) {
const nc = col + dc;
const nr = row + dr;
if (nc < 0 || nc >= gridCols || nr < 0 || nr >= gridRows) continue;
const neighbor = grid[nc * gridRows + nr];
for (const i of cell) {
if (connCounts[i] >= maxConnsPerParticle) continue;
const a = this.particles[i];
for (const j of neighbor) {
if (j <= i) continue; // avoid duplicates
if (connCounts[j] >= maxConnsPerParticle) continue;
const b = this.particles[j];
const dx = a.x - b.x;
const dy = a.y - b.y;
const d = Math.sqrt(dx * dx + dy * dy);
if (d < connDist) {
const alpha = (1 - d / connDist) * connOpacity;
ctx.strokeStyle = `rgba(255, 255, 255, ${alpha})`;
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.stroke();
connCounts[i]++;
connCounts[j]++;
}
}
}
}
} }
} }
} }

View File

@@ -1,6 +1,6 @@
import { import {
Component, ChangeDetectionStrategy, input, inject, Component, ChangeDetectionStrategy, input, inject,
afterNextRender, DestroyRef, signal, ElementRef, afterNextRender, DestroyRef, signal,
} from '@angular/core'; } from '@angular/core';
import { ReducedMotionService } from '../../services/reduced-motion.service'; import { ReducedMotionService } from '../../services/reduced-motion.service';
@@ -13,6 +13,7 @@ import { ReducedMotionService } from '../../services/reduced-motion.service';
host: { 'class': 'fl-scramble-reveal' }, host: { 'class': 'fl-scramble-reveal' },
}) })
export class FlScrambleRevealComponent { export class FlScrambleRevealComponent {
private el = inject(ElementRef);
private reducedMotion = inject(ReducedMotionService); private reducedMotion = inject(ReducedMotionService);
private destroyRef = inject(DestroyRef); private destroyRef = inject(DestroyRef);
@@ -28,6 +29,7 @@ export class FlScrambleRevealComponent {
private scrambleCount = 0; private scrambleCount = 0;
private maxScrambles = 3; private maxScrambles = 3;
private interval: any = null; private interval: any = null;
private observer: IntersectionObserver | null = null;
constructor() { constructor() {
afterNextRender(() => { afterNextRender(() => {
@@ -36,13 +38,24 @@ export class FlScrambleRevealComponent {
return; return;
} }
this.startScramble(); this.observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) {
this.observer?.disconnect();
this.observer = null;
this.startScramble();
}
},
{ threshold: 0.1 }
);
this.observer.observe(this.el.nativeElement);
}); });
this.destroyRef.onDestroy(() => { this.destroyRef.onDestroy(() => {
if (this.interval) { if (this.interval) {
clearInterval(this.interval); clearInterval(this.interval);
} }
this.observer?.disconnect();
}); });
} }

View File

@@ -1,6 +1,6 @@
import { import {
Component, ChangeDetectionStrategy, input, inject, Component, ChangeDetectionStrategy, input, inject,
afterNextRender, DestroyRef, signal, ElementRef, afterNextRender, DestroyRef, signal,
} from '@angular/core'; } from '@angular/core';
import { ReducedMotionService } from '../../services/reduced-motion.service'; import { ReducedMotionService } from '../../services/reduced-motion.service';
@@ -18,6 +18,7 @@ import { ReducedMotionService } from '../../services/reduced-motion.service';
host: { 'class': 'fl-typewriter' }, host: { 'class': 'fl-typewriter' },
}) })
export class FlTypewriterComponent { export class FlTypewriterComponent {
private el = inject(ElementRef);
private reducedMotion = inject(ReducedMotionService); private reducedMotion = inject(ReducedMotionService);
private destroyRef = inject(DestroyRef); private destroyRef = inject(DestroyRef);
@@ -37,6 +38,7 @@ export class FlTypewriterComponent {
private typingInterval: any = null; private typingInterval: any = null;
private cursorInterval: any = null; private cursorInterval: any = null;
private isTyping = false; private isTyping = false;
private observer: IntersectionObserver | null = null;
constructor() { constructor() {
afterNextRender(() => { afterNextRender(() => {
@@ -45,12 +47,23 @@ export class FlTypewriterComponent {
return; return;
} }
setTimeout(() => this.startTyping(), this.delay()); this.observer = new IntersectionObserver(
this.startCursorBlink(); (entries) => {
if (entries[0]?.isIntersecting) {
this.observer?.disconnect();
this.observer = null;
setTimeout(() => this.startTyping(), this.delay());
this.startCursorBlink();
}
},
{ threshold: 0.1 }
);
this.observer.observe(this.el.nativeElement);
}); });
this.destroyRef.onDestroy(() => { this.destroyRef.onDestroy(() => {
this.cleanup(); this.cleanup();
this.observer?.disconnect();
}); });
} }

View File

@@ -52,6 +52,9 @@ export class FlVoronoiMeshComponent {
private pixelRatio = 1; private pixelRatio = 1;
private loopCleanup: (() => void) | null = null; private loopCleanup: (() => void) | null = null;
private resizeObserver: ResizeObserver | null = null; private resizeObserver: ResizeObserver | null = null;
private cellCache: Uint8Array | null = null;
private lastSeedPositions: { x: number; y: number }[] = [];
private cacheStep = 4;
constructor() { constructor() {
afterNextRender(() => { afterNextRender(() => {
@@ -87,7 +90,7 @@ export class FlVoronoiMeshComponent {
this.createSeeds(); this.createSeeds();
const id = `voronoi-${Math.random().toString(36).slice(2)}`; const id = `voronoi-${Math.random().toString(36).slice(2)}`;
this.loopCleanup = this.animationLoop.register(id, (delta) => this.tick(delta)); this.loopCleanup = this.animationLoop.registerWithElement(id, canvas, (delta) => this.tick(delta));
this.ready.emit(); this.ready.emit();
} }
@@ -106,6 +109,7 @@ export class FlVoronoiMeshComponent {
} }
private createSeeds(): void { private createSeeds(): void {
this.cellCache = null;
const count = this.seedCount(); const count = this.seedCount();
const cols = this.colors(); const cols = this.colors();
const spd = this.speed(); const spd = this.speed();
@@ -121,6 +125,17 @@ export class FlVoronoiMeshComponent {
} }
} }
private needsCacheInvalidation(): boolean {
if (!this.cellCache || this.lastSeedPositions.length !== this.seeds.length) return true;
const step = Math.max(4, Math.floor(6 / this.pixelRatio));
for (let i = 0; i < this.seeds.length; i++) {
const dx = this.seeds[i].x - this.lastSeedPositions[i].x;
const dy = this.seeds[i].y - this.lastSeedPositions[i].y;
if (dx * dx + dy * dy > step * step) return true;
}
return false;
}
private tick(delta: number): void { private tick(delta: number): void {
if (!this.ctx) return; if (!this.ctx) return;
@@ -145,55 +160,65 @@ export class FlVoronoiMeshComponent {
const ctx = this.ctx; const ctx = this.ctx;
const w = this.width; const w = this.width;
const h = this.height; const h = this.height;
const step = Math.max(4, Math.floor(6 / this.pixelRatio));
// Use pixel-based approach for Voronoi (brute-force nearest-seed)
// For performance, use a step size
const step = Math.max(2, Math.floor(4 / this.pixelRatio));
ctx.clearRect(0, 0, w, h); ctx.clearRect(0, 0, w, h);
for (let x = 0; x < w; x += step) { const cols = Math.ceil(w / step);
for (let y = 0; y < h; y += step) { const rows = Math.ceil(h / step);
let minDist = Infinity; const totalCells = cols * rows;
let nearestColor = this.seeds[0]?.color ?? '#000';
for (const s of this.seeds) { // Only recalculate cell assignments if seeds moved significantly
const d = (x - s.x) * (x - s.x) + (y - s.y) * (y - s.y); if (this.needsCacheInvalidation()) {
if (d < minDist) { this.cellCache = new Uint8Array(totalCells);
minDist = d; this.cacheStep = step;
nearestColor = s.color;
for (let col = 0; col < cols; col++) {
const x = col * step;
for (let row = 0; row < rows; row++) {
const y = row * step;
let minDist = Infinity;
let nearestIdx = 0;
for (let s = 0; s < this.seeds.length; s++) {
const dx = x - this.seeds[s].x;
const dy = y - this.seeds[s].y;
const d = dx * dx + dy * dy;
if (d < minDist) {
minDist = d;
nearestIdx = s;
}
} }
}
ctx.fillStyle = nearestColor; this.cellCache[col * rows + row] = nearestIdx;
}
}
this.lastSeedPositions = this.seeds.map(s => ({ x: s.x, y: s.y }));
}
// Render from cache
const cache = this.cellCache!;
for (let col = 0; col < cols; col++) {
const x = col * step;
for (let row = 0; row < rows; row++) {
const y = row * step;
const seedIdx = cache[col * rows + row];
ctx.fillStyle = this.seeds[seedIdx]?.color ?? '#000';
ctx.fillRect(x, y, step, step); ctx.fillRect(x, y, step, step);
} }
} }
// Draw edges using second-nearest distance comparison // Draw edges only when showEdges is true
if (this.showEdges()) { if (this.showEdges()) {
ctx.strokeStyle = this.edgeColor(); const edgeColor = this.edgeColor();
ctx.lineWidth = this.edgeWidth(); for (let col = 1; col < cols; col++) {
for (let row = 1; row < rows; row++) {
for (let x = 0; x < w; x += step) { const idx = cache[col * rows + row];
for (let y = 0; y < h; y += step) { // Check if neighboring cells have different seed assignments
let min1 = Infinity; if (idx !== cache[(col - 1) * rows + row] || idx !== cache[col * rows + (row - 1)]) {
let min2 = Infinity; ctx.fillStyle = edgeColor;
ctx.fillRect(col * step, row * step, step, step);
for (const s of this.seeds) {
const d = (x - s.x) * (x - s.x) + (y - s.y) * (y - s.y);
if (d < min1) {
min2 = min1;
min1 = d;
} else if (d < min2) {
min2 = d;
}
}
const ratio = min1 / min2;
if (ratio > 0.95) {
ctx.fillStyle = this.edgeColor();
ctx.fillRect(x, y, step, step);
} }
} }
} }

View File

@@ -16,6 +16,10 @@ export class AnimationLoopService {
private startTime = 0; private startTime = 0;
private running = false; private running = false;
/** Track which registered IDs are currently visible */
private visibilityMap = new Map<string, boolean>();
private observers = new Map<string, IntersectionObserver>();
constructor() { constructor() {
this.destroyRef.onDestroy(() => { this.destroyRef.onDestroy(() => {
this.stop(); this.stop();
@@ -42,6 +46,37 @@ export class AnimationLoopService {
} }
} }
/** Register a callback tied to an element's visibility. Auto-pauses when off-screen. */
registerWithElement(id: string, element: Element, callback: (delta: number, elapsed: number) => void, priority = 0): () => void {
this.visibilityMap.set(id, false);
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
this.visibilityMap.set(id, entry.isIntersecting);
}
},
{ threshold: 0 }
);
observer.observe(element);
this.observers.set(id, observer);
const wrappedCallback = (delta: number, elapsed: number): void => {
if (this.visibilityMap.get(id)) {
callback(delta, elapsed);
}
};
const baseCleanup = this.register(id, wrappedCallback, priority);
return () => {
baseCleanup();
observer.disconnect();
this.observers.delete(id);
this.visibilityMap.delete(id);
};
}
private start(): void { private start(): void {
if (this.running) return; if (this.running) return;
if (this.reducedMotion.reduced()) return; if (this.reducedMotion.reduced()) return;

View File

@@ -114,5 +114,5 @@
@keyframes fl-float { @keyframes fl-float {
0%, 100% { transform: translateY(0); } 0%, 100% { transform: translateY(0); }
50% { transform: translateY(-#{$distance}); } 50% { transform: translateY(-10px); }
} }