diff --git a/demo/angular.json b/demo/angular.json new file mode 100644 index 0000000..6350d38 --- /dev/null +++ b/demo/angular.json @@ -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" + } + } + } + } + } +} diff --git a/demo/package.json b/demo/package.json new file mode 100644 index 0000000..3c9d483 --- /dev/null +++ b/demo/package.json @@ -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" + } +} diff --git a/demo/src/app.component.ts b/demo/src/app.component.ts new file mode 100644 index 0000000..6eb3a3b --- /dev/null +++ b/demo/src/app.component.ts @@ -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: ` +
+ + + + + + +
+ +
+ + Flair Elements UI + + + +
+
+ + + + +
+

Interactive Cards

+
+ +
+

Tilt Card

+

3D tilt effect that follows your cursor

+
+
+ + +
+

Holographic Card

+

Iridescent holographic shimmer effect

+
+
+ + +
+

Spotlight Card

+

Dynamic spotlight that follows your mouse

+
+
+
+
+ + + + +
+

Text Effects

+
+
+ + + Beautiful Gradients + +
+ +
+ + Shimmering Effect +
+ +
+ + +
+ +
+ + +
+ +
+ + + +
+
+
+ + + + +
+

Canvas Backgrounds

+
+
+ + Flow Field +
+ +
+ + Matrix Rain +
+ +
+ + Aurora +
+ +
+ + Voronoi Mesh +
+ +
+ + Fractal Landscape +
+
+
+ + + + +
+

Generative Art

+
+
+ + Blob +
+ +
+ + Generative Pattern +
+ +
+ + Gradient Mesh +
+
+
+ + + + +
+

Celebrations

+
+ + + + + + + + + +
+ + @if (triggerConfetti) { + + } + @if (triggerFireworks) { + + } + @if (triggerSparkle) { + + } + @if (triggerEmoji) { + + } + @if (triggerCheckmark) { + + } +
+ + + + +
+

Interactive Directives

+
+
+

Hover Lift

+

Elevates on hover

+
+ +
+

Magnetic

+

Attracted to cursor

+
+ +
+

Ripple

+

Click for ripple effect

+
+ +
+

Neon Glow

+

Glowing neon border

+
+ +
+

Cursor Glow

+

Glow follows cursor

+
+ +
+

Reveal on Scroll

+

Animates into view

+
+ +
+

Press Squish

+

Click to squish

+
+ +
+

Text Gradient

+

Gradient text directive

+
+
+
+ + + + +
+

Patterns & Textures

+
+
+ + Dot Grid +
+ +
+ + Blueprint Grid +
+ +
+ + Topographic Lines +
+ +
+ + Circuit Board +
+
+
+ + + + +
+

Status Indicators

+
+
+ + Breathing Dot +
+ +
+ + Live Indicator +
+ +
+ + Signal Strength +
+ +
+ + Heartbeat Line +
+
+
+ + + + +
+

Ambient Effects

+

+ Ambient Light is active throughout this entire showcase, + creating subtle atmospheric effects in the background. +

+
+ + +
+ `, + 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; +} diff --git a/demo/src/index.html b/demo/src/index.html new file mode 100644 index 0000000..2c23593 --- /dev/null +++ b/demo/src/index.html @@ -0,0 +1,13 @@ + + + + + Flair Elements UI Demo + + + + + + + + diff --git a/demo/src/main.ts b/demo/src/main.ts new file mode 100644 index 0000000..de41a48 --- /dev/null +++ b/demo/src/main.ts @@ -0,0 +1,6 @@ +import { bootstrapApplication } from '@angular/platform-browser'; +import { AppComponent } from './app.component'; + +bootstrapApplication(AppComponent, { + providers: [] +}).catch((err) => console.error(err)); diff --git a/demo/src/styles.scss b/demo/src/styles.scss new file mode 100644 index 0000000..fc8e71c --- /dev/null +++ b/demo/src/styles.scss @@ -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; +} diff --git a/demo/tsconfig.app.json b/demo/tsconfig.app.json new file mode 100644 index 0000000..7d7c716 --- /dev/null +++ b/demo/tsconfig.app.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/app", + "types": [] + }, + "files": [ + "src/main.ts" + ], + "include": [ + "src/**/*.d.ts" + ] +} diff --git a/demo/tsconfig.json b/demo/tsconfig.json new file mode 100644 index 0000000..1860915 --- /dev/null +++ b/demo/tsconfig.json @@ -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 + } +} diff --git a/src/components/fl-ambient-light/fl-ambient-light.component.scss b/src/components/fl-ambient-light/fl-ambient-light.component.scss index c789411..a1922ce 100644 --- a/src/components/fl-ambient-light/fl-ambient-light.component.scss +++ b/src/components/fl-ambient-light/fl-ambient-light.component.scss @@ -10,22 +10,18 @@ .fl-ambient-light__layer-2, .fl-ambient-light__layer-3 { position: absolute; - inset: 0; + inset: -20%; 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); } diff --git a/src/components/fl-ambient-light/fl-ambient-light.component.ts b/src/components/fl-ambient-light/fl-ambient-light.component.ts index 111ad73..b104c3a 100644 --- a/src/components/fl-ambient-light/fl-ambient-light.component.ts +++ b/src/components/fl-ambient-light/fl-ambient-light.component.ts @@ -48,7 +48,7 @@ export class FlAmbientLightComponent { } 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(() => { diff --git a/src/components/fl-aurora/fl-aurora.component.ts b/src/components/fl-aurora/fl-aurora.component.ts index b99ea2e..e84a12b 100644 --- a/src/components/fl-aurora/fl-aurora.component.ts +++ b/src/components/fl-aurora/fl-aurora.component.ts @@ -82,7 +82,7 @@ export class FlAuroraComponent { this.renderStaticState(); } else { 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(); diff --git a/src/components/fl-confetti/fl-confetti.component.ts b/src/components/fl-confetti/fl-confetti.component.ts index 5a754e5..2627702 100644 --- a/src/components/fl-confetti/fl-confetti.component.ts +++ b/src/components/fl-confetti/fl-confetti.component.ts @@ -59,14 +59,14 @@ export class FlConfettiComponent { this.resizeCanvas(); window.addEventListener('resize', () => this.resizeCanvas()); - }); - // Watch for active changes - effect(() => { - const isActive = this.active(); - if (isActive && !this.isAnimating) { - this.trigger(); - } + // Watch for active changes (must be after canvas is ready) + effect(() => { + const isActive = this.active(); + if (isActive && !this.isAnimating) { + this.trigger(); + } + }); }); this.destroyRef.onDestroy(() => { diff --git a/src/components/fl-counter-rollup/fl-counter-rollup.component.ts b/src/components/fl-counter-rollup/fl-counter-rollup.component.ts index b494f2e..f248595 100644 --- a/src/components/fl-counter-rollup/fl-counter-rollup.component.ts +++ b/src/components/fl-counter-rollup/fl-counter-rollup.component.ts @@ -1,6 +1,6 @@ import { Component, ChangeDetectionStrategy, input, inject, - afterNextRender, DestroyRef, signal, effect, + ElementRef, afterNextRender, DestroyRef, signal, effect, } from '@angular/core'; import { ReducedMotionService } from '../../services/reduced-motion.service'; @@ -13,6 +13,7 @@ import { ReducedMotionService } from '../../services/reduced-motion.service'; host: { 'class': 'fl-counter-rollup' }, }) export class FlCounterRollupComponent { + private el = inject(ElementRef); private reducedMotion = inject(ReducedMotionService); private destroyRef = inject(DestroyRef); @@ -32,24 +33,48 @@ export class FlCounterRollupComponent { private targetValue = 0; private startTime = 0; private animationFrame: number | null = null; + private observer: IntersectionObserver | null = null; + private hasAnimated = false; constructor() { 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(() => { - // React to value changes + // React to value changes after initial animation const newValue = this.value(); this.startValue = this.currentValue; this.targetValue = newValue; - this.startAnimation(); + if (this.hasAnimated) { + this.startAnimation(); + } }); this.destroyRef.onDestroy(() => { if (this.animationFrame !== null) { cancelAnimationFrame(this.animationFrame); } + this.observer?.disconnect(); }); } diff --git a/src/components/fl-fireworks/fl-fireworks.component.ts b/src/components/fl-fireworks/fl-fireworks.component.ts index c5b6606..ffa52bc 100644 --- a/src/components/fl-fireworks/fl-fireworks.component.ts +++ b/src/components/fl-fireworks/fl-fireworks.component.ts @@ -66,14 +66,14 @@ export class FlFireworksComponent { this.resizeCanvas(); window.addEventListener('resize', () => this.resizeCanvas()); - }); - // Watch for active changes - effect(() => { - const isActive = this.active(); - if (isActive && !this.isAnimating) { - this.trigger(); - } + // Watch for active changes (must be after canvas is ready) + effect(() => { + const isActive = this.active(); + if (isActive && !this.isAnimating) { + this.trigger(); + } + }); }); this.destroyRef.onDestroy(() => { diff --git a/src/components/fl-floating-layers/fl-floating-layers.component.ts b/src/components/fl-floating-layers/fl-floating-layers.component.ts index fdcf895..5e3cbe7 100644 --- a/src/components/fl-floating-layers/fl-floating-layers.component.ts +++ b/src/components/fl-floating-layers/fl-floating-layers.component.ts @@ -37,7 +37,7 @@ export class FlFloatingLayersComponent { 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.loopCleanup = this.animationLoop.registerWithElement(id, this.el.nativeElement, () => this.tick()); }); this.destroyRef.onDestroy(() => { diff --git a/src/components/fl-flow-field/fl-flow-field.component.ts b/src/components/fl-flow-field/fl-flow-field.component.ts index 510695c..d8ef9a5 100644 --- a/src/components/fl-flow-field/fl-flow-field.component.ts +++ b/src/components/fl-flow-field/fl-flow-field.component.ts @@ -91,7 +91,7 @@ export class FlFlowFieldComponent { 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.loopCleanup = this.animationLoop.registerWithElement(id, canvas, (delta) => this.tick(delta)); this.ready.emit(); } diff --git a/src/components/fl-fractal-landscape/fl-fractal-landscape.component.ts b/src/components/fl-fractal-landscape/fl-fractal-landscape.component.ts index 9ae3dd5..a3ac393 100644 --- a/src/components/fl-fractal-landscape/fl-fractal-landscape.component.ts +++ b/src/components/fl-fractal-landscape/fl-fractal-landscape.component.ts @@ -76,7 +76,7 @@ export class FlFractalLandscapeComponent { this.renderFrame(0); } else { 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(); diff --git a/src/components/fl-gradient-mesh/fl-gradient-mesh.component.ts b/src/components/fl-gradient-mesh/fl-gradient-mesh.component.ts index 732f1d9..9945f39 100644 --- a/src/components/fl-gradient-mesh/fl-gradient-mesh.component.ts +++ b/src/components/fl-gradient-mesh/fl-gradient-mesh.component.ts @@ -62,7 +62,7 @@ export class FlGradientMeshComponent { } 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(() => { diff --git a/src/components/fl-matrix-rain/fl-matrix-rain.component.ts b/src/components/fl-matrix-rain/fl-matrix-rain.component.ts index 637205b..54b3061 100644 --- a/src/components/fl-matrix-rain/fl-matrix-rain.component.ts +++ b/src/components/fl-matrix-rain/fl-matrix-rain.component.ts @@ -81,7 +81,7 @@ export class FlMatrixRainComponent { this.initColumns(); 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(); } diff --git a/src/components/fl-particle-field/fl-particle-field.component.ts b/src/components/fl-particle-field/fl-particle-field.component.ts index 2be6c0d..4eb5aea 100644 --- a/src/components/fl-particle-field/fl-particle-field.component.ts +++ b/src/components/fl-particle-field/fl-particle-field.component.ts @@ -108,7 +108,7 @@ export class FlParticleFieldComponent { } 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(); } @@ -192,21 +192,68 @@ export class FlParticleFieldComponent { if (p.y > h + p.size) p.y = -p.size; } - // Draw connections + // Draw connections using spatial grid 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 j = i + 1; j < this.particles.length; j++) { - const a = this.particles[i]; - const b = this.particles[j]; - const d = distance2D(a.x, a.y, b.x, b.y); - if (d < connDist) { - const alpha = (1 - d / connDist) * connOpacity; - ctx.strokeStyle = `rgba(255, 255, 255, ${alpha})`; - ctx.lineWidth = 0.5; - ctx.beginPath(); - ctx.moveTo(a.x, a.y); - ctx.lineTo(b.x, b.y); - ctx.stroke(); + const p = this.particles[i]; + const col = Math.floor(p.x / cellSize); + const row = Math.floor(p.y / cellSize); + if (col >= 0 && col < gridCols && row >= 0 && row < gridRows) { + grid[col * gridRows + row].push(i); + } + } + + const maxConnsPerParticle = 5; + const connCounts = new Uint8Array(this.particles.length); + const drawn = new Set(); + + 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]++; + } + } + } + } } } } diff --git a/src/components/fl-scramble-reveal/fl-scramble-reveal.component.ts b/src/components/fl-scramble-reveal/fl-scramble-reveal.component.ts index 815307e..f480da2 100644 --- a/src/components/fl-scramble-reveal/fl-scramble-reveal.component.ts +++ b/src/components/fl-scramble-reveal/fl-scramble-reveal.component.ts @@ -1,6 +1,6 @@ import { Component, ChangeDetectionStrategy, input, inject, - afterNextRender, DestroyRef, signal, + ElementRef, afterNextRender, DestroyRef, signal, } from '@angular/core'; import { ReducedMotionService } from '../../services/reduced-motion.service'; @@ -13,6 +13,7 @@ import { ReducedMotionService } from '../../services/reduced-motion.service'; host: { 'class': 'fl-scramble-reveal' }, }) export class FlScrambleRevealComponent { + private el = inject(ElementRef); private reducedMotion = inject(ReducedMotionService); private destroyRef = inject(DestroyRef); @@ -28,6 +29,7 @@ export class FlScrambleRevealComponent { private scrambleCount = 0; private maxScrambles = 3; private interval: any = null; + private observer: IntersectionObserver | null = null; constructor() { afterNextRender(() => { @@ -36,13 +38,24 @@ export class FlScrambleRevealComponent { 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(() => { if (this.interval) { clearInterval(this.interval); } + this.observer?.disconnect(); }); } diff --git a/src/components/fl-typewriter/fl-typewriter.component.ts b/src/components/fl-typewriter/fl-typewriter.component.ts index c5b5582..d92107f 100644 --- a/src/components/fl-typewriter/fl-typewriter.component.ts +++ b/src/components/fl-typewriter/fl-typewriter.component.ts @@ -1,6 +1,6 @@ import { Component, ChangeDetectionStrategy, input, inject, - afterNextRender, DestroyRef, signal, + ElementRef, afterNextRender, DestroyRef, signal, } from '@angular/core'; import { ReducedMotionService } from '../../services/reduced-motion.service'; @@ -18,6 +18,7 @@ import { ReducedMotionService } from '../../services/reduced-motion.service'; host: { 'class': 'fl-typewriter' }, }) export class FlTypewriterComponent { + private el = inject(ElementRef); private reducedMotion = inject(ReducedMotionService); private destroyRef = inject(DestroyRef); @@ -37,6 +38,7 @@ export class FlTypewriterComponent { private typingInterval: any = null; private cursorInterval: any = null; private isTyping = false; + private observer: IntersectionObserver | null = null; constructor() { afterNextRender(() => { @@ -45,12 +47,23 @@ export class FlTypewriterComponent { return; } - setTimeout(() => this.startTyping(), this.delay()); - this.startCursorBlink(); + this.observer = new IntersectionObserver( + (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.cleanup(); + this.observer?.disconnect(); }); } diff --git a/src/components/fl-voronoi-mesh/fl-voronoi-mesh.component.ts b/src/components/fl-voronoi-mesh/fl-voronoi-mesh.component.ts index 18181bc..38ad99d 100644 --- a/src/components/fl-voronoi-mesh/fl-voronoi-mesh.component.ts +++ b/src/components/fl-voronoi-mesh/fl-voronoi-mesh.component.ts @@ -52,6 +52,9 @@ export class FlVoronoiMeshComponent { private pixelRatio = 1; private loopCleanup: (() => void) | null = null; private resizeObserver: ResizeObserver | null = null; + private cellCache: Uint8Array | null = null; + private lastSeedPositions: { x: number; y: number }[] = []; + private cacheStep = 4; constructor() { afterNextRender(() => { @@ -87,7 +90,7 @@ export class FlVoronoiMeshComponent { this.createSeeds(); 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(); } @@ -106,6 +109,7 @@ export class FlVoronoiMeshComponent { } private createSeeds(): void { + this.cellCache = null; const count = this.seedCount(); const cols = this.colors(); 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 { if (!this.ctx) return; @@ -145,55 +160,65 @@ export class FlVoronoiMeshComponent { const ctx = this.ctx; const w = this.width; const h = this.height; - - // 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)); + const step = Math.max(4, Math.floor(6 / this.pixelRatio)); ctx.clearRect(0, 0, w, h); - for (let x = 0; x < w; x += step) { - for (let y = 0; y < h; y += step) { - let minDist = Infinity; - let nearestColor = this.seeds[0]?.color ?? '#000'; + const cols = Math.ceil(w / step); + const rows = Math.ceil(h / step); + const totalCells = cols * rows; - for (const s of this.seeds) { - const d = (x - s.x) * (x - s.x) + (y - s.y) * (y - s.y); - if (d < minDist) { - minDist = d; - nearestColor = s.color; + // Only recalculate cell assignments if seeds moved significantly + if (this.needsCacheInvalidation()) { + this.cellCache = new Uint8Array(totalCells); + this.cacheStep = step; + + 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); } } - // Draw edges using second-nearest distance comparison + // Draw edges only when showEdges is true if (this.showEdges()) { - ctx.strokeStyle = this.edgeColor(); - ctx.lineWidth = this.edgeWidth(); - - for (let x = 0; x < w; x += step) { - for (let y = 0; y < h; y += step) { - let min1 = Infinity; - let min2 = Infinity; - - 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); + const edgeColor = this.edgeColor(); + for (let col = 1; col < cols; col++) { + for (let row = 1; row < rows; row++) { + const idx = cache[col * rows + row]; + // Check if neighboring cells have different seed assignments + if (idx !== cache[(col - 1) * rows + row] || idx !== cache[col * rows + (row - 1)]) { + ctx.fillStyle = edgeColor; + ctx.fillRect(col * step, row * step, step, step); } } } diff --git a/src/services/animation-loop.service.ts b/src/services/animation-loop.service.ts index 917adf7..a2f293d 100644 --- a/src/services/animation-loop.service.ts +++ b/src/services/animation-loop.service.ts @@ -16,6 +16,10 @@ export class AnimationLoopService { private startTime = 0; private running = false; + /** Track which registered IDs are currently visible */ + private visibilityMap = new Map(); + private observers = new Map(); + constructor() { this.destroyRef.onDestroy(() => { 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 { if (this.running) return; if (this.reducedMotion.reduced()) return; diff --git a/src/styles/_mixins.scss b/src/styles/_mixins.scss index f071865..4edbb93 100644 --- a/src/styles/_mixins.scss +++ b/src/styles/_mixins.scss @@ -114,5 +114,5 @@ @keyframes fl-float { 0%, 100% { transform: translateY(0); } - 50% { transform: translateY(-#{$distance}); } + 50% { transform: translateY(-10px); } }