winx_prinx-api / client /src /components /ChatterOrbit.jsx
Sasha
refactor: modularize client App.jsx tab components and update Netlify CSP
fdc7871
Raw
History Blame Contribute Delete
8.25 kB
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>
);
}