Spaces:
Sleeping
Sleeping
| 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 ( | |
| <div ref={containerRef} style={{ display: 'flex', flexDirection: 'column', width: '100%', position: 'relative' }}> | |
| <canvas ref={canvasRef} style={{ borderRadius: '8px', display: 'block', width: '100%' }} /> | |
| <div style={{ position: 'absolute', bottom: '10px', right: '12px', fontSize: '11px', color: 'rgba(180,180,200,0.7)', display: 'flex', gap: '14px' }}> | |
| <span><span style={{ display: 'inline-block', width: '6px', height: '6px', background: 'rgba(200,200,220,0.5)', borderRadius: '50%', marginRight: '4px'}}></span>Зрители</span> | |
| <span><span style={{ display: 'inline-block', width: '6px', height: '6px', background: '#c8c8d8', borderRadius: '50%', marginRight: '4px'}}></span>Чатеры</span> | |
| <span><span style={{ display: 'inline-block', width: '6px', height: '6px', background: '#FF007F', borderRadius: '50%', marginRight: '4px'}}></span>Сабы</span> | |
| <span><span style={{ display: 'inline-block', width: '6px', height: '6px', background: '#00F5D4', borderRadius: '50%', marginRight: '4px'}}></span>Модеры</span> | |
| </div> | |
| </div> | |
| ); | |
| } | |