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