From daf6182e94f5771fa7d36b585a586497f964ead3 Mon Sep 17 00:00:00 2001 From: Giuliano Silvestro Date: Mon, 9 Mar 2026 10:24:52 +1000 Subject: [PATCH] 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 --- .gitignore | 38 +++ build-for-dev.sh | 9 + ng-package.json | 12 + package.json | 39 +++ .../fl-ambient-light.component.scss | 31 ++ .../fl-ambient-light.component.ts | 99 ++++++ src/components/fl-ambient-light/index.ts | 1 + .../fl-animated-border.component.scss | 46 +++ .../fl-animated-border.component.ts | 56 ++++ src/components/fl-animated-border/index.ts | 1 + .../fl-ascii-art/fl-ascii-art.component.scss | 15 + .../fl-ascii-art/fl-ascii-art.component.ts | 66 ++++ src/components/fl-ascii-art/index.ts | 1 + .../fl-aurora/fl-aurora.component.scss | 20 ++ .../fl-aurora/fl-aurora.component.ts | 157 ++++++++++ src/components/fl-aurora/index.ts | 1 + src/components/fl-blob/fl-blob.component.scss | 9 + src/components/fl-blob/fl-blob.component.ts | 95 ++++++ src/components/fl-blob/index.ts | 1 + .../fl-blueprint-grid.component.scss | 12 + .../fl-blueprint-grid.component.ts | 104 +++++++ src/components/fl-blueprint-grid/index.ts | 1 + .../fl-breathing-dot.component.scss | 28 ++ .../fl-breathing-dot.component.ts | 37 +++ src/components/fl-breathing-dot/index.ts | 1 + .../fl-card-stack.component.scss | 30 ++ .../fl-card-stack/fl-card-stack.component.ts | 62 ++++ src/components/fl-card-stack/index.ts | 1 + .../fl-checkmark-flourish.component.scss | 36 +++ .../fl-checkmark-flourish.component.ts | 181 +++++++++++ src/components/fl-checkmark-flourish/index.ts | 1 + .../fl-circuit-board.component.scss | 12 + .../fl-circuit-board.component.ts | 157 ++++++++++ src/components/fl-circuit-board/index.ts | 1 + .../fl-color-splash.component.scss | 24 ++ .../fl-color-splash.component.ts | 29 ++ src/components/fl-color-splash/index.ts | 1 + .../fl-confetti/fl-confetti.component.scss | 14 + .../fl-confetti/fl-confetti.component.ts | 153 +++++++++ src/components/fl-confetti/index.ts | 1 + .../fl-counter-rollup.component.scss | 4 + .../fl-counter-rollup.component.ts | 99 ++++++ src/components/fl-counter-rollup/index.ts | 1 + .../fl-counter-trigger.component.scss | 4 + .../fl-counter-trigger.component.ts | 101 ++++++ src/components/fl-counter-trigger/index.ts | 1 + .../fl-dot-grid/fl-dot-grid.component.scss | 11 + .../fl-dot-grid/fl-dot-grid.component.ts | 85 +++++ src/components/fl-dot-grid/index.ts | 1 + .../fl-duotone-filter.component.scss | 16 + .../fl-duotone-filter.component.ts | 72 +++++ src/components/fl-duotone-filter/index.ts | 1 + .../fl-emoji-rain.component.scss | 15 + .../fl-emoji-rain/fl-emoji-rain.component.ts | 139 +++++++++ src/components/fl-emoji-rain/index.ts | 1 + .../fl-fireworks/fl-fireworks.component.scss | 14 + .../fl-fireworks/fl-fireworks.component.ts | 193 ++++++++++++ src/components/fl-fireworks/index.ts | 1 + .../fl-floating-layers.component.scss | 17 + .../fl-floating-layers.component.ts | 79 +++++ src/components/fl-floating-layers/index.ts | 1 + .../fl-flow-field.component.scss | 14 + .../fl-flow-field/fl-flow-field.component.ts | 196 ++++++++++++ src/components/fl-flow-field/index.ts | 1 + .../fl-fluid-container.component.scss | 19 ++ .../fl-fluid-container.component.ts | 125 ++++++++ src/components/fl-fluid-container/index.ts | 1 + .../fl-fractal-landscape.component.scss | 14 + .../fl-fractal-landscape.component.ts | 224 +++++++++++++ src/components/fl-fractal-landscape/index.ts | 1 + .../fl-frequency-rings.component.scss | 33 ++ .../fl-frequency-rings.component.ts | 60 ++++ src/components/fl-frequency-rings/index.ts | 1 + .../fl-generative-pattern.component.scss | 11 + .../fl-generative-pattern.component.ts | 211 +++++++++++++ src/components/fl-generative-pattern/index.ts | 1 + .../fl-glitch-image.component.scss | 101 ++++++ .../fl-glitch-image.component.ts | 33 ++ src/components/fl-glitch-image/index.ts | 1 + .../fl-glitch-text.component.scss | 75 +++++ .../fl-glitch-text.component.ts | 53 ++++ src/components/fl-glitch-text/index.ts | 1 + .../fl-glow-badge.component.scss | 35 +++ .../fl-glow-badge/fl-glow-badge.component.ts | 42 +++ src/components/fl-glow-badge/index.ts | 1 + .../fl-glow-divider.component.scss | 31 ++ .../fl-glow-divider.component.ts | 57 ++++ src/components/fl-glow-divider/index.ts | 1 + .../fl-gradient-divider.component.scss | 22 ++ .../fl-gradient-divider.component.ts | 50 +++ src/components/fl-gradient-divider/index.ts | 1 + .../fl-gradient-mesh.component.scss | 19 ++ .../fl-gradient-mesh.component.ts | 121 +++++++ src/components/fl-gradient-mesh/index.ts | 1 + .../fl-gradient-text.component.scss | 8 + .../fl-gradient-text.component.ts | 60 ++++ src/components/fl-gradient-text/index.ts | 1 + .../fl-grid-matrix.component.scss | 44 +++ .../fl-grid-matrix.component.ts | 63 ++++ src/components/fl-grid-matrix/index.ts | 1 + .../fl-heartbeat-line.component.scss | 34 ++ .../fl-heartbeat-line.component.ts | 65 ++++ src/components/fl-heartbeat-line/index.ts | 1 + .../fl-holographic-card.component.scss | 28 ++ .../fl-holographic-card.component.ts | 89 ++++++ src/components/fl-holographic-card/index.ts | 1 + .../fl-image-reveal.component.scss | 74 +++++ .../fl-image-reveal.component.ts | 81 +++++ src/components/fl-image-reveal/index.ts | 1 + .../fl-ken-burns/fl-ken-burns.component.scss | 71 +++++ .../fl-ken-burns/fl-ken-burns.component.ts | 32 ++ src/components/fl-ken-burns/index.ts | 1 + .../fl-liquid-button.component.scss | 74 +++++ .../fl-liquid-button.component.ts | 56 ++++ src/components/fl-liquid-button/index.ts | 1 + .../fl-live-indicator.component.scss | 45 +++ .../fl-live-indicator.component.ts | 38 +++ src/components/fl-live-indicator/index.ts | 1 + .../fl-matrix-rain.component.scss | 14 + .../fl-matrix-rain.component.ts | 194 ++++++++++++ src/components/fl-matrix-rain/index.ts | 1 + .../fl-menu-reveal.component.scss | 79 +++++ .../fl-menu-reveal.component.ts | 55 ++++ src/components/fl-menu-reveal/index.ts | 1 + .../fl-mesh-gradient.component.scss | 18 ++ .../fl-mesh-gradient.component.ts | 78 +++++ src/components/fl-mesh-gradient/index.ts | 1 + .../fl-morph-shape.component.scss | 14 + .../fl-morph-shape.component.ts | 99 ++++++ src/components/fl-morph-shape/index.ts | 1 + .../fl-noise-texture.component.scss | 20 ++ .../fl-noise-texture.component.ts | 70 +++++ src/components/fl-noise-texture/index.ts | 1 + .../fl-parallax-layer.component.scss | 9 + .../fl-parallax-layer.component.ts | 70 +++++ src/components/fl-parallax-layer/index.ts | 1 + .../fl-particle-field.component.scss | 21 ++ .../fl-particle-field.component.ts | 246 +++++++++++++++ src/components/fl-particle-field/index.ts | 1 + .../fl-scramble-reveal.component.scss | 5 + .../fl-scramble-reveal.component.ts | 83 +++++ src/components/fl-scramble-reveal/index.ts | 1 + .../fl-scroll-progress.component.scss | 20 ++ .../fl-scroll-progress.component.ts | 80 +++++ src/components/fl-scroll-progress/index.ts | 1 + .../fl-signal-strength.component.scss | 40 +++ .../fl-signal-strength.component.ts | 56 ++++ src/components/fl-signal-strength/index.ts | 1 + .../fl-sparkle/fl-sparkle.component.scss | 18 ++ .../fl-sparkle/fl-sparkle.component.ts | 141 +++++++++ src/components/fl-sparkle/index.ts | 1 + .../fl-spotlight-card.component.scss | 27 ++ .../fl-spotlight-card.component.ts | 85 +++++ src/components/fl-spotlight-card/index.ts | 1 + .../fl-terminal-output.component.scss | 79 +++++ .../fl-terminal-output.component.ts | 86 +++++ src/components/fl-terminal-output/index.ts | 1 + .../fl-text-shimmer.component.scss | 28 ++ .../fl-text-shimmer.component.ts | 46 +++ src/components/fl-text-shimmer/index.ts | 1 + .../fl-tilt-card/fl-tilt-card.component.scss | 25 ++ .../fl-tilt-card/fl-tilt-card.component.ts | 115 +++++++ src/components/fl-tilt-card/index.ts | 1 + .../fl-topographic-lines.component.scss | 11 + .../fl-topographic-lines.component.ts | 103 ++++++ src/components/fl-topographic-lines/index.ts | 1 + .../fl-transition-overlay.component.scss | 27 ++ .../fl-transition-overlay.component.ts | 55 ++++ src/components/fl-transition-overlay/index.ts | 1 + .../fl-typewriter.component.scss | 22 ++ .../fl-typewriter/fl-typewriter.component.ts | 97 ++++++ src/components/fl-typewriter/index.ts | 1 + .../fl-underline-morph.component.scss | 27 ++ .../fl-underline-morph.component.ts | 69 ++++ src/components/fl-underline-morph/index.ts | 1 + .../fl-voronoi-mesh.component.scss | 13 + .../fl-voronoi-mesh.component.ts | 224 +++++++++++++ src/components/fl-voronoi-mesh/index.ts | 1 + .../fl-wave-divider.component.scss | 29 ++ .../fl-wave-divider.component.ts | 74 +++++ src/components/fl-wave-divider/index.ts | 1 + .../fl-wave-layers.component.scss | 31 ++ .../fl-wave-layers.component.ts | 76 +++++ src/components/fl-wave-layers/index.ts | 1 + .../fl-waveform-visualizer.component.scss | 13 + .../fl-waveform-visualizer.component.ts | 181 +++++++++++ .../fl-waveform-visualizer/index.ts | 1 + src/components/index.ts | 92 ++++++ src/directives/fl-cursor-glow.directive.ts | 77 +++++ src/directives/fl-hover-lift.directive.ts | 50 +++ src/directives/fl-image-tilt.directive.ts | 55 ++++ src/directives/fl-inner-light.directive.ts | 45 +++ src/directives/fl-light-source.directive.ts | 55 ++++ src/directives/fl-long-shadow.directive.ts | 56 ++++ src/directives/fl-magnetic.directive.ts | 69 ++++ src/directives/fl-neon-glow.directive.ts | 87 ++++++ .../fl-page-transition.directive.ts | 37 +++ src/directives/fl-press-squish.directive.ts | 44 +++ .../fl-reveal-on-scroll.directive.ts | 93 ++++++ src/directives/fl-ripple.directive.ts | 78 +++++ src/directives/fl-shake-error.directive.ts | 53 ++++ src/directives/fl-text-gradient.directive.ts | 33 ++ src/directives/fl-wobble.directive.ts | 50 +++ src/directives/index.ts | 22 ++ src/index.ts | 50 +++ src/providers/flair-config.provider.ts | 24 ++ src/providers/index.ts | 5 + src/services/animation-loop.service.ts | 87 ++++++ src/services/audio-analyzer.service.ts | 119 +++++++ src/services/canvas-pool.service.ts | 46 +++ src/services/celebration.service.ts | 294 ++++++++++++++++++ src/services/cursor-tracker.service.ts | 94 ++++++ src/services/index.ts | 12 + src/services/reduced-motion.service.ts | 47 +++ src/services/scroll-observer.service.ts | 81 +++++ src/services/transition.service.ts | 142 +++++++++ src/styles/_index.scss | 11 + src/styles/_mixins.scss | 118 +++++++ src/styles/_tokens.scss | 127 ++++++++ src/types/animation.types.ts | 53 ++++ src/types/config.types.ts | 40 +++ src/types/effect.types.ts | 93 ++++++ src/types/index.ts | 8 + src/types/particle.types.ts | 48 +++ src/utils/canvas.utils.ts | 46 +++ src/utils/index.ts | 7 + src/utils/math.utils.ts | 103 ++++++ src/utils/svg.utils.ts | 86 +++++ tsconfig.json | 28 ++ tsconfig.lib.json | 15 + 230 files changed, 10642 insertions(+) create mode 100644 .gitignore create mode 100755 build-for-dev.sh create mode 100644 ng-package.json create mode 100644 package.json create mode 100644 src/components/fl-ambient-light/fl-ambient-light.component.scss create mode 100644 src/components/fl-ambient-light/fl-ambient-light.component.ts create mode 100644 src/components/fl-ambient-light/index.ts create mode 100644 src/components/fl-animated-border/fl-animated-border.component.scss create mode 100644 src/components/fl-animated-border/fl-animated-border.component.ts create mode 100644 src/components/fl-animated-border/index.ts create mode 100644 src/components/fl-ascii-art/fl-ascii-art.component.scss create mode 100644 src/components/fl-ascii-art/fl-ascii-art.component.ts create mode 100644 src/components/fl-ascii-art/index.ts create mode 100644 src/components/fl-aurora/fl-aurora.component.scss create mode 100644 src/components/fl-aurora/fl-aurora.component.ts create mode 100644 src/components/fl-aurora/index.ts create mode 100644 src/components/fl-blob/fl-blob.component.scss create mode 100644 src/components/fl-blob/fl-blob.component.ts create mode 100644 src/components/fl-blob/index.ts create mode 100644 src/components/fl-blueprint-grid/fl-blueprint-grid.component.scss create mode 100644 src/components/fl-blueprint-grid/fl-blueprint-grid.component.ts create mode 100644 src/components/fl-blueprint-grid/index.ts create mode 100644 src/components/fl-breathing-dot/fl-breathing-dot.component.scss create mode 100644 src/components/fl-breathing-dot/fl-breathing-dot.component.ts create mode 100644 src/components/fl-breathing-dot/index.ts create mode 100644 src/components/fl-card-stack/fl-card-stack.component.scss create mode 100644 src/components/fl-card-stack/fl-card-stack.component.ts create mode 100644 src/components/fl-card-stack/index.ts create mode 100644 src/components/fl-checkmark-flourish/fl-checkmark-flourish.component.scss create mode 100644 src/components/fl-checkmark-flourish/fl-checkmark-flourish.component.ts create mode 100644 src/components/fl-checkmark-flourish/index.ts create mode 100644 src/components/fl-circuit-board/fl-circuit-board.component.scss create mode 100644 src/components/fl-circuit-board/fl-circuit-board.component.ts create mode 100644 src/components/fl-circuit-board/index.ts create mode 100644 src/components/fl-color-splash/fl-color-splash.component.scss create mode 100644 src/components/fl-color-splash/fl-color-splash.component.ts create mode 100644 src/components/fl-color-splash/index.ts create mode 100644 src/components/fl-confetti/fl-confetti.component.scss create mode 100644 src/components/fl-confetti/fl-confetti.component.ts create mode 100644 src/components/fl-confetti/index.ts create mode 100644 src/components/fl-counter-rollup/fl-counter-rollup.component.scss create mode 100644 src/components/fl-counter-rollup/fl-counter-rollup.component.ts create mode 100644 src/components/fl-counter-rollup/index.ts create mode 100644 src/components/fl-counter-trigger/fl-counter-trigger.component.scss create mode 100644 src/components/fl-counter-trigger/fl-counter-trigger.component.ts create mode 100644 src/components/fl-counter-trigger/index.ts create mode 100644 src/components/fl-dot-grid/fl-dot-grid.component.scss create mode 100644 src/components/fl-dot-grid/fl-dot-grid.component.ts create mode 100644 src/components/fl-dot-grid/index.ts create mode 100644 src/components/fl-duotone-filter/fl-duotone-filter.component.scss create mode 100644 src/components/fl-duotone-filter/fl-duotone-filter.component.ts create mode 100644 src/components/fl-duotone-filter/index.ts create mode 100644 src/components/fl-emoji-rain/fl-emoji-rain.component.scss create mode 100644 src/components/fl-emoji-rain/fl-emoji-rain.component.ts create mode 100644 src/components/fl-emoji-rain/index.ts create mode 100644 src/components/fl-fireworks/fl-fireworks.component.scss create mode 100644 src/components/fl-fireworks/fl-fireworks.component.ts create mode 100644 src/components/fl-fireworks/index.ts create mode 100644 src/components/fl-floating-layers/fl-floating-layers.component.scss create mode 100644 src/components/fl-floating-layers/fl-floating-layers.component.ts create mode 100644 src/components/fl-floating-layers/index.ts create mode 100644 src/components/fl-flow-field/fl-flow-field.component.scss create mode 100644 src/components/fl-flow-field/fl-flow-field.component.ts create mode 100644 src/components/fl-flow-field/index.ts create mode 100644 src/components/fl-fluid-container/fl-fluid-container.component.scss create mode 100644 src/components/fl-fluid-container/fl-fluid-container.component.ts create mode 100644 src/components/fl-fluid-container/index.ts create mode 100644 src/components/fl-fractal-landscape/fl-fractal-landscape.component.scss create mode 100644 src/components/fl-fractal-landscape/fl-fractal-landscape.component.ts create mode 100644 src/components/fl-fractal-landscape/index.ts create mode 100644 src/components/fl-frequency-rings/fl-frequency-rings.component.scss create mode 100644 src/components/fl-frequency-rings/fl-frequency-rings.component.ts create mode 100644 src/components/fl-frequency-rings/index.ts create mode 100644 src/components/fl-generative-pattern/fl-generative-pattern.component.scss create mode 100644 src/components/fl-generative-pattern/fl-generative-pattern.component.ts create mode 100644 src/components/fl-generative-pattern/index.ts create mode 100644 src/components/fl-glitch-image/fl-glitch-image.component.scss create mode 100644 src/components/fl-glitch-image/fl-glitch-image.component.ts create mode 100644 src/components/fl-glitch-image/index.ts create mode 100644 src/components/fl-glitch-text/fl-glitch-text.component.scss create mode 100644 src/components/fl-glitch-text/fl-glitch-text.component.ts create mode 100644 src/components/fl-glitch-text/index.ts create mode 100644 src/components/fl-glow-badge/fl-glow-badge.component.scss create mode 100644 src/components/fl-glow-badge/fl-glow-badge.component.ts create mode 100644 src/components/fl-glow-badge/index.ts create mode 100644 src/components/fl-glow-divider/fl-glow-divider.component.scss create mode 100644 src/components/fl-glow-divider/fl-glow-divider.component.ts create mode 100644 src/components/fl-glow-divider/index.ts create mode 100644 src/components/fl-gradient-divider/fl-gradient-divider.component.scss create mode 100644 src/components/fl-gradient-divider/fl-gradient-divider.component.ts create mode 100644 src/components/fl-gradient-divider/index.ts create mode 100644 src/components/fl-gradient-mesh/fl-gradient-mesh.component.scss create mode 100644 src/components/fl-gradient-mesh/fl-gradient-mesh.component.ts create mode 100644 src/components/fl-gradient-mesh/index.ts create mode 100644 src/components/fl-gradient-text/fl-gradient-text.component.scss create mode 100644 src/components/fl-gradient-text/fl-gradient-text.component.ts create mode 100644 src/components/fl-gradient-text/index.ts create mode 100644 src/components/fl-grid-matrix/fl-grid-matrix.component.scss create mode 100644 src/components/fl-grid-matrix/fl-grid-matrix.component.ts create mode 100644 src/components/fl-grid-matrix/index.ts create mode 100644 src/components/fl-heartbeat-line/fl-heartbeat-line.component.scss create mode 100644 src/components/fl-heartbeat-line/fl-heartbeat-line.component.ts create mode 100644 src/components/fl-heartbeat-line/index.ts create mode 100644 src/components/fl-holographic-card/fl-holographic-card.component.scss create mode 100644 src/components/fl-holographic-card/fl-holographic-card.component.ts create mode 100644 src/components/fl-holographic-card/index.ts create mode 100644 src/components/fl-image-reveal/fl-image-reveal.component.scss create mode 100644 src/components/fl-image-reveal/fl-image-reveal.component.ts create mode 100644 src/components/fl-image-reveal/index.ts create mode 100644 src/components/fl-ken-burns/fl-ken-burns.component.scss create mode 100644 src/components/fl-ken-burns/fl-ken-burns.component.ts create mode 100644 src/components/fl-ken-burns/index.ts create mode 100644 src/components/fl-liquid-button/fl-liquid-button.component.scss create mode 100644 src/components/fl-liquid-button/fl-liquid-button.component.ts create mode 100644 src/components/fl-liquid-button/index.ts create mode 100644 src/components/fl-live-indicator/fl-live-indicator.component.scss create mode 100644 src/components/fl-live-indicator/fl-live-indicator.component.ts create mode 100644 src/components/fl-live-indicator/index.ts create mode 100644 src/components/fl-matrix-rain/fl-matrix-rain.component.scss create mode 100644 src/components/fl-matrix-rain/fl-matrix-rain.component.ts create mode 100644 src/components/fl-matrix-rain/index.ts create mode 100644 src/components/fl-menu-reveal/fl-menu-reveal.component.scss create mode 100644 src/components/fl-menu-reveal/fl-menu-reveal.component.ts create mode 100644 src/components/fl-menu-reveal/index.ts create mode 100644 src/components/fl-mesh-gradient/fl-mesh-gradient.component.scss create mode 100644 src/components/fl-mesh-gradient/fl-mesh-gradient.component.ts create mode 100644 src/components/fl-mesh-gradient/index.ts create mode 100644 src/components/fl-morph-shape/fl-morph-shape.component.scss create mode 100644 src/components/fl-morph-shape/fl-morph-shape.component.ts create mode 100644 src/components/fl-morph-shape/index.ts create mode 100644 src/components/fl-noise-texture/fl-noise-texture.component.scss create mode 100644 src/components/fl-noise-texture/fl-noise-texture.component.ts create mode 100644 src/components/fl-noise-texture/index.ts create mode 100644 src/components/fl-parallax-layer/fl-parallax-layer.component.scss create mode 100644 src/components/fl-parallax-layer/fl-parallax-layer.component.ts create mode 100644 src/components/fl-parallax-layer/index.ts create mode 100644 src/components/fl-particle-field/fl-particle-field.component.scss create mode 100644 src/components/fl-particle-field/fl-particle-field.component.ts create mode 100644 src/components/fl-particle-field/index.ts create mode 100644 src/components/fl-scramble-reveal/fl-scramble-reveal.component.scss create mode 100644 src/components/fl-scramble-reveal/fl-scramble-reveal.component.ts create mode 100644 src/components/fl-scramble-reveal/index.ts create mode 100644 src/components/fl-scroll-progress/fl-scroll-progress.component.scss create mode 100644 src/components/fl-scroll-progress/fl-scroll-progress.component.ts create mode 100644 src/components/fl-scroll-progress/index.ts create mode 100644 src/components/fl-signal-strength/fl-signal-strength.component.scss create mode 100644 src/components/fl-signal-strength/fl-signal-strength.component.ts create mode 100644 src/components/fl-signal-strength/index.ts create mode 100644 src/components/fl-sparkle/fl-sparkle.component.scss create mode 100644 src/components/fl-sparkle/fl-sparkle.component.ts create mode 100644 src/components/fl-sparkle/index.ts create mode 100644 src/components/fl-spotlight-card/fl-spotlight-card.component.scss create mode 100644 src/components/fl-spotlight-card/fl-spotlight-card.component.ts create mode 100644 src/components/fl-spotlight-card/index.ts create mode 100644 src/components/fl-terminal-output/fl-terminal-output.component.scss create mode 100644 src/components/fl-terminal-output/fl-terminal-output.component.ts create mode 100644 src/components/fl-terminal-output/index.ts create mode 100644 src/components/fl-text-shimmer/fl-text-shimmer.component.scss create mode 100644 src/components/fl-text-shimmer/fl-text-shimmer.component.ts create mode 100644 src/components/fl-text-shimmer/index.ts create mode 100644 src/components/fl-tilt-card/fl-tilt-card.component.scss create mode 100644 src/components/fl-tilt-card/fl-tilt-card.component.ts create mode 100644 src/components/fl-tilt-card/index.ts create mode 100644 src/components/fl-topographic-lines/fl-topographic-lines.component.scss create mode 100644 src/components/fl-topographic-lines/fl-topographic-lines.component.ts create mode 100644 src/components/fl-topographic-lines/index.ts create mode 100644 src/components/fl-transition-overlay/fl-transition-overlay.component.scss create mode 100644 src/components/fl-transition-overlay/fl-transition-overlay.component.ts create mode 100644 src/components/fl-transition-overlay/index.ts create mode 100644 src/components/fl-typewriter/fl-typewriter.component.scss create mode 100644 src/components/fl-typewriter/fl-typewriter.component.ts create mode 100644 src/components/fl-typewriter/index.ts create mode 100644 src/components/fl-underline-morph/fl-underline-morph.component.scss create mode 100644 src/components/fl-underline-morph/fl-underline-morph.component.ts create mode 100644 src/components/fl-underline-morph/index.ts create mode 100644 src/components/fl-voronoi-mesh/fl-voronoi-mesh.component.scss create mode 100644 src/components/fl-voronoi-mesh/fl-voronoi-mesh.component.ts create mode 100644 src/components/fl-voronoi-mesh/index.ts create mode 100644 src/components/fl-wave-divider/fl-wave-divider.component.scss create mode 100644 src/components/fl-wave-divider/fl-wave-divider.component.ts create mode 100644 src/components/fl-wave-divider/index.ts create mode 100644 src/components/fl-wave-layers/fl-wave-layers.component.scss create mode 100644 src/components/fl-wave-layers/fl-wave-layers.component.ts create mode 100644 src/components/fl-wave-layers/index.ts create mode 100644 src/components/fl-waveform-visualizer/fl-waveform-visualizer.component.scss create mode 100644 src/components/fl-waveform-visualizer/fl-waveform-visualizer.component.ts create mode 100644 src/components/fl-waveform-visualizer/index.ts create mode 100644 src/components/index.ts create mode 100644 src/directives/fl-cursor-glow.directive.ts create mode 100644 src/directives/fl-hover-lift.directive.ts create mode 100644 src/directives/fl-image-tilt.directive.ts create mode 100644 src/directives/fl-inner-light.directive.ts create mode 100644 src/directives/fl-light-source.directive.ts create mode 100644 src/directives/fl-long-shadow.directive.ts create mode 100644 src/directives/fl-magnetic.directive.ts create mode 100644 src/directives/fl-neon-glow.directive.ts create mode 100644 src/directives/fl-page-transition.directive.ts create mode 100644 src/directives/fl-press-squish.directive.ts create mode 100644 src/directives/fl-reveal-on-scroll.directive.ts create mode 100644 src/directives/fl-ripple.directive.ts create mode 100644 src/directives/fl-shake-error.directive.ts create mode 100644 src/directives/fl-text-gradient.directive.ts create mode 100644 src/directives/fl-wobble.directive.ts create mode 100644 src/directives/index.ts create mode 100644 src/index.ts create mode 100644 src/providers/flair-config.provider.ts create mode 100644 src/providers/index.ts create mode 100644 src/services/animation-loop.service.ts create mode 100644 src/services/audio-analyzer.service.ts create mode 100644 src/services/canvas-pool.service.ts create mode 100644 src/services/celebration.service.ts create mode 100644 src/services/cursor-tracker.service.ts create mode 100644 src/services/index.ts create mode 100644 src/services/reduced-motion.service.ts create mode 100644 src/services/scroll-observer.service.ts create mode 100644 src/services/transition.service.ts create mode 100644 src/styles/_index.scss create mode 100644 src/styles/_mixins.scss create mode 100644 src/styles/_tokens.scss create mode 100644 src/types/animation.types.ts create mode 100644 src/types/config.types.ts create mode 100644 src/types/effect.types.ts create mode 100644 src/types/index.ts create mode 100644 src/types/particle.types.ts create mode 100644 src/utils/canvas.utils.ts create mode 100644 src/utils/index.ts create mode 100644 src/utils/math.utils.ts create mode 100644 src/utils/svg.utils.ts create mode 100644 tsconfig.json create mode 100644 tsconfig.lib.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3b5dbdc --- /dev/null +++ b/.gitignore @@ -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 diff --git a/build-for-dev.sh b/build-for-dev.sh new file mode 100755 index 0000000..905d72a --- /dev/null +++ b/build-for-dev.sh @@ -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/" diff --git a/ng-package.json b/ng-package.json new file mode 100644 index 0000000..1775ca2 --- /dev/null +++ b/ng-package.json @@ -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" + ] +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..a10f552 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/components/fl-ambient-light/fl-ambient-light.component.scss b/src/components/fl-ambient-light/fl-ambient-light.component.scss new file mode 100644 index 0000000..c789411 --- /dev/null +++ b/src/components/fl-ambient-light/fl-ambient-light.component.scss @@ -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); +} diff --git a/src/components/fl-ambient-light/fl-ambient-light.component.ts b/src/components/fl-ambient-light/fl-ambient-light.component.ts new file mode 100644 index 0000000..111ad73 --- /dev/null +++ b/src/components/fl-ambient-light/fl-ambient-light.component.ts @@ -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: ` +
+
+
+ `, + 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(['#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}%)`); + } +} diff --git a/src/components/fl-ambient-light/index.ts b/src/components/fl-ambient-light/index.ts new file mode 100644 index 0000000..80d0eb7 --- /dev/null +++ b/src/components/fl-ambient-light/index.ts @@ -0,0 +1 @@ +export * from './fl-ambient-light.component'; diff --git a/src/components/fl-animated-border/fl-animated-border.component.scss b/src/components/fl-animated-border/fl-animated-border.component.scss new file mode 100644 index 0000000..4a1d5a4 --- /dev/null +++ b/src/components/fl-animated-border/fl-animated-border.component.scss @@ -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); + } +} diff --git a/src/components/fl-animated-border/fl-animated-border.component.ts b/src/components/fl-animated-border/fl-animated-border.component.ts new file mode 100644 index 0000000..50a8f22 --- /dev/null +++ b/src/components/fl-animated-border/fl-animated-border.component.ts @@ -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: ` +
+ +
+ `, + 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(['#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'); + } + } +} diff --git a/src/components/fl-animated-border/index.ts b/src/components/fl-animated-border/index.ts new file mode 100644 index 0000000..bb58118 --- /dev/null +++ b/src/components/fl-animated-border/index.ts @@ -0,0 +1 @@ +export * from './fl-animated-border.component'; diff --git a/src/components/fl-ascii-art/fl-ascii-art.component.scss b/src/components/fl-ascii-art/fl-ascii-art.component.scss new file mode 100644 index 0000000..15f45c5 --- /dev/null +++ b/src/components/fl-ascii-art/fl-ascii-art.component.scss @@ -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; +} diff --git a/src/components/fl-ascii-art/fl-ascii-art.component.ts b/src/components/fl-ascii-art/fl-ascii-art.component.ts new file mode 100644 index 0000000..10ad3eb --- /dev/null +++ b/src/components/fl-ascii-art/fl-ascii-art.component.ts @@ -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: ` +
{{ displayText() }}
+ `, + 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; + } +} diff --git a/src/components/fl-ascii-art/index.ts b/src/components/fl-ascii-art/index.ts new file mode 100644 index 0000000..044bc98 --- /dev/null +++ b/src/components/fl-ascii-art/index.ts @@ -0,0 +1 @@ +export * from './fl-ascii-art.component'; diff --git a/src/components/fl-aurora/fl-aurora.component.scss b/src/components/fl-aurora/fl-aurora.component.scss new file mode 100644 index 0000000..e1cda02 --- /dev/null +++ b/src/components/fl-aurora/fl-aurora.component.scss @@ -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; + } +} diff --git a/src/components/fl-aurora/fl-aurora.component.ts b/src/components/fl-aurora/fl-aurora.component.ts new file mode 100644 index 0000000..b99ea2e --- /dev/null +++ b/src/components/fl-aurora/fl-aurora.component.ts @@ -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: ` + + + + + + + + + `, + 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>('svg'); + + // Inputs + readonly colors = input(['#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(); + + 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); + } + } +} diff --git a/src/components/fl-aurora/index.ts b/src/components/fl-aurora/index.ts new file mode 100644 index 0000000..7a19390 --- /dev/null +++ b/src/components/fl-aurora/index.ts @@ -0,0 +1 @@ +export * from './fl-aurora.component'; diff --git a/src/components/fl-blob/fl-blob.component.scss b/src/components/fl-blob/fl-blob.component.scss new file mode 100644 index 0000000..c187cf5 --- /dev/null +++ b/src/components/fl-blob/fl-blob.component.scss @@ -0,0 +1,9 @@ +:host { + display: inline-block; +} + +.fl-blob__svg { + width: 100%; + height: 100%; + display: block; +} diff --git a/src/components/fl-blob/fl-blob.component.ts b/src/components/fl-blob/fl-blob.component.ts new file mode 100644 index 0000000..edc49f6 --- /dev/null +++ b/src/components/fl-blob/fl-blob.component.ts @@ -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: ` + + + + @for (color of colors(); track $index) { + + } + + + + + `, + 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(['#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); + } +} diff --git a/src/components/fl-blob/index.ts b/src/components/fl-blob/index.ts new file mode 100644 index 0000000..079cc30 --- /dev/null +++ b/src/components/fl-blob/index.ts @@ -0,0 +1 @@ +export * from './fl-blob.component'; diff --git a/src/components/fl-blueprint-grid/fl-blueprint-grid.component.scss b/src/components/fl-blueprint-grid/fl-blueprint-grid.component.scss new file mode 100644 index 0000000..b378ab3 --- /dev/null +++ b/src/components/fl-blueprint-grid/fl-blueprint-grid.component.scss @@ -0,0 +1,12 @@ +:host { + display: block; + width: 100%; + height: 100%; + background: #0a1628; +} + +.fl-blueprint-grid__svg { + display: block; + width: 100%; + height: 100%; +} diff --git a/src/components/fl-blueprint-grid/fl-blueprint-grid.component.ts b/src/components/fl-blueprint-grid/fl-blueprint-grid.component.ts new file mode 100644 index 0000000..780d4d0 --- /dev/null +++ b/src/components/fl-blueprint-grid/fl-blueprint-grid.component.ts @@ -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: ` + + + + + + + + + + + + + + + + + + `, + 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})`; + } +} diff --git a/src/components/fl-blueprint-grid/index.ts b/src/components/fl-blueprint-grid/index.ts new file mode 100644 index 0000000..99c7933 --- /dev/null +++ b/src/components/fl-blueprint-grid/index.ts @@ -0,0 +1 @@ +export * from './fl-blueprint-grid.component'; diff --git a/src/components/fl-breathing-dot/fl-breathing-dot.component.scss b/src/components/fl-breathing-dot/fl-breathing-dot.component.scss new file mode 100644 index 0000000..8433245 --- /dev/null +++ b/src/components/fl-breathing-dot/fl-breathing-dot.component.scss @@ -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; + } +} diff --git a/src/components/fl-breathing-dot/fl-breathing-dot.component.ts b/src/components/fl-breathing-dot/fl-breathing-dot.component.ts new file mode 100644 index 0000000..1bae6bb --- /dev/null +++ b/src/components/fl-breathing-dot/fl-breathing-dot.component.ts @@ -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: ` +
+ `, + 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()); +} diff --git a/src/components/fl-breathing-dot/index.ts b/src/components/fl-breathing-dot/index.ts new file mode 100644 index 0000000..2816642 --- /dev/null +++ b/src/components/fl-breathing-dot/index.ts @@ -0,0 +1 @@ +export * from './fl-breathing-dot.component'; diff --git a/src/components/fl-card-stack/fl-card-stack.component.scss b/src/components/fl-card-stack/fl-card-stack.component.scss new file mode 100644 index 0000000..9563441 --- /dev/null +++ b/src/components/fl-card-stack/fl-card-stack.component.scss @@ -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; + } +} diff --git a/src/components/fl-card-stack/fl-card-stack.component.ts b/src/components/fl-card-stack/fl-card-stack.component.ts new file mode 100644 index 0000000..90ba366 --- /dev/null +++ b/src/components/fl-card-stack/fl-card-stack.component.ts @@ -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: ` +
+ +
+ `, + 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'; + }); + } +} diff --git a/src/components/fl-card-stack/index.ts b/src/components/fl-card-stack/index.ts new file mode 100644 index 0000000..2483674 --- /dev/null +++ b/src/components/fl-card-stack/index.ts @@ -0,0 +1 @@ +export * from './fl-card-stack.component'; diff --git a/src/components/fl-checkmark-flourish/fl-checkmark-flourish.component.scss b/src/components/fl-checkmark-flourish/fl-checkmark-flourish.component.scss new file mode 100644 index 0000000..51938a6 --- /dev/null +++ b/src/components/fl-checkmark-flourish/fl-checkmark-flourish.component.scss @@ -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); + } +} diff --git a/src/components/fl-checkmark-flourish/fl-checkmark-flourish.component.ts b/src/components/fl-checkmark-flourish/fl-checkmark-flourish.component.ts new file mode 100644 index 0000000..6fbae82 --- /dev/null +++ b/src/components/fl-checkmark-flourish/fl-checkmark-flourish.component.ts @@ -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: ` + + + + + @if (showSparkles()) { + @for (sparkle of sparkles(); track $index) { +
+ } + } + `, + 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>([]); + + 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; + } +} diff --git a/src/components/fl-checkmark-flourish/index.ts b/src/components/fl-checkmark-flourish/index.ts new file mode 100644 index 0000000..2e925c7 --- /dev/null +++ b/src/components/fl-checkmark-flourish/index.ts @@ -0,0 +1 @@ +export * from './fl-checkmark-flourish.component'; diff --git a/src/components/fl-circuit-board/fl-circuit-board.component.scss b/src/components/fl-circuit-board/fl-circuit-board.component.scss new file mode 100644 index 0000000..c6f3502 --- /dev/null +++ b/src/components/fl-circuit-board/fl-circuit-board.component.scss @@ -0,0 +1,12 @@ +:host { + display: block; + width: 100%; + height: 100%; + background: #0a0a0a; +} + +.fl-circuit-board__svg { + width: 100%; + height: 100%; + display: block; +} diff --git a/src/components/fl-circuit-board/fl-circuit-board.component.ts b/src/components/fl-circuit-board/fl-circuit-board.component.ts new file mode 100644 index 0000000..012e923 --- /dev/null +++ b/src/components/fl-circuit-board/fl-circuit-board.component.ts @@ -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: ` + + + + + + + + + + + @for (circuit of circuits(); track $index) { + + } + + + @if (!reducedMotion.reduced()) { + @for (circuit of circuits(); track $index) { + + } + } + + + @for (node of nodes(); track $index) { + + } + + `, + 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([]); + 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); + } +} diff --git a/src/components/fl-circuit-board/index.ts b/src/components/fl-circuit-board/index.ts new file mode 100644 index 0000000..c7fd0c6 --- /dev/null +++ b/src/components/fl-circuit-board/index.ts @@ -0,0 +1 @@ +export * from './fl-circuit-board.component'; diff --git a/src/components/fl-color-splash/fl-color-splash.component.scss b/src/components/fl-color-splash/fl-color-splash.component.scss new file mode 100644 index 0000000..8852937 --- /dev/null +++ b/src/components/fl-color-splash/fl-color-splash.component.scss @@ -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; + } +} diff --git a/src/components/fl-color-splash/fl-color-splash.component.ts b/src/components/fl-color-splash/fl-color-splash.component.ts new file mode 100644 index 0000000..624b4be --- /dev/null +++ b/src/components/fl-color-splash/fl-color-splash.component.ts @@ -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: ` +
+ +
+ `, + 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); +} diff --git a/src/components/fl-color-splash/index.ts b/src/components/fl-color-splash/index.ts new file mode 100644 index 0000000..711d649 --- /dev/null +++ b/src/components/fl-color-splash/index.ts @@ -0,0 +1 @@ +export * from './fl-color-splash.component'; diff --git a/src/components/fl-confetti/fl-confetti.component.scss b/src/components/fl-confetti/fl-confetti.component.scss new file mode 100644 index 0000000..cfede61 --- /dev/null +++ b/src/components/fl-confetti/fl-confetti.component.scss @@ -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%; +} diff --git a/src/components/fl-confetti/fl-confetti.component.ts b/src/components/fl-confetti/fl-confetti.component.ts new file mode 100644 index 0000000..5a754e5 --- /dev/null +++ b/src/components/fl-confetti/fl-confetti.component.ts @@ -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: ` + + `, + 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(['#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(); + } +} diff --git a/src/components/fl-confetti/index.ts b/src/components/fl-confetti/index.ts new file mode 100644 index 0000000..cc18a80 --- /dev/null +++ b/src/components/fl-confetti/index.ts @@ -0,0 +1 @@ +export * from './fl-confetti.component'; diff --git a/src/components/fl-counter-rollup/fl-counter-rollup.component.scss b/src/components/fl-counter-rollup/fl-counter-rollup.component.scss new file mode 100644 index 0000000..cb45d61 --- /dev/null +++ b/src/components/fl-counter-rollup/fl-counter-rollup.component.scss @@ -0,0 +1,4 @@ +.fl-counter-rollup { + display: inline-block; + font-variant-numeric: tabular-nums; +} diff --git a/src/components/fl-counter-rollup/fl-counter-rollup.component.ts b/src/components/fl-counter-rollup/fl-counter-rollup.component.ts new file mode 100644 index 0000000..b494f2e --- /dev/null +++ b/src/components/fl-counter-rollup/fl-counter-rollup.component.ts @@ -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(); + 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('.'); + } +} diff --git a/src/components/fl-counter-rollup/index.ts b/src/components/fl-counter-rollup/index.ts new file mode 100644 index 0000000..c6bfdbc --- /dev/null +++ b/src/components/fl-counter-rollup/index.ts @@ -0,0 +1 @@ +export * from './fl-counter-rollup.component'; diff --git a/src/components/fl-counter-trigger/fl-counter-trigger.component.scss b/src/components/fl-counter-trigger/fl-counter-trigger.component.scss new file mode 100644 index 0000000..c6675f3 --- /dev/null +++ b/src/components/fl-counter-trigger/fl-counter-trigger.component.scss @@ -0,0 +1,4 @@ +.fl-counter-trigger { + display: inline-block; + font-variant-numeric: tabular-nums; +} diff --git a/src/components/fl-counter-trigger/fl-counter-trigger.component.ts b/src/components/fl-counter-trigger/fl-counter-trigger.component.ts new file mode 100644 index 0000000..6a65dcc --- /dev/null +++ b/src/components/fl-counter-trigger/fl-counter-trigger.component.ts @@ -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(); + 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, ','); + } +} diff --git a/src/components/fl-counter-trigger/index.ts b/src/components/fl-counter-trigger/index.ts new file mode 100644 index 0000000..4dcc23d --- /dev/null +++ b/src/components/fl-counter-trigger/index.ts @@ -0,0 +1 @@ +export * from './fl-counter-trigger.component'; diff --git a/src/components/fl-dot-grid/fl-dot-grid.component.scss b/src/components/fl-dot-grid/fl-dot-grid.component.scss new file mode 100644 index 0000000..d447b25 --- /dev/null +++ b/src/components/fl-dot-grid/fl-dot-grid.component.scss @@ -0,0 +1,11 @@ +:host { + display: block; + width: 100%; + height: 100%; +} + +.fl-dot-grid__svg { + display: block; + width: 100%; + height: 100%; +} diff --git a/src/components/fl-dot-grid/fl-dot-grid.component.ts b/src/components/fl-dot-grid/fl-dot-grid.component.ts new file mode 100644 index 0000000..451b813 --- /dev/null +++ b/src/components/fl-dot-grid/fl-dot-grid.component.ts @@ -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: ` + + + + + + + + + `, + 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)); + } +} diff --git a/src/components/fl-dot-grid/index.ts b/src/components/fl-dot-grid/index.ts new file mode 100644 index 0000000..10f4dec --- /dev/null +++ b/src/components/fl-dot-grid/index.ts @@ -0,0 +1 @@ +export * from './fl-dot-grid.component'; diff --git a/src/components/fl-duotone-filter/fl-duotone-filter.component.scss b/src/components/fl-duotone-filter/fl-duotone-filter.component.scss new file mode 100644 index 0000000..ecea8dc --- /dev/null +++ b/src/components/fl-duotone-filter/fl-duotone-filter.component.scss @@ -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%; +} diff --git a/src/components/fl-duotone-filter/fl-duotone-filter.component.ts b/src/components/fl-duotone-filter/fl-duotone-filter.component.ts new file mode 100644 index 0000000..7ba6418 --- /dev/null +++ b/src/components/fl-duotone-filter/fl-duotone-filter.component.ts @@ -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: ` + + + + + + + +
+ +
+ `, + 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 }; + } +} diff --git a/src/components/fl-duotone-filter/index.ts b/src/components/fl-duotone-filter/index.ts new file mode 100644 index 0000000..79a987e --- /dev/null +++ b/src/components/fl-duotone-filter/index.ts @@ -0,0 +1 @@ +export * from './fl-duotone-filter.component'; diff --git a/src/components/fl-emoji-rain/fl-emoji-rain.component.scss b/src/components/fl-emoji-rain/fl-emoji-rain.component.scss new file mode 100644 index 0000000..65efee9 --- /dev/null +++ b/src/components/fl-emoji-rain/fl-emoji-rain.component.scss @@ -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; +} diff --git a/src/components/fl-emoji-rain/fl-emoji-rain.component.ts b/src/components/fl-emoji-rain/fl-emoji-rain.component.ts new file mode 100644 index 0000000..bcfd9ca --- /dev/null +++ b/src/components/fl-emoji-rain/fl-emoji-rain.component.ts @@ -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) { +
{{ drop.emoji }}
+ } + `, + 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(['🎉', '🎊', '🎈', '⭐', '✨']); + readonly count = input(30); + readonly duration = input(3000); + + // Internal + private dropsData: EmojiDrop[] = []; + private loopCleanup: (() => void) | null = null; + private isAnimating = false; + + // Signals + readonly drops = signal([]); + + 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; + } + } +} diff --git a/src/components/fl-emoji-rain/index.ts b/src/components/fl-emoji-rain/index.ts new file mode 100644 index 0000000..d57a93f --- /dev/null +++ b/src/components/fl-emoji-rain/index.ts @@ -0,0 +1 @@ +export * from './fl-emoji-rain.component'; diff --git a/src/components/fl-fireworks/fl-fireworks.component.scss b/src/components/fl-fireworks/fl-fireworks.component.scss new file mode 100644 index 0000000..b5e324f --- /dev/null +++ b/src/components/fl-fireworks/fl-fireworks.component.scss @@ -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%; +} diff --git a/src/components/fl-fireworks/fl-fireworks.component.ts b/src/components/fl-fireworks/fl-fireworks.component.ts new file mode 100644 index 0000000..c5b6606 --- /dev/null +++ b/src/components/fl-fireworks/fl-fireworks.component.ts @@ -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: ` + + `, + 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(['#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, + }); + } + } +} diff --git a/src/components/fl-fireworks/index.ts b/src/components/fl-fireworks/index.ts new file mode 100644 index 0000000..d42e77a --- /dev/null +++ b/src/components/fl-fireworks/index.ts @@ -0,0 +1 @@ +export * from './fl-fireworks.component'; diff --git a/src/components/fl-floating-layers/fl-floating-layers.component.scss b/src/components/fl-floating-layers/fl-floating-layers.component.scss new file mode 100644 index 0000000..1be3083 --- /dev/null +++ b/src/components/fl-floating-layers/fl-floating-layers.component.scss @@ -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; + } + } +} diff --git a/src/components/fl-floating-layers/fl-floating-layers.component.ts b/src/components/fl-floating-layers/fl-floating-layers.component.ts new file mode 100644 index 0000000..fdcf895 --- /dev/null +++ b/src/components/fl-floating-layers/fl-floating-layers.component.ts @@ -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: ``, + 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)`; + } + } +} diff --git a/src/components/fl-floating-layers/index.ts b/src/components/fl-floating-layers/index.ts new file mode 100644 index 0000000..4426141 --- /dev/null +++ b/src/components/fl-floating-layers/index.ts @@ -0,0 +1 @@ +export * from './fl-floating-layers.component'; diff --git a/src/components/fl-flow-field/fl-flow-field.component.scss b/src/components/fl-flow-field/fl-flow-field.component.scss new file mode 100644 index 0000000..4dae921 --- /dev/null +++ b/src/components/fl-flow-field/fl-flow-field.component.scss @@ -0,0 +1,14 @@ +:host { + display: block; + position: relative; + overflow: hidden; + width: 100%; + height: 100%; + background: #000; + + canvas { + display: block; + width: 100%; + height: 100%; + } +} diff --git a/src/components/fl-flow-field/fl-flow-field.component.ts b/src/components/fl-flow-field/fl-flow-field.component.ts new file mode 100644 index 0000000..510695c --- /dev/null +++ b/src/components/fl-flow-field/fl-flow-field.component.ts @@ -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: ``, + 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>('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(['#6366f1', '#8b5cf6', '#a78bfa', '#c4b5fd']); + readonly lineWidth = input(1); + readonly responsive = input(true); + + // Outputs + readonly ready = output(); + + 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(); + } +} diff --git a/src/components/fl-flow-field/index.ts b/src/components/fl-flow-field/index.ts new file mode 100644 index 0000000..3174966 --- /dev/null +++ b/src/components/fl-flow-field/index.ts @@ -0,0 +1 @@ +export * from './fl-flow-field.component'; diff --git a/src/components/fl-fluid-container/fl-fluid-container.component.scss b/src/components/fl-fluid-container/fl-fluid-container.component.scss new file mode 100644 index 0000000..ddf6327 --- /dev/null +++ b/src/components/fl-fluid-container/fl-fluid-container.component.scss @@ -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; +} diff --git a/src/components/fl-fluid-container/fl-fluid-container.component.ts b/src/components/fl-fluid-container/fl-fluid-container.component.ts new file mode 100644 index 0000000..22e282f --- /dev/null +++ b/src/components/fl-fluid-container/fl-fluid-container.component.ts @@ -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: ` + + + + + + + +
+ +
+ `, + 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 '); + } +} diff --git a/src/components/fl-fluid-container/index.ts b/src/components/fl-fluid-container/index.ts new file mode 100644 index 0000000..57dd4a0 --- /dev/null +++ b/src/components/fl-fluid-container/index.ts @@ -0,0 +1 @@ +export * from './fl-fluid-container.component'; diff --git a/src/components/fl-fractal-landscape/fl-fractal-landscape.component.scss b/src/components/fl-fractal-landscape/fl-fractal-landscape.component.scss new file mode 100644 index 0000000..97ad91f --- /dev/null +++ b/src/components/fl-fractal-landscape/fl-fractal-landscape.component.scss @@ -0,0 +1,14 @@ +:host { + display: block; + position: relative; + overflow: hidden; + width: 100%; + height: 100%; + background: #0a0a0a; + + canvas { + display: block; + width: 100%; + height: 100%; + } +} diff --git a/src/components/fl-fractal-landscape/fl-fractal-landscape.component.ts b/src/components/fl-fractal-landscape/fl-fractal-landscape.component.ts new file mode 100644 index 0000000..9ae3dd5 --- /dev/null +++ b/src/components/fl-fractal-landscape/fl-fractal-landscape.component.ts @@ -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: ``, + styleUrl: './fl-fractal-landscape.component.scss', + host: { 'class': 'fl-fractal-landscape' }, +}) +export class FlFractalLandscapeComponent { + private config = inject(FL_CONFIG); + private reducedMotion = inject(ReducedMotionService); + private animationLoop = inject(AnimationLoopService); + private destroyRef = inject(DestroyRef); + + protected canvasRef = viewChild.required>('canvas'); + + // Inputs + readonly gridSize = input(64); + readonly roughness = input(0.6); + readonly speed = input(0.3); + readonly wireColor = input('#6366f1'); + readonly wireOpacity = input(0.6); + readonly perspective = input(400); + readonly elevation = input(0.4); + readonly rotationSpeed = input(0.1); + readonly responsive = input(true); + + // Outputs + readonly ready = output(); + + private heightmap: number[][] = []; + private ctx: CanvasRenderingContext2D | null = null; + private width = 0; + private height = 0; + private pixelRatio = 1; + private loopCleanup: (() => void) | null = null; + private resizeObserver: ResizeObserver | null = null; + + constructor() { + afterNextRender(() => { + this.initialize(); + }); + + this.destroyRef.onDestroy(() => { + this.loopCleanup?.(); + this.resizeObserver?.disconnect(); + }); + } + + private initialize(): void { + const canvas = this.canvasRef().nativeElement; + this.ctx = canvas.getContext('2d'); + if (!this.ctx) return; + + this.pixelRatio = getPixelRatio(this.config.maxPixelRatio); + this.resize(); + + if (this.responsive()) { + this.resizeObserver = new ResizeObserver(() => this.resize()); + this.resizeObserver.observe(canvas.parentElement!); + } + + this.generateHeightmap(); + + if (this.reducedMotion.reduced()) { + this.renderFrame(0); + } else { + const id = `fractal-${Math.random().toString(36).slice(2)}`; + this.loopCleanup = this.animationLoop.register(id, (_, elapsed) => this.renderFrame(elapsed)); + } + + this.ready.emit(); + } + + private resize(): void { + const canvas = this.canvasRef().nativeElement; + const parent = canvas.parentElement; + if (!parent) return; + + this.width = parent.clientWidth; + this.height = parent.clientHeight; + canvas.width = this.width * this.pixelRatio; + canvas.height = this.height * this.pixelRatio; + canvas.style.width = `${this.width}px`; + canvas.style.height = `${this.height}px`; + this.ctx?.scale(this.pixelRatio, this.pixelRatio); + } + + /** Diamond-square heightmap generation */ + private generateHeightmap(): void { + const size = this.gridSize() + 1; + const roughness = this.roughness(); + + this.heightmap = Array.from({ length: size }, () => new Array(size).fill(0)); + + // Seed corners + this.heightmap[0][0] = randomInRange(-1, 1); + this.heightmap[0][size - 1] = randomInRange(-1, 1); + this.heightmap[size - 1][0] = randomInRange(-1, 1); + this.heightmap[size - 1][size - 1] = randomInRange(-1, 1); + + let step = size - 1; + let scale = roughness; + + while (step > 1) { + const half = step >> 1; + + // Diamond step + for (let y = 0; y < size - 1; y += step) { + for (let x = 0; x < size - 1; x += step) { + const avg = ( + this.heightmap[y][x] + + this.heightmap[y][x + step] + + this.heightmap[y + step][x] + + this.heightmap[y + step][x + step] + ) / 4; + this.heightmap[y + half][x + half] = avg + randomInRange(-scale, scale); + } + } + + // Square step + for (let y = 0; y < size; y += half) { + for (let x = (y + half) % step; x < size; x += step) { + let sum = 0; + let count = 0; + + if (y - half >= 0) { sum += this.heightmap[y - half][x]; count++; } + if (y + half < size) { sum += this.heightmap[y + half][x]; count++; } + if (x - half >= 0) { sum += this.heightmap[y][x - half]; count++; } + if (x + half < size) { sum += this.heightmap[y][x + half]; count++; } + + this.heightmap[y][x] = sum / count + randomInRange(-scale, scale); + } + } + + step = half; + scale *= 0.5; + } + } + + private renderFrame(elapsed: number): void { + if (!this.ctx) return; + + const ctx = this.ctx; + const w = this.width; + const h = this.height; + const size = this.gridSize() + 1; + const perspective = this.perspective(); + const elevation = this.elevation(); + const rotation = elapsed * this.rotationSpeed(); + const color = this.wireColor(); + const opacity = this.wireOpacity(); + + ctx.clearRect(0, 0, w, h); + ctx.strokeStyle = color; + ctx.globalAlpha = opacity; + ctx.lineWidth = 0.5; + + const cx = w / 2; + const cy = h * 0.65; + const cosR = Math.cos(rotation); + const sinR = Math.sin(rotation); + + const project = (gx: number, gy: number, gz: number): { x: number; y: number; z: number } => { + // Rotate around Y axis + const rx = gx * cosR - gz * sinR; + const rz = gx * sinR + gz * cosR; + const ry = gy; + + // Perspective projection + const scale = perspective / (perspective + rz + size * 0.5); + return { + x: cx + rx * scale, + y: cy + ry * scale, + z: rz, + }; + }; + + // Draw wireframe grid + for (let y = 0; y < size - 1; y++) { + for (let x = 0; x < size - 1; x++) { + const gx = (x - size / 2) * 4; + const gz = (y - size / 2) * 4; + const gy = -this.heightmap[y][x] * size * elevation; + + const gx1 = ((x + 1) - size / 2) * 4; + const gz1 = ((y + 1) - size / 2) * 4; + const gy1x = -this.heightmap[y][x + 1] * size * elevation; + const gy1y = -this.heightmap[y + 1][x] * size * elevation; + + const p0 = project(gx, gy, gz); + const p1 = project(gx1, gy1x, gz); + const p2 = project(gx, gy1y, gz1); + + // Depth-based opacity + const depthAlpha = Math.max(0.1, 1 - (p0.z + size) / (size * 2)); + ctx.globalAlpha = opacity * depthAlpha; + + // Horizontal line + ctx.beginPath(); + ctx.moveTo(p0.x, p0.y); + ctx.lineTo(p1.x, p1.y); + ctx.stroke(); + + // Vertical line + ctx.beginPath(); + ctx.moveTo(p0.x, p0.y); + ctx.lineTo(p2.x, p2.y); + ctx.stroke(); + } + } + ctx.globalAlpha = 1; + } +} diff --git a/src/components/fl-fractal-landscape/index.ts b/src/components/fl-fractal-landscape/index.ts new file mode 100644 index 0000000..fcb8fcd --- /dev/null +++ b/src/components/fl-fractal-landscape/index.ts @@ -0,0 +1 @@ +export * from './fl-fractal-landscape.component'; diff --git a/src/components/fl-frequency-rings/fl-frequency-rings.component.scss b/src/components/fl-frequency-rings/fl-frequency-rings.component.scss new file mode 100644 index 0000000..1a24278 --- /dev/null +++ b/src/components/fl-frequency-rings/fl-frequency-rings.component.scss @@ -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; + } +} diff --git a/src/components/fl-frequency-rings/fl-frequency-rings.component.ts b/src/components/fl-frequency-rings/fl-frequency-rings.component.ts new file mode 100644 index 0000000..3045012 --- /dev/null +++ b/src/components/fl-frequency-rings/fl-frequency-rings.component.ts @@ -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: ` + + @for (ring of ringData(); track ring.index) { + + } + + `, + 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 }; + }); + }); +} diff --git a/src/components/fl-frequency-rings/index.ts b/src/components/fl-frequency-rings/index.ts new file mode 100644 index 0000000..6113c88 --- /dev/null +++ b/src/components/fl-frequency-rings/index.ts @@ -0,0 +1 @@ +export * from './fl-frequency-rings.component'; diff --git a/src/components/fl-generative-pattern/fl-generative-pattern.component.scss b/src/components/fl-generative-pattern/fl-generative-pattern.component.scss new file mode 100644 index 0000000..9aa4969 --- /dev/null +++ b/src/components/fl-generative-pattern/fl-generative-pattern.component.scss @@ -0,0 +1,11 @@ +:host { + display: block; + width: 100%; + height: 100%; +} + +.fl-generative-pattern__svg { + width: 100%; + height: 100%; + display: block; +} diff --git a/src/components/fl-generative-pattern/fl-generative-pattern.component.ts b/src/components/fl-generative-pattern/fl-generative-pattern.component.ts new file mode 100644 index 0000000..295d254 --- /dev/null +++ b/src/components/fl-generative-pattern/fl-generative-pattern.component.ts @@ -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: ` + + + + @switch (pattern()) { + @case ('triangles') { + @for (item of triangles(); track $index) { + + } + } + @case ('hexagons') { + @for (item of hexagons(); track $index) { + + } + } + @case ('circles') { + @for (item of circles(); track $index) { + + } + } + @case ('squares') { + @for (item of squares(); track $index) { + + } + } + } + + + + + `, + 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(['#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>([]); + readonly hexagons = signal>([]); + readonly circles = signal>([]); + readonly squares = signal>([]); + + 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); + } +} diff --git a/src/components/fl-generative-pattern/index.ts b/src/components/fl-generative-pattern/index.ts new file mode 100644 index 0000000..1d948ac --- /dev/null +++ b/src/components/fl-generative-pattern/index.ts @@ -0,0 +1 @@ +export * from './fl-generative-pattern.component'; diff --git a/src/components/fl-glitch-image/fl-glitch-image.component.scss b/src/components/fl-glitch-image/fl-glitch-image.component.scss new file mode 100644 index 0000000..264ac48 --- /dev/null +++ b/src/components/fl-glitch-image/fl-glitch-image.component.scss @@ -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); + } +} diff --git a/src/components/fl-glitch-image/fl-glitch-image.component.ts b/src/components/fl-glitch-image/fl-glitch-image.component.ts new file mode 100644 index 0000000..6b43851 --- /dev/null +++ b/src/components/fl-glitch-image/fl-glitch-image.component.ts @@ -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: ` +
+ +
+ `, + 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); +} diff --git a/src/components/fl-glitch-image/index.ts b/src/components/fl-glitch-image/index.ts new file mode 100644 index 0000000..a2e285e --- /dev/null +++ b/src/components/fl-glitch-image/index.ts @@ -0,0 +1 @@ +export * from './fl-glitch-image.component'; diff --git a/src/components/fl-glitch-text/fl-glitch-text.component.scss b/src/components/fl-glitch-text/fl-glitch-text.component.scss new file mode 100644 index 0000000..a5e626e --- /dev/null +++ b/src/components/fl-glitch-text/fl-glitch-text.component.scss @@ -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)); + } +} diff --git a/src/components/fl-glitch-text/fl-glitch-text.component.ts b/src/components/fl-glitch-text/fl-glitch-text.component.ts new file mode 100644 index 0000000..ec4cb9e --- /dev/null +++ b/src/components/fl-glitch-text/fl-glitch-text.component.ts @@ -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: ` + {{ text() }} + `, + 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(); + 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`); + } +} diff --git a/src/components/fl-glitch-text/index.ts b/src/components/fl-glitch-text/index.ts new file mode 100644 index 0000000..f1a37ae --- /dev/null +++ b/src/components/fl-glitch-text/index.ts @@ -0,0 +1 @@ +export * from './fl-glitch-text.component'; diff --git a/src/components/fl-glow-badge/fl-glow-badge.component.scss b/src/components/fl-glow-badge/fl-glow-badge.component.scss new file mode 100644 index 0000000..5ef6d51 --- /dev/null +++ b/src/components/fl-glow-badge/fl-glow-badge.component.scss @@ -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; + } +} diff --git a/src/components/fl-glow-badge/fl-glow-badge.component.ts b/src/components/fl-glow-badge/fl-glow-badge.component.ts new file mode 100644 index 0000000..5ba653a --- /dev/null +++ b/src/components/fl-glow-badge/fl-glow-badge.component.ts @@ -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: ` +
+ +
+ `, + 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}`; + }); +} diff --git a/src/components/fl-glow-badge/index.ts b/src/components/fl-glow-badge/index.ts new file mode 100644 index 0000000..ec15665 --- /dev/null +++ b/src/components/fl-glow-badge/index.ts @@ -0,0 +1 @@ +export * from './fl-glow-badge.component'; diff --git a/src/components/fl-glow-divider/fl-glow-divider.component.scss b/src/components/fl-glow-divider/fl-glow-divider.component.scss new file mode 100644 index 0000000..bfa6c7c --- /dev/null +++ b/src/components/fl-glow-divider/fl-glow-divider.component.scss @@ -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); + } +} diff --git a/src/components/fl-glow-divider/fl-glow-divider.component.ts b/src/components/fl-glow-divider/fl-glow-divider.component.ts new file mode 100644 index 0000000..f7d8686 --- /dev/null +++ b/src/components/fl-glow-divider/fl-glow-divider.component.ts @@ -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: `
`, + 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`); + } + } +} diff --git a/src/components/fl-glow-divider/index.ts b/src/components/fl-glow-divider/index.ts new file mode 100644 index 0000000..a79c77b --- /dev/null +++ b/src/components/fl-glow-divider/index.ts @@ -0,0 +1 @@ +export * from './fl-glow-divider.component'; diff --git a/src/components/fl-gradient-divider/fl-gradient-divider.component.scss b/src/components/fl-gradient-divider/fl-gradient-divider.component.scss new file mode 100644 index 0000000..dd69c24 --- /dev/null +++ b/src/components/fl-gradient-divider/fl-gradient-divider.component.scss @@ -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%; + } +} diff --git a/src/components/fl-gradient-divider/fl-gradient-divider.component.ts b/src/components/fl-gradient-divider/fl-gradient-divider.component.ts new file mode 100644 index 0000000..6c82016 --- /dev/null +++ b/src/components/fl-gradient-divider/fl-gradient-divider.component.ts @@ -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: `
`, + 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(['#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'); + } + } +} diff --git a/src/components/fl-gradient-divider/index.ts b/src/components/fl-gradient-divider/index.ts new file mode 100644 index 0000000..0b7bd12 --- /dev/null +++ b/src/components/fl-gradient-divider/index.ts @@ -0,0 +1 @@ +export * from './fl-gradient-divider.component'; diff --git a/src/components/fl-gradient-mesh/fl-gradient-mesh.component.scss b/src/components/fl-gradient-mesh/fl-gradient-mesh.component.scss new file mode 100644 index 0000000..cf8a1f3 --- /dev/null +++ b/src/components/fl-gradient-mesh/fl-gradient-mesh.component.scss @@ -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; +} diff --git a/src/components/fl-gradient-mesh/fl-gradient-mesh.component.ts b/src/components/fl-gradient-mesh/fl-gradient-mesh.component.ts new file mode 100644 index 0000000..732f1d9 --- /dev/null +++ b/src/components/fl-gradient-mesh/fl-gradient-mesh.component.ts @@ -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) { +
+ } + `, + 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(['#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>([]); + + 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, + })) + ); + } +} diff --git a/src/components/fl-gradient-mesh/index.ts b/src/components/fl-gradient-mesh/index.ts new file mode 100644 index 0000000..8657d8d --- /dev/null +++ b/src/components/fl-gradient-mesh/index.ts @@ -0,0 +1 @@ +export * from './fl-gradient-mesh.component'; diff --git a/src/components/fl-gradient-text/fl-gradient-text.component.scss b/src/components/fl-gradient-text/fl-gradient-text.component.scss new file mode 100644 index 0000000..f46ef8d --- /dev/null +++ b/src/components/fl-gradient-text/fl-gradient-text.component.scss @@ -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; +} diff --git a/src/components/fl-gradient-text/fl-gradient-text.component.ts b/src/components/fl-gradient-text/fl-gradient-text.component.ts new file mode 100644 index 0000000..b14cfe3 --- /dev/null +++ b/src/components/fl-gradient-text/fl-gradient-text.component.ts @@ -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: ``, + 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(['#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}%`; + } +} diff --git a/src/components/fl-gradient-text/index.ts b/src/components/fl-gradient-text/index.ts new file mode 100644 index 0000000..2c276d5 --- /dev/null +++ b/src/components/fl-gradient-text/index.ts @@ -0,0 +1 @@ +export * from './fl-gradient-text.component'; diff --git a/src/components/fl-grid-matrix/fl-grid-matrix.component.scss b/src/components/fl-grid-matrix/fl-grid-matrix.component.scss new file mode 100644 index 0000000..7e68d7d --- /dev/null +++ b/src/components/fl-grid-matrix/fl-grid-matrix.component.scss @@ -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; + } +} diff --git a/src/components/fl-grid-matrix/fl-grid-matrix.component.ts b/src/components/fl-grid-matrix/fl-grid-matrix.component.ts new file mode 100644 index 0000000..dd01c8b --- /dev/null +++ b/src/components/fl-grid-matrix/fl-grid-matrix.component.ts @@ -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: ` +
+ @for (dot of dots(); track dot.index) { +
+ } +
+ `, + 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 }; + }); + }); +} diff --git a/src/components/fl-grid-matrix/index.ts b/src/components/fl-grid-matrix/index.ts new file mode 100644 index 0000000..c3d24dd --- /dev/null +++ b/src/components/fl-grid-matrix/index.ts @@ -0,0 +1 @@ +export * from './fl-grid-matrix.component'; diff --git a/src/components/fl-heartbeat-line/fl-heartbeat-line.component.scss b/src/components/fl-heartbeat-line/fl-heartbeat-line.component.scss new file mode 100644 index 0000000..5b8d946 --- /dev/null +++ b/src/components/fl-heartbeat-line/fl-heartbeat-line.component.scss @@ -0,0 +1,34 @@ +:host { + display: block; + width: 100%; + height: 60px; + overflow: hidden; +} + +.fl-heartbeat-line__svg { + width: 100%; + height: 100%; + + path { + animation: fl-heartbeat-line-scroll 2s linear infinite; + stroke-dasharray: 400; + stroke-dashoffset: 400; + } +} + +@keyframes fl-heartbeat-line-scroll { + 0% { + stroke-dashoffset: 400; + } + 100% { + stroke-dashoffset: 0; + } +} + +:host.fl-heartbeat-line--reduced-motion { + .fl-heartbeat-line__svg path { + animation: none; + stroke-dasharray: none; + stroke-dashoffset: 0; + } +} diff --git a/src/components/fl-heartbeat-line/fl-heartbeat-line.component.ts b/src/components/fl-heartbeat-line/fl-heartbeat-line.component.ts new file mode 100644 index 0000000..6d845f9 --- /dev/null +++ b/src/components/fl-heartbeat-line/fl-heartbeat-line.component.ts @@ -0,0 +1,65 @@ +import { + Component, ChangeDetectionStrategy, input, inject, computed, + DestroyRef, +} from '@angular/core'; +import { ReducedMotionService } from '../../services/reduced-motion.service'; + +@Component({ + selector: 'fl-heartbeat-line', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + + + `, + styleUrl: './fl-heartbeat-line.component.scss', + host: { + 'class': 'fl-heartbeat-line', + '[class.fl-heartbeat-line--reduced-motion]': 'reducedMotion.reduced()', + }, +}) +export class FlHeartbeatLineComponent { + private reducedMotion = inject(ReducedMotionService); + private destroyRef = inject(DestroyRef); + + // Inputs + readonly color = input('#ef4444'); + readonly rate = input(1); + readonly lineWidth = input(2); + + // Computed + readonly animationDuration = computed(() => 2 / this.rate()); + + // Heartbeat path data (EKG-style waveform) + readonly heartbeatPath = ` + M 0,30 + L 40,30 + L 42,30 + L 44,15 + L 46,40 + L 48,20 + L 50,30 + L 90,30 + L 92,30 + L 94,15 + L 96,40 + L 98,20 + L 100,30 + L 140,30 + L 142,30 + L 144,15 + L 146,40 + L 148,20 + L 150,30 + L 200,30 + `; +} diff --git a/src/components/fl-heartbeat-line/index.ts b/src/components/fl-heartbeat-line/index.ts new file mode 100644 index 0000000..be98a3b --- /dev/null +++ b/src/components/fl-heartbeat-line/index.ts @@ -0,0 +1 @@ +export * from './fl-heartbeat-line.component'; diff --git a/src/components/fl-holographic-card/fl-holographic-card.component.scss b/src/components/fl-holographic-card/fl-holographic-card.component.scss new file mode 100644 index 0000000..897ae77 --- /dev/null +++ b/src/components/fl-holographic-card/fl-holographic-card.component.scss @@ -0,0 +1,28 @@ +:host { + display: block; + position: relative; + overflow: hidden; + border-radius: inherit; + + @media (prefers-reduced-motion: reduce) { + .fl-holographic-card__sheen { + display: none; + } + } +} + +.fl-holographic-card__content { + position: relative; + z-index: 1; +} + +.fl-holographic-card__sheen { + position: absolute; + inset: 0; + z-index: 2; + pointer-events: none; + mix-blend-mode: color-dodge; + opacity: 0; + transition: opacity 300ms ease; + border-radius: inherit; +} diff --git a/src/components/fl-holographic-card/fl-holographic-card.component.ts b/src/components/fl-holographic-card/fl-holographic-card.component.ts new file mode 100644 index 0000000..d23629b --- /dev/null +++ b/src/components/fl-holographic-card/fl-holographic-card.component.ts @@ -0,0 +1,89 @@ +import { + Component, ChangeDetectionStrategy, input, inject, + ElementRef, afterNextRender, DestroyRef, +} 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 { clamp } from '../../utils/math.utils'; + +@Component({ + selector: 'fl-holographic-card', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ +
+
+ `, + styleUrl: './fl-holographic-card.component.scss', + host: { + 'class': 'fl-holographic-card', + '(mouseenter)': 'onEnter()', + '(mouseleave)': 'onLeave()', + }, +}) +export class FlHolographicCardComponent { + private el = inject(ElementRef); + private reducedMotion = inject(ReducedMotionService); + private cursorTracker = inject(CursorTrackerService); + private animationLoop = inject(AnimationLoopService); + private destroyRef = inject(DestroyRef); + + // Inputs + readonly intensity = input(0.5); + readonly speed = input(1); + readonly colors = input([ + '#ff0000', '#ff8800', '#ffff00', '#00ff00', '#00ffff', '#0000ff', '#8800ff', + ]); + + private isHovered = false; + private cursorCleanup: (() => void) | null = null; + private loopCleanup: (() => void) | null = null; + + constructor() { + afterNextRender(() => { + if (this.reducedMotion.reduced()) return; + this.cursorCleanup = this.cursorTracker.subscribe(); + const id = `holographic-${Math.random().toString(36).slice(2)}`; + this.loopCleanup = this.animationLoop.register(id, (_, elapsed) => this.tick(elapsed)); + }); + + this.destroyRef.onDestroy(() => { + this.cursorCleanup?.(); + this.loopCleanup?.(); + }); + } + + protected onEnter(): void { + this.isHovered = true; + } + + protected onLeave(): void { + this.isHovered = false; + const sheen = (this.el.nativeElement as HTMLElement).querySelector('.fl-holographic-card__sheen') as HTMLElement | null; + if (sheen) { + sheen.style.opacity = '0'; + } + } + + private tick(elapsed: number): void { + const host = this.el.nativeElement as HTMLElement; + const sheen = host.querySelector('.fl-holographic-card__sheen') as HTMLElement | null; + if (!sheen) return; + + if (this.isHovered) { + const rect = host.getBoundingClientRect(); + const pos = this.cursorTracker.position(); + const relX = clamp((pos.x - rect.left) / rect.width, 0, 1); + const relY = clamp((pos.y - rect.top) / rect.height, 0, 1); + + const angle = relX * 360 + elapsed * this.speed() * 60; + const intensity = this.intensity(); + + sheen.style.opacity = `${intensity}`; + sheen.style.background = `conic-gradient(from ${angle}deg at ${relX * 100}% ${relY * 100}%, ${this.colors().join(', ')})`; + } + } +} diff --git a/src/components/fl-holographic-card/index.ts b/src/components/fl-holographic-card/index.ts new file mode 100644 index 0000000..9f5b712 --- /dev/null +++ b/src/components/fl-holographic-card/index.ts @@ -0,0 +1 @@ +export * from './fl-holographic-card.component'; diff --git a/src/components/fl-image-reveal/fl-image-reveal.component.scss b/src/components/fl-image-reveal/fl-image-reveal.component.scss new file mode 100644 index 0000000..8161a64 --- /dev/null +++ b/src/components/fl-image-reveal/fl-image-reveal.component.scss @@ -0,0 +1,74 @@ +:host { + display: block; + position: relative; + overflow: hidden; +} + +.fl-image-reveal__content { + width: 100%; + height: 100%; + + &[data-type='wipe-left'] { + clip-path: inset(0 100% 0 0); + } + + &[data-type='wipe-right'] { + clip-path: inset(0 0 0 100%); + } + + &[data-type='dissolve'] { + opacity: 0; + } + + &[data-type='circle'] { + clip-path: circle(0% at 50% 50%); + } + + &--revealed { + &[data-type='wipe-left'] { + animation: reveal-wipe-left forwards; + } + + &[data-type='wipe-right'] { + animation: reveal-wipe-right forwards; + } + + &[data-type='dissolve'] { + animation: reveal-dissolve forwards; + } + + &[data-type='circle'] { + animation: reveal-circle forwards; + } + } + + &--reduced-motion { + animation: none !important; + clip-path: none !important; + opacity: 1 !important; + } +} + +@keyframes reveal-wipe-left { + to { + clip-path: inset(0 0 0 0); + } +} + +@keyframes reveal-wipe-right { + to { + clip-path: inset(0 0 0 0); + } +} + +@keyframes reveal-dissolve { + to { + opacity: 1; + } +} + +@keyframes reveal-circle { + to { + clip-path: circle(150% at 50% 50%); + } +} diff --git a/src/components/fl-image-reveal/fl-image-reveal.component.ts b/src/components/fl-image-reveal/fl-image-reveal.component.ts new file mode 100644 index 0000000..c32f201 --- /dev/null +++ b/src/components/fl-image-reveal/fl-image-reveal.component.ts @@ -0,0 +1,81 @@ +import { + Component, ChangeDetectionStrategy, input, inject, + DestroyRef, ElementRef, afterNextRender, signal, +} from '@angular/core'; +import { ReducedMotionService } from '../../services/reduced-motion.service'; + +@Component({ + selector: 'fl-image-reveal', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ +
+ `, + styleUrl: './fl-image-reveal.component.scss', + host: { 'class': 'fl-image-reveal' }, +}) +export class FlImageRevealComponent { + private el = inject(ElementRef); + protected reducedMotion = inject(ReducedMotionService); + private destroyRef = inject(DestroyRef); + + // Inputs + readonly type = input<'wipe-left' | 'wipe-right' | 'dissolve' | 'circle'>('wipe-left'); + readonly duration = input(800); + readonly delay = input(0); + readonly trigger = input<'scroll' | 'immediate'>('immediate'); + + protected readonly revealed = signal(false); + + private observer: IntersectionObserver | null = null; + + constructor() { + afterNextRender(() => { + if (this.trigger() === 'immediate') { + this.reveal(); + } else { + this.setupScrollTrigger(); + } + }); + + this.destroyRef.onDestroy(() => { + this.observer?.disconnect(); + }); + } + + private reveal(): void { + setTimeout(() => { + this.revealed.set(true); + }, this.delay()); + } + + private setupScrollTrigger(): void { + if (typeof IntersectionObserver === 'undefined') { + this.reveal(); + return; + } + + this.observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting && !this.revealed()) { + this.reveal(); + this.observer?.disconnect(); + } + }); + }, + { threshold: 0.1 } + ); + + this.observer.observe(this.el.nativeElement); + } +} diff --git a/src/components/fl-image-reveal/index.ts b/src/components/fl-image-reveal/index.ts new file mode 100644 index 0000000..426d7d3 --- /dev/null +++ b/src/components/fl-image-reveal/index.ts @@ -0,0 +1 @@ +export * from './fl-image-reveal.component'; diff --git a/src/components/fl-ken-burns/fl-ken-burns.component.scss b/src/components/fl-ken-burns/fl-ken-burns.component.scss new file mode 100644 index 0000000..c52ad60 --- /dev/null +++ b/src/components/fl-ken-burns/fl-ken-burns.component.scss @@ -0,0 +1,71 @@ +:host { + display: block; + overflow: hidden; + position: relative; + width: 100%; + height: 100%; +} + +.fl-ken-burns__content { + width: 100%; + height: 100%; + animation-timing-function: ease-in-out; + animation-iteration-count: infinite; + animation-direction: alternate; + + &[data-direction='zoom-in'] { + animation-name: ken-burns-zoom-in; + } + + &[data-direction='zoom-out'] { + animation-name: ken-burns-zoom-out; + } + + &[data-direction='pan-left'] { + animation-name: ken-burns-pan-left; + } + + &[data-direction='pan-right'] { + animation-name: ken-burns-pan-right; + } + + &--reduced-motion { + animation: none !important; + } +} + +@keyframes ken-burns-zoom-in { + from { + transform: scale(1); + } + to { + transform: scale(var(--fl-ken-burns-scale, 1.2)); + } +} + +@keyframes ken-burns-zoom-out { + from { + transform: scale(var(--fl-ken-burns-scale, 1.2)); + } + to { + transform: scale(1); + } +} + +@keyframes ken-burns-pan-left { + from { + transform: scale(var(--fl-ken-burns-scale, 1.2)) translateX(0); + } + to { + transform: scale(var(--fl-ken-burns-scale, 1.2)) translateX(-10%); + } +} + +@keyframes ken-burns-pan-right { + from { + transform: scale(var(--fl-ken-burns-scale, 1.2)) translateX(-10%); + } + to { + transform: scale(var(--fl-ken-burns-scale, 1.2)) translateX(0); + } +} diff --git a/src/components/fl-ken-burns/fl-ken-burns.component.ts b/src/components/fl-ken-burns/fl-ken-burns.component.ts new file mode 100644 index 0000000..eab555d --- /dev/null +++ b/src/components/fl-ken-burns/fl-ken-burns.component.ts @@ -0,0 +1,32 @@ +import { + Component, ChangeDetectionStrategy, input, inject, + DestroyRef, computed, +} from '@angular/core'; +import { ReducedMotionService } from '../../services/reduced-motion.service'; + +@Component({ + selector: 'fl-ken-burns', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ +
+ `, + styleUrl: './fl-ken-burns.component.scss', + host: { 'class': 'fl-ken-burns' }, +}) +export class FlKenBurnsComponent { + protected reducedMotion = inject(ReducedMotionService); + private destroyRef = inject(DestroyRef); + + // Inputs + readonly duration = input(10); + readonly scale = input(1.2); + readonly direction = input<'zoom-in' | 'zoom-out' | 'pan-left' | 'pan-right'>('zoom-in'); +} diff --git a/src/components/fl-ken-burns/index.ts b/src/components/fl-ken-burns/index.ts new file mode 100644 index 0000000..3021d93 --- /dev/null +++ b/src/components/fl-ken-burns/index.ts @@ -0,0 +1 @@ +export * from './fl-ken-burns.component'; diff --git a/src/components/fl-liquid-button/fl-liquid-button.component.scss b/src/components/fl-liquid-button/fl-liquid-button.component.scss new file mode 100644 index 0000000..7e01c95 --- /dev/null +++ b/src/components/fl-liquid-button/fl-liquid-button.component.scss @@ -0,0 +1,74 @@ +:host { + display: inline-block; +} + +.fl-liquid-button__svg { + position: absolute; + width: 0; + height: 0; + pointer-events: none; +} + +.fl-liquid-button__button { + --fl-liquid-color: #ff6b6b; + --fl-liquid-text-color: #ffffff; + + position: relative; + border: none; + background: none; + cursor: pointer; + padding: 0; + overflow: hidden; + + &[data-size='sm'] { + font-size: 0.875rem; + padding: 0.5rem 1.5rem; + } + + &[data-size='md'] { + font-size: 1rem; + padding: 0.75rem 2rem; + } + + &[data-size='lg'] { + font-size: 1.125rem; + padding: 1rem 2.5rem; + } +} + +.fl-liquid-button__blob { + position: absolute; + top: 50%; + left: 50%; + width: 100%; + height: 100%; + background: var(--fl-liquid-color); + border-radius: 50%; + transform: translate(-50%, -50%) scale(1); + transition: transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1); + z-index: 0; +} + +.fl-liquid-button__text { + position: relative; + color: var(--fl-liquid-text-color); + font-weight: 600; + z-index: 1; +} + +.fl-liquid-button__button:hover .fl-liquid-button__blob { + transform: translate(-50%, -50%) scale(1.3); +} + +.fl-liquid-button__button:active .fl-liquid-button__blob { + transform: translate(-50%, -50%) scale(0.95); +} + +.fl-liquid-button__button--reduced-motion .fl-liquid-button__blob { + border-radius: 0.5rem; + transition: none; + + &:hover { + transform: translate(-50%, -50%) scale(1); + } +} diff --git a/src/components/fl-liquid-button/fl-liquid-button.component.ts b/src/components/fl-liquid-button/fl-liquid-button.component.ts new file mode 100644 index 0000000..91041a3 --- /dev/null +++ b/src/components/fl-liquid-button/fl-liquid-button.component.ts @@ -0,0 +1,56 @@ +import { + Component, ChangeDetectionStrategy, input, inject, + DestroyRef, +} from '@angular/core'; +import { ReducedMotionService } from '../../services/reduced-motion.service'; + +@Component({ + selector: 'fl-liquid-button', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + + + + + + + + + `, + styleUrl: './fl-liquid-button.component.scss', + host: { 'class': 'fl-liquid-button' }, +}) +export class FlLiquidButtonComponent { + protected reducedMotion = inject(ReducedMotionService); + private destroyRef = inject(DestroyRef); + + // Inputs + readonly color = input('#ff6b6b'); + readonly textColor = input('#ffffff'); + readonly size = input<'sm' | 'md' | 'lg'>('md'); + + protected readonly filterId = `liquid-${Math.random().toString(36).slice(2)}`; + + protected get filterValue(): string { + return this.reducedMotion.reduced() ? 'none' : `url(#${this.filterId})`; + } +} diff --git a/src/components/fl-liquid-button/index.ts b/src/components/fl-liquid-button/index.ts new file mode 100644 index 0000000..82a99ba --- /dev/null +++ b/src/components/fl-liquid-button/index.ts @@ -0,0 +1 @@ +export * from './fl-liquid-button.component'; diff --git a/src/components/fl-live-indicator/fl-live-indicator.component.scss b/src/components/fl-live-indicator/fl-live-indicator.component.scss new file mode 100644 index 0000000..33fbf1f --- /dev/null +++ b/src/components/fl-live-indicator/fl-live-indicator.component.scss @@ -0,0 +1,45 @@ +:host { + display: inline-flex; +} + +.fl-live-indicator__container { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.fl-live-indicator__dot { + border-radius: 50%; + animation: fl-live-indicator-pulse 2s ease-in-out infinite; + box-shadow: 0 0 0 0 currentColor; +} + +.fl-live-indicator__label { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: currentColor; +} + +@keyframes fl-live-indicator-pulse { + 0% { + box-shadow: 0 0 0 0 currentColor; + opacity: 1; + } + 50% { + box-shadow: 0 0 0 8px transparent; + opacity: 0.8; + } + 100% { + box-shadow: 0 0 0 0 transparent; + opacity: 1; + } +} + +:host.fl-live-indicator--reduced-motion { + .fl-live-indicator__dot { + animation: none; + box-shadow: 0 0 4px currentColor; + } +} diff --git a/src/components/fl-live-indicator/fl-live-indicator.component.ts b/src/components/fl-live-indicator/fl-live-indicator.component.ts new file mode 100644 index 0000000..92525e3 --- /dev/null +++ b/src/components/fl-live-indicator/fl-live-indicator.component.ts @@ -0,0 +1,38 @@ +import { + Component, ChangeDetectionStrategy, input, inject, + DestroyRef, +} from '@angular/core'; +import { ReducedMotionService } from '../../services/reduced-motion.service'; + +@Component({ + selector: 'fl-live-indicator', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+
+ @if (label()) { + {{ label() }} + } +
+ `, + styleUrl: './fl-live-indicator.component.scss', + host: { + 'class': 'fl-live-indicator', + '[class.fl-live-indicator--reduced-motion]': 'reducedMotion.reduced()', + }, +}) +export class FlLiveIndicatorComponent { + private reducedMotion = inject(ReducedMotionService); + private destroyRef = inject(DestroyRef); + + // Inputs + readonly color = input('#ef4444'); + readonly size = input(8); + readonly label = input('LIVE'); +} diff --git a/src/components/fl-live-indicator/index.ts b/src/components/fl-live-indicator/index.ts new file mode 100644 index 0000000..42153c2 --- /dev/null +++ b/src/components/fl-live-indicator/index.ts @@ -0,0 +1 @@ +export * from './fl-live-indicator.component'; diff --git a/src/components/fl-matrix-rain/fl-matrix-rain.component.scss b/src/components/fl-matrix-rain/fl-matrix-rain.component.scss new file mode 100644 index 0000000..4dae921 --- /dev/null +++ b/src/components/fl-matrix-rain/fl-matrix-rain.component.scss @@ -0,0 +1,14 @@ +:host { + display: block; + position: relative; + overflow: hidden; + width: 100%; + height: 100%; + background: #000; + + canvas { + display: block; + width: 100%; + height: 100%; + } +} diff --git a/src/components/fl-matrix-rain/fl-matrix-rain.component.ts b/src/components/fl-matrix-rain/fl-matrix-rain.component.ts new file mode 100644 index 0000000..637205b --- /dev/null +++ b/src/components/fl-matrix-rain/fl-matrix-rain.component.ts @@ -0,0 +1,194 @@ +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'; + +const KATAKANA = 'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン'; +const LATIN = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + +@Component({ + selector: 'fl-matrix-rain', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ``, + styleUrl: './fl-matrix-rain.component.scss', + host: { 'class': 'fl-matrix-rain' }, +}) +export class FlMatrixRainComponent { + private config = inject(FL_CONFIG); + private reducedMotion = inject(ReducedMotionService); + private animationLoop = inject(AnimationLoopService); + private destroyRef = inject(DestroyRef); + + protected canvasRef = viewChild.required>('canvas'); + + // Inputs + readonly fontSize = input(14); + readonly color = input('#00ff00'); + readonly speed = input(1); + readonly density = input(0.95); + readonly characters = input(KATAKANA + LATIN); + readonly responsive = input(true); + + // Outputs + readonly ready = output(); + + private ctx: CanvasRenderingContext2D | null = null; + private width = 0; + private height = 0; + private pixelRatio = 1; + private columns: number[] = []; + private loopCleanup: (() => void) | null = null; + private resizeObserver: ResizeObserver | null = null; + private accumulator = 0; + + 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.initColumns(); + }); + this.resizeObserver.observe(canvas.parentElement!); + } + + this.initColumns(); + + const id = `matrix-rain-${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 initColumns(): void { + const fs = this.fontSize(); + const columnCount = Math.ceil(this.width / fs); + this.columns = new Array(columnCount).fill(0).map(() => + Math.floor(randomInRange(0, this.height / fs)) + ); + } + + private tick(delta: number): void { + if (!this.ctx) return; + + const ctx = this.ctx; + const w = this.width; + const h = this.height; + const fs = this.fontSize(); + const chars = this.characters(); + const color = this.color(); + const density = this.density(); + const spd = this.speed(); + + // Frame rate control: ~30 fps equivalent speed + this.accumulator += delta * spd * 30; + if (this.accumulator < 1) return; + this.accumulator = 0; + + // Fade previous frame + ctx.fillStyle = 'rgba(0, 0, 0, 0.05)'; + ctx.fillRect(0, 0, w, h); + + ctx.font = `${fs}px monospace`; + + for (let i = 0; i < this.columns.length; i++) { + const char = chars[Math.floor(Math.random() * chars.length)]; + const x = i * fs; + const y = this.columns[i] * fs; + + // Leading bright character + ctx.fillStyle = '#ffffff'; + ctx.fillText(char, x, y); + + // Trailing colored character (slightly above) + ctx.fillStyle = color; + ctx.globalAlpha = 0.8; + const trailChar = chars[Math.floor(Math.random() * chars.length)]; + ctx.fillText(trailChar, x, y - fs); + ctx.globalAlpha = 1; + + // Reset column randomly + if (y > h && Math.random() > density) { + this.columns[i] = 0; + } else { + this.columns[i]++; + } + } + } + + 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(); + + const ctx = this.ctx; + const fs = this.fontSize(); + const chars = this.characters(); + const color = this.color(); + + ctx.fillStyle = '#000'; + ctx.fillRect(0, 0, this.width, this.height); + ctx.font = `${fs}px monospace`; + + const cols = Math.ceil(this.width / fs); + const rows = Math.ceil(this.height / fs); + + for (let c = 0; c < cols; c++) { + const streamLen = Math.floor(randomInRange(3, rows * 0.6)); + const startRow = Math.floor(randomInRange(0, rows)); + for (let r = 0; r < streamLen; r++) { + const row = startRow + r; + if (row >= rows) break; + const alpha = 1 - r / streamLen; + ctx.fillStyle = r === 0 ? '#ffffff' : color; + ctx.globalAlpha = alpha * 0.6; + const char = chars[Math.floor(Math.random() * chars.length)]; + ctx.fillText(char, c * fs, row * fs); + } + } + ctx.globalAlpha = 1; + this.ready.emit(); + } +} diff --git a/src/components/fl-matrix-rain/index.ts b/src/components/fl-matrix-rain/index.ts new file mode 100644 index 0000000..9050999 --- /dev/null +++ b/src/components/fl-matrix-rain/index.ts @@ -0,0 +1 @@ +export * from './fl-matrix-rain.component'; diff --git a/src/components/fl-menu-reveal/fl-menu-reveal.component.scss b/src/components/fl-menu-reveal/fl-menu-reveal.component.scss new file mode 100644 index 0000000..efaa9eb --- /dev/null +++ b/src/components/fl-menu-reveal/fl-menu-reveal.component.scss @@ -0,0 +1,79 @@ +:host { + display: block; +} + +.fl-menu-reveal__container { + display: flex; + flex-direction: column; + gap: 0.5rem; + + > * { + animation-fill-mode: both; + animation-duration: 0.4s; + animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + } +} + +.fl-menu-reveal__item--fade { + animation-name: fl-menu-reveal-fade; +} + +.fl-menu-reveal__item--slide { + animation-name: fl-menu-reveal-slide; +} + +.fl-menu-reveal__item--scale { + animation-name: fl-menu-reveal-scale; +} + +@keyframes fl-menu-reveal-fade { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes fl-menu-reveal-slide { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +:host[data-direction="up"] { + @keyframes fl-menu-reveal-slide { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } + } +} + +@keyframes fl-menu-reveal-scale { + from { + opacity: 0; + transform: scale(0.8); + } + to { + opacity: 1; + transform: scale(1); + } +} + +:host.fl-menu-reveal--reduced-motion { + .fl-menu-reveal__container > * { + animation: none !important; + opacity: 1; + transform: none; + } +} diff --git a/src/components/fl-menu-reveal/fl-menu-reveal.component.ts b/src/components/fl-menu-reveal/fl-menu-reveal.component.ts new file mode 100644 index 0000000..3f47a3b --- /dev/null +++ b/src/components/fl-menu-reveal/fl-menu-reveal.component.ts @@ -0,0 +1,55 @@ +import { + Component, ChangeDetectionStrategy, input, inject, computed, + DestroyRef, afterNextRender, ElementRef, +} from '@angular/core'; +import { ReducedMotionService } from '../../services/reduced-motion.service'; + +@Component({ + selector: 'fl-menu-reveal', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ +
+ `, + styleUrl: './fl-menu-reveal.component.scss', + host: { + 'class': 'fl-menu-reveal', + '[class.fl-menu-reveal--reduced-motion]': 'reducedMotion.reduced()', + '[attr.data-animation]': 'animation()', + '[attr.data-direction]': 'direction()', + }, +}) +export class FlMenuRevealComponent { + private reducedMotion = inject(ReducedMotionService); + private destroyRef = inject(DestroyRef); + private el = inject(ElementRef); + + // Inputs + readonly staggerDelay = input(0.05); + readonly animation = input<'fade' | 'slide' | 'scale'>('fade'); + readonly direction = input<'down' | 'up'>('down'); + + constructor() { + afterNextRender(() => { + this.applyStaggerAnimation(); + }); + } + + private applyStaggerAnimation(): void { + const container = this.el.nativeElement.querySelector('.fl-menu-reveal__container') as HTMLElement; + if (!container) return; + + const children = Array.from(container.children) as HTMLElement[]; + const delay = this.staggerDelay(); + const animationType = this.animation(); + const dir = this.direction(); + + children.forEach((child, index) => { + const actualIndex = dir === 'up' ? children.length - 1 - index : index; + child.style.animationDelay = `${actualIndex * delay}s`; + child.classList.add(`fl-menu-reveal__item--${animationType}`); + }); + } +} diff --git a/src/components/fl-menu-reveal/index.ts b/src/components/fl-menu-reveal/index.ts new file mode 100644 index 0000000..7769c12 --- /dev/null +++ b/src/components/fl-menu-reveal/index.ts @@ -0,0 +1 @@ +export * from './fl-menu-reveal.component'; diff --git a/src/components/fl-mesh-gradient/fl-mesh-gradient.component.scss b/src/components/fl-mesh-gradient/fl-mesh-gradient.component.scss new file mode 100644 index 0000000..01dacfa --- /dev/null +++ b/src/components/fl-mesh-gradient/fl-mesh-gradient.component.scss @@ -0,0 +1,18 @@ +:host { + display: block; + width: 100%; + height: 100%; +} + +.fl-mesh-gradient__content { + width: 100%; + height: 100%; + background-size: 100% 100%; + filter: blur(40px); + opacity: 0.8; + transition: background-image 0.5s ease; + + &--reduced-motion { + transition: none; + } +} diff --git a/src/components/fl-mesh-gradient/fl-mesh-gradient.component.ts b/src/components/fl-mesh-gradient/fl-mesh-gradient.component.ts new file mode 100644 index 0000000..54d4bca --- /dev/null +++ b/src/components/fl-mesh-gradient/fl-mesh-gradient.component.ts @@ -0,0 +1,78 @@ +import { + Component, ChangeDetectionStrategy, input, inject, + DestroyRef, ElementRef, afterNextRender, +} from '@angular/core'; +import { ReducedMotionService } from '../../services/reduced-motion.service'; +import { AnimationLoopService } from '../../services/animation-loop.service'; + +@Component({ + selector: 'fl-mesh-gradient', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ `, + styleUrl: './fl-mesh-gradient.component.scss', + host: { 'class': 'fl-mesh-gradient' }, +}) +export class FlMeshGradientComponent { + private el = inject(ElementRef); + protected reducedMotion = inject(ReducedMotionService); + private animationLoop = inject(AnimationLoopService); + private destroyRef = inject(DestroyRef); + + // Inputs + readonly colors = input(['#ff6b6b', '#4ecdc4', '#45b7d1', '#f7b731']); + readonly animated = input(true); + readonly speed = input(1); + + private loopCleanup: (() => void) | null = null; + private time = 0; + + constructor() { + afterNextRender(() => { + this.updateGradient(); + + if (this.animated() && !this.reducedMotion.reduced()) { + const id = `mesh-gradient-${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.updateGradient(); + } + + private updateGradient(): void { + const host = this.el.nativeElement as HTMLElement; + const content = host.querySelector('.fl-mesh-gradient__content') as HTMLElement; + + if (!content) return; + + const colors = this.colors(); + const gradients: string[] = []; + + // Create multiple radial gradients at different positions + colors.forEach((color, i) => { + const angle = (i / colors.length) * Math.PI * 2 + this.time; + const x = 50 + Math.cos(angle) * 30; + const y = 50 + Math.sin(angle) * 30; + const size = 40 + Math.sin(this.time + i) * 10; + + gradients.push( + `radial-gradient(circle at ${x}% ${y}%, ${color} 0%, transparent ${size}%)` + ); + }); + + content.style.backgroundImage = gradients.join(', '); + } +} diff --git a/src/components/fl-mesh-gradient/index.ts b/src/components/fl-mesh-gradient/index.ts new file mode 100644 index 0000000..de97029 --- /dev/null +++ b/src/components/fl-mesh-gradient/index.ts @@ -0,0 +1 @@ +export * from './fl-mesh-gradient.component'; diff --git a/src/components/fl-morph-shape/fl-morph-shape.component.scss b/src/components/fl-morph-shape/fl-morph-shape.component.scss new file mode 100644 index 0000000..e12d64b --- /dev/null +++ b/src/components/fl-morph-shape/fl-morph-shape.component.scss @@ -0,0 +1,14 @@ +:host { + display: block; + width: 100%; + height: 100%; +} + +.fl-morph-shape__svg { + width: 100%; + height: 100%; +} + +.fl-morph-shape__path { + transition: d 0.3s ease, fill 0.3s ease; +} diff --git a/src/components/fl-morph-shape/fl-morph-shape.component.ts b/src/components/fl-morph-shape/fl-morph-shape.component.ts new file mode 100644 index 0000000..15086a3 --- /dev/null +++ b/src/components/fl-morph-shape/fl-morph-shape.component.ts @@ -0,0 +1,99 @@ +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-morph-shape', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + + + `, + styleUrl: './fl-morph-shape.component.scss', + host: { 'class': 'fl-morph-shape' }, +}) +export class FlMorphShapeComponent { + private reducedMotion = inject(ReducedMotionService); + private animationLoop = inject(AnimationLoopService); + private destroyRef = inject(DestroyRef); + + // Inputs + readonly paths = input([ + 'M 50,10 L 90,90 L 10,90 Z', // Triangle + 'M 10,50 L 50,10 L 90,50 L 50,90 Z', // Diamond + ]); + readonly duration = input(2); + readonly colors = input(['#ff6b6b']); + readonly loop = input(true); + + protected readonly currentPath = signal(''); + protected readonly currentColor = signal('#ff6b6b'); + + private loopCleanup: (() => void) | null = null; + private elapsed = 0; + private currentIndex = 0; + + constructor() { + afterNextRender(() => { + this.currentPath.set(this.paths()[0] || ''); + this.currentColor.set(this.colors()[0] || '#ff6b6b'); + + if (!this.reducedMotion.reduced() && this.paths().length > 1) { + const id = `morph-shape-${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; + const paths = this.paths(); + const colors = this.colors(); + const duration = this.duration(); + + if (this.elapsed >= duration) { + this.elapsed = 0; + this.currentIndex++; + + if (this.currentIndex >= paths.length) { + if (this.loop()) { + this.currentIndex = 0; + } else { + this.currentIndex = paths.length - 1; + this.loopCleanup?.(); + return; + } + } + } + + const progress = this.elapsed / duration; + const fromIndex = this.currentIndex; + const toIndex = (this.currentIndex + 1) % paths.length; + + // Simple path interpolation (in real implementation, would use more sophisticated morphing) + this.currentPath.set(progress < 0.5 ? paths[fromIndex] : paths[toIndex]); + + // Color interpolation + if (colors.length > 1) { + const colorIndex = Math.floor((fromIndex / paths.length) * colors.length); + this.currentColor.set(colors[colorIndex] || colors[0]); + } + } +} diff --git a/src/components/fl-morph-shape/index.ts b/src/components/fl-morph-shape/index.ts new file mode 100644 index 0000000..863fac8 --- /dev/null +++ b/src/components/fl-morph-shape/index.ts @@ -0,0 +1 @@ +export * from './fl-morph-shape.component'; diff --git a/src/components/fl-noise-texture/fl-noise-texture.component.scss b/src/components/fl-noise-texture/fl-noise-texture.component.scss new file mode 100644 index 0000000..b078497 --- /dev/null +++ b/src/components/fl-noise-texture/fl-noise-texture.component.scss @@ -0,0 +1,20 @@ +:host { + display: block; + width: 100%; + height: 100%; + pointer-events: none; + position: absolute; + top: 0; + left: 0; +} + +.fl-noise-texture__svg { + width: 100%; + height: 100%; +} + +:host.fl-noise-texture--reduced-motion { + .fl-noise-texture__svg animate { + display: none; + } +} diff --git a/src/components/fl-noise-texture/fl-noise-texture.component.ts b/src/components/fl-noise-texture/fl-noise-texture.component.ts new file mode 100644 index 0000000..e66ea8c --- /dev/null +++ b/src/components/fl-noise-texture/fl-noise-texture.component.ts @@ -0,0 +1,70 @@ +import { + Component, ChangeDetectionStrategy, input, inject, computed, + DestroyRef, +} from '@angular/core'; +import { ReducedMotionService } from '../../services/reduced-motion.service'; + +@Component({ + selector: 'fl-noise-texture', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + + + + @if (animated() && !reducedMotion.reduced()) { + + } + + + + + + + + + + + `, + styleUrl: './fl-noise-texture.component.scss', + host: { + 'class': 'fl-noise-texture', + '[class.fl-noise-texture--reduced-motion]': 'reducedMotion.reduced()', + }, +}) +export class FlNoiseTextureComponent { + protected reducedMotion = inject(ReducedMotionService); + private destroyRef = inject(DestroyRef); + + // Inputs + readonly opacity = input(0.15); + readonly scale = input(1); + readonly animated = input(true); + + // Computed + readonly filterId = computed(() => `fl-noise-${Math.random().toString(36).slice(2)}`); + readonly baseFrequency = computed(() => 0.65 * this.scale()); + readonly animateToFrequency = computed(() => 0.85 * this.scale()); +} diff --git a/src/components/fl-noise-texture/index.ts b/src/components/fl-noise-texture/index.ts new file mode 100644 index 0000000..604a979 --- /dev/null +++ b/src/components/fl-noise-texture/index.ts @@ -0,0 +1 @@ +export * from './fl-noise-texture.component'; diff --git a/src/components/fl-parallax-layer/fl-parallax-layer.component.scss b/src/components/fl-parallax-layer/fl-parallax-layer.component.scss new file mode 100644 index 0000000..8fccaca --- /dev/null +++ b/src/components/fl-parallax-layer/fl-parallax-layer.component.scss @@ -0,0 +1,9 @@ +.fl-parallax-layer { + display: block; + position: relative; + overflow: hidden; + + &__content { + will-change: transform; + } +} diff --git a/src/components/fl-parallax-layer/fl-parallax-layer.component.ts b/src/components/fl-parallax-layer/fl-parallax-layer.component.ts new file mode 100644 index 0000000..b88d6bb --- /dev/null +++ b/src/components/fl-parallax-layer/fl-parallax-layer.component.ts @@ -0,0 +1,70 @@ +import { + Component, ChangeDetectionStrategy, input, inject, + ElementRef, afterNextRender, DestroyRef, +} from '@angular/core'; +import { ReducedMotionService } from '../../services/reduced-motion.service'; + +@Component({ + selector: 'fl-parallax-layer', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ +
+ `, + styleUrl: './fl-parallax-layer.component.scss', + host: { 'class': 'fl-parallax-layer' }, +}) +export class FlParallaxLayerComponent { + private el = inject(ElementRef); + private reducedMotion = inject(ReducedMotionService); + private destroyRef = inject(DestroyRef); + + // Inputs + readonly depth = input(0.5); // 0 = no movement, 1 = full viewport movement + readonly direction = input<'vertical' | 'horizontal'>('vertical'); + + private scrollHandler: (() => void) | null = null; + + constructor() { + afterNextRender(() => { + if (this.reducedMotion.reduced()) return; + + this.scrollHandler = this.onScroll.bind(this); + window.addEventListener('scroll', this.scrollHandler, { passive: true }); + this.onScroll(); + }); + + this.destroyRef.onDestroy(() => { + if (this.scrollHandler) { + window.removeEventListener('scroll', this.scrollHandler); + } + }); + } + + private onScroll(): void { + const host = this.el.nativeElement as HTMLElement; + const content = host.querySelector('.fl-parallax-layer__content') as HTMLElement; + + if (!content) return; + + const rect = host.getBoundingClientRect(); + const windowHeight = window.innerHeight; + + // Calculate how much the element is in view + const inView = Math.max(0, Math.min(1, (windowHeight - rect.top) / (windowHeight + rect.height))); + + const depth = this.depth(); + const direction = this.direction(); + const maxMove = 100; // Maximum pixels to move + + const movement = (inView - 0.5) * 2 * maxMove * depth; + + if (direction === 'vertical') { + content.style.transform = `translateY(${-movement}px)`; + } else { + content.style.transform = `translateX(${-movement}px)`; + } + } +} diff --git a/src/components/fl-parallax-layer/index.ts b/src/components/fl-parallax-layer/index.ts new file mode 100644 index 0000000..e48ebf2 --- /dev/null +++ b/src/components/fl-parallax-layer/index.ts @@ -0,0 +1 @@ +export * from './fl-parallax-layer.component'; diff --git a/src/components/fl-particle-field/fl-particle-field.component.scss b/src/components/fl-particle-field/fl-particle-field.component.scss new file mode 100644 index 0000000..4901787 --- /dev/null +++ b/src/components/fl-particle-field/fl-particle-field.component.scss @@ -0,0 +1,21 @@ +:host { + display: block; + position: relative; + overflow: hidden; + width: 100%; + height: 100%; + + canvas { + display: block; + position: absolute; + inset: 0; + width: 100%; + height: 100%; + pointer-events: none; + } + + ::ng-deep > :not(canvas) { + position: relative; + z-index: 1; + } +} diff --git a/src/components/fl-particle-field/fl-particle-field.component.ts b/src/components/fl-particle-field/fl-particle-field.component.ts new file mode 100644 index 0000000..2be6c0d --- /dev/null +++ b/src/components/fl-particle-field/fl-particle-field.component.ts @@ -0,0 +1,246 @@ +import { + Component, ChangeDetectionStrategy, input, output, signal, inject, + viewChild, ElementRef, effect, afterNextRender, DestroyRef, +} from '@angular/core'; +import { ReducedMotionService } from '../../services/reduced-motion.service'; +import { AnimationLoopService } from '../../services/animation-loop.service'; +import { CursorTrackerService } from '../../services/cursor-tracker.service'; +import { FL_CONFIG } from '../../providers/flair-config.provider'; +import { getPixelRatio } from '../../utils/canvas.utils'; +import { randomInRange, distance2D, lerp } from '../../utils/math.utils'; +import type { FlParticlePreset, FlParticle, FlParticleDirection, FlParticleShape } from '../../types/particle.types'; + +const PRESET_CONFIGS: Record> = { + stars: { speed: 0.2, sizeRange: [1, 3], colors: ['#ffffff', '#e0e7ff', '#c7d2fe'], connections: true, connectionDistance: 120, direction: 'none', shape: 'circle', opacity: 0.8 }, + snow: { speed: 1, sizeRange: [2, 5], colors: ['#ffffff', '#f0f9ff', '#e0f2fe'], connections: false, direction: 'down', shape: 'circle', opacity: 0.7 }, + fireflies: { speed: 0.5, sizeRange: [2, 4], colors: ['#fde68a', '#fbbf24', '#f59e0b'], connections: false, direction: 'random', shape: 'circle', opacity: 0.9 }, + dust: { speed: 0.3, sizeRange: [1, 2], colors: ['#d1d5db', '#9ca3af', '#6b7280'], connections: false, direction: 'random', shape: 'circle', opacity: 0.4 }, + bubbles: { speed: 0.8, sizeRange: [4, 12], colors: ['rgba(99,102,241,0.3)', 'rgba(139,92,246,0.3)', 'rgba(236,72,153,0.3)'], connections: false, direction: 'up', shape: 'circle', opacity: 0.5 }, + custom: {}, +}; + +@Component({ + selector: 'fl-particle-field', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + + `, + styleUrl: './fl-particle-field.component.scss', + host: { 'class': 'fl-particle-field' }, +}) +export class FlParticleFieldComponent { + private config = inject(FL_CONFIG); + private reducedMotion = inject(ReducedMotionService); + private animationLoop = inject(AnimationLoopService); + private cursorTracker = inject(CursorTrackerService); + private destroyRef = inject(DestroyRef); + + protected canvasRef = viewChild.required>('canvas'); + + // Inputs + readonly preset = input('stars'); + readonly count = input(this.config.defaultParticleCount); + readonly speed = input(1); + readonly sizeRange = input<[number, number]>([1, 3]); + readonly colors = input(['#ffffff']); + readonly connections = input(false); + readonly connectionDistance = input(100); + readonly connectionOpacity = input(0.3); + readonly mouseInteraction = input(false); + readonly mouseRadius = input(150); + readonly mouseForce = input(0.5); + readonly opacity = input(1); + readonly direction = input('none'); + readonly responsive = input(true); + + // Outputs + readonly ready = output(); + + // Internal + private particles: FlParticle[] = []; + private ctx: CanvasRenderingContext2D | null = null; + private width = 0; + private height = 0; + private pixelRatio = 1; + private loopCleanup: (() => void) | null = null; + private cursorCleanup: (() => 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.cursorCleanup?.(); + 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.createParticles(); + + if (this.mouseInteraction()) { + this.cursorCleanup = this.cursorTracker.subscribe(); + } + + const id = `particle-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 presetConfig = PRESET_CONFIGS[this.preset()] ?? {}; + const count = this.count(); + const sr = presetConfig.sizeRange ?? this.sizeRange(); + const cols = presetConfig.colors ?? this.colors(); + const dir = presetConfig.direction ?? this.direction(); + const spd = (presetConfig.speed ?? this.speed()) * 60; // per-second velocity + + this.particles = []; + for (let i = 0; i < count; i++) { + const vx = dir === 'left' ? -spd : dir === 'right' ? spd : dir === 'random' || dir === 'none' ? randomInRange(-spd, spd) : 0; + const vy = dir === 'up' ? -spd : dir === 'down' ? spd : dir === 'random' || dir === 'none' ? randomInRange(-spd, spd) : 0; + + this.particles.push({ + x: randomInRange(0, this.width), + y: randomInRange(0, this.height), + vx, + vy, + size: randomInRange(sr[0], sr[1]), + color: cols[i % cols.length], + opacity: presetConfig.opacity ?? this.opacity(), + life: 1, + maxLife: 1, + }); + } + } + + private tick(delta: number): void { + if (!this.ctx) return; + + const w = this.width; + const h = this.height; + const ctx = this.ctx; + ctx.clearRect(0, 0, w, h); + + const mouseInteraction = this.mouseInteraction(); + const mousePos = mouseInteraction ? this.cursorTracker.position() : null; + const mouseRadius = this.mouseRadius(); + const mouseForce = this.mouseForce(); + + const useConnections = (PRESET_CONFIGS[this.preset()]?.connections ?? this.connections()); + const connDist = PRESET_CONFIGS[this.preset()]?.connectionDistance ?? this.connectionDistance(); + const connOpacity = this.connectionOpacity(); + + // Update particles + for (const p of this.particles) { + p.x += p.vx * delta; + p.y += p.vy * delta; + + // Mouse repulsion + if (mouseInteraction && mousePos) { + const d = distance2D(p.x, p.y, mousePos.x, mousePos.y); + if (d < mouseRadius && d > 0) { + const force = (1 - d / mouseRadius) * mouseForce * 60 * delta; + p.x += ((p.x - mousePos.x) / d) * force; + p.y += ((p.y - mousePos.y) / d) * force; + } + } + + // Wrap around edges + if (p.x < -p.size) p.x = w + p.size; + if (p.x > w + p.size) p.x = -p.size; + if (p.y < -p.size) p.y = h + p.size; + if (p.y > h + p.size) p.y = -p.size; + } + + // Draw connections + if (useConnections) { + 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(); + } + } + } + } + + // Draw particles + for (const p of this.particles) { + ctx.globalAlpha = p.opacity; + ctx.fillStyle = p.color; + ctx.beginPath(); + ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); + ctx.fill(); + } + 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(); + + // Draw a single static frame + for (const p of this.particles) { + this.ctx.globalAlpha = p.opacity * 0.5; + this.ctx.fillStyle = p.color; + this.ctx.beginPath(); + this.ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); + this.ctx.fill(); + } + this.ctx.globalAlpha = 1; + this.ready.emit(); + } +} diff --git a/src/components/fl-particle-field/index.ts b/src/components/fl-particle-field/index.ts new file mode 100644 index 0000000..7c830c8 --- /dev/null +++ b/src/components/fl-particle-field/index.ts @@ -0,0 +1 @@ +export * from './fl-particle-field.component'; diff --git a/src/components/fl-scramble-reveal/fl-scramble-reveal.component.scss b/src/components/fl-scramble-reveal/fl-scramble-reveal.component.scss new file mode 100644 index 0000000..b64ca86 --- /dev/null +++ b/src/components/fl-scramble-reveal/fl-scramble-reveal.component.scss @@ -0,0 +1,5 @@ +.fl-scramble-reveal { + display: inline-block; + font-family: monospace; + white-space: pre-wrap; +} diff --git a/src/components/fl-scramble-reveal/fl-scramble-reveal.component.ts b/src/components/fl-scramble-reveal/fl-scramble-reveal.component.ts new file mode 100644 index 0000000..815307e --- /dev/null +++ b/src/components/fl-scramble-reveal/fl-scramble-reveal.component.ts @@ -0,0 +1,83 @@ +import { + Component, ChangeDetectionStrategy, input, inject, + afterNextRender, DestroyRef, signal, +} from '@angular/core'; +import { ReducedMotionService } from '../../services/reduced-motion.service'; + +@Component({ + selector: 'fl-scramble-reveal', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: `{{ displayText() }}`, + styleUrl: './fl-scramble-reveal.component.scss', + host: { 'class': 'fl-scramble-reveal' }, +}) +export class FlScrambleRevealComponent { + private reducedMotion = inject(ReducedMotionService); + private destroyRef = inject(DestroyRef); + + // Inputs + readonly text = input.required(); + readonly speed = input(30); + readonly characters = input('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*'); + + // Internal + readonly displayText = signal(''); + + private currentIndex = 0; + private scrambleCount = 0; + private maxScrambles = 3; + private interval: any = null; + + constructor() { + afterNextRender(() => { + if (this.reducedMotion.reduced()) { + this.displayText.set(this.text()); + return; + } + + this.startScramble(); + }); + + this.destroyRef.onDestroy(() => { + if (this.interval) { + clearInterval(this.interval); + } + }); + } + + private startScramble(): void { + const targetText = this.text(); + this.currentIndex = 0; + this.scrambleCount = 0; + + this.interval = setInterval(() => { + if (this.currentIndex >= targetText.length) { + clearInterval(this.interval); + this.displayText.set(targetText); + return; + } + + if (this.scrambleCount < this.maxScrambles) { + // Scramble current position + const revealed = targetText.slice(0, this.currentIndex); + const scrambled = this.getRandomChar(); + const remaining = targetText.slice(this.currentIndex + 1).split('').map(() => this.getRandomChar()).join(''); + this.displayText.set(revealed + scrambled + remaining); + this.scrambleCount++; + } else { + // Reveal current character + this.currentIndex++; + this.scrambleCount = 0; + const revealed = targetText.slice(0, this.currentIndex); + const remaining = targetText.slice(this.currentIndex).split('').map(() => this.getRandomChar()).join(''); + this.displayText.set(revealed + remaining); + } + }, this.speed()); + } + + private getRandomChar(): string { + const chars = this.characters(); + return chars[Math.floor(Math.random() * chars.length)]; + } +} diff --git a/src/components/fl-scramble-reveal/index.ts b/src/components/fl-scramble-reveal/index.ts new file mode 100644 index 0000000..a71f789 --- /dev/null +++ b/src/components/fl-scramble-reveal/index.ts @@ -0,0 +1 @@ +export * from './fl-scramble-reveal.component'; diff --git a/src/components/fl-scroll-progress/fl-scroll-progress.component.scss b/src/components/fl-scroll-progress/fl-scroll-progress.component.scss new file mode 100644 index 0000000..763dff7 --- /dev/null +++ b/src/components/fl-scroll-progress/fl-scroll-progress.component.scss @@ -0,0 +1,20 @@ +.fl-scroll-progress { + position: fixed; + left: 0; + width: 100%; + overflow: hidden; + + &--top { + top: 0; + } + + &--bottom { + bottom: 0; + } + + &__bar { + height: 100%; + transition: width 0.1s ease-out; + transform-origin: left; + } +} diff --git a/src/components/fl-scroll-progress/fl-scroll-progress.component.ts b/src/components/fl-scroll-progress/fl-scroll-progress.component.ts new file mode 100644 index 0000000..4765af2 --- /dev/null +++ b/src/components/fl-scroll-progress/fl-scroll-progress.component.ts @@ -0,0 +1,80 @@ +import { + Component, ChangeDetectionStrategy, input, inject, + ElementRef, afterNextRender, DestroyRef, signal, +} from '@angular/core'; +import { ReducedMotionService } from '../../services/reduced-motion.service'; + +@Component({ + selector: 'fl-scroll-progress', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ `, + styleUrl: './fl-scroll-progress.component.scss', + host: { + 'class': 'fl-scroll-progress', + '[class.fl-scroll-progress--top]': 'position() === "top"', + '[class.fl-scroll-progress--bottom]': 'position() === "bottom"', + }, +}) +export class FlScrollProgressComponent { + private el = inject(ElementRef); + private reducedMotion = inject(ReducedMotionService); + private destroyRef = inject(DestroyRef); + + // Inputs + readonly color = input('#4ecdc4'); + readonly height = input(4); + readonly position = input<'top' | 'bottom'>('top'); + readonly zIndex = input(1000); + + // Internal + readonly progress = signal(0); + + private scrollHandler: (() => void) | null = null; + + constructor() { + afterNextRender(() => { + this.updateStyles(); + this.scrollHandler = this.onScroll.bind(this); + window.addEventListener('scroll', this.scrollHandler, { passive: true }); + this.onScroll(); + }); + + this.destroyRef.onDestroy(() => { + if (this.scrollHandler) { + window.removeEventListener('scroll', this.scrollHandler); + } + }); + } + + private onScroll(): void { + const windowHeight = window.innerHeight; + const documentHeight = document.documentElement.scrollHeight; + const scrollTop = window.scrollY || document.documentElement.scrollTop; + + const scrollableHeight = documentHeight - windowHeight; + const scrolled = scrollableHeight > 0 ? (scrollTop / scrollableHeight) * 100 : 0; + + this.progress.set(Math.min(100, Math.max(0, scrolled))); + } + + private updateStyles(): void { + const host = this.el.nativeElement as HTMLElement; + const bar = host.querySelector('.fl-scroll-progress__bar') as HTMLElement; + + if (!bar) return; + + const color = this.color(); + const height = this.height(); + const zIndex = this.zIndex(); + + host.style.height = `${height}px`; + host.style.zIndex = `${zIndex}`; + bar.style.backgroundColor = color; + } +} diff --git a/src/components/fl-scroll-progress/index.ts b/src/components/fl-scroll-progress/index.ts new file mode 100644 index 0000000..6993117 --- /dev/null +++ b/src/components/fl-scroll-progress/index.ts @@ -0,0 +1 @@ +export * from './fl-scroll-progress.component'; diff --git a/src/components/fl-signal-strength/fl-signal-strength.component.scss b/src/components/fl-signal-strength/fl-signal-strength.component.scss new file mode 100644 index 0000000..3db0837 --- /dev/null +++ b/src/components/fl-signal-strength/fl-signal-strength.component.scss @@ -0,0 +1,40 @@ +:host { + display: inline-flex; + align-items: flex-end; +} + +.fl-signal-strength__container { + display: inline-flex; + align-items: flex-end; + gap: 2px; + height: 100%; +} + +.fl-signal-strength__bar { + border-radius: 2px; + opacity: 0.3; + transition: opacity 0.3s ease; + + &.fl-signal-strength__bar--active { + opacity: 1; + + &.fl-signal-strength__bar--animated { + animation: fl-signal-strength-pulse 1.5s ease-in-out infinite; + } + } +} + +@keyframes fl-signal-strength-pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +:host.fl-signal-strength--reduced-motion { + .fl-signal-strength__bar { + animation: none !important; + } +} diff --git a/src/components/fl-signal-strength/fl-signal-strength.component.ts b/src/components/fl-signal-strength/fl-signal-strength.component.ts new file mode 100644 index 0000000..4f313c6 --- /dev/null +++ b/src/components/fl-signal-strength/fl-signal-strength.component.ts @@ -0,0 +1,56 @@ +import { + Component, ChangeDetectionStrategy, input, inject, computed, + DestroyRef, +} from '@angular/core'; +import { ReducedMotionService } from '../../services/reduced-motion.service'; + +@Component({ + selector: 'fl-signal-strength', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ @for (bar of bars(); track bar.index) { +
+ } +
+ `, + styleUrl: './fl-signal-strength.component.scss', + host: { + 'class': 'fl-signal-strength', + '[class.fl-signal-strength--reduced-motion]': 'reducedMotion.reduced()', + }, +}) +export class FlSignalStrengthComponent { + private reducedMotion = inject(ReducedMotionService); + private destroyRef = inject(DestroyRef); + + // Inputs + readonly level = input(4); + readonly color = input('#3b82f6'); + readonly size = input(24); + readonly animated = input(true); + + // Computed + readonly barWidth = computed(() => this.size() / 6); + readonly bars = computed(() => { + const currentLevel = Math.max(0, Math.min(4, this.level())); + const baseSize = this.size(); + + return Array.from({ length: 4 }, (_, i) => { + const height = (baseSize / 4) * (i + 1); + const active = i < currentLevel; + const delay = i * 0.1; + + return { index: i, height, active, delay }; + }); + }); +} diff --git a/src/components/fl-signal-strength/index.ts b/src/components/fl-signal-strength/index.ts new file mode 100644 index 0000000..79f0a09 --- /dev/null +++ b/src/components/fl-signal-strength/index.ts @@ -0,0 +1 @@ +export * from './fl-signal-strength.component'; diff --git a/src/components/fl-sparkle/fl-sparkle.component.scss b/src/components/fl-sparkle/fl-sparkle.component.scss new file mode 100644 index 0000000..e18e8be --- /dev/null +++ b/src/components/fl-sparkle/fl-sparkle.component.scss @@ -0,0 +1,18 @@ +:host { + display: block; + position: relative; + width: 100%; + height: 100%; + pointer-events: none; +} + +.fl-sparkle__item { + position: absolute; + will-change: transform, opacity; + + svg { + display: block; + width: 100%; + height: 100%; + } +} diff --git a/src/components/fl-sparkle/fl-sparkle.component.ts b/src/components/fl-sparkle/fl-sparkle.component.ts new file mode 100644 index 0000000..36f8ea6 --- /dev/null +++ b/src/components/fl-sparkle/fl-sparkle.component.ts @@ -0,0 +1,141 @@ +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 Sparkle { + id: string; + x: number; + y: number; + color: string; + size: number; + rotation: number; + opacity: number; + life: number; + maxLife: number; +} + +@Component({ + selector: 'fl-sparkle', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + @for (sparkle of sparkles(); track sparkle.id) { +
+ + + +
+ } + `, + styleUrl: './fl-sparkle.component.scss', + host: { + 'class': 'fl-sparkle', + }, +}) +export class FlSparkleComponent { + private el = inject(ElementRef); + private reducedMotion = inject(ReducedMotionService); + private animationLoop = inject(AnimationLoopService); + private destroyRef = inject(DestroyRef); + + // Inputs + readonly active = input(false); + readonly count = input(12); + readonly colors = input(['#fbbf24', '#f59e0b', '#fef3c7']); + readonly size = input(20); + + // Internal + private sparkleData: Sparkle[] = []; + private loopCleanup: (() => void) | null = null; + private isAnimating = false; + + // Signals + readonly sparkles = signal([]); + + 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.sparkleData = []; + const count = this.count(); + const colors = this.colors(); + const size = this.size(); + + for (let i = 0; i < count; i++) { + const angle = (Math.PI * 2 * i) / count; + const distance = 30 + Math.random() * 20; + this.sparkleData.push({ + id: `sparkle-${Date.now()}-${i}`, + x: 50 + Math.cos(angle) * distance, + y: 50 + Math.sin(angle) * distance, + color: colors[Math.floor(Math.random() * colors.length)], + size: size * (0.5 + Math.random() * 0.5), + rotation: Math.random() * 360, + opacity: 0, + life: 1, + maxLife: 1, + }); + } + + this.isAnimating = true; + const id = `sparkle-${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 sparkle of this.sparkleData) { + sparkle.life -= delta; + + // Fade in then fade out + if (sparkle.life > 0.5) { + sparkle.opacity = Math.min(1, (1 - sparkle.life) * 4); + } else { + sparkle.opacity = sparkle.life * 2; + } + + if (sparkle.life > 0) { + activeCount++; + } + } + + this.sparkles.set([...this.sparkleData]); + + if (activeCount === 0) { + this.isAnimating = false; + this.sparkles.set([]); + this.loopCleanup?.(); + this.loopCleanup = null; + } + } +} diff --git a/src/components/fl-sparkle/index.ts b/src/components/fl-sparkle/index.ts new file mode 100644 index 0000000..ee8c252 --- /dev/null +++ b/src/components/fl-sparkle/index.ts @@ -0,0 +1 @@ +export * from './fl-sparkle.component'; diff --git a/src/components/fl-spotlight-card/fl-spotlight-card.component.scss b/src/components/fl-spotlight-card/fl-spotlight-card.component.scss new file mode 100644 index 0000000..d812803 --- /dev/null +++ b/src/components/fl-spotlight-card/fl-spotlight-card.component.scss @@ -0,0 +1,27 @@ +:host { + display: block; + position: relative; + overflow: hidden; + border-radius: inherit; + + @media (prefers-reduced-motion: reduce) { + .fl-spotlight-card__glow { + display: none; + } + } +} + +.fl-spotlight-card__content { + position: relative; + z-index: 1; +} + +.fl-spotlight-card__glow { + position: absolute; + inset: 0; + z-index: 2; + pointer-events: none; + opacity: 0; + transition: opacity 300ms ease; + border-radius: inherit; +} diff --git a/src/components/fl-spotlight-card/fl-spotlight-card.component.ts b/src/components/fl-spotlight-card/fl-spotlight-card.component.ts new file mode 100644 index 0000000..6bcdcf3 --- /dev/null +++ b/src/components/fl-spotlight-card/fl-spotlight-card.component.ts @@ -0,0 +1,85 @@ +import { + Component, ChangeDetectionStrategy, input, inject, + ElementRef, afterNextRender, DestroyRef, +} 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 { clamp } from '../../utils/math.utils'; + +@Component({ + selector: 'fl-spotlight-card', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ +
+
+ `, + styleUrl: './fl-spotlight-card.component.scss', + host: { + 'class': 'fl-spotlight-card', + '(mouseenter)': 'onEnter()', + '(mouseleave)': 'onLeave()', + }, +}) +export class FlSpotlightCardComponent { + private el = inject(ElementRef); + private reducedMotion = inject(ReducedMotionService); + private cursorTracker = inject(CursorTrackerService); + private animationLoop = inject(AnimationLoopService); + private destroyRef = inject(DestroyRef); + + // Inputs + readonly color = input('rgba(255, 255, 255, 0.15)'); + readonly radius = input(200); + readonly intensity = input(1); + readonly blend = input('soft-light'); + + private isHovered = false; + private cursorCleanup: (() => void) | null = null; + private loopCleanup: (() => void) | null = null; + + constructor() { + afterNextRender(() => { + if (this.reducedMotion.reduced()) return; + this.cursorCleanup = this.cursorTracker.subscribe(); + const id = `spotlight-${Math.random().toString(36).slice(2)}`; + this.loopCleanup = this.animationLoop.register(id, () => this.tick()); + }); + + this.destroyRef.onDestroy(() => { + this.cursorCleanup?.(); + this.loopCleanup?.(); + }); + } + + protected onEnter(): void { + this.isHovered = true; + } + + protected onLeave(): void { + this.isHovered = false; + const glow = (this.el.nativeElement as HTMLElement).querySelector('.fl-spotlight-card__glow') as HTMLElement | null; + if (glow) { + glow.style.opacity = '0'; + } + } + + private tick(): void { + const host = this.el.nativeElement as HTMLElement; + const glow = host.querySelector('.fl-spotlight-card__glow') as HTMLElement | null; + if (!glow || !this.isHovered) return; + + const rect = host.getBoundingClientRect(); + const pos = this.cursorTracker.position(); + const relX = clamp((pos.x - rect.left) / rect.width * 100, 0, 100); + const relY = clamp((pos.y - rect.top) / rect.height * 100, 0, 100); + const radius = this.radius(); + + glow.style.opacity = `${this.intensity()}`; + glow.style.background = `radial-gradient(circle ${radius}px at ${relX}% ${relY}%, ${this.color()}, transparent)`; + glow.style.mixBlendMode = this.blend(); + } +} diff --git a/src/components/fl-spotlight-card/index.ts b/src/components/fl-spotlight-card/index.ts new file mode 100644 index 0000000..e3679df --- /dev/null +++ b/src/components/fl-spotlight-card/index.ts @@ -0,0 +1 @@ +export * from './fl-spotlight-card.component'; diff --git a/src/components/fl-terminal-output/fl-terminal-output.component.scss b/src/components/fl-terminal-output/fl-terminal-output.component.scss new file mode 100644 index 0000000..95018f2 --- /dev/null +++ b/src/components/fl-terminal-output/fl-terminal-output.component.scss @@ -0,0 +1,79 @@ +:host { + display: block; +} + +.fl-terminal-output__terminal { + font-family: 'Courier New', Courier, monospace; + padding: 1rem; + border-radius: 0.5rem; + + &[data-theme='dark'] { + background: #1a1a1a; + color: #00ff00; + } + + &[data-theme='green'] { + background: #0a0a0a; + color: #00ff41; + } + + &[data-theme='amber'] { + background: #000000; + color: #ffb000; + } +} + +.fl-terminal-output__line { + margin-bottom: 0.5rem; + white-space: pre-wrap; + word-break: break-word; + animation: terminal-line-appear 0.2s ease; + + &:last-child { + margin-bottom: 0; + } +} + +.fl-terminal-output__prefix { + margin-right: 0.5rem; + opacity: 0.7; +} + +.fl-terminal-output__text { + font-size: 0.875rem; + line-height: 1.5; +} + +.fl-terminal-output__cursor { + display: inline-block; + animation: terminal-cursor-blink 1s step-end infinite; + margin-left: 0.25rem; +} + +.fl-terminal-output__terminal--reduced-motion .fl-terminal-output__line { + animation: none; +} + +.fl-terminal-output__terminal--reduced-motion .fl-terminal-output__cursor { + animation: none; +} + +@keyframes terminal-line-appear { + from { + opacity: 0; + transform: translateX(-0.5rem); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes terminal-cursor-blink { + 0%, 50% { + opacity: 1; + } + 51%, 100% { + opacity: 0; + } +} diff --git a/src/components/fl-terminal-output/fl-terminal-output.component.ts b/src/components/fl-terminal-output/fl-terminal-output.component.ts new file mode 100644 index 0000000..0d373ed --- /dev/null +++ b/src/components/fl-terminal-output/fl-terminal-output.component.ts @@ -0,0 +1,86 @@ +import { + Component, ChangeDetectionStrategy, input, inject, + DestroyRef, afterNextRender, signal, +} from '@angular/core'; +import { ReducedMotionService } from '../../services/reduced-motion.service'; + +@Component({ + selector: 'fl-terminal-output', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ @for (line of visibleLines(); track $index) { +
+ {{ prefix() }} + {{ line }} +
+ } + @if (cursor() && !isComplete()) { + _ + } +
+ `, + styleUrl: './fl-terminal-output.component.scss', + host: { 'class': 'fl-terminal-output' }, +}) +export class FlTerminalOutputComponent { + protected reducedMotion = inject(ReducedMotionService); + private destroyRef = inject(DestroyRef); + + // Inputs + readonly lines = input([]); + readonly speed = input(100); + readonly prefix = input('$'); + readonly cursor = input(true); + readonly theme = input<'dark' | 'green' | 'amber'>('dark'); + + protected readonly visibleLines = signal([]); + private currentLineIndex = 0; + private timerId: number | null = null; + + constructor() { + afterNextRender(() => { + if (this.reducedMotion.reduced()) { + this.visibleLines.set(this.lines()); + } else { + this.startAnimation(); + } + }); + + this.destroyRef.onDestroy(() => { + if (this.timerId !== null) { + clearTimeout(this.timerId); + } + }); + } + + protected isComplete(): boolean { + return this.currentLineIndex >= this.lines().length; + } + + private startAnimation(): void { + this.showNextLine(); + } + + private showNextLine(): void { + if (this.currentLineIndex >= this.lines().length) { + return; + } + + const currentLines = this.visibleLines(); + currentLines.push(this.lines()[this.currentLineIndex]); + this.visibleLines.set([...currentLines]); + this.currentLineIndex++; + + if (this.currentLineIndex < this.lines().length) { + this.timerId = setTimeout(() => { + this.showNextLine(); + }, this.speed()) as unknown as number; + } + } +} diff --git a/src/components/fl-terminal-output/index.ts b/src/components/fl-terminal-output/index.ts new file mode 100644 index 0000000..87e0cda --- /dev/null +++ b/src/components/fl-terminal-output/index.ts @@ -0,0 +1 @@ +export * from './fl-terminal-output.component'; diff --git a/src/components/fl-text-shimmer/fl-text-shimmer.component.scss b/src/components/fl-text-shimmer/fl-text-shimmer.component.scss new file mode 100644 index 0000000..f9b3603 --- /dev/null +++ b/src/components/fl-text-shimmer/fl-text-shimmer.component.scss @@ -0,0 +1,28 @@ +.fl-text-shimmer { + --fl-shimmer-color: rgba(255, 255, 255, 0.8); + --fl-shimmer-duration: 2000ms; + --fl-shimmer-angle: 45deg; + + display: inline-block; + position: relative; + background: linear-gradient( + var(--fl-shimmer-angle), + transparent 30%, + var(--fl-shimmer-color) 50%, + transparent 70% + ); + background-size: 200% 100%; + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + animation: fl-text-shimmer var(--fl-shimmer-duration) ease-in-out infinite; +} + +@keyframes fl-text-shimmer { + 0% { + background-position: -200% 0; + } + 100% { + background-position: 200% 0; + } +} diff --git a/src/components/fl-text-shimmer/fl-text-shimmer.component.ts b/src/components/fl-text-shimmer/fl-text-shimmer.component.ts new file mode 100644 index 0000000..295b32e --- /dev/null +++ b/src/components/fl-text-shimmer/fl-text-shimmer.component.ts @@ -0,0 +1,46 @@ +import { + Component, ChangeDetectionStrategy, input, inject, + ElementRef, afterNextRender, DestroyRef, +} from '@angular/core'; +import { ReducedMotionService } from '../../services/reduced-motion.service'; + +@Component({ + selector: 'fl-text-shimmer', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ``, + styleUrl: './fl-text-shimmer.component.scss', + host: { 'class': 'fl-text-shimmer' }, +}) +export class FlTextShimmerComponent { + private el = inject(ElementRef); + private reducedMotion = inject(ReducedMotionService); + private destroyRef = inject(DestroyRef); + + // Inputs + readonly color = input('rgba(255, 255, 255, 0.8)'); + readonly duration = input(2000); + readonly angle = input(45); + + constructor() { + afterNextRender(() => { + this.updateStyles(); + }); + } + + private updateStyles(): void { + const host = this.el.nativeElement as HTMLElement; + const color = this.color(); + const duration = this.duration(); + const angle = this.angle(); + + if (this.reducedMotion.reduced()) { + host.style.animation = 'none'; + return; + } + + host.style.setProperty('--fl-shimmer-color', color); + host.style.setProperty('--fl-shimmer-duration', `${duration}ms`); + host.style.setProperty('--fl-shimmer-angle', `${angle}deg`); + } +} diff --git a/src/components/fl-text-shimmer/index.ts b/src/components/fl-text-shimmer/index.ts new file mode 100644 index 0000000..1708025 --- /dev/null +++ b/src/components/fl-text-shimmer/index.ts @@ -0,0 +1 @@ +export * from './fl-text-shimmer.component'; diff --git a/src/components/fl-tilt-card/fl-tilt-card.component.scss b/src/components/fl-tilt-card/fl-tilt-card.component.scss new file mode 100644 index 0000000..b9f2240 --- /dev/null +++ b/src/components/fl-tilt-card/fl-tilt-card.component.scss @@ -0,0 +1,25 @@ +:host { + display: block; + will-change: transform; + transform-style: preserve-3d; + transition: transform var(--fl-tilt-speed, 300ms) var(--fl-easing-default); + + @media (prefers-reduced-motion: reduce) { + transform: none !important; + transition: none !important; + } +} + +.fl-tilt-card__inner { + position: relative; + z-index: 1; +} + +.fl-tilt-card__glare { + position: absolute; + inset: 0; + z-index: 2; + border-radius: inherit; + pointer-events: none; + mix-blend-mode: overlay; +} diff --git a/src/components/fl-tilt-card/fl-tilt-card.component.ts b/src/components/fl-tilt-card/fl-tilt-card.component.ts new file mode 100644 index 0000000..541d837 --- /dev/null +++ b/src/components/fl-tilt-card/fl-tilt-card.component.ts @@ -0,0 +1,115 @@ +import { + Component, ChangeDetectionStrategy, input, inject, + ElementRef, afterNextRender, DestroyRef, signal, +} 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, clamp, mapRange } from '../../utils/math.utils'; + +@Component({ + selector: 'fl-tilt-card', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ +
+ @if (glare()) { +
+ } + `, + styleUrl: './fl-tilt-card.component.scss', + host: { + 'class': 'fl-tilt-card', + '(mouseenter)': 'onEnter()', + '(mouseleave)': 'onLeave()', + }, +}) +export class FlTiltCardComponent { + private el = inject(ElementRef); + private reducedMotion = inject(ReducedMotionService); + private cursorTracker = inject(CursorTrackerService); + private animationLoop = inject(AnimationLoopService); + private destroyRef = inject(DestroyRef); + + // Inputs + readonly maxTiltX = input(15); + readonly maxTiltY = input(15); + readonly perspective = input(1000); + readonly scale = input(1.02); + readonly speed = input(0.08); + readonly glare = input(true); + readonly glareMaxOpacity = input(0.3); + readonly reset = input(true); + + // Internal + private currentTiltX = 0; + private currentTiltY = 0; + private targetTiltX = 0; + private targetTiltY = 0; + private isHovered = false; + private cursorCleanup: (() => void) | null = null; + private loopCleanup: (() => void) | null = null; + + constructor() { + afterNextRender(() => { + if (this.reducedMotion.reduced()) return; + this.cursorCleanup = this.cursorTracker.subscribe(); + const id = `tilt-card-${Math.random().toString(36).slice(2)}`; + this.loopCleanup = this.animationLoop.register(id, () => this.tick()); + }); + + this.destroyRef.onDestroy(() => { + this.cursorCleanup?.(); + this.loopCleanup?.(); + }); + } + + protected onEnter(): void { + this.isHovered = true; + } + + protected onLeave(): void { + this.isHovered = false; + if (this.reset()) { + this.targetTiltX = 0; + this.targetTiltY = 0; + } + } + + private tick(): void { + const host = this.el.nativeElement as HTMLElement; + + if (this.isHovered) { + const rect = host.getBoundingClientRect(); + const pos = this.cursorTracker.position(); + const relX = (pos.x - rect.left) / rect.width; + const relY = (pos.y - rect.top) / rect.height; + + this.targetTiltX = mapRange(relY, 0, 1, this.maxTiltX(), -this.maxTiltX()); + this.targetTiltY = mapRange(relX, 0, 1, -this.maxTiltY(), this.maxTiltY()); + } + + const spd = this.speed(); + this.currentTiltX = lerp(this.currentTiltX, this.targetTiltX, spd); + this.currentTiltY = lerp(this.currentTiltY, this.targetTiltY, spd); + + const scl = this.isHovered ? this.scale() : 1; + host.style.transform = `perspective(${this.perspective()}px) rotateX(${this.currentTiltX}deg) rotateY(${this.currentTiltY}deg) scale3d(${scl}, ${scl}, ${scl})`; + + // Update glare + if (this.glare()) { + const glareEl = host.querySelector('.fl-tilt-card__glare') as HTMLElement | null; + if (glareEl) { + const pos = this.cursorTracker.position(); + const rect = host.getBoundingClientRect(); + const relX = clamp((pos.x - rect.left) / rect.width, 0, 1); + const relY = clamp((pos.y - rect.top) / rect.height, 0, 1); + const angle = Math.atan2(relY - 0.5, relX - 0.5) * (180 / Math.PI) + 90; + const opacity = this.isHovered ? this.glareMaxOpacity() : 0; + glareEl.style.background = `linear-gradient(${angle}deg, rgba(255,255,255,${opacity}) 0%, transparent 80%)`; + } + } + } +} diff --git a/src/components/fl-tilt-card/index.ts b/src/components/fl-tilt-card/index.ts new file mode 100644 index 0000000..6f68a9d --- /dev/null +++ b/src/components/fl-tilt-card/index.ts @@ -0,0 +1 @@ +export * from './fl-tilt-card.component'; diff --git a/src/components/fl-topographic-lines/fl-topographic-lines.component.scss b/src/components/fl-topographic-lines/fl-topographic-lines.component.scss new file mode 100644 index 0000000..11d6ce8 --- /dev/null +++ b/src/components/fl-topographic-lines/fl-topographic-lines.component.scss @@ -0,0 +1,11 @@ +:host { + display: block; + width: 100%; + height: 100%; +} + +.fl-topographic-lines__svg { + width: 100%; + height: 100%; + display: block; +} diff --git a/src/components/fl-topographic-lines/fl-topographic-lines.component.ts b/src/components/fl-topographic-lines/fl-topographic-lines.component.ts new file mode 100644 index 0000000..6ab3401 --- /dev/null +++ b/src/components/fl-topographic-lines/fl-topographic-lines.component.ts @@ -0,0 +1,103 @@ +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-topographic-lines', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + @for (path of paths(); track $index) { + + } + + `, + styleUrl: './fl-topographic-lines.component.scss', + host: { 'class': 'fl-topographic-lines' }, +}) +export class FlTopographicLinesComponent { + private reducedMotion = inject(ReducedMotionService); + private animationLoop = inject(AnimationLoopService); + private destroyRef = inject(DestroyRef); + + // Inputs + readonly lineCount = input(15); + readonly color = input('#4ecdc4'); + readonly speed = input(0.5); + readonly animated = input(true); + + protected readonly paths = signal([]); + + private loopCleanup: (() => void) | null = null; + private time = 0; + + constructor() { + afterNextRender(() => { + this.updatePaths(); + + if (this.animated() && !this.reducedMotion.reduced()) { + const id = `topographic-${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.updatePaths(); + } + + private updatePaths(): void { + const newPaths: string[] = []; + const count = this.lineCount(); + + for (let i = 0; i < count; i++) { + const offset = (i / count) * 80 + 10; + const path = this.createContourLine(offset, i); + newPaths.push(path); + } + + this.paths.set(newPaths); + } + + private createContourLine(baseOffset: number, index: number): string { + const points: string[] = []; + const segments = 50; + + for (let i = 0; i <= segments; i++) { + const t = (i / segments) * Math.PI * 2; + const noise = Math.sin(t * 3 + this.time + index * 0.5) * 5; + const noise2 = Math.cos(t * 5 - this.time * 0.7 + index * 0.3) * 3; + + const x = 50 + Math.cos(t) * (baseOffset + noise + noise2); + const y = 50 + Math.sin(t) * (baseOffset + noise + noise2); + + if (i === 0) { + points.push(`M ${x} ${y}`); + } else { + points.push(`L ${x} ${y}`); + } + } + + points.push('Z'); + return points.join(' '); + } +} diff --git a/src/components/fl-topographic-lines/index.ts b/src/components/fl-topographic-lines/index.ts new file mode 100644 index 0000000..d706c5c --- /dev/null +++ b/src/components/fl-topographic-lines/index.ts @@ -0,0 +1 @@ +export * from './fl-topographic-lines.component'; diff --git a/src/components/fl-transition-overlay/fl-transition-overlay.component.scss b/src/components/fl-transition-overlay/fl-transition-overlay.component.scss new file mode 100644 index 0000000..7c7998c --- /dev/null +++ b/src/components/fl-transition-overlay/fl-transition-overlay.component.scss @@ -0,0 +1,27 @@ +:host { + display: block; + position: fixed; + inset: 0; + pointer-events: none; + z-index: 9999; +} + +.fl-transition-overlay__inner { + position: absolute; + inset: 0; + will-change: opacity, transform; + + &--fade { + transition: opacity 300ms ease-in-out; + } + + &--slide { + transition: transform 300ms ease-in-out; + } + + &--zoom { + transition: transform 300ms ease-in-out, opacity 300ms ease-in-out; + border-radius: 50%; + transform-origin: center center; + } +} diff --git a/src/components/fl-transition-overlay/fl-transition-overlay.component.ts b/src/components/fl-transition-overlay/fl-transition-overlay.component.ts new file mode 100644 index 0000000..6189f4d --- /dev/null +++ b/src/components/fl-transition-overlay/fl-transition-overlay.component.ts @@ -0,0 +1,55 @@ +import { + Component, ChangeDetectionStrategy, input, inject, + DestroyRef, effect, +} from '@angular/core'; +import { ReducedMotionService } from '../../services/reduced-motion.service'; +import { TransitionService } from '../../services/transition.service'; +import type { FlTransitionType } from '../../types/animation.types'; + +@Component({ + selector: 'fl-transition-overlay', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + @if (transitionService.active()) { +
+ } + `, + styleUrl: './fl-transition-overlay.component.scss', + host: { + 'class': 'fl-transition-overlay', + }, +}) +export class FlTransitionOverlayComponent { + private reducedMotion = inject(ReducedMotionService); + protected transitionService = inject(TransitionService); + private destroyRef = inject(DestroyRef); + + // Inputs + readonly type = input('fade'); + readonly color = input('#000000'); + readonly active = input(false); + + constructor() { + // Watch for active changes + effect(() => { + const isActive = this.active(); + if (isActive) { + this.transitionService.start({ + type: this.type(), + color: this.color(), + duration: 300, + }); + } else { + this.transitionService.end(300); + } + }); + } +} diff --git a/src/components/fl-transition-overlay/index.ts b/src/components/fl-transition-overlay/index.ts new file mode 100644 index 0000000..4eeb4c2 --- /dev/null +++ b/src/components/fl-transition-overlay/index.ts @@ -0,0 +1 @@ +export * from './fl-transition-overlay.component'; diff --git a/src/components/fl-typewriter/fl-typewriter.component.scss b/src/components/fl-typewriter/fl-typewriter.component.scss new file mode 100644 index 0000000..1632463 --- /dev/null +++ b/src/components/fl-typewriter/fl-typewriter.component.scss @@ -0,0 +1,22 @@ +.fl-typewriter { + display: inline-block; + font-family: monospace; + + &__text { + white-space: pre-wrap; + } + + &__cursor { + display: inline-block; + animation: fl-typewriter-blink 1.06s step-end infinite; + } +} + +@keyframes fl-typewriter-blink { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0; + } +} diff --git a/src/components/fl-typewriter/fl-typewriter.component.ts b/src/components/fl-typewriter/fl-typewriter.component.ts new file mode 100644 index 0000000..c5b5582 --- /dev/null +++ b/src/components/fl-typewriter/fl-typewriter.component.ts @@ -0,0 +1,97 @@ +import { + Component, ChangeDetectionStrategy, input, inject, + afterNextRender, DestroyRef, signal, +} from '@angular/core'; +import { ReducedMotionService } from '../../services/reduced-motion.service'; + +@Component({ + selector: 'fl-typewriter', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + {{ displayText() }} + @if (cursor() && showCursor()) { + {{ cursorChar() }} + } + `, + styleUrl: './fl-typewriter.component.scss', + host: { 'class': 'fl-typewriter' }, +}) +export class FlTypewriterComponent { + private reducedMotion = inject(ReducedMotionService); + private destroyRef = inject(DestroyRef); + + // Inputs + readonly text = input.required(); + readonly speed = input(50); + readonly cursor = input(true); + readonly cursorChar = input('|'); + readonly loop = input(false); + readonly delay = input(0); + + // Internal + readonly displayText = signal(''); + readonly showCursor = signal(true); + + private currentIndex = 0; + private typingInterval: any = null; + private cursorInterval: any = null; + private isTyping = false; + + constructor() { + afterNextRender(() => { + if (this.reducedMotion.reduced()) { + this.displayText.set(this.text()); + return; + } + + setTimeout(() => this.startTyping(), this.delay()); + this.startCursorBlink(); + }); + + this.destroyRef.onDestroy(() => { + this.cleanup(); + }); + } + + private startTyping(): void { + if (this.isTyping) return; + this.isTyping = true; + this.currentIndex = 0; + this.displayText.set(''); + + this.typingInterval = setInterval(() => { + const fullText = this.text(); + if (this.currentIndex < fullText.length) { + this.displayText.set(fullText.slice(0, this.currentIndex + 1)); + this.currentIndex++; + } else { + clearInterval(this.typingInterval); + this.isTyping = false; + + if (this.loop()) { + setTimeout(() => { + this.currentIndex = 0; + this.displayText.set(''); + setTimeout(() => this.startTyping(), 500); + }, 2000); + } + } + }, this.speed()); + } + + private startCursorBlink(): void { + this.cursorInterval = setInterval(() => { + this.showCursor.update(v => !v); + }, 530); + } + + private cleanup(): void { + if (this.typingInterval) { + clearInterval(this.typingInterval); + } + if (this.cursorInterval) { + clearInterval(this.cursorInterval); + } + } +} diff --git a/src/components/fl-typewriter/index.ts b/src/components/fl-typewriter/index.ts new file mode 100644 index 0000000..89f1748 --- /dev/null +++ b/src/components/fl-typewriter/index.ts @@ -0,0 +1 @@ +export * from './fl-typewriter.component'; diff --git a/src/components/fl-underline-morph/fl-underline-morph.component.scss b/src/components/fl-underline-morph/fl-underline-morph.component.scss new file mode 100644 index 0000000..79f0e6d --- /dev/null +++ b/src/components/fl-underline-morph/fl-underline-morph.component.scss @@ -0,0 +1,27 @@ +:host { + display: block; +} + +.fl-underline-morph__container { + position: relative; + display: inline-flex; + gap: 1rem; + padding-bottom: 4px; +} + +.fl-underline-morph__bar { + position: absolute; + bottom: 0; + left: 0; + height: 2px; + border-radius: 1px; + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), + width 0.3s cubic-bezier(0.4, 0, 0.2, 1); + will-change: transform, width; +} + +:host.fl-underline-morph--reduced-motion { + .fl-underline-morph__bar { + transition: none; + } +} diff --git a/src/components/fl-underline-morph/fl-underline-morph.component.ts b/src/components/fl-underline-morph/fl-underline-morph.component.ts new file mode 100644 index 0000000..dd93251 --- /dev/null +++ b/src/components/fl-underline-morph/fl-underline-morph.component.ts @@ -0,0 +1,69 @@ +import { + Component, ChangeDetectionStrategy, input, inject, computed, + DestroyRef, contentChildren, ElementRef, afterNextRender, effect, +} from '@angular/core'; +import { ReducedMotionService } from '../../services/reduced-motion.service'; + +@Component({ + selector: 'fl-underline-morph', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ +
+
+ `, + styleUrl: './fl-underline-morph.component.scss', + host: { + 'class': 'fl-underline-morph', + '[class.fl-underline-morph--reduced-motion]': 'reducedMotion.reduced()', + }, +}) +export class FlUnderlineMorphComponent { + private reducedMotion = inject(ReducedMotionService); + private destroyRef = inject(DestroyRef); + private el = inject(ElementRef); + + // Inputs + readonly activeIndex = input(0); + readonly color = input('#3b82f6'); + readonly height = input(2); + + constructor() { + afterNextRender(() => { + this.updateUnderlinePosition(); + }); + + effect(() => { + const index = this.activeIndex(); + this.updateUnderlinePosition(); + }); + } + + private updateUnderlinePosition(): void { + const container = this.el.nativeElement.querySelector('.fl-underline-morph__container') as HTMLElement; + const bar = this.el.nativeElement.querySelector('.fl-underline-morph__bar') as HTMLElement; + if (!container || !bar) return; + + const children = Array.from(container.children).filter( + (child) => !child.classList.contains('fl-underline-morph__bar') + ) as HTMLElement[]; + + const activeChild = children[this.activeIndex()]; + if (!activeChild) return; + + const containerRect = container.getBoundingClientRect(); + const childRect = activeChild.getBoundingClientRect(); + + const left = childRect.left - containerRect.left; + const width = childRect.width; + + bar.style.transform = `translateX(${left}px)`; + bar.style.width = `${width}px`; + } +} diff --git a/src/components/fl-underline-morph/index.ts b/src/components/fl-underline-morph/index.ts new file mode 100644 index 0000000..a3b9bb5 --- /dev/null +++ b/src/components/fl-underline-morph/index.ts @@ -0,0 +1 @@ +export * from './fl-underline-morph.component'; diff --git a/src/components/fl-voronoi-mesh/fl-voronoi-mesh.component.scss b/src/components/fl-voronoi-mesh/fl-voronoi-mesh.component.scss new file mode 100644 index 0000000..1a44902 --- /dev/null +++ b/src/components/fl-voronoi-mesh/fl-voronoi-mesh.component.scss @@ -0,0 +1,13 @@ +:host { + display: block; + position: relative; + overflow: hidden; + width: 100%; + height: 100%; + + canvas { + display: block; + width: 100%; + height: 100%; + } +} diff --git a/src/components/fl-voronoi-mesh/fl-voronoi-mesh.component.ts b/src/components/fl-voronoi-mesh/fl-voronoi-mesh.component.ts new file mode 100644 index 0000000..18181bc --- /dev/null +++ b/src/components/fl-voronoi-mesh/fl-voronoi-mesh.component.ts @@ -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, distance2D } from '../../utils/math.utils'; + +interface VoronoiSeed { + x: number; + y: number; + vx: number; + vy: number; + color: string; +} + +@Component({ + selector: 'fl-voronoi-mesh', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ``, + styleUrl: './fl-voronoi-mesh.component.scss', + host: { 'class': 'fl-voronoi-mesh' }, +}) +export class FlVoronoiMeshComponent { + private config = inject(FL_CONFIG); + private reducedMotion = inject(ReducedMotionService); + private animationLoop = inject(AnimationLoopService); + private destroyRef = inject(DestroyRef); + + protected canvasRef = viewChild.required>('canvas'); + + // Inputs + readonly seedCount = input(30); + readonly speed = input(0.5); + readonly colors = input(['#1e1b4b', '#312e81', '#3730a3', '#4338ca', '#4f46e5', '#6366f1']); + readonly showEdges = input(true); + readonly edgeColor = input('rgba(255, 255, 255, 0.15)'); + readonly edgeWidth = input(1); + readonly showSeeds = input(false); + readonly responsive = input(true); + + // Outputs + readonly ready = output(); + + private seeds: VoronoiSeed[] = []; + 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(() => { + 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.createSeeds(); + }); + this.resizeObserver.observe(canvas.parentElement!); + } + + this.createSeeds(); + + const id = `voronoi-${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 createSeeds(): void { + const count = this.seedCount(); + const cols = this.colors(); + const spd = this.speed(); + this.seeds = []; + for (let i = 0; i < count; i++) { + this.seeds.push({ + x: randomInRange(0, this.width), + y: randomInRange(0, this.height), + vx: randomInRange(-spd, spd) * 60, + vy: randomInRange(-spd, spd) * 60, + color: cols[i % cols.length], + }); + } + } + + private tick(delta: number): void { + if (!this.ctx) return; + + // Move seeds + for (const s of this.seeds) { + s.x += s.vx * delta; + s.y += s.vy * delta; + + if (s.x < 0 || s.x > this.width) s.vx *= -1; + if (s.y < 0 || s.y > this.height) s.vy *= -1; + + s.x = Math.max(0, Math.min(this.width, s.x)); + s.y = Math.max(0, Math.min(this.height, s.y)); + } + + this.renderVoronoi(); + } + + private renderVoronoi(): void { + if (!this.ctx) return; + + 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)); + + 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'; + + 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; + } + } + + ctx.fillStyle = nearestColor; + ctx.fillRect(x, y, step, step); + } + } + + // Draw edges using second-nearest distance comparison + 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); + } + } + } + } + + // Draw seed points + if (this.showSeeds()) { + for (const s of this.seeds) { + ctx.fillStyle = '#ffffff'; + ctx.beginPath(); + ctx.arc(s.x, s.y, 3, 0, Math.PI * 2); + ctx.fill(); + } + } + } + + 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.createSeeds(); + this.renderVoronoi(); + this.ready.emit(); + } +} diff --git a/src/components/fl-voronoi-mesh/index.ts b/src/components/fl-voronoi-mesh/index.ts new file mode 100644 index 0000000..26657bd --- /dev/null +++ b/src/components/fl-voronoi-mesh/index.ts @@ -0,0 +1 @@ +export * from './fl-voronoi-mesh.component'; diff --git a/src/components/fl-wave-divider/fl-wave-divider.component.scss b/src/components/fl-wave-divider/fl-wave-divider.component.scss new file mode 100644 index 0000000..42702ba --- /dev/null +++ b/src/components/fl-wave-divider/fl-wave-divider.component.scss @@ -0,0 +1,29 @@ +.fl-wave-divider { + display: block; + width: 100%; + line-height: 0; + + &__svg { + display: block; + width: 100%; + height: auto; + + &--flip { + transform: scaleY(-1); + } + + &--animated path { + animation: fl-wave-divider-flow 10s ease-in-out infinite; + transform-origin: center; + } + } +} + +@keyframes fl-wave-divider-flow { + 0%, 100% { + transform: translateX(0); + } + 50% { + transform: translateX(-5%); + } +} diff --git a/src/components/fl-wave-divider/fl-wave-divider.component.ts b/src/components/fl-wave-divider/fl-wave-divider.component.ts new file mode 100644 index 0000000..dce8512 --- /dev/null +++ b/src/components/fl-wave-divider/fl-wave-divider.component.ts @@ -0,0 +1,74 @@ +import { + Component, ChangeDetectionStrategy, input, inject, + computed, ElementRef, afterNextRender, DestroyRef, +} from '@angular/core'; +import { ReducedMotionService } from '../../services/reduced-motion.service'; + +@Component({ + selector: 'fl-wave-divider', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + + + `, + styleUrl: './fl-wave-divider.component.scss', + host: { 'class': 'fl-wave-divider' }, +}) +export class FlWaveDividerComponent { + private el = inject(ElementRef); + private reducedMotion = inject(ReducedMotionService); + private destroyRef = inject(DestroyRef); + + // Inputs + readonly amplitude = input(20); + readonly frequency = input(2); + readonly color = input('currentColor'); + readonly flip = input(false); + readonly animated = input(true); + + // Computed + readonly wavePath = computed(() => { + const amp = this.amplitude(); + const freq = this.frequency(); + const width = 1200; + const height = 120; + const baseY = height / 2; + + let path = `M0,${baseY}`; + + for (let x = 0; x <= width; x += 10) { + const y = baseY + Math.sin((x / width) * Math.PI * 2 * freq) * amp; + path += ` L${x},${y}`; + } + + path += ` L${width},${height} L0,${height} Z`; + return path; + }); + + constructor() { + afterNextRender(() => { + this.updateAnimation(); + }); + } + + private updateAnimation(): void { + const host = this.el.nativeElement as HTMLElement; + const svg = host.querySelector('.fl-wave-divider__svg') as SVGElement; + + if (!svg || this.reducedMotion.reduced() || !this.animated()) { + return; + } + + svg.classList.add('fl-wave-divider__svg--animated'); + } +} diff --git a/src/components/fl-wave-divider/index.ts b/src/components/fl-wave-divider/index.ts new file mode 100644 index 0000000..82a1b15 --- /dev/null +++ b/src/components/fl-wave-divider/index.ts @@ -0,0 +1 @@ +export * from './fl-wave-divider.component'; diff --git a/src/components/fl-wave-layers/fl-wave-layers.component.scss b/src/components/fl-wave-layers/fl-wave-layers.component.scss new file mode 100644 index 0000000..c2c5dca --- /dev/null +++ b/src/components/fl-wave-layers/fl-wave-layers.component.scss @@ -0,0 +1,31 @@ +:host { + display: block; + width: 100%; + height: 100%; + overflow: hidden; +} + +.fl-wave-layers__svg { + width: 100%; + height: 100%; + + path { + animation: fl-wave-layers-drift 8s ease-in-out infinite; + transform-origin: center; + } +} + +@keyframes fl-wave-layers-drift { + 0%, 100% { + transform: translateX(0) scaleY(1); + } + 50% { + transform: translateX(-20px) scaleY(1.05); + } +} + +:host.fl-wave-layers--reduced-motion { + .fl-wave-layers__svg path { + animation: none; + } +} diff --git a/src/components/fl-wave-layers/fl-wave-layers.component.ts b/src/components/fl-wave-layers/fl-wave-layers.component.ts new file mode 100644 index 0000000..56e8644 --- /dev/null +++ b/src/components/fl-wave-layers/fl-wave-layers.component.ts @@ -0,0 +1,76 @@ +import { + Component, ChangeDetectionStrategy, input, inject, computed, + DestroyRef, +} from '@angular/core'; +import { ReducedMotionService } from '../../services/reduced-motion.service'; + +@Component({ + selector: 'fl-wave-layers', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + @for (wave of waveData(); track $index) { + + } + + `, + styleUrl: './fl-wave-layers.component.scss', + host: { + 'class': 'fl-wave-layers', + '[class.fl-wave-layers--reduced-motion]': 'reducedMotion.reduced()', + }, +}) +export class FlWaveLayersComponent { + private reducedMotion = inject(ReducedMotionService); + private destroyRef = inject(DestroyRef); + + // Inputs + readonly layers = input(3); + readonly colors = input(['#3b82f6', '#8b5cf6', '#ec4899']); + readonly speed = input(1); + readonly amplitude = input(40); + + // Computed + readonly waveData = computed(() => { + const layerCount = this.layers(); + const waveColors = this.colors(); + const amp = this.amplitude(); + const spd = this.speed(); + + return Array.from({ length: layerCount }, (_, i) => { + const color = waveColors[i % waveColors.length]; + const opacity = 0.3 + (i / layerCount) * 0.4; + const yOffset = 200 + i * 30; + const waveAmp = amp - i * 10; + const frequency = 0.003 + i * 0.001; + + const path = this.generateWavePath(yOffset, waveAmp, frequency); + const duration = (8 - i) / spd; + const delay = -i * 0.5; + + return { path, color, opacity, duration, delay }; + }); + }); + + private generateWavePath(yOffset: number, amplitude: number, frequency: number): string { + const points: string[] = []; + const width = 1200; + const steps = 100; + + for (let i = 0; i <= steps; i++) { + const x = (i / steps) * width; + const y = yOffset + Math.sin(x * frequency) * amplitude; + points.push(`${x},${y}`); + } + + const pathData = `M0,400 L0,${points[0].split(',')[1]} ${points.map(p => `L${p}`).join(' ')} L1200,400 Z`; + return pathData; + } +} diff --git a/src/components/fl-wave-layers/index.ts b/src/components/fl-wave-layers/index.ts new file mode 100644 index 0000000..8c80dd8 --- /dev/null +++ b/src/components/fl-wave-layers/index.ts @@ -0,0 +1 @@ +export * from './fl-wave-layers.component'; diff --git a/src/components/fl-waveform-visualizer/fl-waveform-visualizer.component.scss b/src/components/fl-waveform-visualizer/fl-waveform-visualizer.component.scss new file mode 100644 index 0000000..1a44902 --- /dev/null +++ b/src/components/fl-waveform-visualizer/fl-waveform-visualizer.component.scss @@ -0,0 +1,13 @@ +:host { + display: block; + position: relative; + overflow: hidden; + width: 100%; + height: 100%; + + canvas { + display: block; + width: 100%; + height: 100%; + } +} diff --git a/src/components/fl-waveform-visualizer/fl-waveform-visualizer.component.ts b/src/components/fl-waveform-visualizer/fl-waveform-visualizer.component.ts new file mode 100644 index 0000000..99dacb6 --- /dev/null +++ b/src/components/fl-waveform-visualizer/fl-waveform-visualizer.component.ts @@ -0,0 +1,181 @@ +import { + Component, ChangeDetectionStrategy, input, output, inject, + viewChild, ElementRef, effect, afterNextRender, DestroyRef, +} from '@angular/core'; +import { ReducedMotionService } from '../../services/reduced-motion.service'; +import { AnimationLoopService } from '../../services/animation-loop.service'; +import { AudioAnalyzerService } from '../../services/audio-analyzer.service'; +import { FL_CONFIG } from '../../providers/flair-config.provider'; +import { getPixelRatio } from '../../utils/canvas.utils'; +import { mapRange } from '../../utils/math.utils'; + +type VisualizerMode = 'bars' | 'wave' | 'circle'; + +@Component({ + selector: 'fl-waveform-visualizer', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ``, + styleUrl: './fl-waveform-visualizer.component.scss', + host: { 'class': 'fl-waveform-visualizer' }, +}) +export class FlWaveformVisualizerComponent { + private config = inject(FL_CONFIG); + private reducedMotion = inject(ReducedMotionService); + private animationLoop = inject(AnimationLoopService); + protected audioAnalyzer = inject(AudioAnalyzerService); + private destroyRef = inject(DestroyRef); + + protected canvasRef = viewChild.required>('canvas'); + + // Inputs + readonly mode = input('bars'); + readonly barWidth = input(3); + readonly barGap = input(1); + readonly barCount = input(64); + readonly colorLow = input('#22c55e'); + readonly colorMid = input('#eab308'); + readonly colorHigh = input('#ef4444'); + readonly lineWidth = input(2); + readonly ringCount = input(5); + readonly smoothing = input(0.8); + readonly responsive = input(true); + + // Outputs + readonly ready = output(); + + 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(() => { + if (this.reducedMotion.reduced()) 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.resizeObserver.observe(canvas.parentElement!); + } + + const id = `waveform-${Math.random().toString(36).slice(2)}`; + this.loopCleanup = this.animationLoop.register(id, () => this.tick()); + 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 tick(): void { + if (!this.ctx) return; + + const ctx = this.ctx; + ctx.clearRect(0, 0, this.width, this.height); + + switch (this.mode()) { + case 'bars': this.renderBars(ctx); break; + case 'wave': this.renderWave(ctx); break; + case 'circle': this.renderCircle(ctx); break; + } + } + + private getBarColor(value: number): string { + if (value > 0.66) return this.colorHigh(); + if (value > 0.33) return this.colorMid(); + return this.colorLow(); + } + + private renderBars(ctx: CanvasRenderingContext2D): void { + const bands = this.audioAnalyzer.getBands(this.barCount()); + const bw = this.barWidth(); + const gap = this.barGap(); + const totalWidth = bands.length * (bw + gap) - gap; + const startX = (this.width - totalWidth) / 2; + + for (let i = 0; i < bands.length; i++) { + const value = bands[i]; + const barHeight = value * this.height * 0.9; + const x = startX + i * (bw + gap); + const y = this.height - barHeight; + + ctx.fillStyle = this.getBarColor(value); + ctx.fillRect(x, y, bw, barHeight); + } + } + + private renderWave(ctx: CanvasRenderingContext2D): void { + const waveform = this.audioAnalyzer.waveformData(); + if (waveform.length === 0) return; + + const sliceWidth = this.width / waveform.length; + const midY = this.height / 2; + + ctx.strokeStyle = this.colorLow(); + ctx.lineWidth = this.lineWidth(); + ctx.beginPath(); + + for (let i = 0; i < waveform.length; i++) { + const v = waveform[i] / 128.0; + const y = v * midY; + const x = i * sliceWidth; + + if (i === 0) { + ctx.moveTo(x, y); + } else { + ctx.lineTo(x, y); + } + } + + ctx.stroke(); + } + + private renderCircle(ctx: CanvasRenderingContext2D): void { + const bands = this.audioAnalyzer.getBands(this.ringCount()); + const cx = this.width / 2; + const cy = this.height / 2; + const maxRadius = Math.min(this.width, this.height) * 0.4; + + for (let i = bands.length - 1; i >= 0; i--) { + const value = bands[i]; + const baseRadius = (i / bands.length) * maxRadius; + const radius = baseRadius + value * maxRadius * 0.3; + + ctx.strokeStyle = this.getBarColor(value); + ctx.lineWidth = this.lineWidth(); + ctx.globalAlpha = 0.5 + value * 0.5; + ctx.beginPath(); + ctx.arc(cx, cy, radius, 0, Math.PI * 2); + ctx.stroke(); + } + ctx.globalAlpha = 1; + } +} diff --git a/src/components/fl-waveform-visualizer/index.ts b/src/components/fl-waveform-visualizer/index.ts new file mode 100644 index 0000000..d243c33 --- /dev/null +++ b/src/components/fl-waveform-visualizer/index.ts @@ -0,0 +1 @@ +export * from './fl-waveform-visualizer.component'; diff --git a/src/components/index.ts b/src/components/index.ts new file mode 100644 index 0000000..8f28a55 --- /dev/null +++ b/src/components/index.ts @@ -0,0 +1,92 @@ +/** + * Components barrel export + */ + +// Phase 2: Complex Canvas/SVG +export * from './fl-particle-field'; +export * from './fl-flow-field'; +export * from './fl-matrix-rain'; +export * from './fl-aurora'; +export * from './fl-voronoi-mesh'; +export * from './fl-fractal-landscape'; + +// Phase 3: Complex Interaction +export * from './fl-tilt-card'; +export * from './fl-holographic-card'; +export * from './fl-spotlight-card'; +export * from './fl-floating-layers'; +export * from './fl-waveform-visualizer'; + +// Phase 5: Text Effects +export * from './fl-gradient-text'; +export * from './fl-typewriter'; +export * from './fl-text-shimmer'; +export * from './fl-glitch-text'; +export * from './fl-scramble-reveal'; +export * from './fl-counter-rollup'; + +// Phase 6: Animated Borders +export * from './fl-animated-border'; + +// Phase 7: Dividers +export * from './fl-wave-divider'; +export * from './fl-gradient-divider'; +export * from './fl-glow-divider'; + +// Phase 8: Scroll Effects +export * from './fl-scroll-progress'; +export * from './fl-parallax-layer'; +export * from './fl-counter-trigger'; + +// Phase 9: Ambient/Mood +export * from './fl-breathing-dot'; +export * from './fl-glow-badge'; +export * from './fl-ambient-light'; +export * from './fl-transition-overlay'; + +// Phase 10: Generative Art +export * from './fl-blob'; +export * from './fl-generative-pattern'; +export * from './fl-gradient-mesh'; + +// Phase 11: Celebration +export * from './fl-confetti'; +export * from './fl-fireworks'; +export * from './fl-sparkle'; +export * from './fl-checkmark-flourish'; +export * from './fl-emoji-rain'; + +// Phase 12: Image Treatments +export * from './fl-duotone-filter'; +export * from './fl-ken-burns'; +export * from './fl-image-reveal'; +export * from './fl-color-splash'; +export * from './fl-glitch-image'; + +// Phase 13: Morphing/Shape +export * from './fl-morph-shape'; +export * from './fl-liquid-button'; +export * from './fl-fluid-container'; + +// Phase 14: Terminal/Code +export * from './fl-terminal-output'; +export * from './fl-ascii-art'; + +// Phase 15: Patterns/Textures +export * from './fl-dot-grid'; +export * from './fl-blueprint-grid'; +export * from './fl-topographic-lines'; +export * from './fl-circuit-board'; +export * from './fl-mesh-gradient'; + +// Phase 16: Remaining Components +export * from './fl-wave-layers'; +export * from './fl-grid-matrix'; +export * from './fl-noise-texture'; +export * from './fl-frequency-rings'; +export * from './fl-card-stack'; +export * from './fl-live-indicator'; +export * from './fl-signal-strength'; +export * from './fl-heartbeat-line'; +export * from './fl-underline-morph'; +export * from './fl-menu-reveal'; diff --git a/src/directives/fl-cursor-glow.directive.ts b/src/directives/fl-cursor-glow.directive.ts new file mode 100644 index 0000000..ff57dc8 --- /dev/null +++ b/src/directives/fl-cursor-glow.directive.ts @@ -0,0 +1,77 @@ +import { Directive, input, inject, ElementRef, afterNextRender, DestroyRef } 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 { clamp } from '../utils/math.utils'; + +@Directive({ + selector: '[flCursorGlow]', + standalone: true, +}) +export class FlCursorGlowDirective { + private el = inject(ElementRef); + private reducedMotion = inject(ReducedMotionService); + private cursorTracker = inject(CursorTrackerService); + private animationLoop = inject(AnimationLoopService); + private destroyRef = inject(DestroyRef); + + readonly color = input('rgba(99, 102, 241, 0.4)'); + readonly radius = input(150); + readonly intensity = input(1); + + private glowEl: HTMLElement | null = null; + private cursorCleanup: (() => void) | null = null; + private loopCleanup: (() => void) | null = null; + private isHovered = false; + + constructor() { + afterNextRender(() => { + if (this.reducedMotion.reduced()) return; + + const host = this.el.nativeElement as HTMLElement; + host.style.position = host.style.position || 'relative'; + host.style.overflow = 'hidden'; + + this.glowEl = document.createElement('div'); + this.glowEl.style.cssText = ` + position: absolute; + inset: 0; + pointer-events: none; + opacity: 0; + transition: opacity 200ms ease; + z-index: 0; + border-radius: inherit; + `; + host.appendChild(this.glowEl); + + host.addEventListener('mouseenter', () => { this.isHovered = true; }); + host.addEventListener('mouseleave', () => { + this.isHovered = false; + if (this.glowEl) this.glowEl.style.opacity = '0'; + }); + + this.cursorCleanup = this.cursorTracker.subscribe(); + const id = `cursor-glow-${Math.random().toString(36).slice(2)}`; + this.loopCleanup = this.animationLoop.register(id, () => this.tick()); + }); + + this.destroyRef.onDestroy(() => { + this.glowEl?.remove(); + this.cursorCleanup?.(); + this.loopCleanup?.(); + }); + } + + private tick(): void { + if (!this.glowEl || !this.isHovered) return; + + const host = this.el.nativeElement as HTMLElement; + const rect = host.getBoundingClientRect(); + const pos = this.cursorTracker.position(); + const relX = clamp((pos.x - rect.left) / rect.width * 100, 0, 100); + const relY = clamp((pos.y - rect.top) / rect.height * 100, 0, 100); + + this.glowEl.style.opacity = `${this.intensity()}`; + this.glowEl.style.background = `radial-gradient(circle ${this.radius()}px at ${relX}% ${relY}%, ${this.color()}, transparent)`; + } +} diff --git a/src/directives/fl-hover-lift.directive.ts b/src/directives/fl-hover-lift.directive.ts new file mode 100644 index 0000000..08ae08d --- /dev/null +++ b/src/directives/fl-hover-lift.directive.ts @@ -0,0 +1,50 @@ +import { Directive, input, inject, ElementRef, afterNextRender, DestroyRef } from '@angular/core'; +import { ReducedMotionService } from '../services/reduced-motion.service'; + +@Directive({ + selector: '[flHoverLift]', + standalone: true, +}) +export class FlHoverLiftDirective { + private el = inject(ElementRef); + private reducedMotion = inject(ReducedMotionService); + private destroyRef = inject(DestroyRef); + + readonly distance = input(8); + readonly shadow = input(true); + + constructor() { + afterNextRender(() => { + if (this.reducedMotion.reduced()) return; + + const host = this.el.nativeElement as HTMLElement; + const dist = this.distance(); + const useShadow = this.shadow(); + + host.style.transition = 'transform 0.3s ease, box-shadow 0.3s ease'; + host.style.cursor = 'pointer'; + + const onMouseEnter = () => { + host.style.transform = `translateY(-${dist}px)`; + if (useShadow) { + host.style.boxShadow = `0 ${dist * 2}px ${dist * 4}px rgba(0, 0, 0, 0.15)`; + } + }; + + const onMouseLeave = () => { + host.style.transform = 'translateY(0)'; + if (useShadow) { + host.style.boxShadow = '0 2px 4px rgba(0, 0, 0, 0.1)'; + } + }; + + host.addEventListener('mouseenter', onMouseEnter); + host.addEventListener('mouseleave', onMouseLeave); + + this.destroyRef.onDestroy(() => { + host.removeEventListener('mouseenter', onMouseEnter); + host.removeEventListener('mouseleave', onMouseLeave); + }); + }); + } +} diff --git a/src/directives/fl-image-tilt.directive.ts b/src/directives/fl-image-tilt.directive.ts new file mode 100644 index 0000000..e967b9e --- /dev/null +++ b/src/directives/fl-image-tilt.directive.ts @@ -0,0 +1,55 @@ +import { Directive, input, inject, ElementRef, afterNextRender, DestroyRef } from '@angular/core'; +import { ReducedMotionService } from '../services/reduced-motion.service'; + +@Directive({ + selector: '[flImageTilt]', + standalone: true, +}) +export class FlImageTiltDirective { + private el = inject(ElementRef); + private reducedMotion = inject(ReducedMotionService); + private destroyRef = inject(DestroyRef); + + readonly maxTilt = input(10); + readonly perspective = input(1000); + + constructor() { + afterNextRender(() => { + if (this.reducedMotion.reduced()) return; + + const host = this.el.nativeElement as HTMLElement; + const maxTiltDeg = this.maxTilt(); + const perspectivePx = this.perspective(); + + host.style.transition = 'transform 0.3s ease'; + host.style.transformStyle = 'preserve-3d'; + host.style.perspective = `${perspectivePx}px`; + + const onMouseMove = (e: MouseEvent) => { + const rect = host.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + const centerX = rect.width / 2; + const centerY = rect.height / 2; + + const rotateX = ((y - centerY) / centerY) * maxTiltDeg; + const rotateY = ((centerX - x) / centerX) * maxTiltDeg; + + host.style.transform = `rotateX(${rotateX}deg) rotateY(${rotateY}deg)`; + }; + + const onMouseLeave = () => { + host.style.transform = 'rotateX(0) rotateY(0)'; + }; + + host.addEventListener('mousemove', onMouseMove); + host.addEventListener('mouseleave', onMouseLeave); + + this.destroyRef.onDestroy(() => { + host.removeEventListener('mousemove', onMouseMove); + host.removeEventListener('mouseleave', onMouseLeave); + }); + }); + } +} diff --git a/src/directives/fl-inner-light.directive.ts b/src/directives/fl-inner-light.directive.ts new file mode 100644 index 0000000..2423e84 --- /dev/null +++ b/src/directives/fl-inner-light.directive.ts @@ -0,0 +1,45 @@ +import { Directive, input, inject, ElementRef, afterNextRender, DestroyRef, effect } from '@angular/core'; +import { ReducedMotionService } from '../services/reduced-motion.service'; + +@Directive({ + selector: '[flInnerLight]', + standalone: true, +}) +export class FlInnerLightDirective { + private el = inject(ElementRef); + private reducedMotion = inject(ReducedMotionService); + private destroyRef = inject(DestroyRef); + + readonly color = input('rgba(255, 255, 255, 0.5)'); + readonly size = input(20); + readonly position = input<'top' | 'bottom' | 'left' | 'right'>('top'); + + constructor() { + afterNextRender(() => { + effect(() => { + const host = this.el.nativeElement as HTMLElement; + const shadowColor = this.color(); + const sz = this.size(); + const pos = this.position(); + + let shadow = ''; + switch (pos) { + case 'top': + shadow = `inset 0 ${sz}px ${sz * 2}px -${sz}px ${shadowColor}`; + break; + case 'bottom': + shadow = `inset 0 -${sz}px ${sz * 2}px -${sz}px ${shadowColor}`; + break; + case 'left': + shadow = `inset ${sz}px 0 ${sz * 2}px -${sz}px ${shadowColor}`; + break; + case 'right': + shadow = `inset -${sz}px 0 ${sz * 2}px -${sz}px ${shadowColor}`; + break; + } + + host.style.boxShadow = shadow; + }); + }); + } +} diff --git a/src/directives/fl-light-source.directive.ts b/src/directives/fl-light-source.directive.ts new file mode 100644 index 0000000..793bc6b --- /dev/null +++ b/src/directives/fl-light-source.directive.ts @@ -0,0 +1,55 @@ +import { Directive, input, inject, ElementRef, afterNextRender, DestroyRef } 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 { clamp, mapRange } from '../utils/math.utils'; + +@Directive({ + selector: '[flLightSource]', + standalone: true, +}) +export class FlLightSourceDirective { + private el = inject(ElementRef); + private reducedMotion = inject(ReducedMotionService); + private cursorTracker = inject(CursorTrackerService); + private animationLoop = inject(AnimationLoopService); + private destroyRef = inject(DestroyRef); + + readonly shadowColor = input('rgba(0, 0, 0, 0.3)'); + readonly maxDistance = input(15); + readonly blur = input(20); + + private cursorCleanup: (() => void) | null = null; + private loopCleanup: (() => void) | null = null; + + constructor() { + afterNextRender(() => { + if (this.reducedMotion.reduced()) return; + + this.cursorCleanup = this.cursorTracker.subscribe(); + const id = `light-source-${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 maxDist = this.maxDistance(); + + const cx = rect.left + rect.width / 2; + const cy = rect.top + rect.height / 2; + + // Shadow direction is opposite to cursor + const dx = clamp(mapRange(pos.x - cx, -400, 400, maxDist, -maxDist), -maxDist, maxDist); + const dy = clamp(mapRange(pos.y - cy, -400, 400, maxDist, -maxDist), -maxDist, maxDist); + + host.style.boxShadow = `${dx}px ${dy}px ${this.blur()}px ${this.shadowColor()}`; + } +} diff --git a/src/directives/fl-long-shadow.directive.ts b/src/directives/fl-long-shadow.directive.ts new file mode 100644 index 0000000..59fb7b0 --- /dev/null +++ b/src/directives/fl-long-shadow.directive.ts @@ -0,0 +1,56 @@ +import { Directive, input, inject, ElementRef, afterNextRender, DestroyRef, effect } from '@angular/core'; +import { ReducedMotionService } from '../services/reduced-motion.service'; + +@Directive({ + selector: '[flLongShadow]', + standalone: true, +}) +export class FlLongShadowDirective { + private el = inject(ElementRef); + private reducedMotion = inject(ReducedMotionService); + private destroyRef = inject(DestroyRef); + + readonly color = input('rgba(0, 0, 0, 0.3)'); + readonly length = input(50); + readonly angle = input(45); + + constructor() { + afterNextRender(() => { + effect(() => { + const host = this.el.nativeElement as HTMLElement; + const shadowColor = this.color(); + const len = this.length(); + const ang = this.angle(); + + // Convert angle to radians + const angleRad = (ang * Math.PI) / 180; + const dx = Math.cos(angleRad); + const dy = Math.sin(angleRad); + + // Generate multiple shadow layers + const shadows: string[] = []; + for (let i = 1; i <= len; i++) { + const x = i * dx; + const y = i * dy; + const opacity = 1 - (i / len); + const shadowWithOpacity = shadowColor.replace(/[\d.]+\)$/g, `${opacity * 0.3})`); + shadows.push(`${x}px ${y}px 0 ${shadowWithOpacity}`); + } + + // Determine if it's text or box shadow + const isText = host.tagName === 'SPAN' || + host.tagName === 'P' || + host.tagName === 'H1' || + host.tagName === 'H2' || + host.tagName === 'H3' || + window.getComputedStyle(host).display === 'inline'; + + if (isText) { + host.style.textShadow = shadows.join(', '); + } else { + host.style.boxShadow = shadows.join(', '); + } + }); + }); + } +} diff --git a/src/directives/fl-magnetic.directive.ts b/src/directives/fl-magnetic.directive.ts new file mode 100644 index 0000000..02e6756 --- /dev/null +++ b/src/directives/fl-magnetic.directive.ts @@ -0,0 +1,69 @@ +import { Directive, input, inject, ElementRef, afterNextRender, DestroyRef } 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 { distance2D, lerp } from '../utils/math.utils'; + +@Directive({ + selector: '[flMagnetic]', + standalone: true, +}) +export class FlMagneticDirective { + private el = inject(ElementRef); + private reducedMotion = inject(ReducedMotionService); + private cursorTracker = inject(CursorTrackerService); + private animationLoop = inject(AnimationLoopService); + private destroyRef = inject(DestroyRef); + + readonly strength = input(0.3); + readonly radius = input(200); + readonly ease = input(0.1); + + private currentX = 0; + private currentY = 0; + private cursorCleanup: (() => void) | null = null; + private loopCleanup: (() => void) | null = null; + + constructor() { + afterNextRender(() => { + if (this.reducedMotion.reduced()) return; + + const host = this.el.nativeElement as HTMLElement; + host.style.willChange = 'transform'; + host.style.transition = 'transform 100ms linear'; + + this.cursorCleanup = this.cursorTracker.subscribe(); + const id = `magnetic-${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 cx = rect.left + rect.width / 2; + const cy = rect.top + rect.height / 2; + const pos = this.cursorTracker.position(); + const dist = distance2D(cx, cy, pos.x, pos.y); + const r = this.radius(); + + let targetX = 0; + let targetY = 0; + + if (dist < r) { + const force = (1 - dist / r) * this.strength(); + targetX = (pos.x - cx) * force; + targetY = (pos.y - cy) * force; + } + + this.currentX = lerp(this.currentX, targetX, this.ease()); + this.currentY = lerp(this.currentY, targetY, this.ease()); + + host.style.transform = `translate(${this.currentX}px, ${this.currentY}px)`; + } +} diff --git a/src/directives/fl-neon-glow.directive.ts b/src/directives/fl-neon-glow.directive.ts new file mode 100644 index 0000000..ade3ed9 --- /dev/null +++ b/src/directives/fl-neon-glow.directive.ts @@ -0,0 +1,87 @@ +import { Directive, input, inject, ElementRef, effect, afterNextRender, DestroyRef } from '@angular/core'; +import { ReducedMotionService } from '../services/reduced-motion.service'; + +@Directive({ + selector: '[flNeonGlow]', + standalone: true, +}) +export class FlNeonGlowDirective { + private el = inject(ElementRef); + private reducedMotion = inject(ReducedMotionService); + private destroyRef = inject(DestroyRef); + + readonly color = input('#00ffff'); + readonly size = input(10); + readonly layers = input(3); + readonly pulse = input(false); + readonly pulseSpeed = input(2); + readonly text = input(false); + + private styleEl: HTMLStyleElement | null = null; + + constructor() { + afterNextRender(() => { + this.applyGlow(); + }); + + effect(() => { + // Re-apply when inputs change + this.color(); + this.size(); + this.layers(); + this.pulse(); + this.pulseSpeed(); + this.text(); + this.applyGlow(); + }); + + this.destroyRef.onDestroy(() => { + this.styleEl?.remove(); + }); + } + + private applyGlow(): void { + const host = this.el.nativeElement as HTMLElement; + const color = this.color(); + const size = this.size(); + const layerCount = this.layers(); + const isText = this.text(); + const shouldPulse = this.pulse() && !this.reducedMotion.reduced(); + + // Build shadow layers + const shadows: string[] = []; + for (let i = 0; i < layerCount; i++) { + const spread = size * (i + 1) * 0.5; + shadows.push(`0 0 ${spread}px ${color}`); + } + const shadowStr = shadows.join(', '); + + if (isText) { + host.style.textShadow = shadowStr; + } else { + host.style.boxShadow = shadowStr; + } + + // Pulse animation + if (shouldPulse) { + const className = `fl-neon-pulse-${Math.random().toString(36).slice(2, 8)}`; + host.classList.add(className); + + this.styleEl?.remove(); + this.styleEl = document.createElement('style'); + const prop = isText ? 'text-shadow' : 'box-shadow'; + const dimShadows = shadows.map((s) => s.replace(color, `${color}80`)).join(', '); + + this.styleEl.textContent = ` + .${className} { + animation: ${className}-anim ${this.pulseSpeed()}s ease-in-out infinite; + } + @keyframes ${className}-anim { + 0%, 100% { ${prop}: ${shadowStr}; } + 50% { ${prop}: ${dimShadows}; } + } + `; + document.head.appendChild(this.styleEl); + } + } +} diff --git a/src/directives/fl-page-transition.directive.ts b/src/directives/fl-page-transition.directive.ts new file mode 100644 index 0000000..3e59877 --- /dev/null +++ b/src/directives/fl-page-transition.directive.ts @@ -0,0 +1,37 @@ +import { Directive, input, inject, DestroyRef } from '@angular/core'; +import { TransitionService } from '../services/transition.service'; +import type { FlTransitionType } from '../types/animation.types'; + +@Directive({ + selector: '[flPageTransition]', + standalone: true, + exportAs: 'flPageTransition', +}) +export class FlPageTransitionDirective { + private transitionService = inject(TransitionService); + private destroyRef = inject(DestroyRef); + + readonly type = input('fade'); + readonly duration = input(300); + readonly color = input('#000000'); + + constructor() { + this.destroyRef.onDestroy(() => { + this.transitionService.end(0); + }); + } + + /** Start the transition overlay */ + async start(): Promise { + await this.transitionService.start({ + type: this.type(), + duration: this.duration(), + color: this.color(), + }); + } + + /** End the transition overlay */ + async end(): Promise { + await this.transitionService.end(this.duration()); + } +} diff --git a/src/directives/fl-press-squish.directive.ts b/src/directives/fl-press-squish.directive.ts new file mode 100644 index 0000000..9f9dcea --- /dev/null +++ b/src/directives/fl-press-squish.directive.ts @@ -0,0 +1,44 @@ +import { Directive, input, inject, ElementRef, afterNextRender, DestroyRef } from '@angular/core'; +import { ReducedMotionService } from '../services/reduced-motion.service'; + +@Directive({ + selector: '[flPressSquish]', + standalone: true, +}) +export class FlPressSquishDirective { + private el = inject(ElementRef); + private reducedMotion = inject(ReducedMotionService); + private destroyRef = inject(DestroyRef); + + readonly scale = input(0.95); + + constructor() { + afterNextRender(() => { + if (this.reducedMotion.reduced()) return; + + const host = this.el.nativeElement as HTMLElement; + const targetScale = this.scale(); + + host.style.transition = 'transform 0.1s ease'; + host.style.cursor = 'pointer'; + + const onPointerDown = () => { + host.style.transform = `scale(${targetScale})`; + }; + + const onPointerUp = () => { + host.style.transform = 'scale(1)'; + }; + + host.addEventListener('pointerdown', onPointerDown); + host.addEventListener('pointerup', onPointerUp); + host.addEventListener('pointerleave', onPointerUp); + + this.destroyRef.onDestroy(() => { + host.removeEventListener('pointerdown', onPointerDown); + host.removeEventListener('pointerup', onPointerUp); + host.removeEventListener('pointerleave', onPointerUp); + }); + }); + } +} diff --git a/src/directives/fl-reveal-on-scroll.directive.ts b/src/directives/fl-reveal-on-scroll.directive.ts new file mode 100644 index 0000000..d7ac290 --- /dev/null +++ b/src/directives/fl-reveal-on-scroll.directive.ts @@ -0,0 +1,93 @@ +import { Directive, input, inject, ElementRef, afterNextRender, DestroyRef } from '@angular/core'; +import { ReducedMotionService } from '../services/reduced-motion.service'; +import { ScrollObserverService } from '../services/scroll-observer.service'; +import type { FlScrollRevealConfig } from '../types/animation.types'; + +@Directive({ + selector: '[flRevealOnScroll]', + standalone: true, +}) +export class FlRevealOnScrollDirective { + private el = inject(ElementRef); + private reducedMotion = inject(ReducedMotionService); + private scrollObserver = inject(ScrollObserverService); + private destroyRef = inject(DestroyRef); + + readonly animation = input('fade-up'); + readonly threshold = input(0.1); + readonly duration = input(600); + readonly delay = input(0); + readonly easing = input('easeOutCubic'); + readonly once = input(true); + + private cleanupObserver: (() => void) | null = null; + + constructor() { + afterNextRender(() => { + const host = this.el.nativeElement as HTMLElement; + + if (this.reducedMotion.reduced()) { + host.style.opacity = '1'; + return; + } + + // Set initial hidden state + this.applyInitialState(host); + + this.cleanupObserver = this.scrollObserver.observe( + host, + (entry) => { + if (entry.isIntersecting) { + setTimeout(() => { + this.reveal(host); + }, this.delay()); + + if (this.once()) { + this.cleanupObserver?.(); + this.cleanupObserver = null; + } + } else if (!this.once()) { + this.applyInitialState(host); + } + }, + { threshold: this.threshold() }, + ); + }); + + this.destroyRef.onDestroy(() => { + this.cleanupObserver?.(); + }); + } + + private applyInitialState(el: HTMLElement): void { + const anim = this.animation(); + el.style.opacity = '0'; + el.style.transition = 'none'; + + switch (anim) { + case 'fade-up': + el.style.transform = 'translateY(30px)'; break; + case 'fade-down': + el.style.transform = 'translateY(-30px)'; break; + case 'fade-left': + el.style.transform = 'translateX(30px)'; break; + case 'fade-right': + el.style.transform = 'translateX(-30px)'; break; + case 'zoom-in': + el.style.transform = 'scale(0.9)'; break; + case 'zoom-out': + el.style.transform = 'scale(1.1)'; break; + case 'flip-up': + el.style.transform = 'perspective(800px) rotateX(10deg)'; break; + } + } + + private reveal(el: HTMLElement): void { + const dur = this.duration(); + requestAnimationFrame(() => { + el.style.transition = `opacity ${dur}ms cubic-bezier(0.4, 0, 0.2, 1), transform ${dur}ms cubic-bezier(0.4, 0, 0.2, 1)`; + el.style.opacity = '1'; + el.style.transform = 'none'; + }); + } +} diff --git a/src/directives/fl-ripple.directive.ts b/src/directives/fl-ripple.directive.ts new file mode 100644 index 0000000..5a4d356 --- /dev/null +++ b/src/directives/fl-ripple.directive.ts @@ -0,0 +1,78 @@ +import { Directive, input, inject, ElementRef, afterNextRender, DestroyRef, NgZone } from '@angular/core'; +import { ReducedMotionService } from '../services/reduced-motion.service'; + +@Directive({ + selector: '[flRipple]', + standalone: true, +}) +export class FlRippleDirective { + private el = inject(ElementRef); + private ngZone = inject(NgZone); + private reducedMotion = inject(ReducedMotionService); + private destroyRef = inject(DestroyRef); + + readonly color = input('rgba(255, 255, 255, 0.3)'); + readonly duration = input(600); + readonly centered = input(false); + readonly unbounded = input(false); + + private handler: ((e: PointerEvent) => void) | null = null; + + constructor() { + afterNextRender(() => { + if (this.reducedMotion.reduced()) return; + + const host = this.el.nativeElement as HTMLElement; + host.style.position = host.style.position || 'relative'; + host.style.overflow = this.unbounded() ? 'visible' : 'hidden'; + + this.handler = (e: PointerEvent) => this.createRipple(e); + + this.ngZone.runOutsideAngular(() => { + host.addEventListener('pointerdown', this.handler!, { passive: true }); + }); + }); + + this.destroyRef.onDestroy(() => { + if (this.handler) { + const host = this.el.nativeElement as HTMLElement; + host.removeEventListener('pointerdown', this.handler); + } + }); + } + + private createRipple(e: PointerEvent): void { + const host = this.el.nativeElement as HTMLElement; + const rect = host.getBoundingClientRect(); + + const x = this.centered() ? rect.width / 2 : e.clientX - rect.left; + const y = this.centered() ? rect.height / 2 : e.clientY - rect.top; + const size = Math.max(rect.width, rect.height) * 2; + const dur = this.duration(); + + const ripple = document.createElement('div'); + ripple.style.cssText = ` + position: absolute; + left: ${x - size / 2}px; + top: ${y - size / 2}px; + width: ${size}px; + height: ${size}px; + border-radius: 50%; + background: ${this.color()}; + pointer-events: none; + transform: scale(0); + opacity: 1; + z-index: 0; + `; + + host.appendChild(ripple); + + requestAnimationFrame(() => { + ripple.style.transition = `transform ${dur}ms ease-out, opacity ${dur}ms ease-out`; + ripple.style.transform = 'scale(1)'; + ripple.style.opacity = '0'; + }); + + setTimeout(() => ripple.remove(), dur); + } +} diff --git a/src/directives/fl-shake-error.directive.ts b/src/directives/fl-shake-error.directive.ts new file mode 100644 index 0000000..ad7c6cb --- /dev/null +++ b/src/directives/fl-shake-error.directive.ts @@ -0,0 +1,53 @@ +import { Directive, input, inject, ElementRef, afterNextRender, DestroyRef, effect } from '@angular/core'; +import { ReducedMotionService } from '../services/reduced-motion.service'; + +@Directive({ + selector: '[flShakeError]', + standalone: true, +}) +export class FlShakeErrorDirective { + private el = inject(ElementRef); + private reducedMotion = inject(ReducedMotionService); + private destroyRef = inject(DestroyRef); + + readonly amplitude = input(10); + readonly duration = input(0.5); + readonly active = input(false); + + constructor() { + afterNextRender(() => { + const host = this.el.nativeElement as HTMLElement; + + // Create keyframes dynamically + const amp = this.amplitude(); + const keyframeName = `fl-shake-error-${Math.random().toString(36).slice(2)}`; + + const styleSheet = document.createElement('style'); + styleSheet.textContent = ` + @keyframes ${keyframeName} { + 0%, 100% { transform: translateX(0); } + 10%, 30%, 50%, 70%, 90% { transform: translateX(-${amp}px); } + 20%, 40%, 60%, 80% { transform: translateX(${amp}px); } + } + `; + document.head.appendChild(styleSheet); + + this.destroyRef.onDestroy(() => { + document.head.removeChild(styleSheet); + }); + + effect(() => { + const isActive = this.active(); + const dur = this.duration(); + + if (isActive && !this.reducedMotion.reduced()) { + host.style.animation = `${keyframeName} ${dur}s ease-in-out`; + + setTimeout(() => { + host.style.animation = ''; + }, dur * 1000); + } + }); + }); + } +} diff --git a/src/directives/fl-text-gradient.directive.ts b/src/directives/fl-text-gradient.directive.ts new file mode 100644 index 0000000..2559030 --- /dev/null +++ b/src/directives/fl-text-gradient.directive.ts @@ -0,0 +1,33 @@ +import { Directive, input, inject, ElementRef, afterNextRender, DestroyRef, effect } from '@angular/core'; +import { ReducedMotionService } from '../services/reduced-motion.service'; + +@Directive({ + selector: '[flTextGradient]', + standalone: true, +}) +export class FlTextGradientDirective { + private el = inject(ElementRef); + private reducedMotion = inject(ReducedMotionService); + private destroyRef = inject(DestroyRef); + + readonly colors = input(['#3b82f6', '#8b5cf6', '#ec4899']); + readonly angle = input(90); + + constructor() { + afterNextRender(() => { + effect(() => { + const host = this.el.nativeElement as HTMLElement; + const colorStops = this.colors(); + const ang = this.angle(); + + const gradient = `linear-gradient(${ang}deg, ${colorStops.join(', ')})`; + + host.style.background = gradient; + host.style.webkitBackgroundClip = 'text'; + host.style.backgroundClip = 'text'; + host.style.webkitTextFillColor = 'transparent'; + host.style.color = 'transparent'; + }); + }); + } +} diff --git a/src/directives/fl-wobble.directive.ts b/src/directives/fl-wobble.directive.ts new file mode 100644 index 0000000..c02d9b0 --- /dev/null +++ b/src/directives/fl-wobble.directive.ts @@ -0,0 +1,50 @@ +import { Directive, input, inject, ElementRef, afterNextRender, DestroyRef, effect } from '@angular/core'; +import { ReducedMotionService } from '../services/reduced-motion.service'; + +@Directive({ + selector: '[flWobble]', + standalone: true, +}) +export class FlWobbleDirective { + private el = inject(ElementRef); + private reducedMotion = inject(ReducedMotionService); + private destroyRef = inject(DestroyRef); + + readonly angle = input(5); + readonly speed = input(1); + readonly active = input(true); + + constructor() { + afterNextRender(() => { + const host = this.el.nativeElement as HTMLElement; + const ang = this.angle(); + const keyframeName = `fl-wobble-${Math.random().toString(36).slice(2)}`; + + const styleSheet = document.createElement('style'); + styleSheet.textContent = ` + @keyframes ${keyframeName} { + 0%, 100% { transform: rotate(0deg); } + 25% { transform: rotate(-${ang}deg); } + 75% { transform: rotate(${ang}deg); } + } + `; + document.head.appendChild(styleSheet); + + this.destroyRef.onDestroy(() => { + document.head.removeChild(styleSheet); + }); + + effect(() => { + const isActive = this.active(); + const spd = this.speed(); + + if (isActive && !this.reducedMotion.reduced()) { + const duration = 2 / spd; + host.style.animation = `${keyframeName} ${duration}s ease-in-out infinite`; + } else { + host.style.animation = ''; + } + }); + }); + } +} diff --git a/src/directives/index.ts b/src/directives/index.ts new file mode 100644 index 0000000..37b017e --- /dev/null +++ b/src/directives/index.ts @@ -0,0 +1,22 @@ +/** + * Directives barrel export + */ + +// Phase 4: Core directives +export * from './fl-cursor-glow.directive'; +export * from './fl-magnetic.directive'; +export * from './fl-ripple.directive'; +export * from './fl-reveal-on-scroll.directive'; +export * from './fl-neon-glow.directive'; +export * from './fl-light-source.directive'; +export * from './fl-page-transition.directive'; + +// Phase 17: Remaining directives +export * from './fl-hover-lift.directive'; +export * from './fl-press-squish.directive'; +export * from './fl-shake-error.directive'; +export * from './fl-wobble.directive'; +export * from './fl-long-shadow.directive'; +export * from './fl-inner-light.directive'; +export * from './fl-text-gradient.directive'; +export * from './fl-image-tilt.directive'; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..c86488c --- /dev/null +++ b/src/index.ts @@ -0,0 +1,50 @@ +/** + * Flair Elements UI + * Main library entry point + * + * Decorative and aesthetic visual effects library with animated backgrounds, + * text effects, generative art, celebration animations, scroll effects, + * and micro-interaction directives powered by @sda/base-ui + * + * @example + * ```typescript + * import { Component } from '@angular/core'; + * import { + * FlParticleFieldComponent, + * FlTiltCardComponent, + * FlGradientTextComponent, + * provideFlairConfig, + * } from '@sda/flair-elements-ui'; + * + * @Component({ + * standalone: true, + * imports: [FlParticleFieldComponent, FlTiltCardComponent, FlGradientTextComponent], + * template: ` + * + * + * Hello World + * + * + * ` + * }) + * export class AppComponent {} + * ``` + */ + +// Types +export * from './types'; + +// Utils +export * from './utils'; + +// Providers +export * from './providers'; + +// Services +export * from './services'; + +// Directives +export * from './directives'; + +// Components +export * from './components'; diff --git a/src/providers/flair-config.provider.ts b/src/providers/flair-config.provider.ts new file mode 100644 index 0000000..da4b333 --- /dev/null +++ b/src/providers/flair-config.provider.ts @@ -0,0 +1,24 @@ +import { InjectionToken, makeEnvironmentProviders, EnvironmentProviders } from '@angular/core'; +import type { FlConfig } from '../types/config.types'; + +export const DEFAULT_FL_CONFIG: FlConfig = { + reducedMotion: 'auto', + animationSpeed: 1, + defaultGlowColor: '#6366f1', + runOutsideAngular: true, + defaultParticleCount: 100, + maxPixelRatio: null, + canvasPoolSize: 4, + defaultEasing: 'easeOutCubic', +}; + +export const FL_CONFIG = new InjectionToken('FL_CONFIG', { + providedIn: 'root', + factory: () => DEFAULT_FL_CONFIG, +}); + +export function provideFlairConfig(config: Partial = {}): EnvironmentProviders { + return makeEnvironmentProviders([ + { provide: FL_CONFIG, useValue: { ...DEFAULT_FL_CONFIG, ...config } }, + ]); +} diff --git a/src/providers/index.ts b/src/providers/index.ts new file mode 100644 index 0000000..8bd33b8 --- /dev/null +++ b/src/providers/index.ts @@ -0,0 +1,5 @@ +/** + * Providers barrel export + */ + +export * from './flair-config.provider'; diff --git a/src/services/animation-loop.service.ts b/src/services/animation-loop.service.ts new file mode 100644 index 0000000..917adf7 --- /dev/null +++ b/src/services/animation-loop.service.ts @@ -0,0 +1,87 @@ +import { Injectable, inject, NgZone, DestroyRef } from '@angular/core'; +import { FL_CONFIG } from '../providers/flair-config.provider'; +import { ReducedMotionService } from './reduced-motion.service'; +import type { FlAnimationCallback } from '../types/animation.types'; + +@Injectable({ providedIn: 'root' }) +export class AnimationLoopService { + private config = inject(FL_CONFIG); + private ngZone = inject(NgZone); + private destroyRef = inject(DestroyRef); + private reducedMotion = inject(ReducedMotionService); + + private callbacks: FlAnimationCallback[] = []; + private animationFrameId: number | null = null; + private lastFrameTime = 0; + private startTime = 0; + private running = false; + + constructor() { + this.destroyRef.onDestroy(() => { + this.stop(); + }); + } + + /** Register a callback to run each frame. Returns a cleanup function. */ + register(id: string, callback: (delta: number, elapsed: number) => void, priority = 0): () => void { + this.callbacks.push({ id, callback, priority }); + this.callbacks.sort((a, b) => a.priority - b.priority); + + if (!this.running) { + this.start(); + } + + return () => this.unregister(id); + } + + /** Remove a callback by id */ + unregister(id: string): void { + this.callbacks = this.callbacks.filter(cb => cb.id !== id); + if (this.callbacks.length === 0) { + this.stop(); + } + } + + private start(): void { + if (this.running) return; + if (this.reducedMotion.reduced()) return; + + this.running = true; + this.startTime = performance.now(); + this.lastFrameTime = this.startTime; + + const tick = (time: number): void => { + if (!this.running) return; + + const delta = Math.min((time - this.lastFrameTime) / 1000, 0.1); // Cap at 100ms + const elapsed = (time - this.startTime) / 1000; + this.lastFrameTime = time; + + const speed = this.config.animationSpeed; + const scaledDelta = delta * speed; + const scaledElapsed = elapsed * speed; + + for (const entry of this.callbacks) { + entry.callback(scaledDelta, scaledElapsed); + } + + this.animationFrameId = requestAnimationFrame(tick); + }; + + if (this.config.runOutsideAngular) { + this.ngZone.runOutsideAngular(() => { + this.animationFrameId = requestAnimationFrame(tick); + }); + } else { + this.animationFrameId = requestAnimationFrame(tick); + } + } + + private stop(): void { + if (this.animationFrameId !== null) { + cancelAnimationFrame(this.animationFrameId); + this.animationFrameId = null; + } + this.running = false; + } +} diff --git a/src/services/audio-analyzer.service.ts b/src/services/audio-analyzer.service.ts new file mode 100644 index 0000000..af7d39c --- /dev/null +++ b/src/services/audio-analyzer.service.ts @@ -0,0 +1,119 @@ +import { Injectable, signal, inject, DestroyRef } from '@angular/core'; + +@Injectable({ providedIn: 'root' }) +export class AudioAnalyzerService { + private destroyRef = inject(DestroyRef); + + /** Whether the analyzer is connected */ + readonly connected = signal(false); + + /** Frequency data (0-255 values) */ + readonly frequencyData = signal(new Uint8Array(0)); + + /** Time-domain waveform data (0-255 values) */ + readonly waveformData = signal(new Uint8Array(0)); + + /** Average frequency value (0-255) */ + readonly averageFrequency = signal(0); + + private audioContext: AudioContext | null = null; + private analyser: AnalyserNode | null = null; + private source: MediaStreamAudioSourceNode | MediaElementAudioSourceNode | null = null; + private animationFrameId: number | null = null; + + constructor() { + this.destroyRef.onDestroy(() => { + this.disconnect(); + }); + } + + /** Connect to an audio source (HTMLMediaElement or MediaStream) */ + connect(source: HTMLMediaElement | MediaStream, fftSize = 256): void { + this.disconnect(); + + this.audioContext = new AudioContext(); + this.analyser = this.audioContext.createAnalyser(); + this.analyser.fftSize = fftSize; + + if (source instanceof HTMLMediaElement) { + this.source = this.audioContext.createMediaElementSource(source); + this.source.connect(this.analyser); + this.analyser.connect(this.audioContext.destination); + } else { + this.source = this.audioContext.createMediaStreamSource(source); + this.source.connect(this.analyser); + } + + this.connected.set(true); + this.startAnalysis(); + } + + /** Disconnect from the current audio source */ + disconnect(): void { + if (this.animationFrameId !== null) { + cancelAnimationFrame(this.animationFrameId); + this.animationFrameId = null; + } + if (this.source) { + this.source.disconnect(); + this.source = null; + } + if (this.analyser) { + this.analyser.disconnect(); + this.analyser = null; + } + if (this.audioContext) { + this.audioContext.close(); + this.audioContext = null; + } + this.connected.set(false); + this.frequencyData.set(new Uint8Array(0)); + this.waveformData.set(new Uint8Array(0)); + this.averageFrequency.set(0); + } + + /** Get frequency data for a specific number of bands (averaged) */ + getBands(count: number): number[] { + const data = this.frequencyData(); + if (data.length === 0) return new Array(count).fill(0); + + const bands: number[] = []; + const bandSize = Math.floor(data.length / count); + + for (let i = 0; i < count; i++) { + let sum = 0; + const start = i * bandSize; + for (let j = start; j < start + bandSize && j < data.length; j++) { + sum += data[j]; + } + bands.push(sum / bandSize / 255); + } + + return bands; + } + + private startAnalysis(): void { + if (!this.analyser) return; + + const freqArray = new Uint8Array(this.analyser.frequencyBinCount); + const waveArray = new Uint8Array(this.analyser.fftSize); + + const analyze = () => { + if (!this.analyser) return; + + this.analyser.getByteFrequencyData(freqArray); + this.analyser.getByteTimeDomainData(waveArray); + + this.frequencyData.set(new Uint8Array(freqArray)); + this.waveformData.set(new Uint8Array(waveArray)); + + let sum = 0; + for (let i = 0; i < freqArray.length; i++) sum += freqArray[i]; + this.averageFrequency.set(sum / freqArray.length); + + this.animationFrameId = requestAnimationFrame(analyze); + }; + + this.animationFrameId = requestAnimationFrame(analyze); + } +} diff --git a/src/services/canvas-pool.service.ts b/src/services/canvas-pool.service.ts new file mode 100644 index 0000000..b951940 --- /dev/null +++ b/src/services/canvas-pool.service.ts @@ -0,0 +1,46 @@ +import { Injectable, inject, DestroyRef } from '@angular/core'; +import { FL_CONFIG } from '../providers/flair-config.provider'; + +@Injectable({ providedIn: 'root' }) +export class CanvasPoolService { + private config = inject(FL_CONFIG); + private destroyRef = inject(DestroyRef); + + private pool: HTMLCanvasElement[] = []; + private inUse = new Set(); + + constructor() { + this.destroyRef.onDestroy(() => { + this.pool = []; + this.inUse.clear(); + }); + } + + /** Acquire an offscreen canvas from the pool */ + acquire(width: number, height: number): HTMLCanvasElement { + let canvas = this.pool.pop(); + if (!canvas) { + canvas = document.createElement('canvas'); + } + canvas.width = width; + canvas.height = height; + this.inUse.add(canvas); + return canvas; + } + + /** Return a canvas to the pool for reuse */ + release(canvas: HTMLCanvasElement): void { + if (!this.inUse.has(canvas)) return; + this.inUse.delete(canvas); + + // Clear the canvas + const ctx = canvas.getContext('2d'); + if (ctx) { + ctx.clearRect(0, 0, canvas.width, canvas.height); + } + + if (this.pool.length < this.config.canvasPoolSize) { + this.pool.push(canvas); + } + } +} diff --git a/src/services/celebration.service.ts b/src/services/celebration.service.ts new file mode 100644 index 0000000..eb88614 --- /dev/null +++ b/src/services/celebration.service.ts @@ -0,0 +1,294 @@ +import { Injectable, inject, DestroyRef } from '@angular/core'; +import { ReducedMotionService } from './reduced-motion.service'; +import type { FlConfettiConfig, FlFireworksConfig, FlCelebrationConfig } from '../types/effect.types'; +import { randomInRange } from '../utils/math.utils'; + +interface CelebrationParticle { + x: number; + y: number; + vx: number; + vy: number; + size: number; + color: string; + rotation: number; + rotationSpeed: number; + opacity: number; + shape: string; + life: number; +} + +const DEFAULT_COLORS = ['#6366f1', '#ec4899', '#f59e0b', '#22c55e', '#06b6d4', '#8b5cf6']; + +@Injectable({ providedIn: 'root' }) +export class CelebrationService { + private reducedMotion = inject(ReducedMotionService); + private destroyRef = inject(DestroyRef); + + private activeOverlays: HTMLCanvasElement[] = []; + + constructor() { + this.destroyRef.onDestroy(() => { + for (const overlay of this.activeOverlays) { + overlay.remove(); + } + this.activeOverlays = []; + }); + } + + /** Launch a confetti burst */ + confetti(options?: Partial): void { + if (this.reducedMotion.reduced()) return; + + const config: FlConfettiConfig = { + count: options?.count ?? 100, + duration: options?.duration ?? 3000, + spread: options?.spread ?? 70, + colors: options?.colors ?? DEFAULT_COLORS, + gravity: options?.gravity ?? 0.5, + shapes: options?.shapes ?? ['square', 'circle', 'strip'], + scalar: options?.scalar ?? 1, + }; + + const particles = this.createConfettiParticles(config); + this.animateParticles(particles, config.duration); + } + + /** Launch fireworks */ + fireworks(options?: Partial): void { + if (this.reducedMotion.reduced()) return; + + const config: FlFireworksConfig = { + count: options?.count ?? 60, + duration: options?.duration ?? 2000, + spread: options?.spread ?? 360, + colors: options?.colors ?? DEFAULT_COLORS, + gravity: options?.gravity ?? 0.3, + trailLength: options?.trailLength ?? 3, + flickering: options?.flickering ?? 0.3, + }; + + const particles = this.createFireworkParticles(config); + this.animateParticles(particles, config.duration); + } + + /** Sparkle effect at a target element */ + sparkle(target: HTMLElement, options?: Partial): void { + if (this.reducedMotion.reduced()) return; + + const rect = target.getBoundingClientRect(); + const cx = rect.left + rect.width / 2; + const cy = rect.top + rect.height / 2; + + const config: FlCelebrationConfig = { + count: options?.count ?? 20, + duration: options?.duration ?? 1000, + spread: options?.spread ?? Math.max(rect.width, rect.height), + colors: options?.colors ?? ['#fbbf24', '#f59e0b', '#fde68a'], + gravity: options?.gravity ?? -0.1, + }; + + const particles: CelebrationParticle[] = []; + for (let i = 0; i < config.count; i++) { + const angle = Math.random() * Math.PI * 2; + const speed = randomInRange(1, 4); + particles.push({ + x: cx, + y: cy, + vx: Math.cos(angle) * speed, + vy: Math.sin(angle) * speed, + size: randomInRange(2, 6), + color: config.colors[i % config.colors.length], + rotation: 0, + rotationSpeed: 0, + opacity: 1, + shape: 'circle', + life: 1, + }); + } + + this.animateParticles(particles, config.duration); + } + + /** Emoji rain from the top of the viewport */ + emojiRain(options?: { emojis?: string[]; count?: number; duration?: number }): void { + if (this.reducedMotion.reduced()) return; + + const emojis = options?.emojis ?? ['🎉', '🎊', '✨', '🥳', '🎈']; + const count = options?.count ?? 40; + const duration = options?.duration ?? 3000; + + const canvas = this.createOverlay(); + const ctx = canvas.getContext('2d')!; + const w = canvas.width; + + interface EmojiParticle { + x: number; + y: number; + vy: number; + emoji: string; + size: number; + rotation: number; + rotationSpeed: number; + } + + const particles: EmojiParticle[] = []; + for (let i = 0; i < count; i++) { + particles.push({ + x: randomInRange(0, w), + y: randomInRange(-100, -20), + vy: randomInRange(2, 6), + emoji: emojis[i % emojis.length], + size: randomInRange(16, 32), + rotation: randomInRange(0, Math.PI * 2), + rotationSpeed: randomInRange(-0.05, 0.05), + }); + } + + const start = performance.now(); + const animate = (time: number) => { + const elapsed = time - start; + if (elapsed > duration) { + canvas.remove(); + this.activeOverlays = this.activeOverlays.filter(o => o !== canvas); + return; + } + + ctx.clearRect(0, 0, canvas.width, canvas.height); + const progress = elapsed / duration; + + for (const p of particles) { + p.y += p.vy; + p.rotation += p.rotationSpeed; + + ctx.save(); + ctx.translate(p.x, p.y); + ctx.rotate(p.rotation); + ctx.globalAlpha = progress > 0.7 ? 1 - (progress - 0.7) / 0.3 : 1; + ctx.font = `${p.size}px sans-serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(p.emoji, 0, 0); + ctx.restore(); + } + + requestAnimationFrame(animate); + }; + + requestAnimationFrame(animate); + } + + private createConfettiParticles(config: FlConfettiConfig): CelebrationParticle[] { + const particles: CelebrationParticle[] = []; + const cx = window.innerWidth / 2; + const cy = window.innerHeight * 0.6; + + for (let i = 0; i < config.count; i++) { + const angle = ((-config.spread / 2 + Math.random() * config.spread) * Math.PI) / 180 - Math.PI / 2; + const speed = randomInRange(8, 15) * config.scalar; + particles.push({ + x: cx, + y: cy, + vx: Math.cos(angle) * speed, + vy: Math.sin(angle) * speed, + size: randomInRange(4, 8) * config.scalar, + color: config.colors[i % config.colors.length], + rotation: randomInRange(0, Math.PI * 2), + rotationSpeed: randomInRange(-0.2, 0.2), + opacity: 1, + shape: config.shapes[i % config.shapes.length], + life: 1, + }); + } + return particles; + } + + private createFireworkParticles(config: FlFireworksConfig): CelebrationParticle[] { + const particles: CelebrationParticle[] = []; + const cx = window.innerWidth / 2; + const cy = window.innerHeight * 0.4; + + for (let i = 0; i < config.count; i++) { + const angle = (i / config.count) * Math.PI * 2; + const speed = randomInRange(3, 8); + particles.push({ + x: cx, + y: cy, + vx: Math.cos(angle) * speed, + vy: Math.sin(angle) * speed, + size: randomInRange(2, 4), + color: config.colors[i % config.colors.length], + rotation: 0, + rotationSpeed: 0, + opacity: 1, + shape: 'circle', + life: 1, + }); + } + return particles; + } + + private animateParticles(particles: CelebrationParticle[], duration: number): void { + const canvas = this.createOverlay(); + const ctx = canvas.getContext('2d')!; + const start = performance.now(); + + const animate = (time: number) => { + const elapsed = time - start; + if (elapsed > duration) { + canvas.remove(); + this.activeOverlays = this.activeOverlays.filter(o => o !== canvas); + return; + } + + ctx.clearRect(0, 0, canvas.width, canvas.height); + const progress = elapsed / duration; + + for (const p of particles) { + p.x += p.vx; + p.y += p.vy; + p.vy += 0.15; // gravity + p.rotation += p.rotationSpeed; + p.opacity = 1 - progress; + + ctx.save(); + ctx.translate(p.x, p.y); + ctx.rotate(p.rotation); + ctx.globalAlpha = p.opacity; + ctx.fillStyle = p.color; + + if (p.shape === 'circle') { + ctx.beginPath(); + ctx.arc(0, 0, p.size / 2, 0, Math.PI * 2); + ctx.fill(); + } else if (p.shape === 'strip') { + ctx.fillRect(-p.size / 6, -p.size / 2, p.size / 3, p.size); + } else { + ctx.fillRect(-p.size / 2, -p.size / 2, p.size, p.size); + } + + ctx.restore(); + } + + requestAnimationFrame(animate); + }; + + requestAnimationFrame(animate); + } + + private createOverlay(): HTMLCanvasElement { + const canvas = document.createElement('canvas'); + canvas.width = window.innerWidth; + canvas.height = window.innerHeight; + canvas.style.cssText = ` + position: fixed; + inset: 0; + z-index: 9000; + pointer-events: none; + width: 100vw; + height: 100vh; + `; + document.body.appendChild(canvas); + this.activeOverlays.push(canvas); + return canvas; + } +} diff --git a/src/services/cursor-tracker.service.ts b/src/services/cursor-tracker.service.ts new file mode 100644 index 0000000..3b07969 --- /dev/null +++ b/src/services/cursor-tracker.service.ts @@ -0,0 +1,94 @@ +import { Injectable, signal, inject, NgZone, DestroyRef } from '@angular/core'; + +@Injectable({ providedIn: 'root' }) +export class CursorTrackerService { + private ngZone = inject(NgZone); + private destroyRef = inject(DestroyRef); + + /** Absolute cursor position in pixels */ + readonly position = signal<{ x: number; y: number }>({ x: 0, y: 0 }); + + /** Cursor position normalized to 0-1 range based on viewport */ + readonly normalizedPosition = signal<{ x: number; y: number }>({ x: 0.5, y: 0.5 }); + + /** Whether the cursor is currently over the viewport */ + readonly isActive = signal(false); + + private consumerCount = 0; + private bound = false; + private moveHandler: ((e: PointerEvent) => void) | null = null; + private leaveHandler: (() => void) | null = null; + private enterHandler: (() => void) | null = null; + + constructor() { + this.destroyRef.onDestroy(() => { + this.detach(); + }); + } + + /** Subscribe to cursor updates. Returns cleanup function. Call once per consumer. */ + subscribe(): () => void { + this.consumerCount++; + if (!this.bound) { + this.attach(); + } + + return () => { + this.consumerCount--; + if (this.consumerCount <= 0) { + this.consumerCount = 0; + this.detach(); + } + }; + } + + private attach(): void { + if (this.bound || typeof window === 'undefined') return; + + this.moveHandler = (e: PointerEvent) => { + const x = e.clientX; + const y = e.clientY; + this.position.set({ x, y }); + this.normalizedPosition.set({ + x: x / window.innerWidth, + y: y / window.innerHeight, + }); + }; + + this.leaveHandler = () => { + this.isActive.set(false); + }; + + this.enterHandler = () => { + this.isActive.set(true); + }; + + this.ngZone.runOutsideAngular(() => { + window.addEventListener('pointermove', this.moveHandler!, { passive: true }); + document.addEventListener('pointerleave', this.leaveHandler!); + document.addEventListener('pointerenter', this.enterHandler!); + }); + + this.bound = true; + this.isActive.set(true); + } + + private detach(): void { + if (!this.bound) return; + + if (this.moveHandler) { + window.removeEventListener('pointermove', this.moveHandler); + } + if (this.leaveHandler) { + document.removeEventListener('pointerleave', this.leaveHandler); + } + if (this.enterHandler) { + document.removeEventListener('pointerenter', this.enterHandler); + } + + this.moveHandler = null; + this.leaveHandler = null; + this.enterHandler = null; + this.bound = false; + } +} diff --git a/src/services/index.ts b/src/services/index.ts new file mode 100644 index 0000000..b76a2ec --- /dev/null +++ b/src/services/index.ts @@ -0,0 +1,12 @@ +/** + * Services barrel export + */ + +export * from './reduced-motion.service'; +export * from './animation-loop.service'; +export * from './cursor-tracker.service'; +export * from './scroll-observer.service'; +export * from './canvas-pool.service'; +export * from './celebration.service'; +export * from './audio-analyzer.service'; +export * from './transition.service'; diff --git a/src/services/reduced-motion.service.ts b/src/services/reduced-motion.service.ts new file mode 100644 index 0000000..00fa91f --- /dev/null +++ b/src/services/reduced-motion.service.ts @@ -0,0 +1,47 @@ +import { Injectable, signal, inject, DestroyRef } from '@angular/core'; +import { FL_CONFIG } from '../providers/flair-config.provider'; + +@Injectable({ providedIn: 'root' }) +export class ReducedMotionService { + private config = inject(FL_CONFIG); + private destroyRef = inject(DestroyRef); + + /** Whether reduced motion is currently active */ + readonly reduced = signal(false); + + private mediaQuery: MediaQueryList | null = null; + private listener: ((e: MediaQueryListEvent) => void) | null = null; + + constructor() { + this.init(); + } + + private init(): void { + if (this.config.reducedMotion === 'always') { + this.reduced.set(true); + return; + } + + if (this.config.reducedMotion === 'never') { + this.reduced.set(false); + return; + } + + // 'auto' — listen to OS preference + if (typeof window === 'undefined' || !window.matchMedia) return; + + this.mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)'); + this.reduced.set(this.mediaQuery.matches); + + this.listener = (e: MediaQueryListEvent) => { + this.reduced.set(e.matches); + }; + this.mediaQuery.addEventListener('change', this.listener); + + this.destroyRef.onDestroy(() => { + if (this.mediaQuery && this.listener) { + this.mediaQuery.removeEventListener('change', this.listener); + } + }); + } +} diff --git a/src/services/scroll-observer.service.ts b/src/services/scroll-observer.service.ts new file mode 100644 index 0000000..9ef52b5 --- /dev/null +++ b/src/services/scroll-observer.service.ts @@ -0,0 +1,81 @@ +import { Injectable, inject, NgZone, DestroyRef } from '@angular/core'; + +interface ObserverEntry { + observer: IntersectionObserver; + elements: Map void>; +} + +@Injectable({ providedIn: 'root' }) +export class ScrollObserverService { + private ngZone = inject(NgZone); + private destroyRef = inject(DestroyRef); + + /** Pooled observers keyed by serialized options */ + private observers = new Map(); + + constructor() { + this.destroyRef.onDestroy(() => { + for (const entry of this.observers.values()) { + entry.observer.disconnect(); + } + this.observers.clear(); + }); + } + + /** Observe an element for intersection changes. Returns cleanup function. */ + observe( + element: Element, + callback: (entry: IntersectionObserverEntry) => void, + options?: IntersectionObserverInit, + ): () => void { + const key = this.getOptionsKey(options); + let entry = this.observers.get(key); + + if (!entry) { + const observer = this.createObserver(key, options); + entry = { observer, elements: new Map() }; + this.observers.set(key, entry); + } + + entry.elements.set(element, callback); + entry.observer.observe(element); + + return () => { + const e = this.observers.get(key); + if (e) { + e.observer.unobserve(element); + e.elements.delete(element); + if (e.elements.size === 0) { + e.observer.disconnect(); + this.observers.delete(key); + } + } + }; + } + + private createObserver(key: string, options?: IntersectionObserverInit): IntersectionObserver { + return new IntersectionObserver( + (entries) => { + const observerEntry = this.observers.get(key); + if (!observerEntry) return; + + for (const ioEntry of entries) { + const cb = observerEntry.elements.get(ioEntry.target); + if (cb) { + cb(ioEntry); + } + } + }, + options, + ); + } + + private getOptionsKey(options?: IntersectionObserverInit): string { + if (!options) return 'default'; + return JSON.stringify({ + root: null, + rootMargin: options.rootMargin ?? '0px', + threshold: options.threshold ?? 0, + }); + } +} diff --git a/src/services/transition.service.ts b/src/services/transition.service.ts new file mode 100644 index 0000000..bd2d256 --- /dev/null +++ b/src/services/transition.service.ts @@ -0,0 +1,142 @@ +import { Injectable, signal, inject, DestroyRef } from '@angular/core'; +import { ReducedMotionService } from './reduced-motion.service'; +import type { FlTransitionConfig, FlTransitionType } from '../types/animation.types'; + +@Injectable({ providedIn: 'root' }) +export class TransitionService { + private reducedMotion = inject(ReducedMotionService); + private destroyRef = inject(DestroyRef); + + /** Whether a transition is currently active */ + readonly active = signal(false); + + /** The current transition type */ + readonly currentType = signal(null); + + /** Progress of the current transition (0-1) */ + readonly progress = signal(0); + + private overlay: HTMLElement | null = null; + private animationFrameId: number | null = null; + + constructor() { + this.destroyRef.onDestroy(() => { + this.cleanup(); + }); + } + + /** Start a route transition */ + async start(config?: Partial): Promise { + if (this.reducedMotion.reduced()) return; + + const type = config?.type ?? 'fade'; + const duration = config?.duration ?? 300; + const color = config?.color ?? '#000000'; + + this.currentType.set(type); + this.active.set(true); + + this.overlay = this.createOverlay(type, color); + document.body.appendChild(this.overlay); + + // Animate in + await this.animate(0, 1, duration); + } + + /** End the current transition */ + async end(duration?: number): Promise { + if (!this.overlay || this.reducedMotion.reduced()) { + this.cleanup(); + return; + } + + // Animate out + await this.animate(1, 0, duration ?? 300); + this.cleanup(); + } + + private animate(from: number, to: number, duration: number): Promise { + return new Promise(resolve => { + const start = performance.now(); + + const tick = (time: number) => { + const elapsed = time - start; + const t = Math.min(elapsed / duration, 1); + const progress = from + (to - from) * t; + + this.progress.set(progress); + this.applyProgress(progress); + + if (t < 1) { + this.animationFrameId = requestAnimationFrame(tick); + } else { + resolve(); + } + }; + + this.animationFrameId = requestAnimationFrame(tick); + }); + } + + private applyProgress(progress: number): void { + if (!this.overlay) return; + + const type = this.currentType(); + switch (type) { + case 'fade': + this.overlay.style.opacity = `${progress}`; + break; + case 'slide-left': + this.overlay.style.transform = `translateX(${(1 - progress) * 100}%)`; + break; + case 'slide-right': + this.overlay.style.transform = `translateX(${(progress - 1) * 100}%)`; + break; + case 'slide-up': + this.overlay.style.transform = `translateY(${(1 - progress) * 100}%)`; + break; + case 'slide-down': + this.overlay.style.transform = `translateY(${(progress - 1) * 100}%)`; + break; + case 'zoom': + this.overlay.style.transform = `scale(${progress})`; + this.overlay.style.opacity = `${progress}`; + break; + default: + this.overlay.style.opacity = `${progress}`; + } + } + + private createOverlay(type: FlTransitionType, color: string): HTMLElement { + const el = document.createElement('div'); + el.style.cssText = ` + position: fixed; + inset: 0; + z-index: 9999; + pointer-events: none; + background: ${color}; + will-change: transform, opacity; + `; + + if (type === 'zoom') { + el.style.borderRadius = '50%'; + el.style.transformOrigin = 'center center'; + } + + return el; + } + + private cleanup(): void { + if (this.animationFrameId !== null) { + cancelAnimationFrame(this.animationFrameId); + this.animationFrameId = null; + } + if (this.overlay) { + this.overlay.remove(); + this.overlay = null; + } + this.active.set(false); + this.currentType.set(null); + this.progress.set(0); + } +} diff --git a/src/styles/_index.scss b/src/styles/_index.scss new file mode 100644 index 0000000..6df9056 --- /dev/null +++ b/src/styles/_index.scss @@ -0,0 +1,11 @@ +/** + * Flair Elements UI + * Main styles entry point + * + * Import this file in your application to use the library styles: + * + * @use '@sda/flair-elements-ui/src/styles' as fl; + */ + +@forward './tokens'; +@forward './mixins'; diff --git a/src/styles/_mixins.scss b/src/styles/_mixins.scss new file mode 100644 index 0000000..f071865 --- /dev/null +++ b/src/styles/_mixins.scss @@ -0,0 +1,118 @@ +/** + * Flair Elements UI Mixins + * Reusable SCSS mixins for common patterns + */ + +// Reduced motion media query +@mixin reduced-motion { + @media (prefers-reduced-motion: reduce) { + @content; + } +} + +// Reduced motion override — disable animations +@mixin no-motion { + @include reduced-motion { + animation: none !important; + transition: none !important; + } +} + +// Glow effect +@mixin glow($color: var(--fl-glow-color), $size: var(--fl-glow-size), $intensity: var(--fl-glow-intensity)) { + box-shadow: 0 0 $size $color; + opacity: $intensity; +} + +// Neon text glow +@mixin neon-text($color: var(--fl-neon-color), $size: var(--fl-neon-size)) { + text-shadow: + 0 0 calc(#{$size} * 0.5) $color, + 0 0 $size $color, + 0 0 calc(#{$size} * 2) $color, + 0 0 calc(#{$size} * 4) $color; +} + +// Glass surface +@mixin glass-surface($blur: 12px, $opacity: 0.1) { + background: rgba(255, 255, 255, $opacity); + backdrop-filter: blur($blur); + -webkit-backdrop-filter: blur($blur); +} + +// Particle container — full size, no pointer events +@mixin particle-container { + position: absolute; + inset: 0; + overflow: hidden; + pointer-events: none; + + canvas { + display: block; + width: 100%; + height: 100%; + } +} + +// Absolute fill +@mixin absolute-fill { + position: absolute; + inset: 0; +} + +// Flex center +@mixin flex-center { + display: flex; + align-items: center; + justify-content: center; +} + +// Canvas host +@mixin canvas-host { + display: block; + position: relative; + overflow: hidden; + + canvas { + display: block; + width: 100%; + height: 100%; + } +} + +// Shimmer animation +@mixin shimmer($duration: var(--fl-shimmer-duration)) { + background-size: 200% 100%; + animation: fl-shimmer $duration linear infinite; + + @include no-motion; +} + +@keyframes fl-shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +// Pulse animation +@mixin pulse($duration: 2s) { + animation: fl-pulse $duration ease-in-out infinite; + + @include no-motion; +} + +@keyframes fl-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +// Float animation +@mixin float($distance: 10px, $duration: 3s) { + animation: fl-float $duration ease-in-out infinite; + + @include no-motion; +} + +@keyframes fl-float { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-#{$distance}); } +} diff --git a/src/styles/_tokens.scss b/src/styles/_tokens.scss new file mode 100644 index 0000000..128feac --- /dev/null +++ b/src/styles/_tokens.scss @@ -0,0 +1,127 @@ +/** + * Flair Elements UI Design Tokens + * CSS custom properties for theming and configuration + */ + +:root { + // Animation speed & easing + --fl-animation-speed: 1; + --fl-easing-default: cubic-bezier(0.4, 0, 0.2, 1); + --fl-easing-bounce: cubic-bezier(0.34, 1.56, 0.64, 1); + --fl-easing-elastic: cubic-bezier(0.68, -0.55, 0.27, 1.55); + --fl-duration-fast: 150ms; + --fl-duration-normal: 300ms; + --fl-duration-slow: 600ms; + --fl-duration-slower: 1000ms; + + // Glow & neon + --fl-glow-color: #6366f1; + --fl-glow-size: 20px; + --fl-glow-intensity: 0.6; + --fl-neon-color: #00ffff; + --fl-neon-size: 10px; + --fl-neon-layers: 3; + --fl-neon-pulse-speed: 2s; + + // Particles + --fl-particle-count: 100; + --fl-particle-size-min: 1px; + --fl-particle-size-max: 4px; + --fl-particle-speed: 1; + --fl-particle-color: #ffffff; + --fl-particle-connection-distance: 100px; + --fl-particle-connection-opacity: 0.3; + + // Surfaces / cards + --fl-tilt-max: 15deg; + --fl-tilt-perspective: 1000px; + --fl-tilt-speed: 300ms; + --fl-tilt-glare-opacity: 0.3; + --fl-holographic-intensity: 0.5; + --fl-spotlight-radius: 200px; + --fl-spotlight-color: rgba(255, 255, 255, 0.15); + + // Dividers + --fl-divider-wave-amplitude: 20px; + --fl-divider-wave-frequency: 3; + --fl-divider-gradient-height: 2px; + --fl-divider-glow-width: 2px; + --fl-divider-glow-color: var(--fl-glow-color); + + // Text effects + --fl-typewriter-speed: 50ms; + --fl-typewriter-cursor-color: currentColor; + --fl-shimmer-duration: 2s; + --fl-shimmer-color: rgba(255, 255, 255, 0.4); + --fl-glitch-intensity: 3px; + --fl-scramble-speed: 30ms; + + // Scroll effects + --fl-scroll-progress-height: 3px; + --fl-scroll-progress-color: var(--fl-glow-color); + --fl-parallax-depth: 0.5; + --fl-reveal-distance: 30px; + --fl-reveal-duration: 600ms; + + // Micro-interactions + --fl-lift-distance: -4px; + --fl-lift-shadow: 0 8px 25px rgba(0, 0, 0, 0.15); + --fl-squish-scale: 0.95; + --fl-shake-amplitude: 6px; + --fl-shake-duration: 500ms; + --fl-wobble-angle: 3deg; + --fl-wobble-duration: 1s; + --fl-magnetic-strength: 0.3; + --fl-magnetic-radius: 200px; + + // Ripple + --fl-ripple-color: rgba(255, 255, 255, 0.3); + --fl-ripple-duration: 600ms; + + // Patterns & textures + --fl-dot-size: 2px; + --fl-dot-spacing: 20px; + --fl-dot-color: rgba(128, 128, 128, 0.3); + --fl-grid-spacing: 40px; + --fl-grid-color: rgba(100, 149, 237, 0.2); + --fl-line-thickness: 1px; + --fl-circuit-pulse-speed: 3s; + + // Status indicators + --fl-live-color: #22c55e; + --fl-live-pulse-speed: 2s; + --fl-signal-color: #6366f1; + --fl-heartbeat-color: #ef4444; + --fl-heartbeat-rate: 1s; + + // Audio visualizer + --fl-visualizer-bar-width: 3px; + --fl-visualizer-bar-gap: 1px; + --fl-visualizer-color-low: #22c55e; + --fl-visualizer-color-mid: #eab308; + --fl-visualizer-color-high: #ef4444; + --fl-visualizer-ring-count: 5; + + // Celebration + --fl-confetti-count: 100; + --fl-confetti-duration: 3s; + --fl-fireworks-duration: 2s; + --fl-sparkle-size: 8px; + + // Aurora + --fl-aurora-speed: 8s; + --fl-aurora-blur: 60px; + --fl-aurora-opacity: 0.6; + + // Matrix rain + --fl-matrix-font-size: 14px; + --fl-matrix-color: #00ff00; + --fl-matrix-speed: 1; + + // Z-index layers + --fl-z-background: -1; + --fl-z-default: 0; + --fl-z-overlay: 100; + --fl-z-celebration: 9000; + --fl-z-transition: 9999; +} diff --git a/src/types/animation.types.ts b/src/types/animation.types.ts new file mode 100644 index 0000000..29add20 --- /dev/null +++ b/src/types/animation.types.ts @@ -0,0 +1,53 @@ +/** + * Animation and timing types + */ + +import type { FlEasingName } from './config.types'; + +export interface FlAnimationTiming { + duration: number; + delay: number; + easing: FlEasingName; + iterations: number | 'infinite'; + direction: 'normal' | 'reverse' | 'alternate' | 'alternate-reverse'; + fillMode: 'none' | 'forwards' | 'backwards' | 'both'; +} + +export interface FlLoopConfig { + /** Target frames per second (0 = uncapped) */ + targetFps: number; + /** Whether the loop is paused */ + paused: boolean; +} + +export interface FlAnimationCallback { + id: string; + callback: (delta: number, elapsed: number) => void; + priority: number; +} + +export type FlTransitionType = + | 'fade' + | 'slide-left' + | 'slide-right' + | 'slide-up' + | 'slide-down' + | 'zoom' + | 'flip' + | 'morph'; + +export interface FlTransitionConfig { + type: FlTransitionType; + duration: number; + easing: FlEasingName; + color: string; +} + +export interface FlScrollRevealConfig { + threshold: number; + animation: 'fade-up' | 'fade-down' | 'fade-left' | 'fade-right' | 'zoom-in' | 'zoom-out' | 'flip-up'; + duration: number; + delay: number; + easing: FlEasingName; + once: boolean; +} diff --git a/src/types/config.types.ts b/src/types/config.types.ts new file mode 100644 index 0000000..f2d1807 --- /dev/null +++ b/src/types/config.types.ts @@ -0,0 +1,40 @@ +/** + * Flair Elements UI configuration types + */ + +export interface FlConfig { + /** Whether to respect prefers-reduced-motion and disable animations */ + reducedMotion: 'auto' | 'always' | 'never'; + + /** Global animation speed multiplier (1 = normal, 0.5 = half speed, 2 = double) */ + animationSpeed: number; + + /** Default glow/neon color for effects */ + defaultGlowColor: string; + + /** Run animation loops outside Angular zone for performance */ + runOutsideAngular: boolean; + + /** Default particle count for particle-based components */ + defaultParticleCount: number; + + /** Maximum canvas pixel ratio (null = use device pixel ratio) */ + maxPixelRatio: number | null; + + /** Canvas pool max size */ + canvasPoolSize: number; + + /** Default easing function name */ + defaultEasing: FlEasingName; +} + +export type FlEasingName = + | 'linear' + | 'easeInQuad' + | 'easeOutQuad' + | 'easeInOutQuad' + | 'easeInCubic' + | 'easeOutCubic' + | 'easeInOutCubic' + | 'easeOutElastic' + | 'easeOutBounce'; diff --git a/src/types/effect.types.ts b/src/types/effect.types.ts new file mode 100644 index 0000000..7a17824 --- /dev/null +++ b/src/types/effect.types.ts @@ -0,0 +1,93 @@ +/** + * Visual effect configuration types + */ + +export interface FlGlowConfig { + color: string; + size: number; + intensity: number; + pulse: boolean; + pulseSpeed: number; +} + +export interface FlNeonConfig { + color: string; + size: number; + layers: number; + pulse: boolean; + pulseSpeed: number; + text: boolean; +} + +export interface FlTiltConfig { + maxTiltX: number; + maxTiltY: number; + perspective: number; + scale: number; + speed: number; + glare: boolean; + glareMaxOpacity: number; + reset: boolean; + easing: string; +} + +export interface FlHolographicConfig { + intensity: number; + speed: number; + colors: string[]; +} + +export interface FlSpotlightConfig { + color: string; + radius: number; + intensity: number; + blend: string; +} + +export interface FlRippleConfig { + color: string; + duration: number; + opacity: number; + centered: boolean; + unbounded: boolean; +} + +export interface FlMagneticConfig { + strength: number; + radius: number; + ease: number; +} + +export interface FlCelebrationConfig { + count: number; + duration: number; + spread: number; + colors: string[]; + gravity: number; +} + +export interface FlConfettiConfig extends FlCelebrationConfig { + shapes: ('square' | 'circle' | 'strip')[]; + scalar: number; +} + +export interface FlFireworksConfig extends FlCelebrationConfig { + trailLength: number; + flickering: number; +} + +export interface FlAuroraConfig { + colors: string[]; + layers: number; + speed: number; + blur: number; + opacity: number; +} + +export interface FlMatrixRainConfig { + fontSize: number; + color: string; + speed: number; + density: number; + characters: string; +} diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..7ffa3a4 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,8 @@ +/** + * Type definitions barrel export + */ + +export * from './config.types'; +export * from './particle.types'; +export * from './animation.types'; +export * from './effect.types'; diff --git a/src/types/particle.types.ts b/src/types/particle.types.ts new file mode 100644 index 0000000..07d285b --- /dev/null +++ b/src/types/particle.types.ts @@ -0,0 +1,48 @@ +/** + * Particle system types + */ + +export type FlParticlePreset = 'stars' | 'snow' | 'fireflies' | 'dust' | 'bubbles' | 'custom'; + +export interface FlParticleFieldConfig { + preset: FlParticlePreset; + count: number; + speed: number; + sizeRange: [number, number]; + colors: string[]; + connections: boolean; + connectionDistance: number; + connectionOpacity: number; + mouseInteraction: boolean; + mouseRadius: number; + mouseForce: number; + opacity: number; + direction: FlParticleDirection; + shape: FlParticleShape; +} + +export type FlParticleDirection = 'up' | 'down' | 'left' | 'right' | 'random' | 'none'; +export type FlParticleShape = 'circle' | 'square' | 'triangle' | 'star'; + +export interface FlParticle { + x: number; + y: number; + vx: number; + vy: number; + size: number; + color: string; + opacity: number; + life: number; + maxLife: number; +} + +export interface FlFlowFieldConfig { + resolution: number; + particleCount: number; + speed: number; + noiseScale: number; + noiseSpeed: number; + fadeOpacity: number; + colors: string[]; + lineWidth: number; +} diff --git a/src/utils/canvas.utils.ts b/src/utils/canvas.utils.ts new file mode 100644 index 0000000..6da0e20 --- /dev/null +++ b/src/utils/canvas.utils.ts @@ -0,0 +1,46 @@ +/** + * Canvas utility functions + */ + +export function createCanvas(width: number, height: number, pixelRatio?: number): HTMLCanvasElement { + const canvas = document.createElement('canvas'); + const ratio = pixelRatio ?? window.devicePixelRatio; + canvas.width = width * ratio; + canvas.height = height * ratio; + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; + const ctx = canvas.getContext('2d'); + if (ctx) { + ctx.scale(ratio, ratio); + } + return canvas; +} + +export function resizeCanvas(canvas: HTMLCanvasElement, width: number, height: number, pixelRatio?: number): void { + const ratio = pixelRatio ?? window.devicePixelRatio; + canvas.width = width * ratio; + canvas.height = height * ratio; + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; + const ctx = canvas.getContext('2d'); + if (ctx) { + ctx.scale(ratio, ratio); + } +} + +export function clearCanvas(ctx: CanvasRenderingContext2D, width: number, height: number): void { + ctx.clearRect(0, 0, width, height); +} + +export function getPixelRatio(maxRatio?: number | null): number { + const ratio = window.devicePixelRatio; + if (maxRatio != null && ratio > maxRatio) { + return maxRatio; + } + return ratio; +} + +export function fadeCanvas(ctx: CanvasRenderingContext2D, width: number, height: number, opacity: number): void { + ctx.fillStyle = `rgba(0, 0, 0, ${opacity})`; + ctx.fillRect(0, 0, width, height); +} diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..04bb2b3 --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,7 @@ +/** + * Utilities barrel export + */ + +export * from './canvas.utils'; +export * from './math.utils'; +export * from './svg.utils'; diff --git a/src/utils/math.utils.ts b/src/utils/math.utils.ts new file mode 100644 index 0000000..16be242 --- /dev/null +++ b/src/utils/math.utils.ts @@ -0,0 +1,103 @@ +/** + * Math utility functions + */ + +export function lerp(a: number, b: number, t: number): number { + return a + (b - a) * t; +} + +export function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} + +export function mapRange(value: number, inMin: number, inMax: number, outMin: number, outMax: number): number { + return ((value - inMin) / (inMax - inMin)) * (outMax - outMin) + outMin; +} + +export function randomInRange(min: number, max: number): number { + return Math.random() * (max - min) + min; +} + +export function distance2D(x1: number, y1: number, x2: number, y2: number): number { + const dx = x2 - x1; + const dy = y2 - y1; + return Math.sqrt(dx * dx + dy * dy); +} + +// Simple 2D Perlin noise implementation +const PERM_SIZE = 256; +let perm: number[] | null = null; + +function initPerm(): number[] { + if (perm) return perm; + perm = new Array(PERM_SIZE * 2); + const p = new Array(PERM_SIZE); + for (let i = 0; i < PERM_SIZE; i++) p[i] = i; + for (let i = PERM_SIZE - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [p[i], p[j]] = [p[j], p[i]]; + } + for (let i = 0; i < PERM_SIZE * 2; i++) { + perm[i] = p[i % PERM_SIZE]; + } + return perm; +} + +function fade(t: number): number { + return t * t * t * (t * (t * 6 - 15) + 10); +} + +function grad(hash: number, x: number, y: number): number { + const h = hash & 3; + const u = h < 2 ? x : y; + const v = h < 2 ? y : x; + return ((h & 1) === 0 ? u : -u) + ((h & 2) === 0 ? v : -v); +} + +export function noise(x: number, y: number): number { + const p = initPerm(); + const xi = Math.floor(x) & 255; + const yi = Math.floor(y) & 255; + const xf = x - Math.floor(x); + const yf = y - Math.floor(y); + const u = fade(xf); + const v = fade(yf); + + const aa = p[p[xi] + yi]; + const ab = p[p[xi] + yi + 1]; + const ba = p[p[xi + 1] + yi]; + const bb = p[p[xi + 1] + yi + 1]; + + return lerp( + lerp(grad(aa, xf, yf), grad(ba, xf - 1, yf), u), + lerp(grad(ab, xf, yf - 1), grad(bb, xf - 1, yf - 1), u), + v, + ); +} + +export function easeOutElastic(t: number): number { + if (t === 0 || t === 1) return t; + return Math.pow(2, -10 * t) * Math.sin((t - 0.075) * (2 * Math.PI) / 0.3) + 1; +} + +export function easeOutBounce(t: number): number { + if (t < 1 / 2.75) return 7.5625 * t * t; + if (t < 2 / 2.75) return 7.5625 * (t -= 1.5 / 2.75) * t + 0.75; + if (t < 2.5 / 2.75) return 7.5625 * (t -= 2.25 / 2.75) * t + 0.9375; + return 7.5625 * (t -= 2.625 / 2.75) * t + 0.984375; +} + +export function getEasingFunction(name: string): (t: number) => number { + switch (name) { + case 'linear': return (t: number) => t; + case 'easeInQuad': return (t: number) => t * t; + case 'easeOutQuad': return (t: number) => t * (2 - t); + case 'easeInOutQuad': return (t: number) => t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t; + case 'easeInCubic': return (t: number) => t * t * t; + case 'easeOutCubic': return (t: number) => (--t) * t * t + 1; + case 'easeInOutCubic': return (t: number) => t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1; + case 'easeOutElastic': return easeOutElastic; + case 'easeOutBounce': return easeOutBounce; + default: return (t: number) => t; + } +} diff --git a/src/utils/svg.utils.ts b/src/utils/svg.utils.ts new file mode 100644 index 0000000..3c41e37 --- /dev/null +++ b/src/utils/svg.utils.ts @@ -0,0 +1,86 @@ +/** + * SVG utility functions + */ + +const SVG_NS = 'http://www.w3.org/2000/svg'; + +export function createSvgElement( + tag: K, + attrs?: Record, +): SVGElementTagNameMap[K] { + const el = document.createElementNS(SVG_NS, tag); + if (attrs) { + for (const [key, value] of Object.entries(attrs)) { + el.setAttribute(key, value); + } + } + return el; +} + +export function pathInterpolate(pathA: number[], pathB: number[], t: number): number[] { + const len = Math.min(pathA.length, pathB.length); + const result = new Array(len); + for (let i = 0; i < len; i++) { + result[i] = pathA[i] + (pathB[i] - pathA[i]) * t; + } + return result; +} + +export function generateWavePath( + width: number, + height: number, + amplitude: number, + frequency: number, + phase: number, + yOffset: number, +): string { + const segments = Math.ceil(width / 10); + const points: string[] = []; + + points.push(`M 0 ${height}`); + points.push(`L 0 ${yOffset + amplitude * Math.sin(phase)}`); + + for (let i = 1; i <= segments; i++) { + const x = (i / segments) * width; + const y = yOffset + amplitude * Math.sin((i / segments) * frequency * Math.PI * 2 + phase); + points.push(`L ${x} ${y}`); + } + + points.push(`L ${width} ${height}`); + points.push('Z'); + + return points.join(' '); +} + +export function generateBlobPath( + cx: number, + cy: number, + radius: number, + points: number, + variance: number, + phase: number, +): string { + const angleStep = (Math.PI * 2) / points; + const controlPoints: Array<{ x: number; y: number }> = []; + + for (let i = 0; i < points; i++) { + const angle = i * angleStep + phase; + const r = radius + Math.sin(angle * 3 + phase * 2) * variance; + controlPoints.push({ + x: cx + Math.cos(angle) * r, + y: cy + Math.sin(angle) * r, + }); + } + + let d = `M ${controlPoints[0].x} ${controlPoints[0].y}`; + for (let i = 0; i < controlPoints.length; i++) { + const curr = controlPoints[i]; + const next = controlPoints[(i + 1) % controlPoints.length]; + const mx = (curr.x + next.x) / 2; + const my = (curr.y + next.y) / 2; + d += ` Q ${curr.x} ${curr.y} ${mx} ${my}`; + } + d += ' Z'; + + return d; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..dddab58 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compileOnSave": false, + "compilerOptions": { + "outDir": "./dist/out-tsc", + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "esModuleInterop": true, + "sourceMap": true, + "declaration": true, + "experimentalDecorators": true, + "moduleResolution": "bundler", + "importHelpers": true, + "target": "ES2022", + "module": "ES2022", + "lib": ["ES2022", "dom"], + "useDefineForClassFields": false + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/tsconfig.lib.json b/tsconfig.lib.json new file mode 100644 index 0000000..87916be --- /dev/null +++ b/tsconfig.lib.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/lib", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [] + }, + "exclude": [ + "src/test.ts", + "**/*.spec.ts", + "demo/**/*" + ] +}