Files
bytedance--flowgram.ai/apps/docs/theme/components/background/index.tsx
T
wehub-resource-sync 0232b4e2bb
CI / build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:09:51 +08:00

605 lines
18 KiB
TypeScript

/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
import { useDark } from '@rspress/core/runtime';
import './index.css';
// Performance configuration based on device capabilities
interface PerformanceConfig {
enabled: boolean;
meteorCount: number;
maxFlameTrails: number;
trailLength: number;
animationQuality: 'high' | 'medium' | 'low';
frameSkip: number;
}
// Performance detection utilities
const detectPerformance = (): PerformanceConfig => {
// Check for reduced motion preference
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
return {
enabled: false,
meteorCount: 0,
maxFlameTrails: 0,
trailLength: 0,
animationQuality: 'low',
frameSkip: 0,
};
}
// Basic device capability detection
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
return {
enabled: false,
meteorCount: 0,
maxFlameTrails: 0,
trailLength: 0,
animationQuality: 'low',
frameSkip: 0,
};
}
// Check hardware concurrency (CPU cores)
const cores = navigator.hardwareConcurrency || 2;
// Check memory (if available)
const memory = (navigator as any).deviceMemory || 4;
// Check if mobile device
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
navigator.userAgent
);
// Performance scoring
let score = 0;
score += cores >= 4 ? 2 : cores >= 2 ? 1 : 0;
score += memory >= 8 ? 2 : memory >= 4 ? 1 : 0;
score += isMobile ? -1 : 1;
// Configure based on performance score
if (score >= 4) {
// High performance
return {
enabled: true,
meteorCount: 12,
maxFlameTrails: 15,
trailLength: 20,
animationQuality: 'high',
frameSkip: 1,
};
} else if (score >= 2) {
// Medium performance
return {
enabled: true,
meteorCount: 8,
maxFlameTrails: 8,
trailLength: 12,
animationQuality: 'medium',
frameSkip: 2,
};
} else {
// Low performance - disable
return {
enabled: false,
meteorCount: 0,
maxFlameTrails: 0,
trailLength: 0,
animationQuality: 'low',
frameSkip: 0,
};
}
};
// Trail particle interface for flame effect
interface TrailParticle {
x: number;
y: number;
vx: number;
vy: number;
size: number;
tick: number;
life: number;
alpha: number;
color: string;
}
// Meteor particle interface definition
interface MeteorParticle {
x: number;
y: number;
vx: number;
vy: number;
radius: number;
color: string;
alpha: number;
trail: Array<{ x: number; y: number; alpha: number }>;
trailLength: number;
speed: number;
angle: number;
// New flame trail system
flameTrails: TrailParticle[];
maxFlameTrails: number;
}
// Configuration options for flame effects
const flameOptions = {
trailSizeBaseMultiplier: 0.6,
trailSizeAddedMultiplier: 0.3,
trailSizeSpeedMultiplier: 0.15,
trailAddedBaseRadiant: -0.8,
trailAddedAddedRadiant: 3,
trailBaseLifeSpan: 25,
trailAddedLifeSpan: 20,
trailGenerationChance: 0.3,
};
// Trail class for managing individual flame particles
class Trail {
private particle: TrailParticle;
private parentMeteor: MeteorParticle;
constructor(parent: MeteorParticle) {
this.parentMeteor = parent;
this.particle = this.createTrailParticle();
}
// Create a new trail particle based on parent meteor
private createTrailParticle = (): TrailParticle => {
const baseSize =
this.parentMeteor.radius *
(flameOptions.trailSizeBaseMultiplier +
flameOptions.trailSizeAddedMultiplier * Math.random());
const radiantOffset =
flameOptions.trailAddedBaseRadiant + flameOptions.trailAddedAddedRadiant * Math.random();
const trailAngle = this.parentMeteor.angle + radiantOffset;
const speed = baseSize * flameOptions.trailSizeSpeedMultiplier;
return {
x: this.parentMeteor.x + (Math.random() - 0.5) * this.parentMeteor.radius,
y: this.parentMeteor.y + (Math.random() - 0.5) * this.parentMeteor.radius,
vx: speed * Math.cos(trailAngle),
vy: speed * Math.sin(trailAngle),
size: baseSize,
tick: 0,
life: Math.floor(
flameOptions.trailBaseLifeSpan + flameOptions.trailAddedLifeSpan * Math.random()
),
alpha: 0.8 + Math.random() * 0.2,
color: this.parentMeteor.color,
};
};
// Update trail particle position and lifecycle
public step = (): boolean => {
this.particle.tick++;
// Check if trail particle should be removed
if (this.particle.tick > this.particle.life) {
return false; // Signal for removal
}
// Update position
this.particle.x += this.particle.vx;
this.particle.y += this.particle.vy;
// Apply slight deceleration for more realistic flame behavior
this.particle.vx *= 0.98;
this.particle.vy *= 0.98;
return true; // Continue existing
};
// Render the trail particle
public draw = (ctx: CanvasRenderingContext2D): void => {
const lifeRatio = 1 - this.particle.tick / this.particle.life;
const currentSize = this.particle.size * lifeRatio;
const currentAlpha = this.particle.alpha * lifeRatio;
if (currentSize <= 0 || currentAlpha <= 0) return;
const alphaHex = Math.floor(currentAlpha * 255)
.toString(16)
.padStart(2, '0');
// Draw flame particle with gradient
const gradient = ctx.createRadialGradient(
this.particle.x,
this.particle.y,
0,
this.particle.x,
this.particle.y,
currentSize * 2
);
gradient.addColorStop(0, this.particle.color + 'FF');
gradient.addColorStop(0.4, this.particle.color + alphaHex);
gradient.addColorStop(0.8, this.particle.color + '33');
gradient.addColorStop(1, this.particle.color + '00');
ctx.beginPath();
ctx.arc(this.particle.x, this.particle.y, currentSize, 0, Math.PI * 2);
ctx.fillStyle = gradient;
ctx.fill();
// Add glow effect
ctx.shadowColor = this.particle.color;
ctx.shadowBlur = currentSize * 2;
ctx.fill();
ctx.shadowBlur = 0;
};
}
// Background2 component - Circular meteor with enhanced flame trailing effect
export const Background: React.FC = () => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const animationRef = useRef<number>(0);
const meteorsRef = useRef<MeteorParticle[]>([]);
const mouseRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
const frameCountRef = useRef<number>(0);
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
const isDark = useDark();
// Performance configuration - memoized to avoid recalculation
const performanceConfig = useMemo(() => detectPerformance(), []);
// Early return if animation is disabled
if (!performanceConfig.enabled) {
return null;
}
// Color configuration - adjusted based on theme mode
const lightColors = ['#4062A7', '#5482BE', '#5ABAC2', '#86C8C5'];
const darkColors = ['#6B8CFF', '#8DA9FF', '#7FDBDA', '#A8EDEA'];
const colors = isDark ? darkColors : lightColors;
// Initialize meteor particles with flame trail system - optimized based on performance
const initMeteors = useCallback((): void => {
meteorsRef.current = [];
for (let i = 0; i < performanceConfig.meteorCount; i++) {
const angle = Math.PI * 0.25; // Fixed 45 degrees (down-right)
const radius = Math.random() * 1.2 + 1.8; // Slightly larger meteors for better flame effect
const speed = (radius - 1.8) * 3.0 + 2.0; // Adjusted speed range
const trailLength = Math.floor(
Math.random() * (performanceConfig.trailLength * 0.5) + performanceConfig.trailLength * 0.5
);
meteorsRef.current.push({
x: Math.random() * dimensions.width,
y: Math.random() * dimensions.height,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
radius,
color: colors[Math.floor(Math.random() * colors.length)],
alpha: Math.random() * 0.3 + 0.7,
trail: [],
trailLength,
speed,
angle,
// Initialize flame trail system with performance-based limits
flameTrails: [],
maxFlameTrails: Math.floor(
radius * performanceConfig.maxFlameTrails * 0.5 + performanceConfig.maxFlameTrails * 0.3
),
});
}
}, [performanceConfig, dimensions.width, dimensions.height, colors]);
// Update meteor trail (original trail system) - optimized
const updateTrail = useCallback(
(meteor: MeteorParticle): void => {
meteor.trail.unshift({
x: meteor.x,
y: meteor.y,
alpha: meteor.alpha,
});
if (meteor.trail.length > meteor.trailLength) {
meteor.trail.pop();
}
// Only update alpha for visible trail points in high quality mode
if (performanceConfig.animationQuality === 'high') {
meteor.trail.forEach((point, index) => {
point.alpha = meteor.alpha * (1 - index / meteor.trailLength);
});
}
},
[performanceConfig.animationQuality]
);
// Update flame trails system - optimized
const updateFlameTrails = useCallback(
(meteor: MeteorParticle): void => {
// Reduce flame trail generation based on performance config
const generationChance =
performanceConfig.animationQuality === 'high'
? flameOptions.trailGenerationChance
: performanceConfig.animationQuality === 'medium'
? flameOptions.trailGenerationChance * 0.7
: flameOptions.trailGenerationChance * 0.4;
// Generate new flame trail particles
if (meteor.flameTrails.length < meteor.maxFlameTrails && Math.random() < generationChance) {
const trail = new Trail(meteor);
meteor.flameTrails.push(trail as any); // Type assertion for compatibility
}
// Update existing flame trails and remove expired ones
meteor.flameTrails = meteor.flameTrails.filter((trail: any) => trail.step && trail.step());
},
[performanceConfig.animationQuality]
);
// Draw meteor with enhanced flame trailing effect - optimized
const drawMeteor = useCallback(
(ctx: CanvasRenderingContext2D, meteor: MeteorParticle): void => {
// Draw flame trails first (behind the meteor) - skip in low quality mode
if (performanceConfig.animationQuality !== 'low') {
meteor.flameTrails.forEach((trail: any) => {
if (trail.draw) {
trail.draw(ctx);
}
});
}
// Draw original trail system with reduced complexity for lower quality
const trailStep =
performanceConfig.animationQuality === 'high'
? 1
: performanceConfig.animationQuality === 'medium'
? 2
: 3;
for (let i = 0; i < meteor.trail.length; i += trailStep) {
const point = meteor.trail[i];
const trailRadius = meteor.radius * (1 - i / meteor.trail.length) * 0.8;
const alpha =
performanceConfig.animationQuality === 'high'
? point.alpha
: meteor.alpha * (1 - i / meteor.trail.length);
const alphaHex = Math.floor(alpha * 255)
.toString(16)
.padStart(2, '0');
ctx.beginPath();
ctx.arc(point.x, point.y, trailRadius, 0, Math.PI * 2);
ctx.fillStyle = meteor.color + alphaHex;
ctx.fill();
// Reduce shadow effects for better performance
if (performanceConfig.animationQuality === 'high') {
ctx.shadowColor = meteor.color;
ctx.shadowBlur = trailRadius * 2 + meteor.radius;
ctx.fill();
ctx.shadowBlur = 0;
}
}
// Draw main meteor body
ctx.beginPath();
ctx.arc(meteor.x, meteor.y, meteor.radius, 0, Math.PI * 2);
// Simplified gradient for lower quality modes
if (performanceConfig.animationQuality === 'high') {
const gradient = ctx.createRadialGradient(
meteor.x,
meteor.y,
0,
meteor.x,
meteor.y,
meteor.radius * 2.5
);
gradient.addColorStop(0, meteor.color + 'FF');
gradient.addColorStop(0.5, meteor.color + 'DD');
gradient.addColorStop(0.8, meteor.color + '77');
gradient.addColorStop(1, meteor.color + '00');
ctx.fillStyle = gradient;
} else {
ctx.fillStyle = meteor.color + 'DD';
}
ctx.fill();
// Add bright core
ctx.beginPath();
ctx.arc(meteor.x, meteor.y, meteor.radius * 0.7, 0, Math.PI * 2);
ctx.fillStyle = meteor.color + 'FF';
ctx.fill();
// Enhanced outer glow - only in high quality mode
if (performanceConfig.animationQuality === 'high') {
ctx.shadowColor = meteor.color;
ctx.shadowBlur = meteor.radius * 5 + 3;
ctx.fill();
ctx.shadowBlur = 0;
}
},
[performanceConfig.animationQuality]
);
// Update meteor position and behavior
const updateMeteor = (meteor: MeteorParticle): void => {
// Mouse interaction - meteors are slightly attracted to mouse
const dx = mouseRef.current.x - meteor.x;
const dy = mouseRef.current.y - meteor.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < 120) {
const force = ((120 - distance) / 120) * 0.008; // Slightly reduced force for stability
const angle = Math.atan2(dy, dx);
meteor.vx += Math.cos(angle) * force;
meteor.vy += Math.sin(angle) * force;
}
// Update both trail systems before moving
updateTrail(meteor);
updateFlameTrails(meteor);
// Update position
meteor.x += meteor.vx;
meteor.y += meteor.vy;
// Boundary wrapping with smooth transition
if (meteor.x > dimensions.width + meteor.radius * 3) {
meteor.x = -meteor.radius * 3;
meteor.trail = []; // Clear trail when wrapping
meteor.flameTrails = []; // Clear flame trails when wrapping
}
if (meteor.x < -meteor.radius * 3) {
meteor.x = dimensions.width + meteor.radius * 3;
meteor.trail = [];
meteor.flameTrails = [];
}
if (meteor.y > dimensions.height + meteor.radius * 3) {
meteor.y = -meteor.radius * 3;
meteor.trail = [];
meteor.flameTrails = [];
}
if (meteor.y < -meteor.radius * 3) {
meteor.y = dimensions.height + meteor.radius * 3;
meteor.trail = [];
meteor.flameTrails = [];
}
// Maintain consistent direction - gently guide back to base direction
const baseAngle = Math.PI * 0.25; // 45 degrees
// Gradually adjust direction
meteor.vx += Math.cos(baseAngle) * 0.003;
meteor.vy += Math.sin(baseAngle) * 0.003;
// Apply slight damping to prevent excessive speed
meteor.vx *= 0.999;
meteor.vy *= 0.999;
// Maintain minimum speed to keep meteors moving
const currentSpeed = Math.sqrt(meteor.vx * meteor.vx + meteor.vy * meteor.vy);
if (currentSpeed < 0.5) {
meteor.vx = Math.cos(baseAngle) * 0.8;
meteor.vy = Math.sin(baseAngle) * 0.8;
}
// Update meteor angle for flame trail generation
meteor.angle = Math.atan2(meteor.vy, meteor.vx);
};
// Animation loop - optimized with frame skipping
const animate = useCallback((): void => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Frame skipping for performance optimization
frameCountRef.current++;
if (frameCountRef.current % performanceConfig.frameSkip !== 0) {
animationRef.current = requestAnimationFrame(animate);
return;
}
// Clear canvas completely to avoid permanent trails
ctx.clearRect(0, 0, dimensions.width, dimensions.height);
// Add subtle background with very low opacity for better flame visibility
// Skip background overlay in low quality mode
if (performanceConfig.animationQuality !== 'low') {
const bgColor = isDark ? 'rgba(13, 17, 23, 0.015)' : 'rgba(237, 243, 248, 0.015)';
ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, dimensions.width, dimensions.height);
}
// Update and draw meteors
meteorsRef.current.forEach((meteor) => {
updateMeteor(meteor);
drawMeteor(ctx, meteor);
});
animationRef.current = requestAnimationFrame(animate);
}, [performanceConfig, dimensions, isDark, updateMeteor, drawMeteor]);
// Handle window resize - optimized
useEffect(() => {
const handleResize = (): void => {
setDimensions({
width: window.innerWidth,
height: window.innerHeight,
});
};
handleResize();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
// Handle mouse movement - optimized with throttling
useEffect(() => {
let throttleTimer: NodeJS.Timeout | null = null;
const handleMouseMove = (e: MouseEvent): void => {
if (throttleTimer) return;
throttleTimer = setTimeout(
() => {
mouseRef.current = { x: e.clientX, y: e.clientY };
throttleTimer = null;
},
performanceConfig.animationQuality === 'high'
? 16
: performanceConfig.animationQuality === 'medium'
? 32
: 64
);
};
window.addEventListener('mousemove', handleMouseMove);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
if (throttleTimer) {
clearTimeout(throttleTimer);
}
};
}, [performanceConfig.animationQuality]);
// Initialize and start animation - optimized
useEffect(() => {
if (dimensions.width === 0 || dimensions.height === 0) return;
initMeteors();
animate();
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
};
}, [dimensions, isDark, initMeteors, animate]);
return (
<canvas
ref={canvasRef}
width={dimensions.width}
height={dimensions.height}
className="background2-canvas"
style={{
position: 'absolute',
width: '100%',
height: '100%',
pointerEvents: 'none',
zIndex: -1,
}}
/>
);
};