diff --git a/backend/banners/fireflies.json b/backend/banners/fireflies.json new file mode 100644 index 0000000..142e888 --- /dev/null +++ b/backend/banners/fireflies.json @@ -0,0 +1,59 @@ +{ + "name": "Firefly Swarm", + "slug": "fireflies", + "description": "A swarm of chaotic, glowing fireflies that avoid your cursor as you move through them.", + "settings": { + "count": 150, + "speed": 1.5, + "evasionRadius": 150, + "evasionStrength": 0.2, + "glowSize": 4, + "flickerSpeed": 0.05 + }, + "schema": [ + { + "key": "count", + "label": "Firefly Count", + "type": "number", + "min": 50, + "max": 500, + "description": "Number of glowing fireflies in the sky." + }, + { + "key": "speed", + "label": "Movement Speed", + "type": "slider", + "min": 0.5, + "max": 5.0, + "step": 0.1, + "description": "How fast the fireflies fly autonomously." + }, + { + "key": "evasionRadius", + "label": "Avoidance Distance", + "type": "slider", + "min": 50, + "max": 400, + "step": 10, + "description": "How close your cursor needs to be before they start flying away." + }, + { + "key": "evasionStrength", + "label": "Evasion Agility", + "type": "slider", + "min": 0.05, + "max": 1.0, + "step": 0.05, + "description": "How quickly they dart away from your cursor." + }, + { + "key": "glowSize", + "label": "Glow Intensity", + "type": "slider", + "min": 1, + "max": 10, + "step": 1, + "description": "The size of the glowing aura around each firefly." + } + ] +} diff --git a/frontend/src/components/DynamicBanner.tsx b/frontend/src/components/DynamicBanner.tsx index da2a2fa..a3cc6ce 100644 --- a/frontend/src/components/DynamicBanner.tsx +++ b/frontend/src/components/DynamicBanner.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react'; import api from '../api'; import GenerativeBanner from './GenerativeBanner'; import NebulaBanner from './NebulaBanner'; +import FirefliesBanner from './FirefliesBanner'; interface Banner { id: number; @@ -84,6 +85,10 @@ const DynamicBanner: React.FC = (props) => { return ; } + if (banner.slug === 'fireflies') { + return ; + } + // Fallback return ; }; diff --git a/frontend/src/components/FirefliesBanner.tsx b/frontend/src/components/FirefliesBanner.tsx new file mode 100644 index 0000000..92b14ca --- /dev/null +++ b/frontend/src/components/FirefliesBanner.tsx @@ -0,0 +1,239 @@ +import React, { useRef, useEffect, useMemo } from 'react'; +import { alpha, useTheme } from '@mui/material'; + +interface FirefliesBannerProps { + username: string; + palette: string[]; + ratingsCount: number; + followersCount: number; + settings?: { + count?: number; + speed?: number; + evasionRadius?: number; + evasionStrength?: number; + glowSize?: number; + flickerSpeed?: number; + }; +} + +const FirefliesBanner: React.FC = ({ username, palette, settings }) => { + const canvasRef = useRef(null); + const theme = useTheme(); + + const count = settings?.count ?? 150; + const baseSpeed = settings?.speed ?? 1.5; + const evasionRadius = settings?.evasionRadius ?? 150; + const evasionStrength = settings?.evasionStrength ?? 0.2; + const glowSizeMultiplier = settings?.glowSize ?? 4; + const flickerSpeed = settings?.flickerSpeed ?? 0.05; + + const getSeed = (str: string) => { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash); + } + return hash; + }; + + const seed = useMemo(() => getSeed(username), [username]); + + // Filter for bright colors only + const brightColors = useMemo(() => { + const colors = palette.length >= 2 ? palette : [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main]; + + // Simple brightness heuristic: sum of RGB (very rough but works for hex) + const getBrightness = (hex: string) => { + const r = parseInt(hex.slice(1, 3), 16) || 0; + const g = parseInt(hex.slice(3, 5), 16) || 0; + const b = parseInt(hex.slice(5, 7), 16) || 0; + return (r * 299 + g * 587 + b * 114) / 1000; + }; + + const sorted = [...colors].sort((a, b) => getBrightness(b) - getBrightness(a)); + return sorted.slice(0, 3); // Take the top 3 brightest + }, [palette, theme]); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext('2d', { alpha: true }); + if (!ctx) return; + + let animationFrameId: number; + + interface Firefly { + x: number; + y: number; + vx: number; + vy: number; + size: number; + color: string; + phase: number; + noiseOffset: number; + } + + let swarm: Firefly[] = []; + + const pseudoRandom = (offset: number) => { + const x = Math.sin(seed + offset) * 10000; + return x - Math.floor(x); + }; + + const initSwarm = (width: number, height: number) => { + const newSwarm = []; + for (let i = 0; i < count; i++) { + newSwarm.push({ + x: pseudoRandom(i) * width, + y: pseudoRandom(i + 500) * height, + vx: (pseudoRandom(i + 1000) - 0.5) * baseSpeed, + vy: (pseudoRandom(i + 1500) - 0.5) * baseSpeed, + size: 1 + pseudoRandom(i + 2000) * 2, + color: brightColors[i % brightColors.length], + phase: pseudoRandom(i + 2500) * Math.PI * 2, + noiseOffset: pseudoRandom(i + 3000) * 100 + }); + } + return newSwarm; + }; + + const resize = () => { + const parent = canvas.parentElement; + if (parent) { + const rect = parent.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + canvas.width = rect.width * dpr; + canvas.height = rect.height * dpr; + ctx.scale(dpr, dpr); + canvas.style.width = `${rect.width}px`; + canvas.style.height = `${rect.height}px`; + swarm = initSwarm(rect.width, rect.height); + } + }; + + const resizeObserver = new ResizeObserver(() => resize()); + if (canvas.parentElement) resizeObserver.observe(canvas.parentElement); + resize(); + + let mouse = { x: -1000, y: -1000 }; + const handleMouseMove = (e: MouseEvent) => { + const rect = canvas.getBoundingClientRect(); + mouse.x = e.clientX - rect.left; + mouse.y = e.clientY - rect.top; + }; + + const parent = canvas.parentElement; + if (parent) { + parent.addEventListener('mousemove', handleMouseMove); + parent.addEventListener('mouseleave', () => { mouse.x = -1000; mouse.y = -1000; }); + } + + let time = 0; + const draw = () => { + const width = canvas.width / (window.devicePixelRatio || 1); + const height = canvas.height / (window.devicePixelRatio || 1); + if (!width || !height) { + animationFrameId = requestAnimationFrame(draw); + return; + } + + ctx.clearRect(0, 0, width, height); + + // Subtle forest night background + ctx.fillStyle = '#020408'; + ctx.fillRect(0, 0, width, height); + + time += flickerSpeed; + + swarm.forEach((f) => { + // 1. Autonomous Chaotic Movement (Perlin-lite) + f.vx += Math.sin(time * 0.5 + f.noiseOffset) * 0.1; + f.vy += Math.cos(time * 0.4 + f.noiseOffset) * 0.1; + + // 2. Mouse Evasion + const dx = mouse.x - f.x; + const dy = mouse.y - f.y; + const dist = Math.sqrt(dx * dx + dy * dy); + + if (dist < evasionRadius) { + const force = (evasionRadius - dist) / evasionRadius; + const angle = Math.atan2(dy, dx); + // Push away + f.vx -= Math.cos(angle) * force * evasionStrength * 5; + f.vy -= Math.sin(angle) * force * evasionStrength * 5; + } + + // Limit speed + const speed = Math.sqrt(f.vx * f.vx + f.vy * f.vy); + const maxSpeed = baseSpeed * 2; + if (speed > maxSpeed) { + f.vx = (f.vx / speed) * maxSpeed; + f.vy = (f.vy / speed) * maxSpeed; + } + + // Apply friction + f.vx *= 0.98; + f.vy *= 0.98; + + f.x += f.vx; + f.y += f.vy; + + // Screen Wrap + if (f.x < -20) f.x = width + 20; + if (f.x > width + 20) f.x = -20; + if (f.y < -20) f.y = height + 20; + if (f.y > height + 20) f.y = -20; + + // 3. Flicker Logic + const flicker = 0.4 + Math.sin(time + f.phase) * 0.6; + const opacity = flicker > 0 ? flicker : 0; + + // 4. Render Glow + const grad = ctx.createRadialGradient(f.x, f.y, 0, f.x, f.y, f.size * glowSizeMultiplier); + grad.addColorStop(0, alpha(f.color, opacity)); + grad.addColorStop(0.4, alpha(f.color, opacity * 0.3)); + grad.addColorStop(1, alpha(f.color, 0)); + + ctx.fillStyle = grad; + ctx.beginPath(); + ctx.arc(f.x, f.y, f.size * glowSizeMultiplier, 0, Math.PI * 2); + ctx.fill(); + + // Core + ctx.fillStyle = alpha('#fff', opacity * 0.8); + ctx.beginPath(); + ctx.arc(f.x, f.y, f.size * 0.5, 0, Math.PI * 2); + ctx.fill(); + }); + + animationFrameId = requestAnimationFrame(draw); + }; + + draw(); + + return () => { + resizeObserver.disconnect(); + if (parent) { + parent.removeEventListener('mousemove', handleMouseMove); + } + cancelAnimationFrame(animationFrameId); + }; + }, [seed, brightColors, count, baseSpeed, evasionRadius, evasionStrength, glowSizeMultiplier, flickerSpeed]); + + return ( + + ); +}; + +export default FirefliesBanner;