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

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

View File

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

View File

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

View File

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

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();
});
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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