File size: 10,880 Bytes
fdc7871
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
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>
  );
}