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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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]++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user