fix: improve demo performance and fix broken component animations

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

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

View File

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