v3.1.4: Улучшено равномерное распределение карточек в тесте
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 28s
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 28s
- Добавлен учёт текущей карточки при перераспределении (не показывается следующей) - Исправлен алгоритм равномерного распределения для предотвращения подряд идущих одинаковых карточек - Исправлен правый счётчик: показывает текущий размер пула + показанные карточки (не больше maxCards)
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "play-life-web",
|
"name": "play-life-web",
|
||||||
"version": "3.1.3",
|
"version": "3.1.4",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
|
|||||||
|
|
||||||
const [words, setWords] = useState([]) // Начальный пул всех слов (для статистики)
|
const [words, setWords] = useState([]) // Начальный пул всех слов (для статистики)
|
||||||
const [testWords, setTestWords] = useState([]) // Пул слов для показа
|
const [testWords, setTestWords] = useState([]) // Пул слов для показа
|
||||||
const [currentIndex, setCurrentIndex] = useState(0)
|
const [currentWord, setCurrentWord] = useState(null) // Текущее слово, которое показывается (уже удалено из пула)
|
||||||
const [flippedCards, setFlippedCards] = useState(new Set())
|
const [flippedCards, setFlippedCards] = useState(new Set())
|
||||||
const [wordStats, setWordStats] = useState({}) // Локальная статистика
|
const [wordStats, setWordStats] = useState({}) // Локальная статистика
|
||||||
const [cardsShown, setCardsShown] = useState(0) // Левый счётчик: кол-во показанных карточек
|
const [cardsShown, setCardsShown] = useState(0) // Левый счётчик: кол-во показанных карточек
|
||||||
@@ -28,11 +28,102 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
|
|||||||
const wordStatsRef = useRef({})
|
const wordStatsRef = useRef({})
|
||||||
const processingRef = useRef(false)
|
const processingRef = useRef(false)
|
||||||
|
|
||||||
|
// Функция равномерного распределения слов в пуле с гарантией максимального расстояния между одинаковыми словами
|
||||||
|
// excludeFirstWordId - ID слова, которое не должно быть первым в пуле (только что показанная карточка)
|
||||||
|
const redistributeWordsEvenly = (currentPool, allWords, excludeFirstWordId = null) => {
|
||||||
|
if (currentPool.length === 0 || allWords.length === 0) {
|
||||||
|
return currentPool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подсчитываем, сколько раз каждое слово встречается в текущем пуле
|
||||||
|
const wordCounts = {}
|
||||||
|
currentPool.forEach(word => {
|
||||||
|
wordCounts[word.id] = (wordCounts[word.id] || 0) + 1
|
||||||
|
})
|
||||||
|
|
||||||
|
// Получаем список уникальных слов, которые есть в пуле
|
||||||
|
const uniqueWordIds = Object.keys(wordCounts).map(id => parseInt(id))
|
||||||
|
const uniqueWords = allWords.filter(word => uniqueWordIds.includes(word.id))
|
||||||
|
|
||||||
|
if (uniqueWords.length === 0) {
|
||||||
|
return currentPool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Перемешиваем уникальные слова для случайности порядка
|
||||||
|
const shuffledUniqueWords = [...uniqueWords]
|
||||||
|
for (let i = shuffledUniqueWords.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1))
|
||||||
|
;[shuffledUniqueWords[i], shuffledUniqueWords[j]] = [shuffledUniqueWords[j], shuffledUniqueWords[i]]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаём массив с информацией о каждом слове и его количестве вхождений
|
||||||
|
const wordEntries = shuffledUniqueWords.map(word => ({
|
||||||
|
word,
|
||||||
|
count: wordCounts[word.id]
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Сортируем по убыванию количества вхождений (слова с большим количеством вхождений размещаем первыми)
|
||||||
|
wordEntries.sort((a, b) => b.count - a.count)
|
||||||
|
|
||||||
|
const totalSlots = currentPool.length
|
||||||
|
const newPool = new Array(totalSlots).fill(null)
|
||||||
|
|
||||||
|
// Размещаем каждое слово с максимально возможным расстоянием между его вхождениями
|
||||||
|
for (const entry of wordEntries) {
|
||||||
|
const { word, count } = entry
|
||||||
|
|
||||||
|
// Вычисляем идеальный интервал между вхождениями этого слова
|
||||||
|
const interval = totalSlots / count
|
||||||
|
|
||||||
|
// Размещаем каждое вхождение слова
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
// Начинаем с идеальной позиции
|
||||||
|
let idealPos = Math.floor(i * interval + Math.random() * interval * 0.5)
|
||||||
|
idealPos = Math.min(idealPos, totalSlots - 1)
|
||||||
|
|
||||||
|
// Если это исключаемое слово, не размещаем его на позиции 0
|
||||||
|
if (word.id === excludeFirstWordId && idealPos === 0) {
|
||||||
|
idealPos = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ищем ближайшую свободную позицию
|
||||||
|
let placed = false
|
||||||
|
|
||||||
|
// Сначала ищем вперёд
|
||||||
|
for (let offset = 0; offset < totalSlots && !placed; offset++) {
|
||||||
|
const pos = (idealPos + offset) % totalSlots
|
||||||
|
// Пропускаем позицию 0 для исключаемого слова
|
||||||
|
if (word.id === excludeFirstWordId && pos === 0) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (newPool[pos] === null) {
|
||||||
|
newPool[pos] = word
|
||||||
|
placed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Финальная проверка: если на позиции 0 оказалось исключаемое слово, меняем его с другим
|
||||||
|
if (excludeFirstWordId !== null && newPool[0] && newPool[0].id === excludeFirstWordId) {
|
||||||
|
// Ищем первое слово, которое не является исключаемым
|
||||||
|
for (let i = 1; i < newPool.length; i++) {
|
||||||
|
if (newPool[i] && newPool[i].id !== excludeFirstWordId) {
|
||||||
|
// Меняем местами
|
||||||
|
;[newPool[0], newPool[i]] = [newPool[i], newPool[0]]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newPool
|
||||||
|
}
|
||||||
|
|
||||||
// Загрузка слов при монтировании
|
// Загрузка слов при монтировании
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setWords([])
|
setWords([])
|
||||||
setTestWords([])
|
setTestWords([])
|
||||||
setCurrentIndex(0)
|
setCurrentWord(null)
|
||||||
setFlippedCards(new Set())
|
setFlippedCards(new Set())
|
||||||
setWordStats({})
|
setWordStats({})
|
||||||
wordStatsRef.current = {}
|
wordStatsRef.current = {}
|
||||||
@@ -81,16 +172,13 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
|
|||||||
const n = Math.max(1, Math.floor(0.7 * cardsCount / wordsCount))
|
const n = Math.max(1, Math.floor(0.7 * cardsCount / wordsCount))
|
||||||
|
|
||||||
// Создаем пул, где каждое слово повторяется n раз
|
// Создаем пул, где каждое слово повторяется n раз
|
||||||
const wordPool = []
|
let wordPool = []
|
||||||
for (let i = 0; i < n; i++) {
|
for (let i = 0; i < n; i++) {
|
||||||
wordPool.push(...data)
|
wordPool.push(...data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Перемешиваем пул случайным образом
|
// Равномерно распределяем слова в пуле
|
||||||
for (let i = wordPool.length - 1; i > 0; i--) {
|
wordPool = redistributeWordsEvenly(wordPool, data)
|
||||||
const j = Math.floor(Math.random() * (i + 1));
|
|
||||||
[wordPool[i], wordPool[j]] = [wordPool[j], wordPool[i]]
|
|
||||||
}
|
|
||||||
|
|
||||||
setTestWords(wordPool)
|
setTestWords(wordPool)
|
||||||
setWordStats(stats)
|
setWordStats(stats)
|
||||||
@@ -111,16 +199,9 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
|
|||||||
loadWords()
|
loadWords()
|
||||||
}, [wordCount, configId])
|
}, [wordCount, configId])
|
||||||
|
|
||||||
const getCurrentWord = () => {
|
// Правый счётчик: текущий размер пула + показанные карточки, но не больше maxCards
|
||||||
if (currentIndex < testWords.length && testWords.length > 0) {
|
|
||||||
return testWords[currentIndex]
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Правый счётчик: кол-во полученных ответов + кол-во слов в пуле (не больше maxCards)
|
|
||||||
const getRightCounter = () => {
|
const getRightCounter = () => {
|
||||||
const total = totalAnswers + testWords.length
|
const total = testWords.length + cardsShown
|
||||||
if (maxCards !== null && maxCards > 0) {
|
if (maxCards !== null && maxCards > 0) {
|
||||||
return Math.min(total, maxCards)
|
return Math.min(total, maxCards)
|
||||||
}
|
}
|
||||||
@@ -205,34 +286,39 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Проверка условий завершения и показ следующей карточки
|
// Берём карточку из пула (getAndDelete) и показываем её
|
||||||
const showNextCardOrFinish = (newTestWords, currentCardsShown) => {
|
const showNextCard = () => {
|
||||||
// Проверяем, не завершился ли тест (на случай если finishTest уже был вызван)
|
// Проверяем, не завершился ли тест
|
||||||
if (isFinishingRef.current || showResults) {
|
if (isFinishingRef.current || showResults) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Условие 1: Достигли максимума карточек
|
// Используем функциональное обновление для получения актуального состояния пула
|
||||||
if (maxCards !== null && maxCards > 0 && currentCardsShown >= maxCards) {
|
setTestWords(prevPool => {
|
||||||
finishTest()
|
// Условие 1: Достигли максимума карточек
|
||||||
return
|
const nextCardsShown = cardsShown + 1
|
||||||
}
|
if (maxCards !== null && maxCards > 0 && nextCardsShown > maxCards) {
|
||||||
|
finishTest()
|
||||||
// Условие 2: Пул слов пуст
|
return prevPool
|
||||||
if (newTestWords.length === 0) {
|
}
|
||||||
finishTest()
|
|
||||||
return
|
// Условие 2: Пул слов пуст
|
||||||
}
|
if (prevPool.length === 0) {
|
||||||
|
finishTest()
|
||||||
// Показываем следующую карточку и увеличиваем левый счётчик
|
return prevPool
|
||||||
// Но сначала проверяем, не достигнем ли мы максимума после увеличения
|
}
|
||||||
const nextCardsShown = currentCardsShown + 1
|
|
||||||
if (maxCards !== null && maxCards > 0 && nextCardsShown > maxCards) {
|
// getAndDelete: берём слово из пула и удаляем его
|
||||||
finishTest()
|
const nextWord = prevPool[0]
|
||||||
return
|
const updatedPool = prevPool.slice(1)
|
||||||
}
|
|
||||||
|
// showCard: показываем карточку
|
||||||
setCardsShown(nextCardsShown)
|
setCurrentWord(nextWord)
|
||||||
|
setCardsShown(nextCardsShown)
|
||||||
|
setFlippedCards(new Set())
|
||||||
|
|
||||||
|
return updatedPool
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSuccess = (wordId) => {
|
const handleSuccess = (wordId) => {
|
||||||
@@ -265,25 +351,9 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
|
|||||||
const newTotalAnswers = totalAnswers + 1
|
const newTotalAnswers = totalAnswers + 1
|
||||||
setTotalAnswers(newTotalAnswers)
|
setTotalAnswers(newTotalAnswers)
|
||||||
|
|
||||||
// Убираем только один экземпляр слова из пула (по текущему индексу)
|
// onSuccess: просто повторяем (showNextCard)
|
||||||
const newTestWords = [...testWords]
|
// Карточка уже удалена из пула при показе, просто показываем следующую
|
||||||
newTestWords.splice(currentIndex, 1)
|
showNextCard()
|
||||||
|
|
||||||
// Обновляем индекс: если удалили последний элемент, переходим к предыдущему
|
|
||||||
let newIndex = currentIndex
|
|
||||||
if (newTestWords.length === 0) {
|
|
||||||
newIndex = 0
|
|
||||||
} else if (currentIndex >= newTestWords.length) {
|
|
||||||
newIndex = newTestWords.length - 1
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обновляем состояние
|
|
||||||
setTestWords(newTestWords)
|
|
||||||
setCurrentIndex(newIndex)
|
|
||||||
setFlippedCards(new Set())
|
|
||||||
|
|
||||||
// Проверяем условия завершения или показываем следующую карточку
|
|
||||||
showNextCardOrFinish(newTestWords, cardsShown)
|
|
||||||
|
|
||||||
// Если тест завершился, не сбрасываем processingRef
|
// Если тест завершился, не сбрасываем processingRef
|
||||||
if (isFinishingRef.current) {
|
if (isFinishingRef.current) {
|
||||||
@@ -323,18 +393,20 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
|
|||||||
const newTotalAnswers = totalAnswers + 1
|
const newTotalAnswers = totalAnswers + 1
|
||||||
setTotalAnswers(newTotalAnswers)
|
setTotalAnswers(newTotalAnswers)
|
||||||
|
|
||||||
// Слово остаётся в пуле, переходим к следующему
|
// onFailure: возвращаем карточку в пул, сортируем, повторяем
|
||||||
let newIndex = currentIndex + 1
|
setTestWords(prevPool => {
|
||||||
if (newIndex >= testWords.length) {
|
// cards.add(currentCard): возвращаем слово обратно в пул
|
||||||
newIndex = 0
|
let newTestWords = [...prevPool, word]
|
||||||
}
|
|
||||||
|
// cards.sort(): равномерно перераспределяем слова в пуле
|
||||||
|
// Передаём wordId, чтобы текущая карточка не оказалась первой (следующей для показа)
|
||||||
|
newTestWords = redistributeWordsEvenly(newTestWords, words, wordId)
|
||||||
|
|
||||||
|
return newTestWords
|
||||||
|
})
|
||||||
|
|
||||||
setCurrentIndex(newIndex)
|
// repeat(): показываем следующую карточку
|
||||||
setFlippedCards(new Set())
|
showNextCard()
|
||||||
|
|
||||||
// Проверяем условия завершения или показываем следующую карточку
|
|
||||||
// При failure пул не изменяется
|
|
||||||
showNextCardOrFinish(testWords, cardsShown)
|
|
||||||
|
|
||||||
// Если тест завершился, не сбрасываем processingRef
|
// Если тест завершился, не сбрасываем processingRef
|
||||||
if (isFinishingRef.current) {
|
if (isFinishingRef.current) {
|
||||||
@@ -350,8 +422,8 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
|
|||||||
|
|
||||||
const handleStartTest = () => {
|
const handleStartTest = () => {
|
||||||
setShowPreview(false)
|
setShowPreview(false)
|
||||||
// Показываем первую карточку и увеличиваем левый счётчик
|
// Показываем первую карточку (берём из пула)
|
||||||
setCardsShown(1)
|
showNextCard()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleFinish = () => {
|
const handleFinish = () => {
|
||||||
@@ -431,8 +503,8 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
|
|||||||
{error && (
|
{error && (
|
||||||
<div className="test-error">{error}</div>
|
<div className="test-error">{error}</div>
|
||||||
)}
|
)}
|
||||||
{!loading && !error && !isFinishingRef.current && getCurrentWord() && (() => {
|
{!loading && !error && !isFinishingRef.current && currentWord && (() => {
|
||||||
const word = getCurrentWord()
|
const word = currentWord
|
||||||
const isFlipped = flippedCards.has(word.id)
|
const isFlipped = flippedCards.has(word.id)
|
||||||
const showSide = getRandomSide(word)
|
const showSide = getRandomSide(word)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user