グカネータ改良 (#371) #375
@@ -173,6 +173,16 @@ const value =
|
||||
|
||||
- In TypeScript and TSX, keep short ternary expressions on one line when they
|
||||
fit cleanly under the line limit.
|
||||
- In TypeScript and TSX, prefer ternary expressions for simple conditional
|
||||
value selection. Do not replace a clear ternary with `if` statements, and do
|
||||
not introduce immediately invoked functions just to avoid or reformat a
|
||||
ternary expression.
|
||||
- In TypeScript and TSX, do not write `let` followed by later `if` assignments
|
||||
when the value can be expressed as a single `const` initializer. Prefer
|
||||
`const` because it prevents accidental later reassignment.
|
||||
- When fixing formatting, change formatting only. Do not change expression
|
||||
structure, control flow, or variable mutability unless the requested style
|
||||
explicitly requires it.
|
||||
- Do not add production dependencies without explicit approval.
|
||||
- Do not create, modify, or run tests unless the user explicitly asks for
|
||||
test work. When the user asks for tests, keep working and rerun them until
|
||||
|
||||
+327
-270
@@ -25,6 +25,7 @@ import { gekanatorKeys } from '@/lib/queryKeys'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import type { Transition } from 'framer-motion'
|
||||
|
||||
import type { GekanatorAnswerLog,
|
||||
GekanatorAnswerValue,
|
||||
@@ -243,16 +244,23 @@ const normalizeStoredQuestionId = (
|
||||
|
||||
const normalizeStoredGame = (game: StoredGekanatorGame): StoredGekanatorGame => ({
|
||||
...game,
|
||||
answers: game.answers.map (answer => ({
|
||||
...answer,
|
||||
questionId: normalizeStoredQuestionId (answer.questionId,
|
||||
answer.questionCondition),
|
||||
questionMode: ((answer.questionMode === 'winning_run' || answer.questionMode === 'normal')
|
||||
? answer.questionMode
|
||||
: undefined),
|
||||
questionCondition: (answer.questionCondition
|
||||
? normalizeTitleLengthCondition (answer.questionCondition)
|
||||
: undefined) })),
|
||||
answers: game.answers.map (answer => {
|
||||
const questionMode =
|
||||
answer.questionMode === 'winning_run' || answer.questionMode === 'normal'
|
||||
? answer.questionMode
|
||||
: undefined
|
||||
const questionCondition =
|
||||
answer.questionCondition
|
||||
? normalizeTitleLengthCondition (answer.questionCondition)
|
||||
: undefined
|
||||
|
||||
return {
|
||||
...answer,
|
||||
questionId: normalizeStoredQuestionId (answer.questionId,
|
||||
answer.questionCondition),
|
||||
questionMode,
|
||||
questionCondition }
|
||||
}),
|
||||
askedIds: game.askedIds.map (questionId => normalizeStoredQuestionId (questionId)),
|
||||
softenedQuestionIds: (game.softenedQuestionIds
|
||||
.map (questionId => normalizeStoredQuestionId (questionId))),
|
||||
@@ -474,10 +482,12 @@ const questionCategoryPenalty = (
|
||||
repeatPenalty: number,
|
||||
): number => {
|
||||
const earlyFactor = Math.max (0, (3 - answerCount) / 3)
|
||||
const titleLengthPenalty =
|
||||
titleLengthMinimumForCondition (question.condition) == null
|
||||
? 0
|
||||
: (answerCount === 0 ? 8 : 3.5) * earlyFactor
|
||||
const titleLengthPenalty = (() => {
|
||||
if (titleLengthMinimumForCondition (question.condition) == null)
|
||||
return 0
|
||||
|
||||
return (answerCount === 0 ? 8 : 3.5) * earlyFactor
|
||||
}) ()
|
||||
|
||||
switch (question.kind)
|
||||
{
|
||||
@@ -649,21 +659,21 @@ const buildMaterialIndex = (
|
||||
const originalValue = post.originalCreatedFrom || post.originalCreatedBefore
|
||||
const date =
|
||||
originalValue
|
||||
? new Date (originalValue)
|
||||
: null
|
||||
? new Date (originalValue)
|
||||
: null
|
||||
const validDate =
|
||||
date && !(Number.isNaN (date.getTime ()))
|
||||
? date
|
||||
: null
|
||||
? date
|
||||
: null
|
||||
const originalYear = validDate?.getFullYear () ?? null
|
||||
const originalMonth =
|
||||
validDate
|
||||
? validDate.getMonth () + 1
|
||||
: null
|
||||
? validDate.getMonth () + 1
|
||||
: null
|
||||
const originalMonthDay =
|
||||
validDate
|
||||
? `${ validDate.getMonth () + 1 }-${ validDate.getDate () }`
|
||||
: null
|
||||
? `${ validDate.getMonth () + 1 }-${ validDate.getDate () }`
|
||||
: null
|
||||
originalYearByPostId.set (post.id, originalYear)
|
||||
originalMonthByPostId.set (post.id, originalMonth)
|
||||
originalMonthDayByPostId.set (post.id, originalMonthDay)
|
||||
@@ -758,10 +768,10 @@ const humanPriorityOffsetFor = (question: GekanatorQuestion): number => {
|
||||
case 'source':
|
||||
return -2.5
|
||||
case 'post_similarity':
|
||||
return (
|
||||
question.source === 'user_suggested' || question.source === 'admin_curated'
|
||||
? -3.5
|
||||
: -1.5)
|
||||
if (question.source === 'user_suggested' || question.source === 'admin_curated')
|
||||
return -3.5
|
||||
|
||||
return -1.5
|
||||
case 'original_date':
|
||||
switch (question.condition.type)
|
||||
{
|
||||
@@ -1197,10 +1207,12 @@ const buildQuestionsForCandidateIds = (
|
||||
confirmationPostId?: number | null },
|
||||
): GekanatorQuestion[] => {
|
||||
const total = candidateIds.length
|
||||
const confirmationPost =
|
||||
confirmationPostId == null
|
||||
? null
|
||||
: materialIndex.postById.get (confirmationPostId) ?? null
|
||||
const confirmationPost = (() => {
|
||||
if (confirmationPostId == null)
|
||||
return null
|
||||
|
||||
return materialIndex.postById.get (confirmationPostId) ?? null
|
||||
}) ()
|
||||
|
||||
if (mode === 'split' && total === 0)
|
||||
return acceptedQuestions
|
||||
@@ -1251,17 +1263,23 @@ const buildQuestionsForCandidateIds = (
|
||||
GekanatorQuestionCondition,
|
||||
{ type: 'original-year' | 'original-month' | 'original-month-day' }
|
||||
>,
|
||||
): GekanatorQuestion => buildIndexedQuestion ({
|
||||
): GekanatorQuestion => {
|
||||
const priorityWeight = (() => {
|
||||
if (condition.type === 'original-year')
|
||||
return 1.04
|
||||
if (condition.type === 'original-month-day')
|
||||
return 1.01
|
||||
|
||||
return .92
|
||||
}) ()
|
||||
|
||||
return buildIndexedQuestion ({
|
||||
condition,
|
||||
text: originalDateQuestionTextFor (condition),
|
||||
kind: 'original_date',
|
||||
priorityWeight:
|
||||
condition.type === 'original-year'
|
||||
? 1.04
|
||||
: condition.type === 'original-month-day'
|
||||
? 1.01
|
||||
: .92,
|
||||
priorityWeight,
|
||||
materialIndex })
|
||||
}
|
||||
const specialMonthDays = rankedEntriesForCounts ({
|
||||
counts: monthDayCounts,
|
||||
total,
|
||||
@@ -1342,27 +1360,21 @@ const buildQuestionsForCandidateIds = (
|
||||
const titleLength = materialIndex.titleLengthByPostId.get (targetPostId) ?? 0
|
||||
const tagKeys = materialIndex.tagKeysByPostId.get (targetPostId) ?? []
|
||||
|
||||
addQuestion (
|
||||
host
|
||||
? buildIndexedQuestion ({
|
||||
condition: { type: 'source', host },
|
||||
text: `${ host } の投稿を思い浮かべている?`,
|
||||
kind: 'source',
|
||||
priorityWeight: 1.02,
|
||||
materialIndex })
|
||||
: null)
|
||||
addQuestion (
|
||||
year != null && year != undefined
|
||||
? buildDateQuestion ({
|
||||
type: 'original-year',
|
||||
year })
|
||||
: null)
|
||||
addQuestion (
|
||||
monthDay && specialOriginalMonthDayLabelFor (monthDay)
|
||||
? buildDateQuestion ({
|
||||
type: 'original-month-day',
|
||||
monthDay })
|
||||
: null)
|
||||
if (host)
|
||||
addQuestion (buildIndexedQuestion ({
|
||||
condition: { type: 'source', host },
|
||||
text: `${ host } の投稿を思い浮かべている?`,
|
||||
kind: 'source',
|
||||
priorityWeight: 1.02,
|
||||
materialIndex }))
|
||||
if (year != null)
|
||||
addQuestion (buildDateQuestion ({
|
||||
type: 'original-year',
|
||||
year }))
|
||||
if (monthDay && specialOriginalMonthDayLabelFor (monthDay))
|
||||
addQuestion (buildDateQuestion ({
|
||||
type: 'original-month-day',
|
||||
monthDay }))
|
||||
|
||||
tagKeys
|
||||
.slice (0, 20)
|
||||
@@ -1451,16 +1463,19 @@ const candidatePostsForState = ({
|
||||
if (!(condition))
|
||||
return true
|
||||
|
||||
const matched = question
|
||||
? matchingPostIdsForQuestion ({
|
||||
const matched = (() => {
|
||||
if (question)
|
||||
return matchingPostIdsForQuestion ({
|
||||
posts,
|
||||
materialIndex,
|
||||
matchIndex,
|
||||
question,
|
||||
dynamicMatchIndex })
|
||||
: matchingPostIdsForCondition ({
|
||||
condition,
|
||||
materialIndex })
|
||||
|
||||
return matchingPostIdsForCondition ({
|
||||
condition,
|
||||
materialIndex })
|
||||
}) ()
|
||||
const useExpectedAnswer =
|
||||
question != null
|
||||
&& usesLearnedTagExamples (question)
|
||||
@@ -1477,9 +1492,7 @@ const candidatePostsForState = ({
|
||||
|| expected === answer.answer
|
||||
}
|
||||
|
||||
return answer.answer === 'yes'
|
||||
? matched.has (post.id)
|
||||
: !(matched.has (post.id))
|
||||
return answer.answer === 'yes' ? matched.has (post.id) : !(matched.has (post.id))
|
||||
}
|
||||
|
||||
if (!(question))
|
||||
@@ -1839,17 +1852,21 @@ const contradictionPenaltyFor = ({
|
||||
case 'partial':
|
||||
return sum + (isExclusiveContradiction (question.condition, previous) ? 25 : 0)
|
||||
case 'no':
|
||||
return sum + (
|
||||
if (
|
||||
sameConditionValue (question.condition, previous)
|
||||
|| isMonthCrossMatch (question.condition, previous)
|
||||
? 40
|
||||
: 0)
|
||||
|| isMonthCrossMatch (question.condition, previous)
|
||||
)
|
||||
return sum + 40
|
||||
|
||||
return sum
|
||||
case 'probably_no':
|
||||
return sum + (
|
||||
if (
|
||||
sameConditionValue (question.condition, previous)
|
||||
|| isMonthCrossMatch (question.condition, previous)
|
||||
? 20
|
||||
: 0)
|
||||
|| isMonthCrossMatch (question.condition, previous)
|
||||
)
|
||||
return sum + 20
|
||||
|
||||
return sum
|
||||
default:
|
||||
return sum
|
||||
}
|
||||
@@ -2003,28 +2020,34 @@ const chooseQuestion = (
|
||||
const humanOffset = humanPriorityOffsetFor (question)
|
||||
const sourceBonus = sourcePriorityOffset (question)
|
||||
const priorityBonus = priorityWeightOffset (question)
|
||||
const repeatPenalty =
|
||||
answers.length === 0
|
||||
? (recentFirstQuestionPenaltyById.get (question.id) ?? 0) * 4.5
|
||||
: 0
|
||||
const repeatPenalty = (() => {
|
||||
if (answers.length === 0)
|
||||
return (recentFirstQuestionPenaltyById.get (question.id) ?? 0) * 4.5
|
||||
|
||||
return 0
|
||||
}) ()
|
||||
const categoryPenalty = questionCategoryPenalty (
|
||||
question,
|
||||
answers.length,
|
||||
repeatPenalty)
|
||||
const priorSplitScore =
|
||||
priorWeightTotal <= 0
|
||||
? null
|
||||
: Math.abs (
|
||||
.5 - (
|
||||
priorEntries.reduce (
|
||||
(sum, [postId, weight]) => {
|
||||
return sum + (matched.has (postId) ? weight : 0)
|
||||
},
|
||||
0) / priorWeightTotal))
|
||||
const priorBonus =
|
||||
priorSplitScore == null
|
||||
? 0
|
||||
: Math.max (0, .22 - priorSplitScore) * -18
|
||||
const priorSplitScore = (() => {
|
||||
if (priorWeightTotal <= 0)
|
||||
return null
|
||||
|
||||
return Math.abs (
|
||||
.5 - (
|
||||
priorEntries.reduce (
|
||||
(sum, [postId, weight]) => {
|
||||
return sum + (matched.has (postId) ? weight : 0)
|
||||
},
|
||||
0) / priorWeightTotal))
|
||||
}) ()
|
||||
const priorBonus = (() => {
|
||||
if (priorSplitScore == null)
|
||||
return 0
|
||||
|
||||
return Math.max (0, .22 - priorSplitScore) * -18
|
||||
}) ()
|
||||
const infoGainBonus = -Math.min (1.2, infoGain) * 4
|
||||
|
||||
return { question,
|
||||
@@ -2052,9 +2075,7 @@ const chooseQuestion = (
|
||||
questions.filter (question => !(askedIds.has (question.id)))
|
||||
const ranked = rank (unansweredQuestions, scoredPosts, normalisedWeightedPosts)
|
||||
const pool = (
|
||||
ranked.some (item => !(item.narrow))
|
||||
? ranked.filter (item => !(item.narrow))
|
||||
: ranked)
|
||||
ranked.some (item => !(item.narrow)) ? ranked.filter (item => !(item.narrow)) : ranked)
|
||||
.slice (0, 16)
|
||||
|
||||
if (pool.length === 0)
|
||||
@@ -2133,10 +2154,7 @@ const chooseWinningRunQuestion = ({
|
||||
})
|
||||
.map (question => {
|
||||
const expected = expectedAnswerForQuestion (question, targetPost)
|
||||
const priority =
|
||||
expected == null
|
||||
? null
|
||||
: winningRunPriorityFor (expected)
|
||||
const priority = expected == null ? null : winningRunPriorityFor (expected)
|
||||
if (priority == null)
|
||||
return null
|
||||
|
||||
@@ -2148,9 +2166,7 @@ const chooseWinningRunQuestion = ({
|
||||
question,
|
||||
dynamicMatchIndex })
|
||||
const matchingCount =
|
||||
expected === 'yes' || expected === 'partial'
|
||||
? yesCount
|
||||
: posts.length - yesCount
|
||||
expected === 'yes' || expected === 'partial' ? yesCount : posts.length - yesCount
|
||||
|
||||
return {
|
||||
question,
|
||||
@@ -2291,12 +2307,15 @@ const isWinningRunActive = (
|
||||
const winningRunQuestionCount = (
|
||||
answers: GekanatorAnswerLog[],
|
||||
winningRunStartAnswerCount: number | null,
|
||||
): number => winningRunStartAnswerCount == null
|
||||
? 0
|
||||
: answers
|
||||
.slice (winningRunStartAnswerCount)
|
||||
.filter (answer => answer.questionMode === 'winning_run')
|
||||
.length
|
||||
): number => {
|
||||
if (winningRunStartAnswerCount == null)
|
||||
return 0
|
||||
|
||||
return answers
|
||||
.slice (winningRunStartAnswerCount)
|
||||
.filter (answer => answer.questionMode === 'winning_run')
|
||||
.length
|
||||
}
|
||||
|
||||
|
||||
const nextQuestionPlanFor = (
|
||||
@@ -2335,10 +2354,7 @@ const nextQuestionPlanFor = (
|
||||
questionMode: QuestionMode
|
||||
winningRunTargetId: number | null
|
||||
winningRunStartAnswerCount: number | null } => {
|
||||
const guessablePosts =
|
||||
eligiblePosts.length > 0
|
||||
? eligiblePosts
|
||||
: availablePosts
|
||||
const guessablePosts = eligiblePosts.length > 0 ? eligiblePosts : availablePosts
|
||||
|
||||
const checkpointGuess =
|
||||
answers.length > 0
|
||||
@@ -2366,22 +2382,25 @@ const nextQuestionPlanFor = (
|
||||
winningRunStartAnswerCount }
|
||||
}
|
||||
|
||||
const nextWinningRunTargetId =
|
||||
eligiblePosts.length === 1
|
||||
? eligiblePosts[0]?.id ?? null
|
||||
: null
|
||||
const nextWinningRunStartAnswerCount =
|
||||
nextWinningRunTargetId == null
|
||||
? null
|
||||
: ((isWinningRunActive (winningRunTargetId, winningRunStartAnswerCount)
|
||||
&& winningRunTargetId === nextWinningRunTargetId
|
||||
&& winningRunStartAnswerCount != null)
|
||||
? winningRunStartAnswerCount
|
||||
: answers.length)
|
||||
const nextWinningRunTargetPost =
|
||||
nextWinningRunTargetId == null
|
||||
? null
|
||||
: posts.find (post => post.id === nextWinningRunTargetId) ?? null
|
||||
const nextWinningRunTargetId = eligiblePosts.length === 1 ? eligiblePosts[0]?.id ?? null : null
|
||||
const nextWinningRunStartAnswerCount = (() => {
|
||||
if (nextWinningRunTargetId == null)
|
||||
return null
|
||||
if (
|
||||
isWinningRunActive (winningRunTargetId, winningRunStartAnswerCount)
|
||||
&& winningRunTargetId === nextWinningRunTargetId
|
||||
&& winningRunStartAnswerCount != null
|
||||
)
|
||||
return winningRunStartAnswerCount
|
||||
|
||||
return answers.length
|
||||
}) ()
|
||||
const nextWinningRunTargetPost = (() => {
|
||||
if (nextWinningRunTargetId == null)
|
||||
return null
|
||||
|
||||
return posts.find (post => post.id === nextWinningRunTargetId) ?? null
|
||||
}) ()
|
||||
const buildQuestionsForPosts = (scopePosts: Post[]): GekanatorQuestion[] =>
|
||||
buildQuestionsForCandidateIds ({
|
||||
candidateIds: scopePosts.map (post => post.id),
|
||||
@@ -2580,12 +2599,12 @@ const backgroundPostsFor = ({
|
||||
}): Post[] => {
|
||||
const focusPosts =
|
||||
phase === 'end' || phase === 'review' || phase === 'learned'
|
||||
? [reviewCorrectPost, reviewGuessedPost].filter ((post): post is Post => post != null)
|
||||
: phase === 'guess'
|
||||
? [displayedGuess, ...eligiblePosts].filter ((post): post is Post => post != null)
|
||||
: eligiblePosts.length > 0
|
||||
? eligiblePosts
|
||||
: availablePosts
|
||||
? [reviewCorrectPost, reviewGuessedPost].filter ((post): post is Post => post != null)
|
||||
: phase === 'guess'
|
||||
? [displayedGuess, ...eligiblePosts].filter ((post): post is Post => post != null)
|
||||
: eligiblePosts.length > 0
|
||||
? eligiblePosts
|
||||
: availablePosts
|
||||
|
||||
return [...new Map (focusPosts.map (post => [post.id, post])).values ()]
|
||||
}
|
||||
@@ -2634,20 +2653,20 @@ const GekanatorBackdrop: FC<{
|
||||
{ x: -33.333333, y: -33.333333 }],
|
||||
[])
|
||||
const guessThumbnail =
|
||||
phase === 'guess' && displayedGuess
|
||||
? backgroundThumbnailUrl (displayedGuess)
|
||||
: null
|
||||
phase === 'guess' && displayedGuess ? backgroundThumbnailUrl (displayedGuess) : null
|
||||
const isWinningRunBackdrop =
|
||||
!(guessThumbnail)
|
||||
&& phase === 'question'
|
||||
&& winningRunTargetPost != null
|
||||
&& Boolean (backgroundThumbnailUrl (winningRunTargetPost))
|
||||
const backdropMode =
|
||||
guessThumbnail
|
||||
? 'guess'
|
||||
: isWinningRunBackdrop
|
||||
? 'winning_run'
|
||||
: 'normal'
|
||||
const backdropMode = (() => {
|
||||
if (guessThumbnail)
|
||||
return 'guess'
|
||||
if (isWinningRunBackdrop)
|
||||
return 'winning_run'
|
||||
|
||||
return 'normal'
|
||||
}) ()
|
||||
|
||||
const normalVisiblePosts = useMemo (
|
||||
() => posts
|
||||
@@ -2665,9 +2684,10 @@ const GekanatorBackdrop: FC<{
|
||||
if (mode === 'winning_run' || mode === 'guess')
|
||||
return { columns: 8, rows: 8, opacity: motionMode === 'calm' ? .18 : .24 }
|
||||
|
||||
return motionMode === 'calm'
|
||||
? { columns: 7, rows: 7, opacity: .14 }
|
||||
: { columns: 10, rows: 10, opacity: .2 }
|
||||
if (motionMode === 'calm')
|
||||
return { columns: 7, rows: 7, opacity: .14 }
|
||||
|
||||
return { columns: 10, rows: 10, opacity: .2 }
|
||||
},
|
||||
[motionMode])
|
||||
|
||||
@@ -2723,10 +2743,12 @@ const GekanatorBackdrop: FC<{
|
||||
?? directions[0],
|
||||
[visualSeed, directions])
|
||||
|
||||
const marqueeDuration =
|
||||
backdropMode === 'winning_run'
|
||||
? motionMode === 'calm' ? 28 : 20
|
||||
: motionMode === 'calm' ? 34 : 24
|
||||
const marqueeDuration = (() => {
|
||||
if (backdropMode === 'winning_run')
|
||||
return motionMode === 'calm' ? 28 : 20
|
||||
|
||||
return motionMode === 'calm' ? 34 : 24
|
||||
}) ()
|
||||
const tileFlipDuration = motionMode === 'calm' ? .6 : .45
|
||||
const x = useMotionValue (0)
|
||||
const y = useMotionValue (0)
|
||||
@@ -2944,6 +2966,13 @@ const GekanatorBackdrop: FC<{
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-yellow-50 via-white
|
||||
to-pink-50 dark:from-red-950 dark:via-red-975 dark:to-red-900"/>)
|
||||
|
||||
const backdropTransition: Transition = (() => {
|
||||
if (displayedBackdropMode === 'winning_run' || displayedBackdropMode === 'guess')
|
||||
return { duration: motionMode === 'calm' ? .95 : .75, ease: [.16, 1, .3, 1] }
|
||||
|
||||
return { duration: .2 }
|
||||
}) ()
|
||||
|
||||
return (
|
||||
<div className="fixed [inset:48px_0_0_0] z-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
@@ -2958,11 +2987,7 @@ const GekanatorBackdrop: FC<{
|
||||
animate={{ scale: renderedScale,
|
||||
x: displayedBackdropMode === 'guess' ? guessFocusOffset.x : '0%',
|
||||
y: displayedBackdropMode === 'guess' ? guessFocusOffset.y : '0%' }}
|
||||
transition={(displayedBackdropMode === 'winning_run'
|
||||
|| displayedBackdropMode === 'guess')
|
||||
? { duration: motionMode === 'calm' ? .95 : .75,
|
||||
ease: [.16, 1, .3, 1] }
|
||||
: { duration: .2 }}>
|
||||
transition={backdropTransition}>
|
||||
{Array.from ({ length: 9 }, (_, duplicate) => {
|
||||
const column = duplicate % 3
|
||||
const row = Math.floor (duplicate / 3)
|
||||
@@ -2988,20 +3013,24 @@ const GekanatorBackdrop: FC<{
|
||||
index % Math.max (displayedThumbnails.length, 1)]
|
||||
const frontThumbnail =
|
||||
isFlippingTiles
|
||||
? fromThumbnails[index % Math.max (fromThumbnails.length, 1)]
|
||||
: currentThumbnail
|
||||
? fromThumbnails[index % Math.max (fromThumbnails.length, 1)]
|
||||
: currentThumbnail
|
||||
const backThumbnail =
|
||||
isFlippingTiles
|
||||
? toThumbnails[index % Math.max (toThumbnails.length, 1)]
|
||||
: currentThumbnail
|
||||
? toThumbnails[index % Math.max (toThumbnails.length, 1)]
|
||||
: currentThumbnail
|
||||
const thumbnail =
|
||||
displayedBackdropMode === 'winning_run'
|
||||
|| displayedBackdropMode === 'guess'
|
||||
? nextThumbnails[index % Math.max (nextThumbnails.length, 1)]
|
||||
: currentThumbnail
|
||||
? nextThumbnails[index % Math.max (nextThumbnails.length, 1)]
|
||||
: currentThumbnail
|
||||
if (!(thumbnail) || !(frontThumbnail) || !(backThumbnail))
|
||||
return null
|
||||
|
||||
const imageSource = ['intro', 'end'].includes (phase) ? mascotAsset : thumbnail
|
||||
const showStaticTile =
|
||||
displayedBackdropMode !== 'normal' || !(isFlippingTiles)
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={`${ duplicate }:${ index }`}
|
||||
@@ -3009,16 +3038,14 @@ const GekanatorBackdrop: FC<{
|
||||
layout={displayedBackdropMode !== 'normal'}
|
||||
transition={{ duration: tileFlipDuration, ease: 'easeInOut' }}
|
||||
style={{ perspective: 1600 }}>
|
||||
{(displayedBackdropMode !== 'normal' || !(isFlippingTiles))
|
||||
? (
|
||||
{showStaticTile && (
|
||||
<img
|
||||
src={['intro', 'end'].includes (phase)
|
||||
? mascotAsset
|
||||
: thumbnail}
|
||||
src={imageSource}
|
||||
alt=""
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
style={{ opacity: renderedSettings.opacity }}/>)
|
||||
: (
|
||||
}
|
||||
{!(showStaticTile) && (
|
||||
<motion.div
|
||||
className="absolute inset-0"
|
||||
initial={{ rotateY: 0 }}
|
||||
@@ -3095,9 +3122,12 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
|
||||
const [askedQuestionBank, setAskedQuestionBank] = useState<GekanatorQuestion[]> (
|
||||
() => (storedGame?.askedQuestionBank ?? []).map (restoreGekanatorQuestion))
|
||||
const [storedAskedQuestionBankIds, setStoredAskedQuestionBankIds] = useState<string[]> (
|
||||
(storedGame?.askedQuestionBank?.length ?? 0) > 0
|
||||
? []
|
||||
: storedGame?.askedQuestionBankIds ?? [])
|
||||
() => {
|
||||
if ((storedGame?.askedQuestionBank?.length ?? 0) > 0)
|
||||
return []
|
||||
|
||||
return storedGame?.askedQuestionBankIds ?? []
|
||||
})
|
||||
const [search, setSearch] = useState (storedGame?.search ?? '')
|
||||
const [selectingCorrectPost, setSelectingCorrectPost] = useState (
|
||||
storedGame?.selectingCorrectPost ?? false)
|
||||
@@ -3397,9 +3427,12 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
|
||||
userPriorWeights, materialIndex, acceptedQuestionMatchIndex,
|
||||
lastGuessQuestionCount, winningRunTargetId, winningRunStartAnswerCount])
|
||||
const winningRunTargetPost = useMemo (
|
||||
() => questionPlan.winningRunTargetId == null
|
||||
? null
|
||||
: posts.find (post => post.id === questionPlan.winningRunTargetId) ?? null,
|
||||
() => {
|
||||
if (questionPlan.winningRunTargetId == null)
|
||||
return null
|
||||
|
||||
return posts.find (post => post.id === questionPlan.winningRunTargetId) ?? null
|
||||
},
|
||||
[posts, questionPlan.winningRunTargetId])
|
||||
const winningRunQuestionsAsked = winningRunQuestionCount (
|
||||
answers,
|
||||
@@ -3420,21 +3453,21 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
|
||||
[eligiblePosts, scores])
|
||||
const currentQuestion = questionPlan.question
|
||||
const answerPreviews = useMemo (
|
||||
() => isAdmin && currentQuestion
|
||||
? answerOptions.map (option => previewAnswer ({
|
||||
posts: eligiblePosts,
|
||||
scores,
|
||||
question: currentQuestion,
|
||||
answer: option.value,
|
||||
materialIndex,
|
||||
matchIndex: acceptedQuestionMatchIndex }))
|
||||
: [],
|
||||
() => {
|
||||
if (!(isAdmin) || !(currentQuestion))
|
||||
return []
|
||||
|
||||
return answerOptions.map (option => previewAnswer ({
|
||||
posts: eligiblePosts,
|
||||
scores,
|
||||
question: currentQuestion,
|
||||
answer: option.value,
|
||||
materialIndex,
|
||||
matchIndex: acceptedQuestionMatchIndex }))
|
||||
},
|
||||
[isAdmin, currentQuestion, eligiblePosts, materialIndex,
|
||||
acceptedQuestionMatchIndex, scores])
|
||||
const guessablePosts =
|
||||
eligiblePosts.length > 0
|
||||
? eligiblePosts
|
||||
: availablePosts
|
||||
const guessablePosts = eligiblePosts.length > 0 ? eligiblePosts : availablePosts
|
||||
const guessConfidences = useMemo (
|
||||
() => confidencesFor (guessablePosts, scores),
|
||||
[guessablePosts, scores])
|
||||
@@ -3446,17 +3479,22 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
|
||||
posts.find (post => post.id === reviewGuessedPostId) ?? null
|
||||
const reviewCorrectPost =
|
||||
posts.find (post => post.id === reviewCorrectPostId) ?? null
|
||||
const effectiveResultWon =
|
||||
resultWon
|
||||
?? ((reviewGuessedPostId != null && reviewCorrectPostId != null)
|
||||
? reviewGuessedPostId === reviewCorrectPostId
|
||||
: null)
|
||||
const effectiveBackgroundMotionMode =
|
||||
backgroundMotionMode === 'off'
|
||||
? 'off'
|
||||
: (prefersReducedMotion
|
||||
? 'calm'
|
||||
: backgroundMotionMode)
|
||||
const effectiveResultWon = (() => {
|
||||
if (resultWon != null)
|
||||
return resultWon
|
||||
if (reviewGuessedPostId == null || reviewCorrectPostId == null)
|
||||
return null
|
||||
|
||||
return reviewGuessedPostId === reviewCorrectPostId
|
||||
}) ()
|
||||
const effectiveBackgroundMotionMode = (() => {
|
||||
if (backgroundMotionMode === 'off')
|
||||
return 'off'
|
||||
if (prefersReducedMotion)
|
||||
return 'calm'
|
||||
|
||||
return backgroundMotionMode
|
||||
}) ()
|
||||
const backgroundPosts = useMemo (
|
||||
() => backgroundPostsFor ({
|
||||
phase,
|
||||
@@ -3584,10 +3622,12 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
|
||||
let recoveredStepCount = nextRecoveryStepCount
|
||||
const nextAskedQuestionById =
|
||||
new Map (nextAskedQuestionBank.map (question => [question.id, question]))
|
||||
const answerCountAtRecovery =
|
||||
allowPreQuestionRecovery
|
||||
? nextAnswers.length
|
||||
: Math.max (nextAnswers.length - 1, 0)
|
||||
const answerCountAtRecovery = (() => {
|
||||
if (allowPreQuestionRecovery)
|
||||
return nextAnswers.length
|
||||
|
||||
return Math.max (nextAnswers.length - 1, 0)
|
||||
}) ()
|
||||
let recoveredScores = recalculateScores ({
|
||||
posts,
|
||||
questions: nextAskedQuestionBank,
|
||||
@@ -3863,12 +3903,14 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
|
||||
}
|
||||
|
||||
const finishGame = (correctPostId: number) => {
|
||||
const guessedPostId =
|
||||
phase === 'end' || phase === 'review'
|
||||
? reviewGuessedPostId
|
||||
: phase === 'continue'
|
||||
? lastRejectedGuessId ?? displayedGuess?.id
|
||||
: displayedGuess?.id ?? lastRejectedGuessId
|
||||
const guessedPostId = (() => {
|
||||
if (phase === 'end' || phase === 'review')
|
||||
return reviewGuessedPostId
|
||||
if (phase === 'continue')
|
||||
return lastRejectedGuessId ?? displayedGuess?.id
|
||||
|
||||
return displayedGuess?.id ?? lastRejectedGuessId
|
||||
}) ()
|
||||
if (!(guessedPostId))
|
||||
return
|
||||
|
||||
@@ -4198,13 +4240,18 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
|
||||
const resultDialogue = effectiveResultWon ? winDialogue : loseDialogue
|
||||
|
||||
const dialogue = phase === 'learned' ? resultDialogue : introDialogue
|
||||
const saveStatusMessage =
|
||||
saved
|
||||
&& learnedExampleCount != null
|
||||
? learnedExampleCount > 0
|
||||
? `${ learnedExampleCount }件の回答を学習しました`
|
||||
: null
|
||||
: null
|
||||
const introLoadingMessage =
|
||||
phase === 'intro' ? '投稿を読み込んでいます……' : '前回のグカネータ状態を復元しています……'
|
||||
const questionSuggestionTitle =
|
||||
questionSuggestionEntryMode === 'search' ? 'まず既存質問を探してください。' : '新しい質問を追加します。'
|
||||
const saveStatusMessage = (() => {
|
||||
if (!(saved) || learnedExampleCount == null)
|
||||
return null
|
||||
if (learnedExampleCount <= 0)
|
||||
return null
|
||||
|
||||
return `${ learnedExampleCount }件の回答を学習しました`
|
||||
}) ()
|
||||
|
||||
const introLoading = isLoading || acceptedQuestionsLoading
|
||||
const readyToStart =
|
||||
@@ -4323,18 +4370,23 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
|
||||
</span>
|
||||
{[{ mode: 'off' as const, label: 'オフ' },
|
||||
{ mode: 'on' as const, label: 'オン' }]
|
||||
.map (({ mode, label }) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
className={cn (
|
||||
'rounded-full px-2.5 py-1 transition-colors',
|
||||
backgroundMotionMode === mode
|
||||
? 'bg-pink-600 text-white'
|
||||
: 'text-neutral-600 hover:bg-yellow-100 dark:text-neutral-300 dark:hover:bg-red-900')}
|
||||
onClick={() => setBackgroundMotionMode (mode)}>
|
||||
{label}
|
||||
</button>))}
|
||||
.map (({ mode, label }) => {
|
||||
const modeClass =
|
||||
backgroundMotionMode === mode
|
||||
? 'bg-pink-600 text-white'
|
||||
: 'text-neutral-600 hover:bg-yellow-100 dark:text-neutral-300 dark:hover:bg-red-900'
|
||||
|
||||
return (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
className={cn (
|
||||
'rounded-full px-2.5 py-1 transition-colors',
|
||||
modeClass)}
|
||||
onClick={() => setBackgroundMotionMode (mode)}>
|
||||
{label}
|
||||
</button>)
|
||||
})}
|
||||
{prefersReducedMotion && effectiveBackgroundMotionMode !== 'off' && (
|
||||
<span className="ml-2 text-[11px] text-neutral-500 dark:text-neutral-400">
|
||||
端末設定により控えめ表示
|
||||
@@ -4365,9 +4417,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
|
||||
|
||||
{introLoading && (
|
||||
<p>
|
||||
{phase === 'intro'
|
||||
? '投稿を読み込んでいます……'
|
||||
: '前回のグカネータ状態を復元しています……'}
|
||||
{introLoadingMessage}
|
||||
</p>)}
|
||||
{(Boolean (error) || Boolean (acceptedQuestionsError))
|
||||
&& <p>グカネータの質問データを読み込めませんでした.</p>}
|
||||
@@ -4602,9 +4652,9 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="font-bold">正解の投稿</div>
|
||||
{reviewCorrectPost
|
||||
? <PostMiniCard post={reviewCorrectPost}/>
|
||||
: <p className="text-sm text-red-600">正解投稿を選んでください。</p>}
|
||||
{reviewCorrectPost && <PostMiniCard post={reviewCorrectPost}/>}
|
||||
{!(reviewCorrectPost) && (
|
||||
<p className="text-sm text-red-600">正解投稿を選んでください。</p>)}
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-yellow-300 px-3 py-2
|
||||
@@ -4700,9 +4750,9 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="font-bold">正解の投稿</div>
|
||||
{reviewCorrectPost
|
||||
? <PostMiniCard post={reviewCorrectPost}/>
|
||||
: <p className="text-sm text-red-600">正解投稿を選んでください。</p>}
|
||||
{reviewCorrectPost && <PostMiniCard post={reviewCorrectPost}/>}
|
||||
{!(reviewCorrectPost) && (
|
||||
<p className="text-sm text-red-600">正解投稿を選んでください。</p>)}
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-yellow-300 px-3 py-2
|
||||
@@ -4806,14 +4856,9 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm text-neutral-500">質問追加</p>
|
||||
<p className="text-xl font-bold">
|
||||
{questionSuggestionEntryMode === 'search'
|
||||
? 'まず既存質問を探してください。'
|
||||
: '新しい質問を追加します。'}
|
||||
</p>
|
||||
<p className="text-xl font-bold">{questionSuggestionTitle}</p>
|
||||
</div>
|
||||
{questionSuggestionEntryMode === 'search'
|
||||
? (
|
||||
{questionSuggestionEntryMode === 'search' && (
|
||||
<>
|
||||
<label className="block space-y-2">
|
||||
<span className="font-bold">既存質問を検索</span>
|
||||
@@ -4831,21 +4876,26 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
|
||||
<div className="space-y-2">
|
||||
<div className="font-bold">既存質問候補</div>
|
||||
<div className="max-h-64 space-y-2 overflow-y-auto">
|
||||
{searchableSuggestedQuestions.map (question => (
|
||||
<button
|
||||
key={question.id}
|
||||
type="button"
|
||||
className={cn (
|
||||
'block w-full rounded border px-3 py-2 text-left',
|
||||
questionSuggestionSelectedId === (question.recordId ?? null)
|
||||
? 'border-pink-600 bg-pink-50 dark:bg-red-900/50'
|
||||
: 'border-yellow-200 hover:bg-yellow-100 dark:border-red-800 dark:hover:bg-red-900')}
|
||||
onClick={() => {
|
||||
setQuestionSuggestionSelectedId (question.recordId ?? null)
|
||||
setQuestionSuggestion ('')
|
||||
}}>
|
||||
<div className="font-bold">{question.text}</div>
|
||||
</button>))}
|
||||
{searchableSuggestedQuestions.map (question => {
|
||||
const questionClass =
|
||||
questionSuggestionSelectedId === (question.recordId ?? null)
|
||||
? 'border-pink-600 bg-pink-50 dark:bg-red-900/50'
|
||||
: 'border-yellow-200 hover:bg-yellow-100 dark:border-red-800 dark:hover:bg-red-900'
|
||||
|
||||
return (
|
||||
<button
|
||||
key={question.id}
|
||||
type="button"
|
||||
className={cn (
|
||||
'block w-full rounded border px-3 py-2 text-left',
|
||||
questionClass)}
|
||||
onClick={() => {
|
||||
setQuestionSuggestionSelectedId (question.recordId ?? null)
|
||||
setQuestionSuggestion ('')
|
||||
}}>
|
||||
<div className="font-bold">{question.text}</div>
|
||||
</button>)
|
||||
})}
|
||||
</div>
|
||||
</div>)}
|
||||
{selectedSuggestedQuestion && (
|
||||
@@ -4873,7 +4923,8 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
|
||||
</div>
|
||||
</div>)}
|
||||
</>)
|
||||
: (
|
||||
}
|
||||
{questionSuggestionEntryMode === 'new' && (
|
||||
<div className="space-y-2">
|
||||
<div className="font-bold">新規質問</div>
|
||||
<textarea
|
||||
@@ -4975,18 +5026,24 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
|
||||
</div>
|
||||
<div className="font-bold">{question.text}</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{answerOptions.map (option => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={cn (
|
||||
'rounded border px-3 py-2',
|
||||
extraQuestionAnswers[String (question.id)] === option.value
|
||||
? 'border-pink-600 bg-pink-600 text-white'
|
||||
: 'border-yellow-300 hover:bg-yellow-100 dark:border-red-700 dark:hover:bg-red-900')}
|
||||
onClick={() => answerExtraQuestion (question.id, option.value)}>
|
||||
{option.label}
|
||||
</button>))}
|
||||
{answerOptions.map (option => {
|
||||
const optionClass =
|
||||
extraQuestionAnswers[String (question.id)] === option.value
|
||||
? 'border-pink-600 bg-pink-600 text-white'
|
||||
: 'border-yellow-300 hover:bg-yellow-100 dark:border-red-700 dark:hover:bg-red-900'
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={cn (
|
||||
'rounded border px-3 py-2',
|
||||
optionClass)}
|
||||
onClick={() =>
|
||||
answerExtraQuestion (question.id, option.value)}>
|
||||
{option.label}
|
||||
</button>)
|
||||
})}
|
||||
</div>
|
||||
</div>))}
|
||||
</div>)}
|
||||
|
||||
新しい課題から参照
ユーザをブロックする