Spaces:
Sleeping
Sleeping
| import React from 'react'; | |
| import { Mic, Volume2, MessageSquare } from 'lucide-react'; | |
| export default function WordsTab({ | |
| wordType, | |
| setWordType, | |
| voiceWords, | |
| chatWords | |
| }) { | |
| const currentWordsList = wordType === 'voice' | |
| ? (Array.isArray(voiceWords) ? voiceWords : []) | |
| : (Array.isArray(chatWords) ? chatWords : []); | |
| if (currentWordsList.length === 0) { | |
| return ( | |
| <div className="panel tab-content"> | |
| <div className="panel-header"> | |
| <h3 className="panel-title"><Mic size={20} /> Частотный словарь</h3> | |
| <div className="nav-tabs" style={{ background: 'var(--color-bg-base)', padding: '0.2rem', borderRadius: '8px', border: '1px solid var(--color-border)' }}> | |
| <button | |
| className={`tab-btn ${wordType === 'voice' ? 'active' : ''}`} | |
| onClick={() => setWordType('voice')} | |
| style={{ fontSize: '0.8rem', padding: '0.4rem 0.8rem' }} | |
| > | |
| <Volume2 size={14} /> Из Голоса (Whisper) | |
| </button> | |
| <button | |
| className={`tab-btn ${wordType === 'chat' ? 'active' : ''}`} | |
| onClick={() => setWordType('chat')} | |
| style={{ fontSize: '0.8rem', padding: '0.4rem 0.8rem' }} | |
| > | |
| <MessageSquare size={14} /> Из Чата (Текстом) | |
| </button> | |
| </div> | |
| </div> | |
| <p style={{ color: 'var(--color-text-muted)', fontSize: '0.85rem', marginBottom: '1.5rem', marginTop: '-0.75rem' }}> | |
| {wordType === 'voice' | |
| ? 'Слова, которые стример произнес в микрофон (распознанные через Whisper на локальном ПК).' | |
| : 'Слова, которые зрители написали в текстовый чат Twitch.'} | |
| </p> | |
| <div className="status-msg"> | |
| <Mic size={40} className="status-msg-icon" /> | |
| <p>Нет собранных слов для выбранного стрима</p> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| const counts = currentWordsList.map(x => x.word_count); | |
| const maxCount = Math.max(...counts, 1); | |
| const minCount = Math.min(...counts, 1); | |
| // Helper for Russian pluralization | |
| const getRussianPlural = (count) => { | |
| const lastDigit = count % 10; | |
| const lastTwoDigits = count % 100; | |
| if (lastTwoDigits >= 11 && lastTwoDigits <= 19) { | |
| return 'раз'; | |
| } | |
| if (lastDigit === 1) { | |
| return 'раз'; | |
| } | |
| if (lastDigit >= 2 && lastDigit <= 4) { | |
| return 'раза'; | |
| } | |
| return 'раз'; | |
| }; | |
| // Color assignment based on word frequency: purple gradient | |
| const getWordColor = (count) => { | |
| const scale = maxCount === minCount ? 1 : (count - minCount) / (maxCount - minCount); | |
| // Scale saturation from 35% (desaturated/white-lavender) to 100% (saturated purple) | |
| const sat = Math.round(35 + scale * 65); | |
| // Scale lightness from 92% (light/white-ish) to 60% (vivid deep purple) | |
| const light = Math.round(92 - scale * 32); | |
| return `hsl(265, ${sat}%, ${light}%)`; | |
| }; | |
| // Layout calculations with collision avoidance on stretched elliptical spiral | |
| const placedBoxes = []; | |
| const laidOutWords = []; | |
| const isMobile = typeof window !== 'undefined' && window.innerWidth < 768; | |
| const stretchX = isMobile ? 1.4 : 2.8; | |
| const stretchY = 1.0; | |
| const paddingX = isMobile ? 6 : 12; | |
| const paddingY = isMobile ? 4 : 8; | |
| const fontScale = isMobile ? 0.45 : 1.0; | |
| const remToPx = 16; | |
| const centerGroupThreshold = wordType === 'chat' ? 250 : 500; | |
| // Identify center group: words with count within threshold of the max count, | |
| // but ONLY if the top count is >= threshold. Also cap center group size to at most 3 words. | |
| const centerGroupWords = currentWordsList.filter((w, idx) => | |
| idx === 0 || (maxCount >= centerGroupThreshold && (maxCount - w.word_count) < centerGroupThreshold && idx < 3) | |
| ); | |
| const otherWordsList = currentWordsList.filter((w, idx) => | |
| !(idx === 0 || (maxCount >= centerGroupThreshold && (maxCount - w.word_count) < centerGroupThreshold && idx < 3)) | |
| ); | |
| const maxOtherCount = otherWordsList.length > 0 ? Math.max(...otherWordsList.map(x => x.word_count)) : 1; | |
| const minOtherCount = otherWordsList.length > 0 ? Math.min(...otherWordsList.map(x => x.word_count)) : 1; | |
| const minCenterCount = Math.min(...centerGroupWords.map(x => x.word_count)); | |
| // Function to estimate word width factor based on wide/narrow letters | |
| const getWordWidthFactor = (word) => { | |
| let factor = 0; | |
| const wideChars = /[мжшщыюяwm]/i; | |
| const narrowChars = /[ilj1!|т]/i; | |
| for (const char of word) { | |
| if (wideChars.test(char)) { | |
| factor += 0.75; | |
| } else if (narrowChars.test(char)) { | |
| factor += 0.35; | |
| } else { | |
| factor += 0.55; | |
| } | |
| } | |
| return factor; | |
| }; | |
| for (let i = 0; i < currentWordsList.length; i++) { | |
| const w = currentWordsList[i]; | |
| const isCenterGroup = i === 0 || (maxCount >= centerGroupThreshold && (maxCount - w.word_count) < centerGroupThreshold && i < 3); | |
| let fontSize; | |
| let fontWeight; | |
| let scaleValue = 0; | |
| // Scale down the entire galaxy if the absolute max frequency is low (e.g. less than 200) | |
| // If maxCount is 20, galaxyScale is ~0.65. If 200+, it's 1.0. | |
| const galaxyScale = Math.min(Math.max(maxCount / 200, 0), 1.0) * 0.35 + 0.65; | |
| if (isCenterGroup) { | |
| // Scale font size within center group: ranges from 4.5rem to 6.2rem | |
| const centerScale = maxCount === minCenterCount ? 1 : (w.word_count - minCenterCount) / (maxCount - minCenterCount); | |
| fontSize = 4.5 + centerScale * 1.7; | |
| fontWeight = '900'; | |
| scaleValue = centerScale; | |
| } else { | |
| // Scale font size within other words: ranges from 1.1rem to 3.2rem | |
| const scale = maxOtherCount === minOtherCount ? 1 : (w.word_count - minOtherCount) / (maxOtherCount - minOtherCount); | |
| scaleValue = Math.pow(scale, 1.4); | |
| fontSize = 1.1 + scaleValue * 2.1; | |
| fontWeight = fontSize > 2.0 ? '700' : (fontSize > 1.4 ? '600' : '500'); | |
| } | |
| fontSize = fontSize * fontScale * galaxyScale; | |
| // Precise width calculation based on custom width factors | |
| const wordWidth = fontSize * getWordWidthFactor(w.word) * remToPx; | |
| const wordHeight = fontSize * 1.1 * remToPx; | |
| let x = 0; | |
| let y = 0; | |
| // Generate a stable hash for the word to randomize angle and jitter | |
| let hash = 0; | |
| for (let j = 0; j < w.word.length; j++) { | |
| hash = w.word.charCodeAt(j) + ((hash << 5) - hash); | |
| } | |
| hash = Math.abs(hash); | |
| // Search for position starting from r = 0. | |
| // Since center group words are processed first, they cluster tightly around (0,0). | |
| let found = false; | |
| const rStep = 1.1; | |
| for (let attempt = 0; attempt < 1200; attempt++) { | |
| const startAngle = (hash % 100) * 0.06283; // 0 to 2*PI | |
| const angle = startAngle + attempt * 0.15; | |
| const r = (i === 0 && attempt === 0) ? 0 : (45 + rStep * attempt); | |
| x = r * Math.cos(angle) * stretchX; | |
| y = r * Math.sin(angle) * stretchY; | |
| // Add coordinate noise/jitter (except for the absolute top word at the center) | |
| if (r > 0) { | |
| x += Math.sin(hash * 0.5 + attempt) * 6; | |
| y += Math.cos(hash * 0.8 + attempt) * 4; | |
| } | |
| let collision = false; | |
| for (const box of placedBoxes) { | |
| const halfW1 = wordWidth / 2; | |
| const halfH1 = wordHeight / 2; | |
| const halfW2 = box.w / 2; | |
| const halfH2 = box.h / 2; | |
| if (Math.abs(x - box.x) < (halfW1 + halfW2 + paddingX) && | |
| Math.abs(y - box.y) < (halfH1 + halfH2 + paddingY)) { | |
| collision = true; | |
| break; | |
| } | |
| } | |
| if (!collision) { | |
| found = true; | |
| break; | |
| } | |
| } | |
| placedBoxes.push({ x: x, y: y, w: wordWidth, h: wordHeight }); | |
| const color = getWordColor(w.word_count); | |
| // Floating parameters | |
| const duration = 4.5 + (i % 3) + (i % 4) * 0.4; // 4.5s to 8.5s | |
| const delay = -((i * 1.3) % 7); // negative delay to start asynchronously | |
| const amount = 3 + (i % 4); // 3px to 6px float amount | |
| laidOutWords.push({ | |
| word: w.word, | |
| count: w.word_count, | |
| x: Math.round(x), | |
| y: Math.round(y), | |
| fontSize: `${fontSize}rem`, | |
| fontWeight: fontWeight, | |
| color: color, | |
| scale: scaleValue, | |
| isCenterGroup: isCenterGroup, | |
| duration: `${duration}s`, | |
| delay: `${delay}s`, | |
| amount: `${amount}px` | |
| }); | |
| } | |
| return ( | |
| <div className="panel tab-content"> | |
| <div className="panel-header"> | |
| <h3 className="panel-title"><Mic size={20} /> Частотный словарь</h3> | |
| <div className="nav-tabs" style={{ background: 'var(--color-bg-base)', padding: '0.2rem', borderRadius: '8px', border: '1px solid var(--color-border)' }}> | |
| <button | |
| className={`tab-btn ${wordType === 'voice' ? 'active' : ''}`} | |
| onClick={() => setWordType('voice')} | |
| style={{ fontSize: '0.8rem', padding: '0.4rem 0.8rem' }} | |
| > | |
| <Volume2 size={14} /> Из Голоса (Whisper) | |
| </button> | |
| <button | |
| className={`tab-btn ${wordType === 'chat' ? 'active' : ''}`} | |
| onClick={() => setWordType('chat')} | |
| style={{ fontSize: '0.8rem', padding: '0.4rem 0.8rem' }} | |
| > | |
| <MessageSquare size={14} /> Из Чата (Текстом) | |
| </button> | |
| </div> | |
| </div> | |
| <p style={{ color: 'var(--color-text-muted)', fontSize: '0.85rem', marginBottom: '1.5rem', marginTop: '-0.75rem' }}> | |
| {wordType === 'voice' | |
| ? 'Слова, которые стример произнес в микрофон (распознанные через Whisper на локальном ПК).' | |
| : 'Слова, которые зрители написали в текстовый чат Twitch.'} | |
| </p> | |
| <div className="word-galaxy-container"> | |
| {laidOutWords.map((w, idx) => ( | |
| <div | |
| key={idx} | |
| className="word-galaxy-tag" | |
| style={{ | |
| '--x': `${w.x}px`, | |
| '--y': `${w.y}px`, | |
| '--float-duration': w.duration, | |
| '--float-delay': w.delay, | |
| '--float-amount': w.amount, | |
| fontSize: w.fontSize, | |
| fontWeight: w.fontWeight, | |
| color: w.color, | |
| opacity: 0.95, | |
| zIndex: w.isCenterGroup ? 25 : Math.round(10 + w.scale * 15) | |
| }} | |
| > | |
| {w.word} | |
| <div className="word-tooltip"> | |
| {w.count} {getRussianPlural(w.count)} | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| ); | |
| } | |