From 5c529802c67015d9e4dacdad1037f9276807a301 Mon Sep 17 00:00:00 2001 From: sudacode Date: Mon, 16 Mar 2026 01:51:27 -0700 Subject: [PATCH] fix(stats): restore cross-anime words table --- .../vocabulary/CrossAnimeWordsTable.tsx | 168 ++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 stats/src/components/vocabulary/CrossAnimeWordsTable.tsx diff --git a/stats/src/components/vocabulary/CrossAnimeWordsTable.tsx b/stats/src/components/vocabulary/CrossAnimeWordsTable.tsx new file mode 100644 index 0000000..15b1d5e --- /dev/null +++ b/stats/src/components/vocabulary/CrossAnimeWordsTable.tsx @@ -0,0 +1,168 @@ +import { useMemo, useState } from 'react'; +import { PosBadge } from './pos-helpers'; +import { fullReading } from '../../lib/reading-utils'; +import type { VocabularyEntry } from '../../types/stats'; + +interface CrossAnimeWordsTableProps { + words: VocabularyEntry[]; + knownWords: Set; + onSelectWord?: (word: VocabularyEntry) => void; +} + +const PAGE_SIZE = 25; + +export function CrossAnimeWordsTable({ + words, + knownWords, + onSelectWord, +}: CrossAnimeWordsTableProps) { + const [page, setPage] = useState(0); + const [hideKnown, setHideKnown] = useState(true); + const [collapsed, setCollapsed] = useState(false); + + const hasKnownData = knownWords.size > 0; + + const ranked = useMemo(() => { + let filtered = words.filter((w) => w.animeCount >= 2); + if (hideKnown && hasKnownData) { + filtered = filtered.filter((w) => !knownWords.has(w.headword) && !knownWords.has(w.word)); + } + + const byHeadword = new Map(); + for (const w of filtered) { + const existing = byHeadword.get(w.headword); + if (!existing) { + byHeadword.set(w.headword, { ...w }); + } else { + existing.frequency += w.frequency; + existing.animeCount = Math.max(existing.animeCount, w.animeCount); + if ( + w.frequencyRank != null && + (existing.frequencyRank == null || w.frequencyRank < existing.frequencyRank) + ) { + existing.frequencyRank = w.frequencyRank; + } + if (!existing.reading && w.reading) existing.reading = w.reading; + if (!existing.partOfSpeech && w.partOfSpeech) existing.partOfSpeech = w.partOfSpeech; + } + } + + return [...byHeadword.values()].sort((a, b) => { + if (b.animeCount !== a.animeCount) return b.animeCount - a.animeCount; + return b.frequency - a.frequency; + }); + }, [words, knownWords, hideKnown, hasKnownData]); + + const hasMultiAnimeWords = words.some((w) => w.animeCount >= 2); + if (!hasMultiAnimeWords) return null; + + const totalPages = Math.ceil(ranked.length / PAGE_SIZE); + const paged = ranked.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE); + + return ( +
+
+ +
+ {hasKnownData && ( + + )} + {ranked.length} words +
+
+ {collapsed ? null : ranked.length === 0 ? ( +
+ {hideKnown + ? 'All multi-anime words are already known!' + : 'No words found across multiple anime.'} +
+ ) : ( + <> +
+ + + + + + + + + + + + {paged.map((w) => ( + onSelectWord?.(w)} + className="border-b border-ctp-surface1 last:border-0 cursor-pointer hover:bg-ctp-surface1/50 transition-colors" + > + + + + + + + ))} + +
WordReadingPOSAnimeSeen
{w.headword} + {fullReading(w.headword, w.reading) || w.headword} + + {w.partOfSpeech && } + + {w.animeCount} + + {w.frequency}x +
+
+ {totalPages > 1 && ( +
+ + + {page + 1} / {totalPages} + + +
+ )} + + )} +
+ ); +}