mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-01 07:21:33 -07:00
feat(stats): add frequency heat-map to KanjiBreakdown
- Replace opacity scale with log-scale color tiers (Catppuccin Macchiato) - Add rare→frequent legend with color swatches - Show unique kanji count and total occurrences in header
This commit is contained in:
@@ -1,4 +1,6 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
import type { KanjiEntry } from '../../types/stats';
|
import type { KanjiEntry } from '../../types/stats';
|
||||||
|
import { formatNumber } from '../../lib/formatters';
|
||||||
|
|
||||||
interface KanjiBreakdownProps {
|
interface KanjiBreakdownProps {
|
||||||
kanji: KanjiEntry[];
|
kanji: KanjiEntry[];
|
||||||
@@ -6,34 +8,75 @@ interface KanjiBreakdownProps {
|
|||||||
onSelectKanji?: (entry: KanjiEntry) => void;
|
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({
|
export function KanjiBreakdown({
|
||||||
kanji,
|
kanji,
|
||||||
selectedKanjiId = null,
|
selectedKanjiId = null,
|
||||||
onSelectKanji,
|
onSelectKanji,
|
||||||
}: KanjiBreakdownProps) {
|
}: 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 (
|
return (
|
||||||
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
||||||
<h3 className="text-sm font-semibold text-ctp-text mb-3">Kanji Encountered</h3>
|
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<h3 className="text-sm font-semibold text-ctp-text">
|
||||||
|
Kanji Encountered
|
||||||
|
<span className="ml-2 font-normal text-ctp-subtext0">
|
||||||
|
{formatNumber(kanji.length)} unique · {formatNumber(totalOccurrences)} seen
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-1.5 text-[11px] text-ctp-subtext0">
|
||||||
|
<span>rare</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{[...FREQ_TIERS].reverse().map((tier) => (
|
||||||
|
<span
|
||||||
|
key={tier.label}
|
||||||
|
className={`h-2 w-2 rounded-full ${tier.swatch}`}
|
||||||
|
title={tier.label}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span>frequent</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{kanji.map((k) => {
|
{kanji.map((k) => {
|
||||||
const ratio = k.frequency / maxFreq;
|
// Log scale keeps the heavily-skewed frequency distribution readable.
|
||||||
const opacity = Math.max(0.3, ratio);
|
const intensity = maxLogFreq > 0 ? Math.log(k.frequency + 1) / maxLogFreq : 0;
|
||||||
|
const tier = tierFor(intensity);
|
||||||
|
const selected = selectedKanjiId === k.kanjiId;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
key={k.kanji}
|
key={k.kanji}
|
||||||
className={`cursor-pointer rounded px-1 text-lg text-ctp-teal transition ${
|
className={`cursor-pointer rounded-md px-1.5 py-0.5 text-xl leading-none font-medium transition-colors duration-150 ${tier.color} ${
|
||||||
selectedKanjiId === k.kanjiId
|
selected ? 'bg-ctp-surface2 ring-1 ring-ctp-lavender' : 'hover:bg-ctp-surface1'
|
||||||
? 'bg-ctp-teal/10 ring-1 ring-ctp-teal'
|
|
||||||
: 'hover:bg-ctp-surface1/80'
|
|
||||||
}`}
|
}`}
|
||||||
style={{ opacity }}
|
title={`${k.kanji} — seen ${formatNumber(k.frequency)}×`}
|
||||||
title={`${k.kanji} — seen ${k.frequency}x`}
|
|
||||||
aria-label={`${k.kanji} — seen ${k.frequency} times`}
|
aria-label={`${k.kanji} — seen ${k.frequency} times`}
|
||||||
|
aria-pressed={selected}
|
||||||
onClick={() => onSelectKanji?.(k)}
|
onClick={() => onSelectKanji?.(k)}
|
||||||
>
|
>
|
||||||
{k.kanji}
|
{k.kanji}
|
||||||
|
|||||||
Reference in New Issue
Block a user