GameSentenceMiner 2.15.11__py3-none-any.whl → 2.15.12__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Shared Kanji Grid Renderer Component
3
+ * Handles rendering kanji grids with configurable options for different use cases
4
+ */
5
+
6
+ class KanjiGridRenderer {
7
+ constructor(options = {}) {
8
+ // Default configuration
9
+ this.config = {
10
+ containerSelector: '#kanjiGrid',
11
+ counterSelector: '#kanjiCount',
12
+ colorMode: 'backend', // 'backend' or 'frequency'
13
+ clickHandler: null, // Custom click handler, defaults to search navigation
14
+ emptyMessage: 'No kanji data available',
15
+ showCounter: true,
16
+ showLegend: true,
17
+ ...options
18
+ };
19
+
20
+ // Get DOM elements
21
+ this.container = document.querySelector(this.config.containerSelector);
22
+ this.counter = this.config.counterSelector ? document.querySelector(this.config.counterSelector) : null;
23
+
24
+ if (!this.container) {
25
+ console.error(`KanjiGridRenderer: Container not found with selector: ${this.config.containerSelector}`);
26
+ return;
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Render the kanji grid with provided data
32
+ * @param {Object|Array} kanjiData - Kanji data to render
33
+ */
34
+ render(kanjiData) {
35
+ if (!kanjiData) {
36
+ this.renderEmpty();
37
+ return;
38
+ }
39
+
40
+ // Handle different data formats
41
+ let kanjiList = [];
42
+ if (Array.isArray(kanjiData)) {
43
+ kanjiList = kanjiData;
44
+ } else if (kanjiData.kanji_data && Array.isArray(kanjiData.kanji_data)) {
45
+ kanjiList = kanjiData.kanji_data;
46
+ // Update counter if available
47
+ if (this.counter && this.config.showCounter) {
48
+ this.counter.textContent = kanjiData.unique_count || kanjiList.length;
49
+ }
50
+ } else {
51
+ this.renderEmpty();
52
+ return;
53
+ }
54
+
55
+ if (kanjiList.length === 0) {
56
+ this.renderEmpty();
57
+ return;
58
+ }
59
+
60
+ // Update counter
61
+ if (this.counter && this.config.showCounter) {
62
+ this.counter.textContent = kanjiList.length;
63
+ }
64
+
65
+ // Clear existing grid
66
+ this.container.innerHTML = '';
67
+
68
+ // Render kanji cells
69
+ kanjiList.forEach(item => {
70
+ const cell = this.createKanjiCell(item);
71
+ this.container.appendChild(cell);
72
+ });
73
+ }
74
+
75
+ /**
76
+ * Create individual kanji cell element
77
+ * @param {Object} item - Kanji item with kanji character and frequency
78
+ * @returns {HTMLElement} - Kanji cell element
79
+ */
80
+ createKanjiCell(item) {
81
+ const cell = document.createElement('span');
82
+ cell.className = 'kanji-cell';
83
+ cell.textContent = item.kanji;
84
+
85
+ // Apply colors based on mode
86
+ if (this.config.colorMode === 'backend' && item.color) {
87
+ // Use backend-provided color
88
+ cell.style.backgroundColor = item.color;
89
+ // Determine text color based on background brightness
90
+ const brightness = this.getColorBrightness(item.color);
91
+ cell.style.color = brightness > 128 ? '#333' : '#fff';
92
+ } else if (this.config.colorMode === 'frequency' && item.frequency) {
93
+ // Calculate color based on frequency
94
+ const color = this.getFrequencyColor(item.frequency);
95
+ cell.style.backgroundColor = color;
96
+ // Determine text color for better contrast
97
+ if (item.frequency > 100) {
98
+ cell.style.color = 'white';
99
+ }
100
+ }
101
+
102
+ // Add tooltip
103
+ cell.title = `${item.kanji}: ${item.frequency} encounters`;
104
+
105
+ // Add click handler
106
+ const clickHandler = this.config.clickHandler || this.defaultClickHandler;
107
+ if (clickHandler) {
108
+ cell.style.cursor = 'pointer';
109
+ cell.addEventListener('click', () => clickHandler(item));
110
+ }
111
+
112
+ return cell;
113
+ }
114
+
115
+ /**
116
+ * Default click handler - navigate to search page
117
+ * @param {Object} item - Kanji item
118
+ */
119
+ defaultClickHandler(item) {
120
+ window.location.href = `/search?q=${encodeURIComponent(item.kanji)}`;
121
+ }
122
+
123
+ /**
124
+ * Get color based on frequency (client-side calculation)
125
+ * @param {number} frequency - Kanji frequency
126
+ * @returns {string} - CSS color value
127
+ */
128
+ getFrequencyColor(frequency) {
129
+ if (frequency > 500) return '#2ee6e0'; // Cyan for very frequent
130
+ else if (frequency > 100) return '#3be62f'; // Green for frequent
131
+ else if (frequency > 30) return '#e6dc2e'; // Yellow for moderate
132
+ else if (frequency > 10) return '#e6342e'; // Red for occasional
133
+ else return '#ebedf0'; // Gray for rare
134
+ }
135
+
136
+ /**
137
+ * Calculate color brightness for text contrast
138
+ * @param {string} hexColor - Hex color value
139
+ * @returns {number} - Brightness value (0-255)
140
+ */
141
+ getColorBrightness(hexColor) {
142
+ // Convert hex to RGB
143
+ const hex = hexColor.replace('#', '');
144
+ const r = parseInt(hex.slice(0, 2), 16);
145
+ const g = parseInt(hex.slice(2, 4), 16);
146
+ const b = parseInt(hex.slice(4, 6), 16);
147
+
148
+ // Calculate brightness using standard formula
149
+ return (r * 299 + g * 587 + b * 114) / 1000;
150
+ }
151
+
152
+ /**
153
+ * Render empty state
154
+ */
155
+ renderEmpty() {
156
+ this.container.innerHTML = `<div style="color:var(--text-secondary);padding:16px;text-align:center;">${this.config.emptyMessage}</div>`;
157
+
158
+ if (this.counter && this.config.showCounter) {
159
+ this.counter.textContent = '0';
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Create and render legend (static, always the same)
165
+ * @param {HTMLElement} legendContainer - Container for legend
166
+ */
167
+ renderLegend(legendContainer) {
168
+ if (!legendContainer || !this.config.showLegend) return;
169
+
170
+ legendContainer.innerHTML = `
171
+ <div class="kanji-legend">
172
+ <span>Rarely Seen</span>
173
+ <div class="kanji-legend-item" style="background-color: #ebedf0;" title="No encounters"></div>
174
+ <div class="kanji-legend-item" style="background-color: #e6342e;" title="Seen once"></div>
175
+ <div class="kanji-legend-item" style="background-color: #e6dc2e;" title="Occasionally seen"></div>
176
+ <div class="kanji-legend-item" style="background-color: #3be62f;" title="Frequently seen"></div>
177
+ <div class="kanji-legend-item" style="background-color: #2ee6e0;" title="Most frequently seen"></div>
178
+ <span>Frequently Seen</span>
179
+ </div>
180
+ `;
181
+ }
182
+
183
+ /**
184
+ * Update configuration
185
+ * @param {Object} newConfig - New configuration options
186
+ */
187
+ updateConfig(newConfig) {
188
+ this.config = { ...this.config, ...newConfig };
189
+ }
190
+
191
+ /**
192
+ * Clear the grid
193
+ */
194
+ clear() {
195
+ this.container.innerHTML = '';
196
+ if (this.counter && this.config.showCounter) {
197
+ this.counter.textContent = '0';
198
+ }
199
+ }
200
+ }
201
+
202
+ // Export for use in other scripts
203
+ window.KanjiGridRenderer = KanjiGridRenderer;
@@ -0,0 +1,273 @@
1
+ // Search Page JavaScript
2
+ // Dependencies: shared.js (provides utility functions like escapeHtml, escapeRegex)
3
+
4
+ class SentenceSearchApp {
5
+ constructor() {
6
+ this.searchInput = document.getElementById('searchInput');
7
+ this.gameFilter = document.getElementById('gameFilter');
8
+ this.sortFilter = document.getElementById('sortFilter');
9
+ this.searchResults = document.getElementById('searchResults');
10
+ this.loadingIndicator = document.getElementById('loadingIndicator');
11
+ this.noResults = document.getElementById('noResults');
12
+ this.emptyState = document.getElementById('emptyState');
13
+ this.errorMessage = document.getElementById('errorMessage');
14
+ this.searchStats = document.getElementById('searchStats');
15
+ this.searchTime = document.getElementById('searchTime');
16
+
17
+ this.currentPage = 1;
18
+ this.pageSize = 20;
19
+ this.searchTimeout = null;
20
+ this.currentQuery = '';
21
+ this.totalResults = 0;
22
+
23
+ // Move initialization logic to async method
24
+ this.initialize();
25
+ }
26
+
27
+ async initialize() {
28
+ // Check for ?q= parameter and pre-fill input
29
+ const urlParams = new URLSearchParams(window.location.search);
30
+ const qParam = urlParams.get('q');
31
+ if (qParam) {
32
+ this.searchInput.value = qParam;
33
+ }
34
+
35
+ this.initializeEventListeners();
36
+ await this.loadGamesList();
37
+
38
+ // Trigger search after games list loads if q param is present
39
+ if (qParam) {
40
+ this.performSearch();
41
+ }
42
+ }
43
+
44
+ initializeEventListeners() {
45
+ // Debounced search input
46
+ this.searchInput.addEventListener('input', (e) => {
47
+ clearTimeout(this.searchTimeout);
48
+ this.searchTimeout = setTimeout(() => {
49
+ this.performSearch();
50
+ }, 300);
51
+ });
52
+
53
+ // Filter changes
54
+ this.gameFilter.addEventListener('change', () => this.performSearch());
55
+ this.sortFilter.addEventListener('change', () => this.performSearch());
56
+
57
+ // Pagination
58
+ document.getElementById('prevPage').addEventListener('click', () => {
59
+ if (this.currentPage > 1) {
60
+ this.currentPage--;
61
+ this.performSearch();
62
+ }
63
+ });
64
+
65
+ document.getElementById('nextPage').addEventListener('click', () => {
66
+ this.currentPage++;
67
+ this.performSearch();
68
+ });
69
+ }
70
+
71
+ async loadGamesList() {
72
+ try {
73
+ const response = await fetch('/api/games-list');
74
+ const data = await response.json();
75
+
76
+ if (response.ok && data.games) {
77
+ const gameSelect = this.gameFilter;
78
+ // Clear existing options except "All Games"
79
+ gameSelect.innerHTML = '<option value="">All Games</option>';
80
+
81
+ data.games.forEach(game => {
82
+ const option = document.createElement('option');
83
+ option.value = game.name;
84
+ option.textContent = game.name;
85
+ gameSelect.appendChild(option);
86
+ });
87
+ }
88
+ } catch (error) {
89
+ console.error('Failed to load games list:', error);
90
+ }
91
+ }
92
+
93
+ async performSearch() {
94
+ const query = this.searchInput.value.trim();
95
+ const gameFilter = this.gameFilter.value;
96
+ const sortBy = this.sortFilter.value;
97
+
98
+ // Reset to first page for new searches
99
+ if (query !== this.currentQuery) {
100
+ this.currentPage = 1;
101
+ }
102
+ this.currentQuery = query;
103
+
104
+ // Show appropriate state
105
+ if (!query) {
106
+ this.showEmptyState();
107
+ return;
108
+ }
109
+
110
+ this.showLoadingState();
111
+ const startTime = Date.now();
112
+
113
+ try {
114
+ const params = new URLSearchParams({
115
+ q: query,
116
+ page: this.currentPage,
117
+ page_size: this.pageSize,
118
+ sort: sortBy
119
+ });
120
+
121
+ if (gameFilter) {
122
+ params.append('game', gameFilter);
123
+ }
124
+
125
+ const response = await fetch(`/api/search-sentences?${params}`);
126
+ const data = await response.json();
127
+
128
+ const searchTime = Date.now() - startTime;
129
+
130
+ if (!response.ok) {
131
+ throw new Error(data.error || 'Search failed');
132
+ }
133
+
134
+ this.displayResults(data, searchTime);
135
+
136
+ } catch (error) {
137
+ this.showErrorState(error.message);
138
+ }
139
+ }
140
+
141
+ displayResults(data, searchTime) {
142
+ this.hideAllStates();
143
+ this.totalResults = data.total;
144
+
145
+ // Update stats
146
+ const resultText = data.total === 1 ? 'result' : 'results';
147
+ this.searchStats.textContent = `${data.total.toLocaleString()} ${resultText} found`;
148
+ this.searchTime.textContent = `Search completed in ${searchTime}ms`;
149
+
150
+ if (data.results.length === 0) {
151
+ this.showNoResultsState();
152
+ return;
153
+ }
154
+
155
+ // Render results
156
+ this.searchResults.innerHTML = '';
157
+ data.results.forEach(result => {
158
+ const resultElement = this.createResultElement(result);
159
+ this.searchResults.appendChild(resultElement);
160
+ });
161
+
162
+ this.updatePagination(data);
163
+ this.searchResults.style.display = 'block';
164
+ }
165
+
166
+ createResultElement(result) {
167
+ const div = document.createElement('div');
168
+ div.className = 'search-result';
169
+
170
+ // Highlight search terms
171
+ const highlightedText = this.highlightSearchTerms(result.sentence, this.currentQuery);
172
+
173
+ // Format timestamp to ISO format
174
+ const date = new Date(result.timestamp * 1000);
175
+ const formattedDate = date.toISOString().split('T')[0] + ' ' + date.toTimeString().split(' ')[0];
176
+
177
+ div.innerHTML = `
178
+ <div class="result-sentence">${highlightedText}</div>
179
+ <div class="result-metadata">
180
+ <div class="metadata-item">
181
+ <span class="game-tag">${escapeHtml(result.game_name)}</span>
182
+ </div>
183
+ <div class="metadata-item">
184
+ <span class="metadata-label">📅</span>
185
+ <span class="metadata-value">${formattedDate}</span>
186
+ </div>
187
+ ${result.translation ? `
188
+ <div class="metadata-item">
189
+ <span class="metadata-label">💬</span>
190
+ <span class="metadata-value">Translation available</span>
191
+ </div>
192
+ ` : ''}
193
+ </div>
194
+ `;
195
+
196
+ return div;
197
+ }
198
+
199
+ highlightSearchTerms(text, query) {
200
+ if (!query) return escapeHtml(text);
201
+
202
+ const escapedText = escapeHtml(text);
203
+ const searchTerms = query.split(' ').filter(term => term.length > 0);
204
+
205
+ let result = escapedText;
206
+ searchTerms.forEach(term => {
207
+ const regex = new RegExp(`(${escapeRegex(term)})`, 'gi');
208
+ result = result.replace(regex, '<span class="search-highlight">$1</span>');
209
+ });
210
+
211
+ return result;
212
+ }
213
+
214
+ updatePagination(data) {
215
+ const pagination = document.getElementById('pagination');
216
+ const prevBtn = document.getElementById('prevPage');
217
+ const nextBtn = document.getElementById('nextPage');
218
+ const pageInfo = document.getElementById('pageInfo');
219
+
220
+ const totalPages = Math.ceil(data.total / this.pageSize);
221
+
222
+ if (totalPages <= 1) {
223
+ pagination.style.display = 'none';
224
+ return;
225
+ }
226
+
227
+ pagination.style.display = 'flex';
228
+ prevBtn.disabled = this.currentPage <= 1;
229
+ nextBtn.disabled = this.currentPage >= totalPages;
230
+
231
+ const startResult = (this.currentPage - 1) * this.pageSize + 1;
232
+ const endResult = Math.min(this.currentPage * this.pageSize, data.total);
233
+
234
+ pageInfo.textContent = `Page ${this.currentPage} of ${totalPages} (${startResult}-${endResult} of ${data.total})`;
235
+ }
236
+
237
+ showLoadingState() {
238
+ this.hideAllStates();
239
+ this.loadingIndicator.style.display = 'flex';
240
+ }
241
+
242
+ showEmptyState() {
243
+ this.hideAllStates();
244
+ this.emptyState.style.display = 'block';
245
+ this.searchStats.textContent = 'Ready to search';
246
+ this.searchTime.textContent = '';
247
+ }
248
+
249
+ showNoResultsState() {
250
+ this.hideAllStates();
251
+ this.noResults.style.display = 'block';
252
+ }
253
+
254
+ showErrorState(message) {
255
+ this.hideAllStates();
256
+ this.errorMessage.style.display = 'block';
257
+ document.getElementById('errorText').textContent = message;
258
+ }
259
+
260
+ hideAllStates() {
261
+ this.loadingIndicator.style.display = 'none';
262
+ this.emptyState.style.display = 'none';
263
+ this.noResults.style.display = 'none';
264
+ this.errorMessage.style.display = 'none';
265
+ this.searchResults.style.display = 'none';
266
+ document.getElementById('pagination').style.display = 'none';
267
+ }
268
+ }
269
+
270
+ // Initialize the app when DOM is loaded
271
+ document.addEventListener('DOMContentLoaded', () => {
272
+ new SentenceSearchApp();
273
+ });