import React, { useEffect, useRef } from 'react'; const API_BASE = import.meta.env.VITE_API_URL || (import.meta.env.DEV ? 'http://localhost:3000' : ''); const TWITCH_CHANNEL = 'winx_prinx'; export default function ChatterOrbit({ chatters }) { const canvasRef = useRef(null); const containerRef = useRef(null); const particlesRef = useRef(new Map()); const avatarRef = useRef(null); useEffect(() => { // Load Avatar via backend proxy (avoids CORS issues with decapi.me) if (!avatarRef.current) { const img = new Image(); img.src = `${API_BASE}/api/avatar/${TWITCH_CHANNEL}`; img.onload = () => { avatarRef.current = img; }; img.onerror = () => console.warn('[ChatterOrbit] Avatar load failed'); } }, []); useEffect(() => { const safeChatters = Array.isArray(chatters) ? chatters : []; safeChatters.forEach(c => { const username = c.username || c.display_name || 'unknown'; if (!particlesRef.current.has(username)) { const isLurker = !c.has_chatted && !(c.message_count > 0); // All particles go on the ring — tight band around ring radius ±8% const ringNoise = (Math.random() - 0.5) * 0.16; const radiusNorm = 1.0 + ringNoise; particlesRef.current.set(username, { username, displayName: c.display_name || username, angle: Math.random() * Math.PI * 2, radiusNorm, speed: (0.0003 + Math.random() * 0.0004) * (Math.random() > 0.5 ? 1 : -1), isMod: c.is_mod, isSub: c.is_sub, isLurker, messageCount: c.message_count || 0, isStreamer: username.toLowerCase() === 'winx_prinx', }); } else { const p = particlesRef.current.get(username); const isLurker = !c.has_chatted && !(c.message_count > 0); p.isLurker = isLurker; p.messageCount = c.message_count || p.messageCount; p.isMod = c.is_mod || p.isMod; p.isSub = c.is_sub || p.isSub; } }); }, [chatters]); useEffect(() => { const canvas = canvasRef.current; const container = containerRef.current; if (!canvas || !container) return; const ctx = canvas.getContext('2d'); let animationId; const HEIGHT = 320; const resize = () => { const dpr = window.devicePixelRatio || 1; const rect = container.getBoundingClientRect(); canvas.width = rect.width * dpr; canvas.height = HEIGHT * dpr; canvas.style.width = `${rect.width}px`; canvas.style.height = `${HEIGHT}px`; ctx.scale(dpr, dpr); }; resize(); window.addEventListener('resize', resize); let hoverName = null; let hoverX = 0; let hoverY = 0; let mouseX = -9999; let mouseY = -9999; const handleMouseMove = (e) => { const rect = canvas.getBoundingClientRect(); mouseX = e.clientX - rect.left; mouseY = e.clientY - rect.top; }; const handleMouseLeave = () => { mouseX = -9999; mouseY = -9999; }; canvas.addEventListener('mousemove', handleMouseMove); canvas.addEventListener('mouseleave', handleMouseLeave); const animate = () => { const width = canvas.width / (window.devicePixelRatio || 1); const height = HEIGHT; const cx = width / 2; const cy = height / 2; // Ring fills most of the canvas const RX = width * 0.44; const RY = height * 0.36; const TILT = RY / RX; // Solid near-black background ctx.fillStyle = '#08080f'; ctx.fillRect(0, 0, width, height); const particles = Array.from(particlesRef.current.values()); hoverName = null; // Update positions particles.forEach(p => { p.angle += p.speed; const r = p.radiusNorm * RX; p.x = cx + Math.cos(p.angle) * r; p.y = cy + Math.sin(p.angle) * r * TILT; p.depth = (Math.sin(p.angle) * TILT + TILT) / (2 * TILT); // 0=back, 1=front if (p.isStreamer) { p.baseSize = 5; p.color = '#A370F7'; } else if (p.isLurker) { p.baseSize = 1; p.color = null; } else { p.baseSize = Math.min(4, 1.5 + Math.log10((p.messageCount || 0) + 1) * 1.2); p.color = p.isMod ? '#00F5D4' : p.isSub ? '#FF007F' : '#c8c8d8'; } p.drawSize = p.baseSize; }); // Sort back-to-front particles.sort((a, b) => a.depth - b.depth); // Draw particles particles.forEach(p => { const alpha = p.isLurker ? 0.15 + p.depth * 0.45 : 0.5 + p.depth * 0.5; let color; if (p.isLurker) { const brightness = Math.round(160 + p.depth * 60); color = `rgba(${brightness},${brightness},${brightness + 20},${alpha})`; } else { color = p.color; } const dist = Math.hypot(mouseX - p.x, mouseY - p.y); if (dist < p.drawSize + 6) { hoverName = p.displayName; hoverX = p.x; hoverY = p.y; p.drawSize = Math.max(p.drawSize * 2.5, 5); } ctx.globalAlpha = p.isLurker ? 1 : alpha; ctx.shadowBlur = 0; if (!p.isLurker && (p.isMod || p.isSub || p.isStreamer)) { ctx.shadowBlur = 6; ctx.shadowColor = color; } ctx.beginPath(); ctx.arc(p.x, p.y, p.drawSize, 0, Math.PI * 2); ctx.fillStyle = color; ctx.fill(); }); ctx.globalAlpha = 1; ctx.shadowBlur = 0; // Draw Avatar on top ctx.save(); ctx.beginPath(); ctx.arc(cx, cy, 26, 0, Math.PI * 2); ctx.closePath(); ctx.shadowColor = '#9147ff'; ctx.shadowBlur = 24; ctx.strokeStyle = 'rgba(145, 71, 255, 0.9)'; ctx.lineWidth = 2.5; ctx.stroke(); ctx.shadowBlur = 0; ctx.clip(); if (avatarRef.current) { ctx.drawImage(avatarRef.current, cx - 26, cy - 26, 52, 52); } else { ctx.fillStyle = '#6441A5'; ctx.fill(); } ctx.restore(); // Hover tooltip if (hoverName) { ctx.shadowBlur = 0; ctx.globalAlpha = 1; ctx.font = 'bold 11px Inter, sans-serif'; const tw = ctx.measureText(hoverName).width; const tx = Math.min(hoverX + 12, width - tw - 20); const ty = hoverY - 30 < 5 ? hoverY + 20 : hoverY - 30; ctx.fillStyle = 'rgba(18,18,24,0.92)'; ctx.beginPath(); if (ctx.roundRect) ctx.roundRect(tx - 4, ty, tw + 16, 22, 5); else ctx.rect(tx - 4, ty, tw + 16, 22); ctx.fill(); ctx.strokeStyle = 'rgba(145,71,255,0.5)'; ctx.lineWidth = 1; ctx.stroke(); ctx.fillStyle = '#efeff1'; ctx.fillText(hoverName, tx + 4, ty + 15); } animationId = requestAnimationFrame(animate); }; animate(); return () => { cancelAnimationFrame(animationId); canvas.removeEventListener('mousemove', handleMouseMove); canvas.removeEventListener('mouseleave', handleMouseLeave); window.removeEventListener('resize', resize); }; }, []); return (
Зрители Чатеры Сабы Модеры
); }