887 行
27 KiB
TypeScript
887 行
27 KiB
TypeScript
import { useMutation, useQuery } from '@tanstack/react-query'
|
||
import { useMemo, useState } from 'react'
|
||
import { Helmet } from 'react-helmet-async'
|
||
|
||
import PrefetchLink from '@/components/PrefetchLink'
|
||
import MainArea from '@/components/layout/MainArea'
|
||
import { SITE_TITLE } from '@/config'
|
||
import { buildGekanatorQuestions,
|
||
fetchGekanatorPosts,
|
||
saveGekanatorGame } from '@/lib/gekanator'
|
||
import { gekanatorKeys } from '@/lib/queryKeys'
|
||
import { cn } from '@/lib/utils'
|
||
|
||
import type { FC } from 'react'
|
||
|
||
import type { GekanatorAnswerLog,
|
||
GekanatorAnswerValue,
|
||
GekanatorQuestion } from '@/lib/gekanator'
|
||
import type { Post } from '@/types'
|
||
|
||
type Phase = 'intro' | 'question' | 'guess' | 'continue' | 'learned'
|
||
|
||
type AnswerOption = {
|
||
label: string
|
||
value: GekanatorAnswerValue }
|
||
|
||
type Confidence = {
|
||
post: Post
|
||
score: number
|
||
percent: number }
|
||
|
||
type AnswerPreview = {
|
||
answer: GekanatorAnswerValue
|
||
top: Confidence | null
|
||
candidateCount: number
|
||
effectiveCandidates: number
|
||
entropy: number }
|
||
|
||
type GameSnapshot = {
|
||
phase: Phase
|
||
scores: Map<number, number>
|
||
answers: GekanatorAnswerLog[]
|
||
askedIds: Set<string>
|
||
candidateIds: Set<number> | null
|
||
softenedQuestionIds: Set<string>
|
||
questionBank: GekanatorQuestion[]
|
||
search: string
|
||
rejectedPostIds: Set<number>
|
||
lastGuessQuestionCount: number
|
||
lastRejectedGuessId: number | null
|
||
activeGuessId: number | null }
|
||
|
||
const answerOptions: AnswerOption[] = [
|
||
{ label: 'はい', value: 'yes' },
|
||
{ label: 'いいえ', value: 'no' },
|
||
{ label: '部分的にそう', value: 'partial' },
|
||
{ label: 'たぶんいいえ', value: 'probably_no' },
|
||
{ label: 'わからない', value: 'unknown' }]
|
||
|
||
const questionsBetweenGuesses = 25
|
||
const hardMaxQuestions = 80
|
||
const softenedAnswerWeight = .35
|
||
const confidenceTemperature = 6
|
||
|
||
|
||
const deltaFor = (matched: boolean, answer: GekanatorAnswerValue): number => {
|
||
switch (answer)
|
||
{
|
||
case 'yes':
|
||
return matched ? 4 : -4
|
||
case 'no':
|
||
return matched ? -4 : 4
|
||
case 'partial':
|
||
return matched ? 2 : -1
|
||
case 'probably_no':
|
||
return matched ? -2 : 2
|
||
case 'unknown':
|
||
return 0
|
||
}
|
||
}
|
||
|
||
|
||
const answerWeightFor = (
|
||
questionId: string,
|
||
softenedQuestionIds: Set<string>,
|
||
): number => softenedQuestionIds.has (questionId) ? softenedAnswerWeight : 1
|
||
|
||
|
||
const questionDifficulty = (question: GekanatorQuestion): number => {
|
||
if (question.id === 'structure:many-tags')
|
||
return 6
|
||
if (question.id.startsWith ('date:'))
|
||
return 5
|
||
if (question.id === 'title:long' || question.id === 'title:ascii')
|
||
return 4
|
||
if (question.kind === 'source')
|
||
return 4
|
||
if (question.kind === 'tag')
|
||
return 3
|
||
if (question.kind === 'title' || question.kind === 'structure')
|
||
return 2
|
||
|
||
return 1
|
||
}
|
||
|
||
|
||
const recalculateScores = ({
|
||
posts,
|
||
questions,
|
||
answers,
|
||
softenedQuestionIds,
|
||
}: {
|
||
posts: Post[]
|
||
questions: GekanatorQuestion[]
|
||
answers: GekanatorAnswerLog[]
|
||
softenedQuestionIds: Set<string>
|
||
}): Map<number, number> => {
|
||
const questionById = new Map (questions.map (question => [question.id, question]))
|
||
const nextScores = new Map<number, number> ()
|
||
|
||
answers.forEach (answer => {
|
||
const question = questionById.get (answer.questionId)
|
||
if (!(question))
|
||
return
|
||
|
||
const weight = answerWeightFor (answer.questionId, softenedQuestionIds)
|
||
posts.forEach (post => {
|
||
nextScores.set (
|
||
post.id,
|
||
(nextScores.get (post.id) ?? 0)
|
||
+ deltaFor (question.test (post), answer.answer) * weight)
|
||
})
|
||
})
|
||
|
||
return nextScores
|
||
}
|
||
|
||
|
||
const confidencesFor = (posts: Post[], scores: Map<number, number>): Confidence[] => {
|
||
if (posts.length === 0)
|
||
return []
|
||
|
||
const raw = posts.map (post => ({ post, score: scores.get (post.id) ?? 0 }))
|
||
const maxScore = Math.max (...raw.map (({ score }) => score))
|
||
const weighted = raw.map (item => ({
|
||
...item,
|
||
weight: Math.exp ((item.score - maxScore) / confidenceTemperature) }))
|
||
const total = weighted.reduce ((sum, item) => sum + item.weight, 0) || 1
|
||
|
||
return weighted
|
||
.map (({ post, score, weight }) => ({
|
||
post,
|
||
score,
|
||
percent: weight / total * 100 }))
|
||
.sort ((a, b) => b.percent - a.percent)
|
||
}
|
||
|
||
|
||
const entropyFor = (confidences: Confidence[]): number =>
|
||
confidences.reduce ((sum, item) => {
|
||
const p = item.percent / 100
|
||
return p > 0 ? sum - p * Math.log2 (p) : sum
|
||
}, 0)
|
||
|
||
|
||
const effectiveCandidatesFor = (confidences: Confidence[]): number => {
|
||
const concentration = confidences.reduce ((sum, item) => {
|
||
const p = item.percent / 100
|
||
return sum + p * p
|
||
}, 0)
|
||
|
||
return concentration > 0 ? 1 / concentration : 0
|
||
}
|
||
|
||
|
||
const previewAnswer = ({
|
||
posts,
|
||
scores,
|
||
question,
|
||
answer,
|
||
}: {
|
||
posts: Post[]
|
||
scores: Map<number, number>
|
||
question: GekanatorQuestion
|
||
answer: GekanatorAnswerValue
|
||
}): AnswerPreview => {
|
||
const hardFilteredPosts =
|
||
answer === 'yes'
|
||
? posts.filter (post => question.test (post))
|
||
: answer === 'no'
|
||
? posts.filter (post => !(question.test (post)))
|
||
: posts
|
||
const nextPosts =
|
||
(answer === 'yes' || answer === 'no') && hardFilteredPosts.length > 0
|
||
? hardFilteredPosts
|
||
: posts
|
||
const nextScores = new Map (scores)
|
||
nextPosts.forEach (post => {
|
||
nextScores.set (
|
||
post.id,
|
||
(nextScores.get (post.id) ?? 0) + deltaFor (question.test (post), answer))
|
||
})
|
||
|
||
const confidences = confidencesFor (nextPosts, nextScores)
|
||
|
||
return {
|
||
answer,
|
||
top: confidences[0] ?? null,
|
||
candidateCount: nextPosts.length,
|
||
effectiveCandidates: effectiveCandidatesFor (confidences),
|
||
entropy: entropyFor (confidences) }
|
||
}
|
||
|
||
|
||
const mergeQuestions = (questions: GekanatorQuestion[]): GekanatorQuestion[] => {
|
||
const byId = new Map<string, GekanatorQuestion> ()
|
||
questions.forEach (question => byId.set (question.id, question))
|
||
return [...byId.values ()]
|
||
}
|
||
|
||
|
||
const softenNextQuestionIds = ({
|
||
questions,
|
||
answers,
|
||
softenedQuestionIds,
|
||
}: {
|
||
questions: GekanatorQuestion[]
|
||
answers: GekanatorAnswerLog[]
|
||
softenedQuestionIds: Set<string>
|
||
}): Set<string> | null => {
|
||
const questionById = new Map (questions.map (question => [question.id, question]))
|
||
const candidate = [...answers]
|
||
.reverse ()
|
||
.map (answer => {
|
||
const question = questionById.get (answer.questionId)
|
||
return { answer, question }
|
||
})
|
||
.filter ((item): item is {
|
||
answer: GekanatorAnswerLog
|
||
question: GekanatorQuestion } =>
|
||
item.question !== undefined
|
||
&& item.answer.answer !== 'unknown'
|
||
&& !(softenedQuestionIds.has (item.answer.questionId)))
|
||
.sort ((a, b) => questionDifficulty (b.question) - questionDifficulty (a.question))[0]
|
||
|
||
if (!(candidate))
|
||
return null
|
||
|
||
return new Set ([...softenedQuestionIds, candidate.answer.questionId])
|
||
}
|
||
|
||
|
||
const chooseQuestion = ({
|
||
posts,
|
||
questions,
|
||
scores,
|
||
askedIds,
|
||
}: {
|
||
posts: Post[]
|
||
questions: GekanatorQuestion[]
|
||
scores: Map<number, number>
|
||
askedIds: Set<string>
|
||
}): GekanatorQuestion | null => {
|
||
const scoredPosts = posts
|
||
.map (post => ({ post, score: scores.get (post.id) ?? 0 }))
|
||
.sort ((a, b) => b.score - a.score)
|
||
|
||
const signatureFor = (
|
||
question: GekanatorQuestion,
|
||
candidates: { post: Post; score: number }[],
|
||
): string => candidates.map (({ post }) => question.test (post) ? '1' : '0').join ('')
|
||
|
||
const invertedSignature = (signature: string): string =>
|
||
signature.replace (/[01]/g, value => value === '1' ? '0' : '1')
|
||
|
||
const redundantSignatures = (
|
||
candidates: { post: Post; score: number }[],
|
||
): Set<string> => {
|
||
const signatures = new Set<string> ()
|
||
questions
|
||
.filter (question => askedIds.has (question.id))
|
||
.forEach (question => {
|
||
const signature = signatureFor (question, candidates)
|
||
signatures.add (signature)
|
||
signatures.add (invertedSignature (signature))
|
||
})
|
||
|
||
return signatures
|
||
}
|
||
|
||
const rank = (
|
||
questionsToRank: GekanatorQuestion[],
|
||
candidates: { post: Post; score: number }[],
|
||
) => {
|
||
const redundant = redundantSignatures (candidates)
|
||
|
||
return questionsToRank
|
||
.map (question => {
|
||
const signature = signatureFor (question, candidates)
|
||
if (redundant.has (signature))
|
||
return null
|
||
|
||
const yes = signature.split ('').filter (value => value === '1').length
|
||
const no = candidates.length - yes
|
||
if (yes === 0 || no === 0)
|
||
return null
|
||
|
||
const splitScore = Math.abs (candidates.length / 2 - yes)
|
||
const answerPreviews = answerOptions.map (option =>
|
||
previewAnswer ({
|
||
posts: candidates.map (({ post }) => post),
|
||
scores,
|
||
question,
|
||
answer: option.value }))
|
||
const expectedEntropy =
|
||
answerPreviews.reduce ((sum, preview) => sum + preview.entropy, 0)
|
||
/ answerPreviews.length
|
||
const expectedCandidateCount =
|
||
answerPreviews.reduce ((sum, preview) => sum + preview.candidateCount, 0)
|
||
/ answerPreviews.length
|
||
const kindPenalty = askedIds.has (question.kind) ? 2 : 0
|
||
const tagPenalty = question.kind === 'tag' ? 0 : 10
|
||
const minSide = candidates.length < 10 ? 1 : Math.max (3, candidates.length * .08)
|
||
const narrowPenalty = yes < minSide || no < minSide ? candidates.length : 0
|
||
|
||
return { question,
|
||
score: splitScore + expectedEntropy + expectedCandidateCount / 8
|
||
+ kindPenalty + tagPenalty + narrowPenalty,
|
||
narrow: narrowPenalty > 0 }
|
||
})
|
||
.filter ((item): item is {
|
||
question: GekanatorQuestion
|
||
score: number
|
||
narrow: boolean } => item !== null && Number.isFinite (item.score))
|
||
.sort ((a, b) => a.score - b.score)
|
||
}
|
||
|
||
const unansweredQuestions =
|
||
questions.filter (question => !(askedIds.has (question.id)))
|
||
const ranked = rank (unansweredQuestions, scoredPosts)
|
||
|
||
return (ranked.find (item => !(item.narrow)) ?? ranked[0])?.question ?? null
|
||
}
|
||
|
||
|
||
const bestPost = (posts: Post[], scores: Map<number, number>): Post | null =>
|
||
posts
|
||
.map (post => ({ post, score: scores.get (post.id) ?? 0 }))
|
||
.sort ((a, b) => b.score - a.score)[0]?.post ?? null
|
||
|
||
|
||
const PostMiniCard: FC<{ post: Post }> = ({ post }) => (
|
||
<div className="flex gap-3 items-center min-w-0">
|
||
<img
|
||
src={post.thumbnail || post.thumbnailBase || undefined}
|
||
alt={post.title || post.url}
|
||
className="w-16 h-16 rounded object-cover bg-yellow-100"/>
|
||
<div className="min-w-0">
|
||
<PrefetchLink
|
||
to={`/posts/${ post.id }`}
|
||
className="font-bold text-pink-700 dark:text-pink-200 break-words">
|
||
#{post.id} {post.title || post.url}
|
||
</PrefetchLink>
|
||
<div className="text-sm text-neutral-600 dark:text-neutral-300 line-clamp-1">
|
||
{post.tags.slice (0, 6).map (tag => tag.name).join (' / ')}
|
||
</div>
|
||
</div>
|
||
</div>)
|
||
|
||
|
||
const GekanatorPage: FC = () => {
|
||
const [phase, setPhase] = useState<Phase> ('intro')
|
||
const [scores, setScores] = useState<Map<number, number>> (new Map ())
|
||
const [answers, setAnswers] = useState<GekanatorAnswerLog[]> ([])
|
||
const [askedIds, setAskedIds] = useState<Set<string>> (new Set ())
|
||
const [candidateIds, setCandidateIds] = useState<Set<number> | null> (null)
|
||
const [softenedQuestionIds, setSoftenedQuestionIds] = useState<Set<string>> (new Set ())
|
||
const [questionBank, setQuestionBank] = useState<GekanatorQuestion[]> ([])
|
||
const [search, setSearch] = useState ('')
|
||
const [saved, setSaved] = useState (false)
|
||
const [resultWon, setResultWon] = useState<boolean | null> (null)
|
||
const [rejectedPostIds, setRejectedPostIds] = useState<Set<number>> (new Set ())
|
||
const [lastGuessQuestionCount, setLastGuessQuestionCount] = useState (0)
|
||
const [lastRejectedGuessId, setLastRejectedGuessId] = useState<number | null> (null)
|
||
const [activeGuessId, setActiveGuessId] = useState<number | null> (null)
|
||
const [history, setHistory] = useState<GameSnapshot[]> ([])
|
||
|
||
const { data: posts = [], isLoading, error } = useQuery ({
|
||
queryKey: gekanatorKeys.posts (),
|
||
queryFn: fetchGekanatorPosts })
|
||
|
||
const candidatePosts = useMemo (
|
||
() => posts.filter (post => candidateIds === null || candidateIds.has (post.id)),
|
||
[posts, candidateIds])
|
||
const eligiblePosts = useMemo (
|
||
() => candidatePosts.filter (post => !(rejectedPostIds.has (post.id))),
|
||
[candidatePosts, rejectedPostIds])
|
||
const questions = useMemo (
|
||
() => buildGekanatorQuestions (eligiblePosts.length > 1 ? eligiblePosts : posts),
|
||
[eligiblePosts, posts])
|
||
const scoringQuestions = useMemo (() => {
|
||
return mergeQuestions ([...questions, ...questionBank])
|
||
}, [questions, questionBank])
|
||
const topScoredPosts = useMemo (
|
||
() => eligiblePosts
|
||
.map (post => ({ post, score: scores.get (post.id) ?? 0 }))
|
||
.sort ((a, b) => b.score - a.score)
|
||
.slice (0, 3),
|
||
[eligiblePosts, scores])
|
||
const currentQuestion = chooseQuestion ({
|
||
posts: eligiblePosts, questions: scoringQuestions, scores, askedIds })
|
||
const answerPreviews = useMemo (
|
||
() => currentQuestion
|
||
? answerOptions.map (option => previewAnswer ({
|
||
posts: eligiblePosts,
|
||
scores,
|
||
question: currentQuestion,
|
||
answer: option.value }))
|
||
: [],
|
||
[currentQuestion, eligiblePosts, scores])
|
||
const guess = bestPost (eligiblePosts, scores)
|
||
const displayedGuess =
|
||
posts.find (post => post.id === activeGuessId) ?? guess
|
||
const saveMutation = useMutation ({ mutationFn: saveGekanatorGame })
|
||
|
||
const reset = () => {
|
||
setPhase ('intro')
|
||
setScores (new Map ())
|
||
setAnswers ([])
|
||
setAskedIds (new Set ())
|
||
setCandidateIds (null)
|
||
setSoftenedQuestionIds (new Set ())
|
||
setQuestionBank ([])
|
||
setSearch ('')
|
||
setSaved (false)
|
||
setResultWon (null)
|
||
setRejectedPostIds (new Set ())
|
||
setLastGuessQuestionCount (0)
|
||
setLastRejectedGuessId (null)
|
||
setActiveGuessId (null)
|
||
setHistory ([])
|
||
}
|
||
|
||
const answer = (value: GekanatorAnswerValue) => {
|
||
if (!(currentQuestion))
|
||
{
|
||
setActiveGuessId (guess?.id ?? null)
|
||
setPhase ('guess')
|
||
return
|
||
}
|
||
|
||
setHistory ([...history, {
|
||
phase,
|
||
scores: new Map (scores),
|
||
answers: [...answers],
|
||
askedIds: new Set (askedIds),
|
||
candidateIds: candidateIds === null ? null : new Set (candidateIds),
|
||
softenedQuestionIds: new Set (softenedQuestionIds),
|
||
questionBank: [...questionBank],
|
||
search,
|
||
rejectedPostIds: new Set (rejectedPostIds),
|
||
lastGuessQuestionCount,
|
||
lastRejectedGuessId,
|
||
activeGuessId }])
|
||
const nextAnswers = [...answers, {
|
||
questionId: currentQuestion.id,
|
||
questionText: currentQuestion.text,
|
||
answer: value }]
|
||
const nextAskedIds = new Set ([...askedIds, currentQuestion.id])
|
||
const nextQuestionBank = [
|
||
...questionBank.filter (question => question.id !== currentQuestion.id),
|
||
currentQuestion]
|
||
const hardFilteredPosts =
|
||
value === 'yes'
|
||
? eligiblePosts.filter (post => currentQuestion.test (post))
|
||
: value === 'no'
|
||
? eligiblePosts.filter (post => !(currentQuestion.test (post)))
|
||
: eligiblePosts
|
||
let nextCandidateIds =
|
||
(value === 'yes' || value === 'no') && hardFilteredPosts.length > 0
|
||
? new Set (hardFilteredPosts.map (post => post.id))
|
||
: candidateIds
|
||
let nextSoftenedQuestionIds = new Set (softenedQuestionIds)
|
||
let nextScores = recalculateScores ({
|
||
posts,
|
||
questions: nextQuestionBank,
|
||
answers: nextAnswers,
|
||
softenedQuestionIds: nextSoftenedQuestionIds })
|
||
|
||
let nextEligiblePosts =
|
||
posts.filter (post =>
|
||
(nextCandidateIds === null || nextCandidateIds.has (post.id))
|
||
&& !(rejectedPostIds.has (post.id)))
|
||
let nextScoringQuestions = mergeQuestions ([
|
||
...buildGekanatorQuestions (nextEligiblePosts.length > 1 ? nextEligiblePosts : posts),
|
||
...nextQuestionBank])
|
||
while (
|
||
nextAnswers.length < hardMaxQuestions
|
||
&& nextEligiblePosts.length > 1
|
||
&& !(chooseQuestion ({
|
||
posts: nextEligiblePosts,
|
||
questions: nextScoringQuestions,
|
||
scores: nextScores,
|
||
askedIds: nextAskedIds }))
|
||
)
|
||
{
|
||
const softened = softenNextQuestionIds ({
|
||
questions: nextQuestionBank,
|
||
answers: nextAnswers,
|
||
softenedQuestionIds: nextSoftenedQuestionIds })
|
||
if (!(softened))
|
||
break
|
||
|
||
nextSoftenedQuestionIds = softened
|
||
nextCandidateIds = null
|
||
nextEligiblePosts = posts.filter (post => !(rejectedPostIds.has (post.id)))
|
||
nextScoringQuestions = mergeQuestions ([
|
||
...buildGekanatorQuestions (nextEligiblePosts),
|
||
...nextQuestionBank])
|
||
nextScores = recalculateScores ({
|
||
posts,
|
||
questions: nextQuestionBank,
|
||
answers: nextAnswers,
|
||
softenedQuestionIds: nextSoftenedQuestionIds })
|
||
}
|
||
|
||
setScores (nextScores)
|
||
setAskedIds (nextAskedIds)
|
||
setCandidateIds (nextCandidateIds)
|
||
setSoftenedQuestionIds (nextSoftenedQuestionIds)
|
||
setQuestionBank (nextQuestionBank)
|
||
setAnswers (nextAnswers)
|
||
|
||
const nextGuess = bestPost (nextEligiblePosts, nextScores)
|
||
const nextQuestionCount = answers.length + 1
|
||
const definitelyKnown = nextEligiblePosts.length === 1
|
||
const enoughQuestions =
|
||
nextQuestionCount - lastGuessQuestionCount >= questionsBetweenGuesses
|
||
const shouldGuess =
|
||
nextQuestionCount >= hardMaxQuestions
|
||
|| definitelyKnown
|
||
|| enoughQuestions
|
||
if (shouldGuess)
|
||
{
|
||
setActiveGuessId (nextGuess?.id ?? null)
|
||
setLastGuessQuestionCount (nextQuestionCount)
|
||
setPhase ('guess')
|
||
}
|
||
}
|
||
|
||
const saveResult = (won: boolean, correctPostId: number | null) => {
|
||
const guessedPostId =
|
||
won ? displayedGuess?.id : lastRejectedGuessId ?? displayedGuess?.id
|
||
if (!(guessedPostId) || saved)
|
||
return
|
||
|
||
setSaved (true)
|
||
setResultWon (won)
|
||
saveMutation.mutate ({
|
||
guessedPostId,
|
||
correctPostId,
|
||
won,
|
||
answers })
|
||
setPhase ('learned')
|
||
}
|
||
|
||
const rejectGuess = () => {
|
||
if (!(displayedGuess))
|
||
return
|
||
|
||
setLastRejectedGuessId (displayedGuess.id)
|
||
if (answers.length >= hardMaxQuestions)
|
||
{
|
||
setSearch (' ')
|
||
return
|
||
}
|
||
|
||
setRejectedPostIds (new Set ([...rejectedPostIds, displayedGuess.id]))
|
||
setActiveGuessId (null)
|
||
setSearch ('')
|
||
setLastGuessQuestionCount (answers.length)
|
||
setPhase ('continue')
|
||
}
|
||
|
||
const undoAnswer = () => {
|
||
const snapshot = history[history.length - 1]
|
||
if (!(snapshot) || saved)
|
||
return
|
||
|
||
setPhase (snapshot.phase)
|
||
setScores (snapshot.scores)
|
||
setAnswers (snapshot.answers)
|
||
setAskedIds (snapshot.askedIds)
|
||
setCandidateIds (snapshot.candidateIds)
|
||
setSoftenedQuestionIds (snapshot.softenedQuestionIds)
|
||
setQuestionBank (snapshot.questionBank)
|
||
setSearch (snapshot.search)
|
||
setRejectedPostIds (snapshot.rejectedPostIds)
|
||
setLastGuessQuestionCount (snapshot.lastGuessQuestionCount)
|
||
setLastRejectedGuessId (snapshot.lastRejectedGuessId)
|
||
setActiveGuessId (snapshot.activeGuessId)
|
||
setHistory (history.slice (0, -1))
|
||
}
|
||
|
||
const softenAndContinue = () => {
|
||
const softened = softenNextQuestionIds ({
|
||
questions: scoringQuestions, answers, softenedQuestionIds })
|
||
if (!(softened))
|
||
return
|
||
|
||
setSoftenedQuestionIds (softened)
|
||
setCandidateIds (null)
|
||
setScores (
|
||
recalculateScores ({ posts,
|
||
questions: scoringQuestions,
|
||
answers,
|
||
softenedQuestionIds: softened }))
|
||
}
|
||
|
||
const filteredPosts = posts
|
||
.filter (post => {
|
||
const needle = search.trim ().toLowerCase ()
|
||
if (!(needle))
|
||
return false
|
||
if (/^\d+$/.test (needle) && post.id === Number (needle))
|
||
return true
|
||
|
||
return [post.title, post.url, ...post.tags.map (tag => tag.name)]
|
||
.filter ((value): value is string => Boolean (value))
|
||
.some (value => value.toLowerCase ().includes (needle))
|
||
})
|
||
.sort ((a, b) => {
|
||
const id = Number (search.trim ())
|
||
if (Number.isFinite (id))
|
||
return Number (b.id === id) - Number (a.id === id)
|
||
|
||
return 0
|
||
})
|
||
.slice (0, 20)
|
||
|
||
const dialogue =
|
||
phase === 'learned' && resultWon
|
||
? <>グカカカカwwwww <ruby>洗澡鹿<rt>シーザオグカ</rt></ruby>は何でもお見通し!</>
|
||
: <>私は<ruby>洗澡鹿<rt>シーザオグカ</rt></ruby>.質問から投稿を何でも当ててみせるよ</>
|
||
|
||
return (
|
||
<MainArea className="bg-yellow-50 dark:bg-red-975">
|
||
<Helmet>
|
||
<title>{`グカネータ | ${ SITE_TITLE }`}</title>
|
||
</Helmet>
|
||
|
||
<div className="mx-auto max-w-4xl space-y-6">
|
||
<header className="space-y-2">
|
||
<p className="text-sm text-pink-700 dark:text-pink-200">おたのしみ</p>
|
||
<h1 className="text-3xl font-bold text-pink-700 dark:text-pink-200">
|
||
グカネータ
|
||
</h1>
|
||
</header>
|
||
|
||
<section className="rounded-lg border border-yellow-300 bg-white p-4
|
||
shadow-sm dark:border-red-800 dark:bg-red-950">
|
||
<div className="flex gap-4">
|
||
<div className="grid h-24 w-24 shrink-0 place-items-center rounded-lg
|
||
bg-yellow-200 text-center text-sm font-bold
|
||
text-pink-700 dark:bg-red-900 dark:text-pink-100">
|
||
洗澡鹿
|
||
</div>
|
||
<div className="min-w-0 flex-1 space-y-3">
|
||
<p className="text-lg font-bold">
|
||
{dialogue}
|
||
</p>
|
||
|
||
{isLoading && <p>投稿を読み込んでゐます...</p>}
|
||
{Boolean (error) && <p>投稿を読み込めませんでした.</p>}
|
||
|
||
{phase === 'intro' && !(isLoading) && posts.length > 0 && (
|
||
<button
|
||
type="button"
|
||
className="rounded bg-pink-600 px-4 py-2 font-bold text-white
|
||
hover:bg-pink-500"
|
||
onClick={() => setPhase ('question')}>
|
||
はじめる
|
||
</button>)}
|
||
|
||
{phase === 'question' && currentQuestion && (
|
||
<div className="space-y-4">
|
||
<div>
|
||
<p className="text-sm text-neutral-500">
|
||
質問 {answers.length + 1}
|
||
</p>
|
||
<p className="text-xl font-bold">{currentQuestion.text}</p>
|
||
</div>
|
||
<div className="rounded border border-yellow-100 px-3 py-2
|
||
text-sm dark:border-red-900">
|
||
<div className="font-bold">現在候補: {eligiblePosts.length} 件</div>
|
||
{topScoredPosts.length > 0 && (
|
||
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-1">
|
||
{topScoredPosts.map (item => (
|
||
<span key={item.post.id}>
|
||
#{item.post.id}: score {item.score.toFixed (1)}
|
||
</span>))}
|
||
</div>)}
|
||
</div>
|
||
{answerPreviews.length > 0 && (
|
||
<div className="grid gap-2 text-sm md:grid-cols-2">
|
||
{answerOptions.map (option => {
|
||
const preview =
|
||
answerPreviews.find (item => item.answer === option.value)
|
||
return (
|
||
<div
|
||
key={option.value}
|
||
className="rounded border border-yellow-100 px-3 py-2
|
||
dark:border-red-900">
|
||
<span className="font-bold">{option.label}</span>
|
||
{' '}
|
||
<span className="text-neutral-600 dark:text-neutral-300">
|
||
なら候補 {preview ? preview.candidateCount : 0} 件
|
||
</span>
|
||
</div>)
|
||
})}
|
||
</div>)}
|
||
<div className="flex flex-wrap gap-2">
|
||
{answerOptions.map (option => (
|
||
<button
|
||
key={option.value}
|
||
type="button"
|
||
className="rounded border border-yellow-300 px-3 py-2
|
||
hover:bg-yellow-100 dark:border-red-700
|
||
dark:hover:bg-red-900"
|
||
onClick={() => answer (option.value)}>
|
||
{option.label}
|
||
</button>))}
|
||
{history.length > 0 && (
|
||
<button
|
||
type="button"
|
||
className="rounded border border-neutral-300 px-3 py-2
|
||
hover:bg-neutral-100 dark:border-neutral-700
|
||
dark:hover:bg-red-900"
|
||
onClick={undoAnswer}>
|
||
戻る
|
||
</button>)}
|
||
</div>
|
||
</div>)}
|
||
|
||
{phase === 'question' && !(currentQuestion) && (
|
||
<div className="space-y-4">
|
||
<p className="text-xl font-bold">
|
||
さっきまでの答へを少し疑って考へ直すよ.
|
||
</p>
|
||
{answers.length >= hardMaxQuestions || eligiblePosts.length <= 1
|
||
? (
|
||
<button
|
||
type="button"
|
||
className="rounded border border-yellow-300 px-4 py-2
|
||
hover:bg-yellow-100 dark:border-red-700
|
||
dark:hover:bg-red-900"
|
||
onClick={() => {
|
||
setActiveGuessId (guess?.id ?? null)
|
||
setPhase ('guess')
|
||
}}>
|
||
推測へ
|
||
</button>)
|
||
: (
|
||
<button
|
||
type="button"
|
||
className="rounded bg-pink-600 px-4 py-2 font-bold text-white
|
||
hover:bg-pink-500"
|
||
onClick={softenAndContinue}>
|
||
考へ直す
|
||
</button>)}
|
||
</div>)}
|
||
|
||
{phase === 'guess' && displayedGuess && (
|
||
<div className="space-y-4">
|
||
<p className="text-xl font-bold">これを想像してゐたね?</p>
|
||
<PostMiniCard post={displayedGuess}/>
|
||
<div className="flex flex-wrap gap-2">
|
||
<button
|
||
type="button"
|
||
className="rounded bg-pink-600 px-4 py-2 font-bold text-white
|
||
hover:bg-pink-500"
|
||
onClick={() => saveResult (true, null)}>
|
||
当たり
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="rounded border border-yellow-300 px-4 py-2
|
||
hover:bg-yellow-100 dark:border-red-700
|
||
dark:hover:bg-red-900"
|
||
onClick={rejectGuess}>
|
||
違ふ
|
||
</button>
|
||
{history.length > 0 && (
|
||
<button
|
||
type="button"
|
||
className="rounded border border-neutral-300 px-4 py-2
|
||
hover:bg-neutral-100 dark:border-neutral-700
|
||
dark:hover:bg-red-900"
|
||
onClick={undoAnswer}>
|
||
戻る
|
||
</button>)}
|
||
</div>
|
||
</div>)}
|
||
|
||
{phase === 'continue' && (
|
||
<div className="space-y-4">
|
||
<p className="text-xl font-bold">続けますか?</p>
|
||
<div className="flex flex-wrap gap-2">
|
||
<button
|
||
type="button"
|
||
className="rounded bg-pink-600 px-4 py-2 font-bold text-white
|
||
hover:bg-pink-500"
|
||
onClick={() => setPhase ('question')}>
|
||
はい
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="rounded border border-yellow-300 px-4 py-2
|
||
hover:bg-yellow-100 dark:border-red-700
|
||
dark:hover:bg-red-900"
|
||
onClick={() => setSearch (' ')}>
|
||
いいえ
|
||
</button>
|
||
{history.length > 0 && (
|
||
<button
|
||
type="button"
|
||
className="rounded border border-neutral-300 px-4 py-2
|
||
hover:bg-neutral-100 dark:border-neutral-700
|
||
dark:hover:bg-red-900"
|
||
onClick={undoAnswer}>
|
||
戻る
|
||
</button>)}
|
||
</div>
|
||
</div>)}
|
||
|
||
{phase === 'learned' && (
|
||
<div className="space-y-3">
|
||
<p>覚えたよ.次はもっと見通す.</p>
|
||
{saveMutation.isError && (
|
||
<p className="text-sm text-red-600">
|
||
ただし学習ログの保存には失敗しました.
|
||
</p>)}
|
||
<button
|
||
type="button"
|
||
className="rounded bg-pink-600 px-4 py-2 font-bold text-white
|
||
hover:bg-pink-500"
|
||
onClick={reset}>
|
||
もう一回
|
||
</button>
|
||
</div>)}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
{['guess', 'continue', 'question'].includes (phase) && search !== '' && (
|
||
<section className="rounded-lg border border-yellow-300 bg-white p-4
|
||
dark:border-red-800 dark:bg-red-950">
|
||
<label className="block space-y-2">
|
||
<span className="font-bold">正解の投稿</span>
|
||
<input
|
||
value={search}
|
||
onChange={ev => setSearch (ev.target.value)}
|
||
className="w-full rounded border border-yellow-300 bg-white px-3 py-2
|
||
dark:border-red-700 dark:bg-red-950"
|
||
placeholder="投稿 Id.・タイトル・URL・タグで検索"/>
|
||
</label>
|
||
<div className="mt-4 space-y-3">
|
||
{filteredPosts.map (post => (
|
||
<button
|
||
key={post.id}
|
||
type="button"
|
||
className={cn ('block w-full rounded border border-yellow-200 p-3',
|
||
'text-left hover:bg-yellow-100',
|
||
'dark:border-red-800 dark:hover:bg-red-900')}
|
||
onClick={() => saveResult (false, post.id)}>
|
||
<PostMiniCard post={post}/>
|
||
</button>))}
|
||
{search.trim () && filteredPosts.length === 0 && '見つかりません.'}
|
||
</div>
|
||
</section>)}
|
||
</div>
|
||
</MainArea>)
|
||
}
|
||
|
||
|
||
export default GekanatorPage
|