number-master

web application to practice korean numbers
git clone git://brookjeynes.dev/bjeynes/number-master.git
Log | Files | Refs | README | LICENSE

commit a6887112071801c1356856a444ee92359fbfa90e
parent 5932e3061f68b1ec22385097531f909e3d894996
Author: brookjeynes <me@brookjeynes.dev>
Date:   Sun, 31 May 2026 13:10:48 +1000

feat: add tabs

Signed-off-by: brookjeynes <me@brookjeynes.dev>

Diffstat:
Mindex.html | 15+++++++++++++--
Mminimal.css | 11+++++++++++
Msrc/native.js | 54++++--------------------------------------------------
Msrc/sino.js | 48+-----------------------------------------------
Msrc/store.js | 56+++++++++++++++++++++++++++++++++++++++++++++++++-------
5 files changed, 78 insertions(+), 106 deletions(-)

diff --git a/index.html b/index.html @@ -12,6 +12,16 @@ <body> <h1>숫자마스터</h1> + <div x-data role="tablist" aria-label="number mode tablist"> + <button role="tab" :aria-selected="$store.app.tab === 'sino'" id="panel-sino" + @click="$store.app.changeTab('sino')">한자어</button> + <button role="tab" :aria-selected="$store.app.tab === 'native'" id="panel-native" + @click="$store.app.changeTab('native')">고유어</button> + <button role="tab" id="panel-time" disabled>시간</button> + <button role="tab" id="panel-date" disabled>날짜</button> + <button role="tab" id="panel-counter" disabled>단위</button> + </div> + <fieldset x-data> <legend>모드</legend> <label> @@ -26,7 +36,7 @@ </label> </fieldset> - <fieldset x-data> + <fieldset x-data x-show="$store.app.tab === 'sino'"> <legend>범위</legend> <label> 최소 @@ -84,7 +94,8 @@ </span> </template> <br> - <input autocomplete="off" class="answer-input" id="input" type="text" placeholder="한글 입력" :class="{ + <input autocomplete="off" class="answer-input" id="input" type="text" + :placeholder="$store.app.mode === 'number' ? '한글 입력' : '숫자 입력'" :class="{ '-correct': $store.app.questionState === 'correct', '-incorrect': $store.app.questionState === 'incorrect' }" /> diff --git a/minimal.css b/minimal.css @@ -196,3 +196,14 @@ label { margin: 8px 0; } } + +[role="tablist"] { + display: flex; + gap: 15px; + + [role="tab"][aria-selected="true"] { + text-decoration: underline; + text-decoration-thickness: 1px; + text-underline-offset: 3px; + } +} diff --git a/src/native.js b/src/native.js @@ -9,55 +9,9 @@ export function nativeStringFromNumber(num) { const ONES = ["", "하나", "둘", "셋", "넷", "다섯", "여섯", "일곱", "여덟", "아홉"]; const TENS = ["", "열", "스물", "서른", "마흔", "쉰", "예순", "일흔", "여든", "아흔"]; - const groups = []; - let temp = num; - while (temp > 0) { - groups.push(temp % 10); - temp = Math.floor(temp / 10); - } + const tens = Math.floor(num / 10); + const ones = num % 10; - const ones = ONES[groups[0]]; - - if (groups.length === 1) { - return ones; - } - - const tens = TENS[groups[1]]; - return tens + ones; -} - -/** - * @param {string} str - * @returns {number} - */ -export function numberFromNativeString(str) { - if (str.length === 0) throw new Error("string cannot be of zero length"); - if (str.length > 4) throw new Error("string cannot be greater than 4 characters"); - - const ONES = { "하나": 1, "둘": 2, "셋": 3, "넷": 4, "다섯": 5, "여섯": 6, "일곱": 7, "여덟": 8, "아홉": 9 }; - const TENS = { "열": 10, "스물": 20, "서른": 30, "마흔": 40, "쉰": 50, "예순": 60, "일흔": 70, "여든": 80, "아흔": 90 }; - - /** @type{number} */ - let result = 0; - /** @type{string} */ - let temp = str; - - for (const ten of Object.keys(TENS)) { - if (temp.startsWith(ten)) { - result += TENS[ten]; - temp = temp.slice(ten.length); - break; - } - } - for (const one of Object.keys(ONES)) { - if (temp.startsWith(one)) { - result += ONES[one]; - temp = temp.slice(one.length); - break; - } - } - - if (temp.length !== 0) throw new Error("invalid input"); - - return result; + if (tens === 0) return ONES[ones]; + return TENS[tens] + ONES[ones]; } diff --git a/src/sino.js b/src/sino.js @@ -22,7 +22,7 @@ export function sinoStringFromNumber(num) { temp = Math.floor(temp / 1_0000) } - /** @type {string} */ + /** @type {string[]} */ let result = []; for (let g = groups.length - 1; g >= 0; g--) { /** @type {number} */ @@ -57,49 +57,3 @@ export function sinoStringFromNumber(num) { return result.join(" "); } - -/** - * @param {string} str - * @returns {number} - */ -export function numberFromSinoString(str) { - const UNITS = { "일": 1, "이": 2, "삼": 3, "사": 4, "오": 5, "육": 6, "칠": 7, "팔": 8, "구": 9 }; - const SMALL = { "십": 10, "백": 100, "천": 1000 }; - const LARGE = { "만": 10000, "억": 100000000, "조": 1000000000000 }; - - if (str.length === 0) throw new Error("string cannot be of zero length"); - if (str === "영") return 0; - - /** @type {number} */ - let result = 0; - /** - * Accumulates value within current large-unit group. - * @type {number} - */ - let currentGroup = 0; - /** - * Defaults to 1 to handle omitted 일 (e.g. "십" = 10). - * @type {number} - */ - let currentDigit = 1; - - for (const char of str) { - if (UNITS[char] !== undefined) { - currentDigit = UNITS[char]; - } else if (SMALL[char] !== undefined) { - currentGroup += currentDigit * SMALL[char]; - // reset for next digit (restores 일-omission default) - currentDigit = 1; - } else if (LARGE[char] !== undefined) { - // flush any trailing 일-place digit into group, then apply large unit - result += (currentGroup + currentDigit) * LARGE[char]; - currentGroup = 0; - currentDigit = 1; - } - } - - // flush the final group (no large unit suffix) - result += currentGroup + currentDigit; - - return result; -} diff --git a/src/store.js b/src/store.js @@ -1,5 +1,5 @@ -import { nativeStringFromNumber, numberFromNativeString } from "./native.js"; -import { sinoStringFromNumber, numberFromSinoString } from "./sino.js"; +import { nativeStringFromNumber } from "./native.js"; +import { sinoStringFromNumber } from "./sino.js"; /** * @typedef { @@ -10,6 +10,16 @@ import { sinoStringFromNumber, numberFromSinoString } from "./sino.js"; /** * @typedef { + * | "sino" + * | "native" + * | "time" + * | "date" + * | "counter" + * } Tab + */ + +/** + * @typedef { * | "correct" * | "incorrect" * | "undecided" @@ -38,6 +48,8 @@ window.Alpine.store("app", { max: DEFAULT_CONFIG.max, /** @type {Mode} */ mode: DEFAULT_CONFIG.mode, + /** @type {Tab} */ + tab: "sino", /** @type {QuestionState} */ questionState: "undecided", /** @type {string | null} */ @@ -62,6 +74,9 @@ window.Alpine.store("app", { case "number": this.mode = "number"; break; + default: + this.mode = DEFAULT_CONFIG.mode; + console.error("unknown mode selected"); } this.min = isNaN(min) ? DEFAULT_CONFIG.min : min; @@ -69,19 +84,26 @@ window.Alpine.store("app", { } this.onRangeChange() - this.number = random(this.min, this.max * 10); + this.number = this.randomNumber; }, /** @returns {string} */ get string() { - return sinoStringFromNumber(this.number); + switch (this.tab) { + case "sino": + return sinoStringFromNumber(this.number); + case "native": + return nativeStringFromNumber(this.number); + default: + throw new Error("unknown tab selected"); + } }, /** @returns {string} */ get answer() { switch (this.mode) { case "number": - return sinoStringFromNumber(this.number); + return this.string; case "hangeul": return this.number.toString(); } @@ -96,7 +118,7 @@ window.Alpine.store("app", { const sanitised = input.trim().split(" ").join(""); if (this.questionState !== "undecided") { - this.number = random(this.min, this.max * 10); + this.number = this.randomNumber; event.target.input.value = ""; this.questionState = "undecided"; return @@ -126,7 +148,7 @@ window.Alpine.store("app", { } this.rangeError = null; - this.number = random(this.min, this.max * 10); + this.number = this.randomNumber; this.questionState = "undecided"; }, @@ -140,6 +162,26 @@ window.Alpine.store("app", { this.configSaved = false; }, 2000) }, + + /** + * @param {Tab} tab + */ + changeTab(tab) { + this.questionState = "undecided"; + this.tab = tab; + this.number = this.randomNumber; + }, + + get randomNumber() { + switch (this.tab) { + case "sino": + return random(this.min, this.max * 10); + case "native": + return random(1, 100); + default: + throw new Error("unknown tab selected"); + } + }, }); /**