@ihabdevteam/core 0.133.0 → 0.135.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +122 -0
- package/dist/components.cjs.js +176 -176
- package/dist/components.esm.css +1 -1
- package/dist/hooks.cjs.js +1 -1
- package/dist/i18n.cjs.js +3 -3
- package/dist/ihabdevteam-core.cjs.js +182 -182
- package/dist/ihabdevteam-core.d.ts +8 -2
- package/dist/packages/core/src/components/BarChart.js +3 -1
- package/dist/packages/core/src/components/Breadcrumb.js +5 -1
- package/dist/packages/core/src/components/Dialog.js +32 -9
- package/dist/packages/core/src/components/Header.css +2 -0
- package/dist/packages/core/src/components/Header.js +4 -1
- package/dist/packages/core/src/components/LandingHeader.js +18 -1
- package/dist/packages/core/src/components/Pagination.js +5 -2
- package/dist/packages/core/src/components/Panel.d.ts +1 -1
- package/dist/packages/core/src/components/Panel.js +4 -1
- package/dist/packages/core/src/components/PassCard/passCardProduct.js +6 -0
- package/dist/packages/core/src/components/PaymentPlan.d.ts +6 -0
- package/dist/packages/core/src/components/PaymentPlan.js +5 -3
- package/dist/packages/core/src/components/Popup/ConfirmPopup.js +34 -21
- package/dist/packages/core/src/components/Popup/CouponInputPopup.js +4 -1
- package/dist/packages/core/src/components/Popup/ListPopup.js +7 -2
- package/dist/packages/core/src/components/Popup/SaveTablePopup.js +13 -3
- package/dist/packages/core/src/components/Popup/TrainingResultPopup.js +5 -1
- package/dist/packages/core/src/components/PromoBanner.js +1 -1
- package/dist/packages/core/src/components/Report/ReportDocument.js +35 -10
- package/dist/packages/core/src/components/RingChart.js +14 -3
- package/dist/packages/core/src/components/SideNav.js +1 -1
- package/dist/packages/core/src/components/SoundCompass.js +10 -1
- package/dist/packages/core/src/components/Table.js +15 -5
- package/dist/packages/core/src/components/Tooltip/MenuTooltip.js +7 -2
- package/dist/packages/core/src/components/Training/TrainingBlockButton.js +3 -1
- package/dist/packages/core/src/components/Training/TrainingPagePopup.js +28 -2
- package/dist/packages/core/src/components/Training/TrainingStepOrderList.js +11 -7
- package/dist/packages/core/src/components/Training/TrainingStepTextBox.js +1 -1
- package/dist/packages/core/src/components/Training/TrainingWordCard.js +4 -0
- package/dist/packages/core/src/components/Training/useNoiseFade.js +22 -1
- package/dist/packages/core/src/components/Training/useTTSPlayback.js +9 -0
- package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +3 -2
- package/dist/packages/core/src/components/Word/WordTableBlock.js +2 -1
- package/dist/packages/core/src/components/barChartData.d.ts +8 -0
- package/dist/packages/core/src/components/barChartData.js +21 -0
- package/dist/packages/core/src/components/useBarChartControl.js +5 -3
- package/dist/packages/core/src/hooks/useDailyTrainingSelection.js +17 -1
- package/dist/packages/core/src/hooks/useEditableList.d.ts +6 -0
- package/dist/packages/core/src/hooks/useEditableList.js +20 -1
- package/dist/packages/core/src/hooks/useTablePaging.js +4 -0
- package/dist/packages/core/src/hooks/useVisitHistory.js +2 -2
- package/dist/packages/core/src/hooks/useVisitSchedule.js +5 -4
- package/dist/packages/core/src/hooks/useWordMeanings.js +13 -4
- package/dist/packages/core/src/hooks/useWordTrainingFilter.js +9 -4
- package/dist/packages/core/src/hooks/wordTrainingFilterModel.d.ts +1 -1
- package/dist/packages/core/src/hooks/wordTrainingFilterModel.js +7 -2
- package/dist/packages/core/src/i18n/locales/en.json +13 -1
- package/dist/packages/core/src/i18n/locales/ko.json +13 -1
- package/dist/packages/core/src/utils/format.js +17 -1
- package/dist/packages/core/src/utils/getTrainingData.js +11 -4
- package/dist/packages/core/src/utils/imageConfig.js +6 -3
- package/dist/packages/core/src/utils/koreanPronunciation.js +7 -0
- package/dist/packages/core/src/utils/pressable.js +4 -0
- package/dist/packages/core/src/utils/savedWordTables.js +2 -1
- package/dist/packages/core/src/utils/wordTrainingPool.d.ts +7 -0
- package/dist/packages/core/src/utils/wordTrainingPool.js +30 -0
- package/dist/packages/core/src/utils/wordTrainingRuntime.d.ts +1 -1
- package/dist/packages/core/src/utils/wordTrainingRuntime.js +38 -17
- package/dist/packages/core/src/version.d.ts +1 -1
- package/dist/packages/core/src/version.js +1 -1
- package/dist/utils.cjs.js +2 -2
- package/package.json +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useState } from 'react';
|
|
2
|
-
import { useEditableList } from './useEditableList';
|
|
2
|
+
import { useEditableList, isSameEditableItem } from './useEditableList';
|
|
3
3
|
/**
|
|
4
4
|
* @public
|
|
5
5
|
*/
|
|
@@ -24,7 +24,7 @@ export function useVisitHistory(initialHistory = []) {
|
|
|
24
24
|
closeSubPopup?.();
|
|
25
25
|
};
|
|
26
26
|
const handleVisitEditSave = () => {
|
|
27
|
-
commitEdit(prev => prev.map(v => v
|
|
27
|
+
commitEdit(prev => prev.map(v => (selectedVisit != null && isSameEditableItem(v, selectedVisit)) ? editVisitForm : v), editVisitForm);
|
|
28
28
|
};
|
|
29
29
|
/** 외부(스케줄 훅 등)에서 방문이력에 항목을 추가할 때 사용 */
|
|
30
30
|
const addVisit = (item) => {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { useCallback, useMemo, useState } from 'react';
|
|
3
3
|
import { today } from './useVisitHistory';
|
|
4
|
-
import { useEditableList } from './useEditableList';
|
|
4
|
+
import { useEditableList, isSameEditableItem } from './useEditableList';
|
|
5
5
|
/**
|
|
6
6
|
* 차례를 정하는 열쇠 — **가까운 것이 위**.
|
|
7
7
|
*
|
|
@@ -48,17 +48,18 @@ initialSchedule = []) {
|
|
|
48
48
|
closeSubPopup?.();
|
|
49
49
|
};
|
|
50
50
|
const handleScheduleEditSave = () => {
|
|
51
|
-
commitEdit(prev => sortByDate(prev.map(v => v
|
|
51
|
+
commitEdit(prev => sortByDate(prev.map(v => (selectedSchedule != null && isSameEditableItem(v, selectedSchedule)) ? editScheduleForm : v)), editScheduleForm);
|
|
52
52
|
};
|
|
53
53
|
const handleScheduleDelete = useCallback((item) => {
|
|
54
54
|
remove(item);
|
|
55
55
|
}, [remove]);
|
|
56
56
|
const handleScheduleToVisit = useCallback((item) => {
|
|
57
|
-
|
|
57
|
+
// id 도 넘긴다 — 빠지면 그 방문을 이어서 수정할 때 합성 키로 넘어가 서버가 행을 못 찾았다
|
|
58
|
+
addVisit({ year: item.year, month: item.month, day: item.day, hour: item.hour, minute: item.minute, memo: item.memo, ...(item.id != null ? { id: item.id } : {}) });
|
|
58
59
|
remove(item);
|
|
59
60
|
}, [addVisit, remove]);
|
|
60
61
|
const handleScheduleNoVisit = useCallback((item) => {
|
|
61
|
-
setScheduleList(prev => prev.filter(v => v
|
|
62
|
+
setScheduleList(prev => prev.filter(v => !isSameEditableItem(v, item)));
|
|
62
63
|
setNoShowList(prev => [item, ...prev]);
|
|
63
64
|
setSelectedSchedule(null);
|
|
64
65
|
}, [setScheduleList, setSelectedSchedule]);
|
|
@@ -23,26 +23,35 @@ import { useState, useEffect, useMemo } from 'react';
|
|
|
23
23
|
export const meaningKey = (word, homonum) => `${word}:${homonum ?? 1}`;
|
|
24
24
|
// 세션 동안 유지되는 메모리 캐시 — 페이지를 오가도 재조회하지 않는다
|
|
25
25
|
const memCache = new Map();
|
|
26
|
+
/* 받아 봤는데 뜻이 없던 열쇠 — 다시 묻지 않는다. 전에는 뜻 없는 낱말이 캐시에 안 남아 표를 다시 정렬하거나 쪽을
|
|
27
|
+
넘길 때마다 같은 낱말을 또 조회했다. (조회가 실패한 것은 여기 넣지 않는다 — 다음에 다시 받는다.) */
|
|
28
|
+
const noMeaning = new Set();
|
|
26
29
|
export function useWordMeanings(rows, fetchMeanings) {
|
|
27
30
|
/* 캐시는 모듈 스코프 Map 이라 갱신이 렌더에 자동 반영되지 않는다 — 조회가 끝나면 이 값을
|
|
28
31
|
올려 아래 스냅샷을 다시 계산시킨다(캐시 히트만으로 리렌더가 연쇄되지 않도록 분리). */
|
|
29
32
|
const [fetched, setFetched] = useState(0);
|
|
30
33
|
// rows 는 매 렌더 새 배열이라 의존성으로 쓸 수 없다 — 조회 키만 뽑아 문자열로 비교한다
|
|
31
|
-
|
|
34
|
+
// 차례는 조회와 무관하다 — 정렬해 두어야 같은 낱말들을 다시 정렬만 해도 새 조회로 보지 않는다
|
|
35
|
+
const wantedKey = Array.from(new Set(rows.map(r => meaningKey(r.word, r.homonum)))).sort().join('|');
|
|
32
36
|
useEffect(() => {
|
|
33
37
|
const wanted = wantedKey ? wantedKey.split('|') : [];
|
|
34
|
-
const missing = wanted.filter(k => !memCache.has(k));
|
|
38
|
+
const missing = wanted.filter(k => !memCache.has(k) && !noMeaning.has(k));
|
|
35
39
|
if (missing.length === 0)
|
|
36
40
|
return; // 전부 캐시에 있음 — 네트워크·상태 갱신 불필요
|
|
37
41
|
let cancelled = false;
|
|
38
42
|
const words = Array.from(new Set(missing.map(k => k.slice(0, k.lastIndexOf(':')))));
|
|
39
43
|
void fetchMeanings(words).then(got => {
|
|
40
|
-
|
|
41
|
-
|
|
44
|
+
/* 받은 것은 **취소됐어도 캐시에 담는다** — 쪽을 빨리 넘기면 앞 조회가 취소되며 받은 결과까지 버려, 돌아오면 또
|
|
45
|
+
조회했다. 화면 갱신(setFetched)만 취소된 쪽에서 건너뛴다. */
|
|
42
46
|
for (const row of got) {
|
|
43
47
|
if (row.Meaning)
|
|
44
48
|
memCache.set(meaningKey(row.Word, row.Homonum ?? undefined), row.Meaning);
|
|
45
49
|
}
|
|
50
|
+
for (const k of missing)
|
|
51
|
+
if (!memCache.has(k))
|
|
52
|
+
noMeaning.add(k);
|
|
53
|
+
if (cancelled || got.length === 0)
|
|
54
|
+
return;
|
|
46
55
|
setFetched(v => v + 1);
|
|
47
56
|
}).catch(() => {
|
|
48
57
|
// 조회 함수가 약속을 어기고 거절해도(망 끊김 등) 처리되지 않은 거절로 새지 않게 삼킨다 —
|
|
@@ -85,10 +85,14 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
|
|
|
85
85
|
// 글자 모드에서 마지막으로 선택돼 있던 모음 자모 — 발음↔글자 왕복 시 병합 버튼(ㅔㅐ 등)의
|
|
86
86
|
// 대표 자모로 뭉개지지 않고 원래 자모를 정확히 복원하기 위해 별도로 기억해 둔다.
|
|
87
87
|
const lastCharJungRef = useRef(phonemeView === 'char' ? composition.jung : null);
|
|
88
|
+
// 종성도 — 발음은 대표음(ㅅ→ㄷ)으로 모으므로 되돌릴 원래 자모를 기억한다
|
|
89
|
+
const lastCharJongRef = useRef(phonemeView === 'char' ? composition.jong : null);
|
|
88
90
|
useEffect(() => {
|
|
89
|
-
if (phonemeView === 'char')
|
|
91
|
+
if (phonemeView === 'char') {
|
|
90
92
|
lastCharJungRef.current = composition.jung;
|
|
91
|
-
|
|
93
|
+
lastCharJongRef.current = composition.jong;
|
|
94
|
+
}
|
|
95
|
+
}, [phonemeView, composition.jung, composition.jong]);
|
|
92
96
|
// resetOnMount(단어훈련 만들기): '초기화'는 필터를 모두 비운 중립 상태여야 하므로 선택 토글도 OFF로 시작한다.
|
|
93
97
|
// (기본 ON이면 단음절 음소 합치기 등에서 '이미지 있는 단어만'에 걸려 첫 진입 결과가 0이 된다.)
|
|
94
98
|
// 단, 카드에 이미지가 필요한 타입(듣고 순서대로 기억하기 등)은 defaultImageOnly로 ON을 요청할 수 있다.
|
|
@@ -109,7 +113,8 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
|
|
|
109
113
|
return;
|
|
110
114
|
resetKeyRef.current = opts?.resetKey;
|
|
111
115
|
setComposition(seedComposition());
|
|
112
|
-
|
|
116
|
+
// 이미지 토글도 그 타입의 기본값으로 — 무조건 false 로 두어 defaultImageOnly 를 요청한 타입(듣고 순서대로 기억하기)이 꺼졌다
|
|
117
|
+
setExcludeNoImage(!!opts?.defaultImageOnly);
|
|
113
118
|
setSameStructureOnly(false);
|
|
114
119
|
setGradeFilter([]);
|
|
115
120
|
setPosFilter([]);
|
|
@@ -520,7 +525,7 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
|
|
|
520
525
|
if (next === phonemeView)
|
|
521
526
|
return;
|
|
522
527
|
if (next !== 'direct' && phonemeView !== 'direct') {
|
|
523
|
-
setComposition(c => convertComposition(c, next, lastCharJungRef.current));
|
|
528
|
+
setComposition(c => convertComposition(c, next, lastCharJungRef.current, lastCharJongRef.current));
|
|
524
529
|
}
|
|
525
530
|
setPhonemeView(next);
|
|
526
531
|
}, [phonemeView]);
|
|
@@ -51,7 +51,7 @@ export declare function charToPronValue(part: CompositionPart, jamo: string): st
|
|
|
51
51
|
*/
|
|
52
52
|
export declare function pronToCharValue(part: CompositionPart, value: string, preferredJamo?: string | null): string;
|
|
53
53
|
/** 모드 전환 시 composition의 초성/모음/종성을 대상 모드 값으로 변환. 종성 '없음'(NO_CODA)은 유지. */
|
|
54
|
-
export declare function convertComposition(c: WordComposition, to: 'char' | 'pron', preferredCharJung?: string | null): WordComposition;
|
|
54
|
+
export declare function convertComposition(c: WordComposition, to: 'char' | 'pron', preferredCharJung?: string | null, preferredCharJong?: string | null): WordComposition;
|
|
55
55
|
/** 정답 단어 구성 선택 상태 (정답 단어에만 적용 — 오답은 대립 관계로 결정) */
|
|
56
56
|
export interface WordComposition {
|
|
57
57
|
cho: string | null;
|
|
@@ -83,10 +83,15 @@ export function pronToCharValue(part, value, preferredJamo) {
|
|
|
83
83
|
return preferredJamo;
|
|
84
84
|
return jamos[0] ?? value;
|
|
85
85
|
}
|
|
86
|
+
/* 종성도 같다 — 글자→발음은 대표음(ㅅ·ㅈ·ㅊ·ㅌ·ㅎ → ㄷ, ㄳ → ㄱ)으로 모으므로, 되돌릴 때 전환 직전 글자 모드의 종성이
|
|
87
|
+
그 대표음에 속하면 그것을 복원한다. 전에는 대표음 그대로 돌아와 보기만 바꿔도 훈련 대상이 옷·낫에서 곧·받으로
|
|
88
|
+
바뀌어 저장됐다. */
|
|
89
|
+
if (part === 'jong' && preferredJamo && finalToPhoneme(preferredJamo) === value)
|
|
90
|
+
return preferredJamo;
|
|
86
91
|
return value;
|
|
87
92
|
}
|
|
88
93
|
/** 모드 전환 시 composition의 초성/모음/종성을 대상 모드 값으로 변환. 종성 '없음'(NO_CODA)은 유지. */
|
|
89
|
-
export function convertComposition(c, to, preferredCharJung) {
|
|
94
|
+
export function convertComposition(c, to, preferredCharJung, preferredCharJong) {
|
|
90
95
|
const conv = (part, v) => {
|
|
91
96
|
if (!v)
|
|
92
97
|
return null;
|
|
@@ -94,7 +99,7 @@ export function convertComposition(c, to, preferredCharJung) {
|
|
|
94
99
|
return NO_CODA;
|
|
95
100
|
if (to === 'pron')
|
|
96
101
|
return charToPronValue(part, v);
|
|
97
|
-
return pronToCharValue(part, v, part === 'jung' ? preferredCharJung : null);
|
|
102
|
+
return pronToCharValue(part, v, part === 'jung' ? preferredCharJung : part === 'jong' ? preferredCharJong : null);
|
|
98
103
|
};
|
|
99
104
|
return { ...c, cho: conv('cho', c.cho), jung: conv('jung', c.jung), jong: conv('jong', c.jong) };
|
|
100
105
|
}
|
|
@@ -742,7 +742,8 @@
|
|
|
742
742
|
"descriptionPlaceholder": "Enter a description for this word table.",
|
|
743
743
|
"duplicateName": "This name is already in use.",
|
|
744
744
|
"nameRequired": "Please enter a table name.",
|
|
745
|
-
"autoFillHint": " (Press Tab or Space to autofill)"
|
|
745
|
+
"autoFillHint": " (Press Tab or Space to autofill)",
|
|
746
|
+
"copyName": "{{name}} (copy)"
|
|
746
747
|
},
|
|
747
748
|
"loadTable": {
|
|
748
749
|
"title": "Load Saved Word Table",
|
|
@@ -1390,5 +1391,16 @@
|
|
|
1390
1391
|
},
|
|
1391
1392
|
"loader": {
|
|
1392
1393
|
"loading": "Loading"
|
|
1394
|
+
},
|
|
1395
|
+
"soundCompass": {
|
|
1396
|
+
"state": {
|
|
1397
|
+
"active": "playing",
|
|
1398
|
+
"played": "heard",
|
|
1399
|
+
"correct": "correct",
|
|
1400
|
+
"wrong": "wrong"
|
|
1401
|
+
}
|
|
1402
|
+
},
|
|
1403
|
+
"breadcrumb": {
|
|
1404
|
+
"ariaLabel": "Breadcrumb"
|
|
1393
1405
|
}
|
|
1394
1406
|
}
|
|
@@ -740,7 +740,8 @@
|
|
|
740
740
|
"descriptionPlaceholder": "이 단어표에 대한 설명을 입력하세요.",
|
|
741
741
|
"duplicateName": "이미 사용 중인 이름입니다.",
|
|
742
742
|
"nameRequired": "표 이름을 입력해 주세요.",
|
|
743
|
-
"autoFillHint": " (Tab 또는 space 입력시 자동 입력)"
|
|
743
|
+
"autoFillHint": " (Tab 또는 space 입력시 자동 입력)",
|
|
744
|
+
"copyName": "{{name}} (복사)"
|
|
744
745
|
},
|
|
745
746
|
"loadTable": {
|
|
746
747
|
"title": "저장된 단어 표 불러오기",
|
|
@@ -1388,5 +1389,16 @@
|
|
|
1388
1389
|
},
|
|
1389
1390
|
"loader": {
|
|
1390
1391
|
"loading": "불러오는 중…"
|
|
1392
|
+
},
|
|
1393
|
+
"soundCompass": {
|
|
1394
|
+
"state": {
|
|
1395
|
+
"active": "소리 나는 중",
|
|
1396
|
+
"played": "들음",
|
|
1397
|
+
"correct": "정답",
|
|
1398
|
+
"wrong": "오답"
|
|
1399
|
+
}
|
|
1400
|
+
},
|
|
1401
|
+
"breadcrumb": {
|
|
1402
|
+
"ariaLabel": "이동 경로"
|
|
1391
1403
|
}
|
|
1392
1404
|
}
|
|
@@ -9,7 +9,23 @@ import { toKstYmd } from './date';
|
|
|
9
9
|
* 입력에 대시가 있든 없든, 어떤 포맷이든 처리.
|
|
10
10
|
*/
|
|
11
11
|
export function formatPhone(raw) {
|
|
12
|
-
const
|
|
12
|
+
const all = raw.replace(/\D/g, '');
|
|
13
|
+
/* **앞자리가 네 자리인 번호.** 050X 안심번호·0303 착신전환은 열두 자리까지 가고, 앞자리를 세 자리로 보면
|
|
14
|
+
`0507-1234-5678` 이 `050-7123-4567` 이 되어 **마지막 한 자리가 사라진 채** 멀쩡한 모양으로 저장됐다
|
|
15
|
+
(말귀가 formatPhoneKr 로 감싸 두었다 — 이제 걷어도 된다). */
|
|
16
|
+
if (/^(050\d|0303)/.test(all)) {
|
|
17
|
+
const d = all.slice(0, 12);
|
|
18
|
+
if (d.length <= 4)
|
|
19
|
+
return d;
|
|
20
|
+
if (d.length <= 8)
|
|
21
|
+
return `${d.slice(0, 4)}-${d.slice(4)}`;
|
|
22
|
+
return `${d.slice(0, 4)}-${d.slice(4, d.length - 4)}-${d.slice(d.length - 4)}`;
|
|
23
|
+
}
|
|
24
|
+
/* **대표번호**(1588·1544·1600·1899 …)는 여덟 자리 4-4 다 — 세 자리 앞자리로 보면 `158-8-1234` 가 됐다. */
|
|
25
|
+
if (/^1[5-9]\d{2}/.test(all) && all.length <= 8) {
|
|
26
|
+
return all.length <= 4 ? all : `${all.slice(0, 4)}-${all.slice(4)}`;
|
|
27
|
+
}
|
|
28
|
+
const d = all.slice(0, 11);
|
|
13
29
|
if (d.startsWith('02')) {
|
|
14
30
|
if (d.length <= 2)
|
|
15
31
|
return d;
|
|
@@ -104,6 +104,9 @@ export async function loadTrainingData() {
|
|
|
104
104
|
catch (e) { }
|
|
105
105
|
return;
|
|
106
106
|
}
|
|
107
|
+
/* 다 읽은 뒤에 한꺼번에 넣는다 — 도중에 형식 오류로 멈추면 이미 넣은 것이 남은 채 재시도가 또 넣어, 부를 때마다 풀에
|
|
108
|
+
중복이 쌓였다(하루 선택이 중복 문항을 뽑고 기기마다 세트가 달라짐). */
|
|
109
|
+
const 받음 = { noise: [], short: [], long: [], order: [] };
|
|
107
110
|
try {
|
|
108
111
|
for (const key of Object.keys(map || {})) {
|
|
109
112
|
const groups = map[key] || [];
|
|
@@ -115,16 +118,16 @@ export async function loadTrainingData() {
|
|
|
115
118
|
// Preserve original group structure from allTrainingData.json
|
|
116
119
|
const groupObj = Object.assign({}, gr, { training_type: t, category_0: cat0, category_1: cat1 });
|
|
117
120
|
if (t === 'LISTEN_IN_NOISE') {
|
|
118
|
-
|
|
121
|
+
받음.noise.push(groupObj);
|
|
119
122
|
}
|
|
120
123
|
else if (t === 'SHORT_STORY') {
|
|
121
|
-
|
|
124
|
+
받음.short.push(groupObj);
|
|
122
125
|
}
|
|
123
126
|
else if (t === 'LONG_STORY') {
|
|
124
|
-
|
|
127
|
+
받음.long.push(groupObj);
|
|
125
128
|
}
|
|
126
129
|
else if (t === 'ORDERING_SENTENCE') {
|
|
127
|
-
|
|
130
|
+
받음.order.push(groupObj);
|
|
128
131
|
}
|
|
129
132
|
}
|
|
130
133
|
}
|
|
@@ -142,6 +145,10 @@ export async function loadTrainingData() {
|
|
|
142
145
|
catch { /* SSR 등 */ }
|
|
143
146
|
return;
|
|
144
147
|
}
|
|
148
|
+
NOISE_DATAS.push(...받음.noise);
|
|
149
|
+
SHORT_STORIES.push(...받음.short);
|
|
150
|
+
LONG_STORIES.push(...받음.long);
|
|
151
|
+
ORDER_SETS.push(...받음.order);
|
|
145
152
|
try {
|
|
146
153
|
window.dispatchEvent(new Event('speechear.data.updated'));
|
|
147
154
|
}
|
|
@@ -60,13 +60,16 @@ export async function checkImageExists(word, homonum) {
|
|
|
60
60
|
return false;
|
|
61
61
|
// 타임아웃 없으면 HEAD 요청 하나가 응답 없이 걸릴 때 이 확인을 기다리는
|
|
62
62
|
// 검색 버튼(이미지 있음 필터)이 영구 비활성화된다
|
|
63
|
+
/* **확인이 안 된 것은 캐시하지 않는다.** 망 끊김·타임아웃·5xx 까지 '이미지 없음' 으로 영구 저장해, 한 번 끊긴 단어는 망이
|
|
64
|
+
돌아와도 '이미지 있음' 필터에서 계속 빠졌다(ttsConfig.checkAudioExists 와 같게). 있음(2xx)·없음(4xx)만 담는다. */
|
|
63
65
|
const p = fetch(url, { method: 'HEAD', signal: AbortSignal.timeout(8000) })
|
|
64
|
-
.then(res => res.ok)
|
|
65
|
-
.catch(() => false)
|
|
66
|
+
.then(res => (res.status >= 500 ? null : res.ok), () => null)
|
|
66
67
|
.then(ok => {
|
|
68
|
+
inflight.delete(k);
|
|
69
|
+
if (ok === null)
|
|
70
|
+
return false;
|
|
67
71
|
map[k] = ok;
|
|
68
72
|
scheduleSave();
|
|
69
|
-
inflight.delete(k);
|
|
70
73
|
return ok;
|
|
71
74
|
});
|
|
72
75
|
inflight.set(k, p);
|
|
@@ -147,6 +147,13 @@ export function toStandardPronunciation(word) {
|
|
|
147
147
|
cur.jong = cur.jong === JONG_NH ? JONG_N : cur.jong === JONG_RH ? JONG_R : JONG_NONE;
|
|
148
148
|
continue;
|
|
149
149
|
}
|
|
150
|
+
// 받침 ㅎ 계열 + 초성 ㅅ — ㅎ 은 떨어지고 ㅅ 은 된소리(표준발음법 제12항 2: 닿소 → 다쏘, 많소 → 만쏘, 싫소 → 실쏘).
|
|
151
|
+
// 전에는 이 갈래가 없어 끝소리 규칙으로 ㅎ 이 ㄷ 이 되어 '놓소 → 녿쏘' 가 나왔다.
|
|
152
|
+
if ((cur.jong === JONG_H || cur.jong === JONG_NH || cur.jong === JONG_RH) && next.cho === CHO_S) {
|
|
153
|
+
next.cho = CHO_SS;
|
|
154
|
+
cur.jong = cur.jong === JONG_NH ? JONG_N : cur.jong === JONG_RH ? JONG_R : JONG_NONE;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
150
157
|
// 받침 ㄱㄷㅂㅈ 계열 + 초성 ㅎ (먹히다 → 머키다, 앉히다 → 안치다, 읽히다 → 일키다)
|
|
151
158
|
if (next.cho === CHO_H) {
|
|
152
159
|
if (cur.jong === JONG_NJ) {
|
|
@@ -22,6 +22,10 @@ export function pressable(onPress, 옵션) {
|
|
|
22
22
|
tabIndex: 0,
|
|
23
23
|
...(옵션?.selected === undefined ? {} : { 'aria-selected': 옵션.selected }),
|
|
24
24
|
onKeyDown: (e) => {
|
|
25
|
+
/* **이 자리 자신에서 난 키만** 받는다. 안에 든 진짜 단추(랜딩 띠의 action 등)에서 누른 Enter 가 올라오면
|
|
26
|
+
전에는 여기서 preventDefault 해 그 단추의 click 을 막고 이 자리의 onPress 를 대신 불렀다. */
|
|
27
|
+
if (e.target !== e.currentTarget)
|
|
28
|
+
return;
|
|
25
29
|
if (e.key !== 'Enter' && e.key !== ' ')
|
|
26
30
|
return;
|
|
27
31
|
/* Space 는 그냥 두면 화면이 굴러간다 — 누름으로 바꾼다 */
|
|
@@ -56,11 +56,12 @@ export function duplicateTable(id) {
|
|
|
56
56
|
const original = tables.find(t => t.id === id);
|
|
57
57
|
if (!original)
|
|
58
58
|
return null;
|
|
59
|
+
/* 사본도 설명·검색 구성·묶음을 가진다 — 전에는 items 만 옮겨, 사본을 '수정' 하면 검색 패널이 복원되지 않았다. */
|
|
59
60
|
const copy = {
|
|
61
|
+
...original,
|
|
60
62
|
id: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
|
61
63
|
name: `${original.name} (복사)`,
|
|
62
64
|
savedAt: new Date().toISOString(),
|
|
63
|
-
items: original.items,
|
|
64
65
|
};
|
|
65
66
|
try {
|
|
66
67
|
localStorage.setItem(STORAGE_KEY, JSON.stringify([copy, ...tables]));
|
|
@@ -49,3 +49,10 @@ export declare function buildUsablePool(opts: GenOptions): Promise<Array<{
|
|
|
49
49
|
word: MvpWord;
|
|
50
50
|
usable: string[];
|
|
51
51
|
}>>;
|
|
52
|
+
/**
|
|
53
|
+
* 정답(문제) 단어 거르개 — **난이도 등급 · 단어 구성(초성/중성/종성/받침없음 · 음절 수)**.
|
|
54
|
+
* buildUsablePool 이 후보에 거는 것과 같은 규칙이다(글자입력=철자, 발음입력=표준발음으로 본다).
|
|
55
|
+
* 실단어를 직접 고르는 생성기(음소 합성·받침/자음 탈락·음절 바꾸기)가 이것을 안 걸어, 설정한 필터와 무관한
|
|
56
|
+
* 문제를 냈다(초성 ㄱ·1등급인데 '카·라·파…'). 거를 것이 없으면 null.
|
|
57
|
+
*/
|
|
58
|
+
export declare function answerWordFilter(opts: GenOptions): ((w: MvpWord) => boolean) | null;
|
|
@@ -216,6 +216,36 @@ function _positionalPredicate(composition) {
|
|
|
216
216
|
}
|
|
217
217
|
// 대립(오답) 엔진은 공용 모듈 wordContrast.ts 로 분리 — hasStructureContrast, buildMultiPartPosContrast,
|
|
218
218
|
// minimalPairDistractors 등은 거기서 import 한다(런타임과 훈련/단어표 만들기가 공유).
|
|
219
|
+
/**
|
|
220
|
+
* 정답(문제) 단어 거르개 — **난이도 등급 · 단어 구성(초성/중성/종성/받침없음 · 음절 수)**.
|
|
221
|
+
* buildUsablePool 이 후보에 거는 것과 같은 규칙이다(글자입력=철자, 발음입력=표준발음으로 본다).
|
|
222
|
+
* 실단어를 직접 고르는 생성기(음소 합성·받침/자음 탈락·음절 바꾸기)가 이것을 안 걸어, 설정한 필터와 무관한
|
|
223
|
+
* 문제를 냈다(초성 ㄱ·1등급인데 '카·라·파…'). 거를 것이 없으면 null.
|
|
224
|
+
*/
|
|
225
|
+
export function answerWordFilter(opts) {
|
|
226
|
+
const gradeSet = opts.grade && opts.grade.length > 0 ? new Set(opts.grade) : null;
|
|
227
|
+
const composition = opts.composition ?? null;
|
|
228
|
+
const syllableSet = composition?.syllable != null
|
|
229
|
+
? new Set(Array.isArray(composition.syllable) ? composition.syllable : [composition.syllable])
|
|
230
|
+
: null;
|
|
231
|
+
const preds = composition
|
|
232
|
+
? [
|
|
233
|
+
_positionalPredicate(composition),
|
|
234
|
+
syllableSet ? (word) => syllableSet.has(syllableLength(word)) : null,
|
|
235
|
+
].filter((p) => p != null)
|
|
236
|
+
: [];
|
|
237
|
+
if (!gradeSet && preds.length === 0)
|
|
238
|
+
return null;
|
|
239
|
+
const pron = opts.phonemeView === 'pron';
|
|
240
|
+
return (w) => {
|
|
241
|
+
if (gradeSet && (!w.grade || !gradeSet.has(w.grade)))
|
|
242
|
+
return false;
|
|
243
|
+
if (preds.length === 0)
|
|
244
|
+
return true;
|
|
245
|
+
const target = pron ? pronOfWord(w.w) : w.w;
|
|
246
|
+
return preds.every(pred => pred(target));
|
|
247
|
+
};
|
|
248
|
+
}
|
|
219
249
|
async function _buildUsablePool(opts) {
|
|
220
250
|
const { difficulty = null, choseong = null, composition = null, targetSyllable = null, excludeNoImage = false } = opts;
|
|
221
251
|
// 난이도 등급('1'~'4') 필터 — 정답(후보) 단어에만 적용(구성 필터와 동일 원칙, 오답 풀은 무관)
|
|
@@ -9,7 +9,7 @@ export declare function generateRuntimeItems(typeKey: WordTrainingTypeKey, opts?
|
|
|
9
9
|
* 공통 조합을 백그라운드에서 사전 계산해 결과 캐시를 미리 채운다.
|
|
10
10
|
* 이후 동일 조합으로 generateRuntimeItems 호출 시 즉시(<5ms) 반환.
|
|
11
11
|
* fire-and-forget — 완료 여부에 관계없이 UI 블로킹 없음.
|
|
12
|
-
* UI가 실제로 쓰는
|
|
12
|
+
* UI가 실제로 쓰는 열쇠(count 5 · 난이도 'random'/'count' · 필터 기본값)로 예열한다.
|
|
13
13
|
* (과거 대립종류 cho/jung/jong 예열은 UI 미사용이고 정작 count는 누락돼 첫 생성이 느렸음)
|
|
14
14
|
*/
|
|
15
15
|
export declare function precomputeCommonCombinations(excludeNoImage: boolean): Promise<void>;
|
|
@@ -17,7 +17,7 @@ export { CAPABILITY, cardGroupOf, isValidQuestionWord, hasQuestionWordConstraint
|
|
|
17
17
|
import { CAPABILITY, QUESTION_WORD_RULE } from './wordTrainingRuntimeTypes';
|
|
18
18
|
import { buildDropContrastProblem, buildDropContrast3Problem, buildDropCompoundProblem, buildDropFinalConsonantProblem, buildRemoveConsonantProblem, buildChangeWholeSyllableProblem, buildSyllableMaskIndex } from './wordTrainingGenerators';
|
|
19
19
|
/* 재료 고르기(풀 추리기·캐시·오답 샘플링)는 wordTrainingPool 로 갈라 두었다. */
|
|
20
|
-
import { MVP, buildUsablePool, filterByCachedImage, getPronMap, nextId, pickOne, sample, sampleDistinctPron, _poolCacheKey, _EMPTY_SET, } from './wordTrainingPool';
|
|
20
|
+
import { MVP, buildUsablePool, filterByCachedImage, getPronMap, nextId, pickOne, sample, sampleDistinctPron, _poolCacheKey, _EMPTY_SET, answerWordFilter, } from './wordTrainingPool';
|
|
21
21
|
// ────────────────────────────────────────────────────────────────────────────
|
|
22
22
|
// 카테고리별 generator
|
|
23
23
|
// ────────────────────────────────────────────────────────────────────────────
|
|
@@ -100,11 +100,17 @@ async function genIncluded(opts) {
|
|
|
100
100
|
async function genSameOrDiff(opts) {
|
|
101
101
|
const { count = 10 } = opts;
|
|
102
102
|
const pool = (await buildUsablePool(opts)).filter(e => e.usable.length >= 1);
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
103
|
+
if (!pool.length)
|
|
104
|
+
return [];
|
|
105
|
+
/* 같음·다름을 **절반씩 섞어** 둔다. 전에는 i % 2 로 정해 늘 같음·다름·같음… 순서라 듣지 않고도 답을 맞힐 수
|
|
106
|
+
있었고, 단어를 복원추출(pickOne)해 풀이 작지 않아도 같은 단어가 여러 문항에 나왔다. 풀이 문항 수보다 크면
|
|
107
|
+
겹치지 않게 뽑는다. */
|
|
108
|
+
const entries = pool.length >= count
|
|
109
|
+
? sample(pool, count)
|
|
110
|
+
: shuffle(Array.from({ length: count }, (_, i) => pool[i % pool.length]));
|
|
111
|
+
const sameFlags = shuffle(Array.from({ length: count }, (_, i) => i < Math.ceil(count / 2)));
|
|
112
|
+
return entries.map((entry, i) => {
|
|
113
|
+
const isSame = sameFlags[i];
|
|
108
114
|
let pair;
|
|
109
115
|
if (isSame)
|
|
110
116
|
pair = [entry.word.w, entry.word.w];
|
|
@@ -119,7 +125,7 @@ async function genSameOrDiff(opts) {
|
|
|
119
125
|
pair,
|
|
120
126
|
same: isSame,
|
|
121
127
|
};
|
|
122
|
-
})
|
|
128
|
+
});
|
|
123
129
|
}
|
|
124
130
|
/**
|
|
125
131
|
* STARTS_WITH_SAME_SOUND
|
|
@@ -260,10 +266,14 @@ async function genChangeWholeSyll(N, opts, idPrefix) {
|
|
|
260
266
|
const maskIndex = buildSyllableMaskIndex(wordSet);
|
|
261
267
|
const phonemeView = opts.phonemeView ?? 'char';
|
|
262
268
|
const comp = opts.composition ?? null;
|
|
269
|
+
// 등급은 정답(문제) 단어에만 건다 — 오답 탐색 풀(maskIndex)은 그대로(buildUsablePool 과 같은 원칙). 전에는 등급이 빠졌다.
|
|
270
|
+
const answerOk = answerWordFilter({ grade: opts.grade });
|
|
263
271
|
const out = [];
|
|
264
272
|
for (const w of shuffle(words)) {
|
|
265
273
|
if (out.length >= count)
|
|
266
274
|
break;
|
|
275
|
+
if (answerOk && !answerOk(w))
|
|
276
|
+
continue;
|
|
267
277
|
const prob = buildChangeWholeSyllableProblem(w.w, comp, pronMap, phonemeView, maskIndex, null, null);
|
|
268
278
|
if (!prob)
|
|
269
279
|
continue;
|
|
@@ -449,13 +459,17 @@ async function genListenAndRememberOrder(opts) {
|
|
|
449
459
|
// 음소 단위(합성/탈락) — 실단어 데이터 기반. 이미지 필터 적용.
|
|
450
460
|
// ────────────────────────────────────────────────────────────────────────────
|
|
451
461
|
/** N음절 실단어(이미지 필터 적용) 중 조건(pred: 첫 음절 분해값 기준)을 만족하는 것 */
|
|
452
|
-
function realWordsOfLen(N,
|
|
462
|
+
function realWordsOfLen(N, opts, pred) {
|
|
453
463
|
const all = MVP.filter(w => w.w && w.s === N);
|
|
454
|
-
const ok = excludeNoImage ? filterByCachedImage(all.map(w => w.w)) : null;
|
|
464
|
+
const ok = opts.excludeNoImage ? filterByCachedImage(all.map(w => w.w)) : null;
|
|
465
|
+
// 설정한 등급·단어 구성도 건다 — 전에는 음절 수·이미지만 봐서 필터와 무관한 문제가 나왔다
|
|
466
|
+
const answerOk = answerWordFilter(opts);
|
|
455
467
|
const out = [];
|
|
456
468
|
for (const mw of all) {
|
|
457
469
|
if (ok && !ok.has(mw.w))
|
|
458
470
|
continue;
|
|
471
|
+
if (answerOk && !answerOk(mw))
|
|
472
|
+
continue;
|
|
459
473
|
if (pred) {
|
|
460
474
|
const s = parseSyllable(mw.w);
|
|
461
475
|
if (!s || !pred(s))
|
|
@@ -467,7 +481,7 @@ function realWordsOfLen(N, excludeNoImage, pred) {
|
|
|
467
481
|
}
|
|
468
482
|
/** MERGE_2_PHONEMES — 초성+모음 합쳐 1음절(받침 없음). 실단어 데이터 기반 */
|
|
469
483
|
function genMerge2Phonemes(opts) {
|
|
470
|
-
const pool = realWordsOfLen(1, opts
|
|
484
|
+
const pool = realWordsOfLen(1, opts, s => s.jong === 0);
|
|
471
485
|
return sample(pool, opts.count ?? 10).map((e, i) => {
|
|
472
486
|
const s = parseSyllable(e.w);
|
|
473
487
|
return { id: nextId('M2P-item'), index: i + 1, parts: [CHOSEONG[s.cho], JUNGSEONG[s.jung]], answer: e.w, word: e.w };
|
|
@@ -476,7 +490,7 @@ function genMerge2Phonemes(opts) {
|
|
|
476
490
|
/** MERGE_3_PHONEMES — 초성+모음+종성 합쳐 1음절(홑받침). 실단어 데이터 기반 */
|
|
477
491
|
function genMerge3Phonemes(opts) {
|
|
478
492
|
// 홑받침만(겹받침은 음소 2개라 '3음소'가 아니게 됨)
|
|
479
|
-
const pool = realWordsOfLen(1, opts
|
|
493
|
+
const pool = realWordsOfLen(1, opts, s => s.jong > 0 && !JONG_DECOMPOSE[s.jong]);
|
|
480
494
|
return sample(pool, opts.count ?? 10).map((e, i) => {
|
|
481
495
|
const s = parseSyllable(e.w);
|
|
482
496
|
return { id: nextId('M3P-item'), index: i + 1, parts: [CHOSEONG[s.cho], JUNGSEONG[s.jung], JONGSEONG[s.jong]], answer: e.w, word: e.w };
|
|
@@ -486,7 +500,7 @@ function genMerge3Phonemes(opts) {
|
|
|
486
500
|
* 풀 조건은 QUESTION_WORD_RULE와 공유 — 편집기 무작위 선택/검증이 이 규칙을 그대로 쓴다. */
|
|
487
501
|
function genDropFinalConsonant(opts) {
|
|
488
502
|
const rule = QUESTION_WORD_RULE.DROP_FINAL_CONSONANT_FROM_1SYLL;
|
|
489
|
-
const pool = realWordsOfLen(rule.syllables, opts
|
|
503
|
+
const pool = realWordsOfLen(rule.syllables, opts, rule.syllable);
|
|
490
504
|
const pronMap = getPronMap();
|
|
491
505
|
const out = [];
|
|
492
506
|
for (const e of shuffle(pool)) {
|
|
@@ -506,7 +520,7 @@ function genDropFinalConsonant(opts) {
|
|
|
506
520
|
function genRemoveConsonant(opts) {
|
|
507
521
|
// 받침 없는 자음 초성 1음절만 → 자음을 빼면 순수 모음 음절(ㅇ+모음)이 남는다.
|
|
508
522
|
const rule = QUESTION_WORD_RULE.REMOVE_CONSONANT_AND_IDENTIFY_PHONEME;
|
|
509
|
-
const pool = realWordsOfLen(rule.syllables, opts
|
|
523
|
+
const pool = realWordsOfLen(rule.syllables, opts, rule.syllable);
|
|
510
524
|
const pronMap = getPronMap();
|
|
511
525
|
const out = [];
|
|
512
526
|
for (const e of shuffle(pool)) {
|
|
@@ -585,11 +599,14 @@ async function _generateRuntimeItems(typeKey, opts = {}) {
|
|
|
585
599
|
* 공통 조합을 백그라운드에서 사전 계산해 결과 캐시를 미리 채운다.
|
|
586
600
|
* 이후 동일 조합으로 generateRuntimeItems 호출 시 즉시(<5ms) 반환.
|
|
587
601
|
* fire-and-forget — 완료 여부에 관계없이 UI 블로킹 없음.
|
|
588
|
-
* UI가 실제로 쓰는
|
|
602
|
+
* UI가 실제로 쓰는 열쇠(count 5 · 난이도 'random'/'count' · 필터 기본값)로 예열한다.
|
|
589
603
|
* (과거 대립종류 cho/jung/jong 예열은 UI 미사용이고 정작 count는 누락돼 첫 생성이 느렸음)
|
|
590
604
|
*/
|
|
591
605
|
export async function precomputeCommonCombinations(excludeNoImage) {
|
|
592
|
-
|
|
606
|
+
/* **화면이 실제로 부르는 열쇠와 똑같이** 예열한다. 전에는 count:10 · 난이도 null/'count' 로 돌았는데 화면
|
|
607
|
+
(useWordTrainingDataGeneration)은 count:5 · 필터 기본 난이도 'random'(음소를 고르면 'count')으로 불러 캐시 열쇠가
|
|
608
|
+
달랐다 — 예열 계산을 전부 버리고 첫 생성이 다시 계산했다. */
|
|
609
|
+
const difficulties = ['random', 'count'];
|
|
593
610
|
const types = Object.keys(CAPABILITY);
|
|
594
611
|
let tick = 0;
|
|
595
612
|
for (const diff of difficulties) {
|
|
@@ -597,9 +614,13 @@ export async function precomputeCommonCombinations(excludeNoImage) {
|
|
|
597
614
|
const cap = CAPABILITY[t];
|
|
598
615
|
generateRuntimeItems(t, {
|
|
599
616
|
difficulty: cap.difficulty ? diff : null,
|
|
600
|
-
|
|
601
|
-
|
|
617
|
+
contrastCount: 1,
|
|
618
|
+
composition: null,
|
|
619
|
+
count: 5,
|
|
602
620
|
excludeNoImage,
|
|
621
|
+
sameStructureOnly: true,
|
|
622
|
+
phonemeView: 'char',
|
|
623
|
+
grade: null,
|
|
603
624
|
}).catch(() => { });
|
|
604
625
|
// 6 타입마다 마이크로태스크 양보 → 메인 스레드 프레임 블로킹 방지
|
|
605
626
|
if (++tick % 6 === 0)
|