diff --git a/stats/src/components/vocabulary/KanjiBreakdown.tsx b/stats/src/components/vocabulary/KanjiBreakdown.tsx index 68095d33..d86e2eb2 100644 --- a/stats/src/components/vocabulary/KanjiBreakdown.tsx +++ b/stats/src/components/vocabulary/KanjiBreakdown.tsx @@ -1,4 +1,6 @@ +import { useMemo } from 'react'; import type { KanjiEntry } from '../../types/stats'; +import { formatNumber } from '../../lib/formatters'; interface KanjiBreakdownProps { kanji: KanjiEntry[]; @@ -6,34 +8,75 @@ interface KanjiBreakdownProps { onSelectKanji?: (entry: KanjiEntry) => void; } +// Heat scale from rare (cool) to very frequent (warm). Catppuccin Macchiato. +const FREQ_TIERS = [ + { min: 0.85, color: 'text-ctp-peach', swatch: 'bg-ctp-peach', label: 'Very frequent' }, + { min: 0.6, color: 'text-ctp-yellow', swatch: 'bg-ctp-yellow', label: 'Frequent' }, + { min: 0.35, color: 'text-ctp-green', swatch: 'bg-ctp-green', label: 'Common' }, + { min: 0.15, color: 'text-ctp-teal', swatch: 'bg-ctp-teal', label: 'Occasional' }, + { min: 0, color: 'text-ctp-subtext0', swatch: 'bg-ctp-subtext0', label: 'Rare' }, +] as const; + +function tierFor(intensity: number) { + return FREQ_TIERS.find((tier) => intensity >= tier.min) ?? FREQ_TIERS[FREQ_TIERS.length - 1]; +} + export function KanjiBreakdown({ kanji, selectedKanjiId = null, onSelectKanji, }: KanjiBreakdownProps) { - if (kanji.length === 0) return null; + const { totalOccurrences, maxLogFreq } = useMemo(() => { + let total = 0; + let maxFreq = 1; + for (const entry of kanji) { + total += entry.frequency; + if (entry.frequency > maxFreq) maxFreq = entry.frequency; + } + return { totalOccurrences: total, maxLogFreq: Math.log(maxFreq + 1) }; + }, [kanji]); - const maxFreq = kanji.reduce((max, entry) => Math.max(max, entry.frequency), 1); + if (kanji.length === 0) return null; return (
-

Kanji Encountered

+
+

+ Kanji Encountered + + {formatNumber(kanji.length)} unique · {formatNumber(totalOccurrences)} seen + +

+
+ rare +
+ {[...FREQ_TIERS].reverse().map((tier) => ( + + ))} +
+ frequent +
+
{kanji.map((k) => { - const ratio = k.frequency / maxFreq; - const opacity = Math.max(0.3, ratio); + // Log scale keeps the heavily-skewed frequency distribution readable. + const intensity = maxLogFreq > 0 ? Math.log(k.frequency + 1) / maxLogFreq : 0; + const tier = tierFor(intensity); + const selected = selectedKanjiId === k.kanjiId; return (