@@ -28,6 +28,7 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
const isFinishingRef = useRef ( false )
const wordStatsRef = useRef ( { } )
const processingRef = useRef ( false )
const cardsShownRef = useRef ( 0 ) // Синхронный счётчик для избежания race condition
// Функция равномерного распределения слов в пуле с гарантией максимального расстояния между одинаковыми словами
// excludeFirstWordId - ID слова, которое не должно быть первым в пуле (только что показанная карточка)
@@ -50,73 +51,155 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
return currentPool
}
// Перемешиваем уникальные слова для случайности порядка
const shuffledUniqueWords = [ ... unique Words ]
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 hasOtherWords = uniqueWords . some ( w => w . id !== excludeFirst WordId )
const effectiveExcludeId = hasOtherWords ? excludeFirstWordId : null
// Создаём массив всех экземпляров слов для распределения
const allInstances = [ ]
for ( const word of uniqueWords ) {
const count = wordCounts [ word . id ]
for ( let i = 0 ; i < count ; i ++ ) {
allInstances . push ( { ... word } )
}
}
// Создаём массив с информацией о каждом слове и е г о количестве вхождений
const wordEntries = shuffledUniqueWords . map ( word => ( {
word ,
count : wordCounts [ word . id ]
} ) )
// Сортируем по убыванию количества вхождений (слова с большим количеством вхождений размещаем первыми)
wordEntries . sort ( ( a , b ) => b . count - a . count )
// Перемешиваем экземпляры
for ( let i = allInstances . length - 1 ; i > 0 ; i -- ) {
const j = Math . floor ( Math . random ( ) * ( i + 1 ) )
; [ allInstances [ i ] , allInstances [ j ] ] = [ allInstances [ j ] , allInstances [ i] ]
}
// Используем жадный алгоритм: на каждую позицию выбираем слово,
// которое максимально далеко от своего последнего появления
const totalSlots = currentPool . length
const newPool = new Array ( totalSlots ) . fill ( null )
const lastPosition = { } // Последняя позиция каждого слова
// Размещаем каждое слово с максимально возможным расстоянием между е г о вхождениями
for ( const entry of wordEntries ) {
const { word , count } = entry
for ( let pos = 0 ; pos < totalSlots ; pos ++ ) {
let bestWord = null
let bestWordIndex = - 1
let bestDistance = - 1
// Вычисляем идеальный интервал между вхождениями этого слова
const interval = totalSlots / count
for ( let i = 0 ; i < allInstances . length ; i ++ ) {
const word = allInstances [ i ]
// Размещаем каждое вхождение слова
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 ) {
// Для позиции 0: не выбираем исключаемое слово, если есть альтернативы
if ( pos === 0 && word . id === effectiveExcludeId ) {
// Проверяем, есть ли другие слова
const hasAlternative = allInstances . some ( w => w . id !== effectiveExcludeId )
if ( hasAlternative ) {
continue
}
if ( newPool [ pos ] === null ) {
newPool [ pos ] = word
placed = true
}
// Вычисляем расстояние от последнего появления этого слова
const lastPos = lastPosition [ word . id ]
const distance = lastPos === undefined ? totalSlots : ( pos - lastPos )
// Выбираем слово с максимальным расстоянием
if ( distance > bestDistance ) {
bestDistance = distance
bestWord = word
bestWordIndex = i
}
}
if ( bestWord !== null ) {
newPool [ pos ] = bestWord
lastPosition [ bestWord . id ] = pos
allInstances . splice ( bestWordIndex , 1 )
}
}
// Финальная проверка: если на позиции 0 оказалось исключаемое слово, меняем е г о с ближайшим другим
if ( effectiveExcludeId !== null && newPool [ 0 ] && newPool [ 0 ] . id === effectiveExcludeId ) {
for ( let i = 1 ; i < newPool . length ; i ++ ) {
if ( newPool [ i ] && newPool [ i ] . id !== effectiveExcludeId ) {
; [ newPool [ 0 ] , newPool [ i ] ] = [ newPool [ i ] , newPool [ 0 ] ]
break
}
}
}
// Пост-обработка: исправляем последовательные дубликаты (одинаковые слова подряд)
let iterations = 0
const maxIterations = totalSlots * 2 // Предотвращаем бесконечный цикл
let hasConsecutiveDuplicates = true
while ( hasConsecutiveDuplicates && iterations < maxIterations ) {
hasConsecutiveDuplicates = false
iterations ++
for ( let i = 0 ; i < newPool . length - 1 ; i ++ ) {
if ( newPool [ i ] && newPool [ i + 1 ] && newPool [ i ] . id === newPool [ i + 1 ] . id ) {
// Нашли последовательные дубликаты на позициях i и i+1
// Ищем слово для обмена (не то же самое и не соседнее с дубликатом после обмена)
let swapped = false
for ( let j = i + 2 ; j < newPool . length && ! swapped ; j ++ ) {
if ( ! newPool [ j ] ) continue
// Проверяем, что слово на позиции j отличается от дубликата
if ( newPool [ j ] . id === newPool [ i ] . id ) continue
// Проверяем, что после обмена не создадим новые дубликаты
// Позиция j-1 (если существует) не должна иметь тот же id, что и newPool[i+1]
// Позиция j+1 (если существует) не должна иметь тот же id, что и newPool[i+1]
const wouldCreateDuplicateBefore = j > 0 && newPool [ j - 1 ] && newPool [ j - 1 ] . id === newPool [ i + 1 ] . id
const wouldCreateDuplicateAfter = j < newPool . length - 1 && newPool [ j + 1 ] && newPool [ j + 1 ] . id === newPool [ i + 1 ] . id
if ( ! wouldCreateDuplicateBefore && ! wouldCreateDuplicateAfter ) {
// Меняем местами
; [ newPool [ i + 1 ] , newPool [ j ] ] = [ newPool [ j ] , newPool [ i + 1 ] ]
swapped = true
hasConsecutiveDuplicates = true // Нужна ещё одна итерация для проверки
}
}
// Если не нашли подходящую позицию справа, ищем слева
if ( ! swapped ) {
for ( let j = 0 ; j < i && ! swapped ; j ++ ) {
if ( ! newPool [ j ] ) continue
if ( newPool [ j ] . id === newPool [ i ] . id ) continue
// Для позиции 0: не меняем на исключаемое слово
if ( j === 0 && newPool [ i + 1 ] . id === effectiveExcludeId ) continue
const wouldCreateDuplicateBefore = j > 0 && newPool [ j - 1 ] && newPool [ j - 1 ] . id === newPool [ i + 1 ] . id
const wouldCreateDuplicateAfter = j < newPool . length - 1 && newPool [ j + 1 ] && newPool [ j + 1 ] . id === newPool [ i + 1 ] . id
if ( ! wouldCreateDuplicateBefore && ! wouldCreateDuplicateAfter ) {
; [ newPool [ i + 1 ] , newPool [ j ] ] = [ newPool [ j ] , newPool [ i + 1 ] ]
swapped = true
hasConsecutiveDuplicates = true
}
}
}
}
}
}
// Финальная проверка: если на позиции 0 оказалось исключаемое слово, меняем е г о с другим
if ( excludeFirstWord Id !== null && newPool [ 0 ] && newPool [ 0 ] . id === excludeFirstWord Id ) {
// Ищем первое слово, которое не является исключаемым
// Ещё раз проверяем позицию 0 после всех обменов
if ( effectiveExclude Id !== null && newPool [ 0 ] && newPool [ 0 ] . id === effectiveExclude Id ) {
for ( let i = 1 ; i < newPool . length ; i ++ ) {
if ( newPool [ i ] && newPool [ i ] . id !== excludeFirstWord Id ) {
// Меняем местами
; [ newPool [ 0 ] , newPool [ i ] ] = [ newPool [ i ] , newPool [ 0 ] ]
break
if ( newPool [ i ] && newPool [ i ] . id !== effectiveExclude Id ) {
// Проверяем, не создаст ли обмен дубликат на позиции 1
if ( i === 1 || ( newPool [ 1 ] && newPool [ 1 ] . id != = newPool [ i ] . id ) ) {
; [ newPool [ 0 ] , newPool [ i ] ] = [ newPool [ i ] , newPool [ 0 ] ]
break
}
}
}
}
// Заполняем null-позиции (не должно происходить, но на всякий случай)
for ( let i = 0 ; i < newPool . length ; i ++ ) {
if ( newPool [ i ] === null && currentPool [ i ] ) {
newPool [ i ] = currentPool [ i ]
}
}
return newPool
}
@@ -129,6 +212,7 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
setWordStats ( { } )
wordStatsRef . current = { }
setCardsShown ( 0 )
cardsShownRef . current = 0 // Сбрасываем синхронный счётчик
setTotalAnswers ( 0 )
setError ( '' )
setShowPreview ( false ) // Сбрасываем экран предпросмотра
@@ -290,14 +374,21 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
// Берём карточку из пула (getAndDelete) и показываем её
const showNextCard = ( ) => {
// Проверяем, не завершился ли тест
if ( isFinishingRef . current || showResults ) {
if ( isFinishingRef . current ) {
return
}
// Используем функциональное обновление для получения актуального состояния пула
setTestWords ( prevPool => {
// Повторная проверка внутри callback (на случай если состояние изменилось)
if ( isFinishingRef . current ) {
return prevPool
}
// Используем ref для синхронного доступа к счётчику
const nextCardsShown = cardsShownRef . current + 1
// Условие 1: Достигли максимума карточек
const nextCardsShown = cardsShown + 1
if ( maxCards !== null && maxCards > 0 && nextCardsShown > maxCards ) {
finishTest ( )
return prevPool
@@ -311,8 +402,35 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
// getAndDelete: берём слово из пула и удаляем е г о
const nextWord = prevPool [ 0 ]
// Условие 3: Первое слово в пуле null/undefined (не должно происходить, но на всякий случай)
if ( ! nextWord ) {
// Ищем первое не-null слово в пуле
const validWordIndex = prevPool . findIndex ( w => w !== null && w !== undefined )
if ( validWordIndex === - 1 ) {
// Нет валидных слов - завершаем тест
finishTest ( )
return prevPool
}
// Берём валидное слово
const validWord = prevPool [ validWordIndex ]
const updatedPool = [ ... prevPool . slice ( 0 , validWordIndex ) , ... prevPool . slice ( validWordIndex + 1 ) ]
// Синхронно обновляем ref
cardsShownRef . current = nextCardsShown
setCurrentWord ( validWord )
setCardsShown ( nextCardsShown )
setFlippedCards ( new Set ( ) )
return updatedPool
}
const updatedPool = prevPool . slice ( 1 )
// Синхронно обновляем ref ПЕРЕД установкой state
cardsShownRef . current = nextCardsShown
// showCard: показываем карточку
setCurrentWord ( nextWord )
setCardsShown ( nextCardsShown )