/* 한글 — the reading drill, the syllable diagram, and the jamo tables. The drill is reading-only by design: written form → spoken form, word → meaning, sentence → meaning. Nothing here asks him to produce a sound. */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useStore } from "../../state/store.js"; import { editStudyLog } from "../../db/writes.js"; import { compose, decompose } from "@lib/hangul.js"; import hangulJson from "@data/hangul.json"; import deckJson from "@data/deck.json"; import sentencesJson from "@data/sentences.json"; import "./hangul.css"; interface HangulFile { consonants: { jamo: string; roman: string; name: string; tense: boolean }[]; vowels: { jamo: string; roman: string; kind: string }[]; batchim: { sound: string; roman: string; writtenAs: string }[]; soundRules: { n: string; k: string; p: string; a: string; b: string; r: string }[]; soundPairs: { written: string; spoken: string; rule: string }[]; } const H = hangulJson as unknown as HangulFile; const WORDS = Object.values( (deckJson as unknown as { topics: Record }).topics, ).flat(); const SENTENCES = ( sentencesJson as unknown as { sentences: { ko: string; en: string }[] } ).sentences; const MODES = [ { id: "sound", label: "소리 Sound changes" }, { id: "speed", label: "속독 Speed reading" }, { id: "sentence", label: "문장 Sentences" }, ] as const; type Mode = (typeof MODES)[number]["id"]; const ROUND = 12; interface Question { prompt: string; answer: string; options: string[]; after?: string; big?: boolean; wide?: boolean; } function pick(items: T[], n: number, exclude: (t: T) => boolean): T[] { const pool = items.filter((t) => !exclude(t)); const out: T[] = []; while (out.length < n && pool.length) { out.push(pool.splice(Math.floor(Math.random() * pool.length), 1)[0]!); } return out; } const shuffle = (a: T[]): T[] => a.map((v) => [Math.random(), v] as const).sort((x, y) => x[0] - y[0]).map(([, v]) => v); /** * Distractors for a sound-change question: keep the syllable recognisable * but change exactly one thing — the final consonant or the initial — so the * choice tests the rule rather than general word shape. * * Enumerated rather than sampled. Sampling needs three distinct results and * a nudge in one of two directions only ever yields two, so a "keep drawing * until I have three" loop never terminates. */ function distractors(word: string, want: number): string[] { const chars = [...word]; const out = new Set(); // Vary each decomposable syllable, nearest the end first: that is where // the 받침 lives, and where a sound rule actually applies. const positions = chars.map((c, i) => (decompose(c) ? i : -1)).filter((i) => i >= 0); for (const i of positions.reverse()) { const [initial, medial, final] = decompose(chars[i]!)!; for (const shift of [1, 2, 3, 4, 5, 6, 7]) { if (out.size >= want) break; const swapFinal = compose(initial, medial, (final + shift) % 28); const swapInitial = compose((initial + shift) % 19, medial, final); for (const variant of [swapFinal, swapInitial]) { const candidate = chars.map((c, j) => (j === i ? variant : c)).join(""); if (candidate !== word) out.add(candidate); } } if (out.size >= want) break; } return [...out].slice(0, want); } function buildQuestion(mode: Mode): Question | null { if (mode === "sound") { const pair = H.soundPairs[Math.floor(Math.random() * H.soundPairs.length)]; if (!pair) return null; const wrong = distractors(pair.spoken, 3); if (!wrong.length) return null; return { prompt: pair.written, answer: pair.spoken, options: shuffle([pair.spoken, ...wrong]), after: pair.rule, big: true, }; } if (mode === "speed") { const target = WORDS[Math.floor(Math.random() * WORDS.length)]; if (!target) return null; const wrong = pick(WORDS, 3, (w) => w[2] === target[2]).map((w) => w[2]); return { prompt: target[0], answer: target[2], options: shuffle([target[2], ...wrong]), big: true, }; } const target = SENTENCES[Math.floor(Math.random() * SENTENCES.length)]; if (!target) return null; const wrong = pick(SENTENCES, 3, (s) => s.en === target.en).map((s) => s.en); return { prompt: target.ko, answer: target.en, options: shuffle([target.en, ...wrong]), wide: true, }; } function Drill() { const { db, today, invalidate } = useStore(); // `picked` drives the UI; this guards the write. State is a render behind, // so a fast double-tap would otherwise log two answers for one question. const answering = useRef(false); const [mode, setMode] = useState("sound"); const [question, setQuestion] = useState(null); const [picked, setPicked] = useState(null); const [round, setRound] = useState({ n: 0, correct: 0 }); const nextQuestion = useCallback(() => { setQuestion(buildQuestion(mode)); setPicked(null); }, [mode]); useEffect(() => { setRound({ n: 0, correct: 0 }); nextQuestion(); }, [mode, nextQuestion]); const answer = async (option: string) => { if (answering.current || picked || !question) return; answering.current = true; setPicked(option); const ok = option === question.answer; setRound((r) => ({ n: r.n + 1, correct: r.correct + (ok ? 1 : 0) })); await editStudyLog(db, today, { drills: 1 }); invalidate(); setTimeout(() => { answering.current = false; nextQuestion(); }, ok ? 500 : 1300); }; const finished = round.n >= ROUND; return (

읽기 연습

{round.n ? `${round.correct} / ${round.n} correct` : "reading drill"}
{MODES.map((m) => ( ))}
{finished ? (

{Math.round((round.correct / round.n) * 100)}%

) : ( question && (
Question {round.n + 1} of {ROUND} {picked && question.after && ( 규칙 · {question.after} )}
{question.prompt}
{question.options.map((o) => ( ))}
) )}
); } function JamoGrid({ title, note, cells, }: { title: string; note: string; cells: { glyph: string; roman: string; label: string; shaded?: boolean }[]; }) { const { prefs } = useStore(); return (

{title}

{note}
{cells.map((c) => (
{c.glyph} {prefs.romanization && {c.roman}} {c.label}
))}
); } export function HangulTab() { const consonants = useMemo( () => H.consonants.map((c) => ({ glyph: c.jamo, roman: c.roman, label: c.name, shaded: c.tense })), [], ); const vowels = useMemo( () => H.vowels.map((v) => ({ glyph: v.jamo, roman: v.roman, label: v.kind, shaded: v.kind !== "basic", })), [], ); const batchim = useMemo( () => H.batchim.map((b) => ({ glyph: b.sound, roman: b.roman, label: b.writtenAs })), [], ); return ( <>

한 글자의 구조

Anatomy of a syllable
  • 초성 — the initial consonant
  • 중성 — the vowel
  • 종성 — the final consonant, the 받침. Optional

소리 바뀜

The seven sound rules
{H.soundRules.map((r) => (

{r.k} {r.n}

{r.p}

{r.a} [{r.b}]
))}
); }