@ihabdevteam/core 0.142.0 → 0.144.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 +79 -0
- package/dist/components.cjs.js +185 -180
- package/dist/hooks.cjs.js +1 -1
- package/dist/i18n.cjs.js +1 -1
- package/dist/ihabdevteam-core.cjs.js +191 -186
- package/dist/ihabdevteam-core.d.ts +66 -22
- package/dist/packages/core/src/components/Badge.js +3 -1
- package/dist/packages/core/src/components/BarChart.d.ts +1 -1
- package/dist/packages/core/src/components/BarChart.js +4 -3
- package/dist/packages/core/src/components/BottomNav.js +4 -0
- package/dist/packages/core/src/components/BulkBar.js +1 -1
- package/dist/packages/core/src/components/Button.js +12 -5
- package/dist/packages/core/src/components/Calendar.js +9 -6
- package/dist/packages/core/src/components/DatePicker.js +9 -4
- package/dist/packages/core/src/components/Dialog.js +3 -1
- package/dist/packages/core/src/components/Dropdown.d.ts +3 -1
- package/dist/packages/core/src/components/Dropdown.js +10 -5
- package/dist/packages/core/src/components/FileUpload.js +3 -1
- package/dist/packages/core/src/components/FilterPanel.js +3 -2
- package/dist/packages/core/src/components/Loader.js +6 -1
- package/dist/packages/core/src/components/Pagination.js +1 -1
- package/dist/packages/core/src/components/PanelRow.js +13 -4
- package/dist/packages/core/src/components/Popup/DeviceSideTable.js +3 -1
- package/dist/packages/core/src/components/Popup/LoadTablePopup.js +5 -2
- package/dist/packages/core/src/components/Popup/Popup.d.ts +1 -1
- package/dist/packages/core/src/components/Popup/Popup.js +26 -6
- package/dist/packages/core/src/components/ProgressBar.js +1 -1
- package/dist/packages/core/src/components/SideNav.js +1 -1
- package/dist/packages/core/src/components/Slider.js +10 -1
- package/dist/packages/core/src/components/Table.js +2 -0
- package/dist/packages/core/src/components/TextField.js +3 -1
- package/dist/packages/core/src/components/Toolbar.js +3 -1
- package/dist/packages/core/src/components/Tooltip/Tooltip.js +7 -2
- package/dist/packages/core/src/components/Training/TrainingPagePopup.d.ts +1 -1
- package/dist/packages/core/src/components/Training/TrainingPagePopup.js +3 -1
- package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +49 -2
- package/dist/packages/core/src/components/Training/WordTablePopup.js +9 -1
- package/dist/packages/core/src/components/Training/useAudioPreview.js +28 -3
- package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +19 -5
- package/dist/packages/core/src/components/Word/WordTableBlock.js +5 -2
- package/dist/packages/core/src/components/Word/WordTableButton.d.ts +3 -3
- package/dist/packages/core/src/components/Word/WordTableButton.js +1 -1
- package/dist/packages/core/src/components/Word/useWordTableDisplaySettings.js +1 -1
- package/dist/packages/core/src/components/Word/wordTableBlockTypes.d.ts +2 -2
- package/dist/packages/core/src/components/Word/wordTablePrint.js +27 -8
- package/dist/packages/core/src/components/barChartData.d.ts +3 -5
- package/dist/packages/core/src/components/barChartData.js +7 -3
- package/dist/packages/core/src/components/barChartTypes.d.ts +16 -3
- package/dist/packages/core/src/components/barChartTypes.js +27 -13
- package/dist/packages/core/src/components/useCalendarMonth.js +3 -2
- package/dist/packages/core/src/components/useToastLifecycle.js +5 -0
- package/dist/packages/core/src/components.d.ts +1 -0
- package/dist/packages/core/src/hooks/focusReturn.d.ts +4 -0
- package/dist/packages/core/src/hooks/focusReturn.js +27 -0
- package/dist/packages/core/src/hooks/useVisitHistory.d.ts +2 -0
- package/dist/packages/core/src/hooks/useVisitHistory.js +5 -6
- package/dist/packages/core/src/hooks/useWordMeanings.js +6 -3
- package/dist/packages/core/src/hooks/useWordTrainingFilter.d.ts +2 -2
- package/dist/packages/core/src/hooks/useWordTrainingFilter.js +57 -17
- package/dist/packages/core/src/utils/date.d.ts +18 -0
- package/dist/packages/core/src/utils/date.js +23 -0
- package/dist/packages/core/src/utils/format.js +2 -1
- package/dist/packages/core/src/utils/imageConfig.d.ts +2 -0
- package/dist/packages/core/src/utils/imageConfig.js +34 -5
- package/dist/packages/core/src/utils/josa.js +2 -0
- package/dist/packages/core/src/utils/koreanSyllable.d.ts +5 -0
- package/dist/packages/core/src/utils/koreanSyllable.js +69 -43
- package/dist/packages/core/src/utils/phonemePairSearch.js +17 -0
- package/dist/packages/core/src/utils/timeoutSignal.d.ts +5 -0
- package/dist/packages/core/src/utils/timeoutSignal.js +12 -0
- package/dist/packages/core/src/utils/ttsConfig.js +2 -1
- package/dist/packages/core/src/utils/wordContrast.js +6 -4
- package/dist/packages/core/src/utils/wordTrainingPool.js +14 -5
- package/dist/packages/core/src/utils.d.ts +1 -1
- package/dist/packages/core/src/utils.js +1 -1
- 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
|
@@ -15,6 +15,28 @@ export { valueToJamos, NO_CODA, SYLLABLE_MIN, syllableToFilter, charToPronValue,
|
|
|
15
15
|
// ─── localStorage 지속성 ────────────────────────────────────────────────────
|
|
16
16
|
const LEGACY_STORAGE_KEY = 'wt_filter';
|
|
17
17
|
const storageKey = (groupKey) => `wt_filter_${groupKey}`;
|
|
18
|
+
/* 품사·어종은 **고정 코드**로 든다(상태·저장소·스냅샷). 전에는 번역된 라벨('체언(명사, 수사)')을 그대로 담아, 화면을 연 채
|
|
19
|
+
언어가 바뀌면(말귀: 고객 세션 시작·종료가 그 사람의 표시 언어를 적용) 영어 posMap 에 옛 한국어 값이 없어 **모든 단어가
|
|
20
|
+
떨어졌고** 칩은 꺼진 채로 보였다. 순서는 useWordAttributeFilterKeys 의 posMap·typeMap 열쇠 순서와 같다. */
|
|
21
|
+
const POS_CODES = ['noun', 'verb', 'modifier'];
|
|
22
|
+
const TYPE_CODES = ['native', 'sino', 'foreign', 'mixed'];
|
|
23
|
+
/** 코드 또는 (옛 저장값·스냅샷의) 어느 언어 라벨이든 코드 배열로 */
|
|
24
|
+
function toAttrCodes(values, codes,
|
|
25
|
+
/* 사전 열쇠는 부르는 쪽에서 **글자 그대로** 적는다('wordSearch.filter.pos.…') — 여기서 `${kind}` 로 이으면 열쇠 소유를 가르는
|
|
26
|
+
말귀 가드가 앞머리 'wordSearch.filter' 만 읽어 앱 열쇠 스물셋을 코어 것으로 셌다 */
|
|
27
|
+
keyOf, labelOf, lngs) {
|
|
28
|
+
if (!Array.isArray(values))
|
|
29
|
+
return [];
|
|
30
|
+
const out = [];
|
|
31
|
+
for (const v of values) {
|
|
32
|
+
if (typeof v !== 'string')
|
|
33
|
+
continue;
|
|
34
|
+
const code = codes.includes(v) ? v : codes.find(c => lngs.some(lng => labelOf(lng, keyOf(c)) === v));
|
|
35
|
+
if (code && !out.includes(code))
|
|
36
|
+
out.push(code);
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
18
40
|
function loadSavedFilter(groupKey) {
|
|
19
41
|
try {
|
|
20
42
|
// 그룹별 저장값 우선, 없으면 레거시 공통 저장값으로 폴백
|
|
@@ -102,8 +124,12 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
|
|
|
102
124
|
// 난이도 등급('1'~'4') 필터 — 배열(다중 선택), 빈 배열은 전체(무제약)
|
|
103
125
|
const [gradeFilter, setGradeFilter] = useState(Array.isArray(saved?.gradeFilter) ? saved.gradeFilter : []);
|
|
104
126
|
// 품사/어종 필터 — 단어표·대립쌍표와 동일 라벨 배열(다중 선택). caps로 노출 여부 결정(기본 미노출).
|
|
105
|
-
const
|
|
106
|
-
const
|
|
127
|
+
const 라벨언어들 = Array.from(new Set([i18n.language, 'ko', 'en'].filter(Boolean)));
|
|
128
|
+
const labelOf = (lng, key) => i18n.getFixedT(lng)(key);
|
|
129
|
+
const posCodesOf = (v) => toAttrCodes(v, POS_CODES, c => `wordSearch.filter.pos.${c}`, labelOf, 라벨언어들);
|
|
130
|
+
const typeCodesOf = (v) => toAttrCodes(v, TYPE_CODES, c => `wordSearch.filter.type.${c}`, labelOf, 라벨언어들);
|
|
131
|
+
const [posCodes, setPosCodes] = useState(() => posCodesOf(saved?.posFilter));
|
|
132
|
+
const [typeCodes, setTypeCodes] = useState(() => typeCodesOf(saved?.wordTypeFilter));
|
|
107
133
|
// 훈련 타입(resetKey) 변경 시 필터를 그 타입 기본값으로 초기화 — 이전 타입의 고정값(음절수/받침/한음절 시드)이
|
|
108
134
|
// 새 타입으로 새어들어 엉뚱한 결과(예: 3음절 훈련에 1음절 필터가 남아 0건)가 나오는 것을 막는다.
|
|
109
135
|
// 초기 마운트는 useState 기본값이 이미 처리하므로 ref로 첫 발화를 건너뛴다.
|
|
@@ -117,12 +143,17 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
|
|
|
117
143
|
setExcludeNoImage(!!opts?.defaultImageOnly);
|
|
118
144
|
setSameStructureOnly(false);
|
|
119
145
|
setGradeFilter([]);
|
|
120
|
-
|
|
121
|
-
|
|
146
|
+
setPosCodes([]);
|
|
147
|
+
setTypeCodes([]);
|
|
122
148
|
// seedComposition은 caps에서 파생 — resetKey와 함께 갱신되므로 deps는 resetKey만 둔다.
|
|
123
149
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
124
150
|
}, [opts?.resetKey]);
|
|
125
151
|
const attrKeys = useWordAttributeFilterKeys();
|
|
152
|
+
// 밖으로는(판 선택값·반환값·판정) 지금 언어의 라벨로 보인다
|
|
153
|
+
const posLabels = useMemo(() => Object.keys(attrKeys.posMap), [attrKeys]);
|
|
154
|
+
const typeLabels = useMemo(() => Object.keys(attrKeys.typeMap), [attrKeys]);
|
|
155
|
+
const posFilter = useMemo(() => posCodes.map(c => posLabels[POS_CODES.indexOf(c)]).filter(Boolean), [posCodes, posLabels]);
|
|
156
|
+
const wordTypeFilter = useMemo(() => typeCodes.map(c => typeLabels[TYPE_CODES.indexOf(c)]).filter(Boolean), [typeCodes, typeLabels]);
|
|
126
157
|
const [viable, setViable] = useState({ cho: new Set(), jung: new Set(), jong: new Set(), syllable: new Set() });
|
|
127
158
|
// viable 자모 데이터가 최초 로드되기 전에는 저장된 선택을 해제하지 않는다
|
|
128
159
|
const [viableReady, setViableReady] = useState(false);
|
|
@@ -160,8 +191,8 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
|
|
|
160
191
|
// 마운트 때와 같은 규칙 — 여기서만 'direct' 를 'char' 로 떨구고 있었다
|
|
161
192
|
setPhonemeView(readPhonemeView(s));
|
|
162
193
|
setGradeFilter(Array.isArray(s?.gradeFilter) ? s.gradeFilter : []);
|
|
163
|
-
|
|
164
|
-
|
|
194
|
+
setPosCodes(posCodesOf(s?.posFilter));
|
|
195
|
+
setTypeCodes(typeCodesOf(s?.wordTypeFilter));
|
|
165
196
|
}
|
|
166
197
|
setHydratedKey(groupKey);
|
|
167
198
|
// seedComposition 은 caps 파생이라 groupKey 와 함께 갱신된다
|
|
@@ -175,10 +206,10 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
|
|
|
175
206
|
if (hydratedKey !== groupKey)
|
|
176
207
|
return; // 새 열쇠를 아직 안 얹었다 — 지금 쓰면 앞 그룹 값을 덮는다
|
|
177
208
|
try {
|
|
178
|
-
localStorage.setItem(storageKey(groupKey), JSON.stringify({ contrastCount, composition, excludeNoImage, sameStructureOnly, phonemeView, gradeFilter, posFilter, wordTypeFilter }));
|
|
209
|
+
localStorage.setItem(storageKey(groupKey), JSON.stringify({ contrastCount, composition, excludeNoImage, sameStructureOnly, phonemeView, gradeFilter, posFilter: posCodes, wordTypeFilter: typeCodes }));
|
|
179
210
|
}
|
|
180
211
|
catch { }
|
|
181
|
-
}, [opts?.resetOnMount, groupKey, hydratedKey, contrastCount, composition, excludeNoImage, sameStructureOnly, phonemeView, gradeFilter,
|
|
212
|
+
}, [opts?.resetOnMount, groupKey, hydratedKey, contrastCount, composition, excludeNoImage, sameStructureOnly, phonemeView, gradeFilter, posCodes, typeCodes]);
|
|
182
213
|
const [poolVersion, setPoolVersion] = useState(0);
|
|
183
214
|
// 단어 풀 갱신 이벤트 구독
|
|
184
215
|
useWindowEvent('wordpool:update', () => setPoolVersion(v => v + 1));
|
|
@@ -479,18 +510,24 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
|
|
|
479
510
|
else if (sectionLabel === attrKeys.partOfSpeech) {
|
|
480
511
|
// 품사 — 다중 토글(단어표·대립쌍표와 동일 방식: 라벨 배열)
|
|
481
512
|
if (!value || value === FILTER_ALL_VALUE) {
|
|
482
|
-
|
|
513
|
+
setPosCodes([]);
|
|
483
514
|
return;
|
|
484
515
|
}
|
|
485
|
-
|
|
516
|
+
const code = POS_CODES[posLabels.indexOf(value)] ?? posCodesOf([value])[0];
|
|
517
|
+
if (!code)
|
|
518
|
+
return;
|
|
519
|
+
setPosCodes(prev => prev.includes(code) ? prev.filter(v => v !== code) : [...prev, code]);
|
|
486
520
|
}
|
|
487
521
|
else if (sectionLabel === attrKeys.wordType) {
|
|
488
522
|
// 어종 — 다중 토글(단어표·대립쌍표와 동일 방식: 라벨 배열)
|
|
489
523
|
if (!value || value === FILTER_ALL_VALUE) {
|
|
490
|
-
|
|
524
|
+
setTypeCodes([]);
|
|
491
525
|
return;
|
|
492
526
|
}
|
|
493
|
-
|
|
527
|
+
const code = TYPE_CODES[typeLabels.indexOf(value)] ?? typeCodesOf([value])[0];
|
|
528
|
+
if (!code)
|
|
529
|
+
return;
|
|
530
|
+
setTypeCodes(prev => prev.includes(code) ? prev.filter(v => v !== code) : [...prev, code]);
|
|
494
531
|
}
|
|
495
532
|
else {
|
|
496
533
|
const part = partByLabel[sectionLabel];
|
|
@@ -506,7 +543,9 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
|
|
|
506
543
|
return { ...prev, [part]: value };
|
|
507
544
|
});
|
|
508
545
|
}
|
|
509
|
-
|
|
546
|
+
// posCodesOf·typeCodesOf 는 i18n 에서만 파생 — 라벨 목록과 함께 바뀐다
|
|
547
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
548
|
+
}, [LABELS, partByLabel, attrKeys, posLabels, typeLabels, caps.requireSyllable]);
|
|
510
549
|
// 품사/어종 판정 — 단어표·대립쌍표가 쓰는 공용 predicate(matchesFilters)를 재사용한다.
|
|
511
550
|
// filterSelected엔 품사/어종 key만 채워 넘기므로(등급·음절수는 undefined) 그 두 축만 판정한다.
|
|
512
551
|
// 호출부(TrainingSetPage)는 이 함수로 자신의 후보 단어 필터링(filteredWords)에 품사/어종을 반영한다.
|
|
@@ -533,8 +572,8 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
|
|
|
533
572
|
// 품사·어종도 함께 뜬다 — 빠져 있어서 설정 팝업에서 취소해도 그 둘만 되돌아가지 않았다.
|
|
534
573
|
// contrastCount 는 composition 에서 파생되므로 되돌릴 때 따로 세우지 않는다.
|
|
535
574
|
const getFilterSnapshot = useCallback(() => ({
|
|
536
|
-
composition, contrastCount, excludeNoImage, sameStructureOnly, phonemeView, gradeFilter, posFilter, wordTypeFilter,
|
|
537
|
-
}), [composition, contrastCount, excludeNoImage, sameStructureOnly, phonemeView, gradeFilter,
|
|
575
|
+
composition, contrastCount, excludeNoImage, sameStructureOnly, phonemeView, gradeFilter, posFilter: posCodes, wordTypeFilter: typeCodes,
|
|
576
|
+
}), [composition, contrastCount, excludeNoImage, sameStructureOnly, phonemeView, gradeFilter, posCodes, typeCodes]);
|
|
538
577
|
const restoreFilterSnapshot = useCallback((s) => {
|
|
539
578
|
setComposition(s.composition);
|
|
540
579
|
setExcludeNoImage(s.excludeNoImage);
|
|
@@ -542,9 +581,10 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
|
|
|
542
581
|
setPhonemeView(s.phonemeView);
|
|
543
582
|
setGradeFilter(Array.isArray(s.gradeFilter) ? s.gradeFilter : []);
|
|
544
583
|
if (Array.isArray(s.posFilter))
|
|
545
|
-
|
|
584
|
+
setPosCodes(posCodesOf(s.posFilter));
|
|
546
585
|
if (Array.isArray(s.wordTypeFilter))
|
|
547
|
-
|
|
586
|
+
setTypeCodes(typeCodesOf(s.wordTypeFilter));
|
|
587
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
548
588
|
}, []);
|
|
549
589
|
return {
|
|
550
590
|
difficulty,
|
|
@@ -28,11 +28,29 @@ export declare function formatDatetimeKo(iso: string): string;
|
|
|
28
28
|
export declare function toKstHm(when?: string | number | Date | null): string;
|
|
29
29
|
/** KST(Asia/Seoul) 기준 오늘 날짜를 'YYYY-MM-DD'로 반환. DB 날짜 비교 기준선으로 사용. */
|
|
30
30
|
export declare function getTodayKst(): string;
|
|
31
|
+
/**
|
|
32
|
+
* KST 오늘을 **기기 달력의 자정 Date** 로 — 연·월·일 칸을 `getFullYear()/getMonth()/getDate()` 로 세는 곳(BarChart)의
|
|
33
|
+
* '지금'. 칸에 넣는 자료도 같은 모양(`parseLocalYmd(toKstYmd(시각))`)이어야 해외 기기에서도 KST 의 날에 선다.
|
|
34
|
+
*
|
|
35
|
+
* @public
|
|
36
|
+
*/
|
|
37
|
+
export declare function getTodayKstDate(): Date;
|
|
38
|
+
/**
|
|
39
|
+
* KST 달력 날짜(연, 0부터 세는 월, 일)의 **00:00 KST 순간.** 일·월은 넘쳐도 된다(`Date.UTC` 처럼 — 32일은 다음 달 1일).
|
|
40
|
+
* 서버에 기간을 물을 때(`toISOString()`) 기기 시간대가 아니라 KST 의 하루로 자르려고 쓴다.
|
|
41
|
+
*
|
|
42
|
+
* @public
|
|
43
|
+
*/
|
|
44
|
+
export declare function kstStartOfDay(year: number, month0: number, day: number): Date;
|
|
31
45
|
/**
|
|
32
46
|
* 'YYYY-MM-DD' 날짜 전용 문자열(예: 서버가 KST 기준으로 집계해 내려주는 log_date)을
|
|
33
47
|
* 로컬 타임존 자정 Date로 파싱한다. new Date('YYYY-MM-DD')는 UTC 자정으로 해석되어,
|
|
34
48
|
* UTC보다 서쪽 시간대(예: 미국)에서 getDate()/getMonth() 같은 로컬 getter로 읽으면
|
|
35
49
|
* 하루 전으로 밀린다 — 차트를 날짜별로 버킷팅할 때 특히 문제가 된다.
|
|
50
|
+
*
|
|
51
|
+
* 시각(ISO)에서 KST 의 날을 달력 Date 로 얻으려면 `parseLocalYmd(toKstYmd(시각))`.
|
|
52
|
+
*
|
|
53
|
+
* @public
|
|
36
54
|
*/
|
|
37
55
|
export declare function parseLocalYmd(ymd: string): Date;
|
|
38
56
|
/**
|
|
@@ -98,11 +98,34 @@ export function toKstHm(when) {
|
|
|
98
98
|
export function getTodayKst() {
|
|
99
99
|
return toKstYmd(new Date());
|
|
100
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* KST 오늘을 **기기 달력의 자정 Date** 로 — 연·월·일 칸을 `getFullYear()/getMonth()/getDate()` 로 세는 곳(BarChart)의
|
|
103
|
+
* '지금'. 칸에 넣는 자료도 같은 모양(`parseLocalYmd(toKstYmd(시각))`)이어야 해외 기기에서도 KST 의 날에 선다.
|
|
104
|
+
*
|
|
105
|
+
* @public
|
|
106
|
+
*/
|
|
107
|
+
export function getTodayKstDate() {
|
|
108
|
+
return parseLocalYmd(getTodayKst());
|
|
109
|
+
}
|
|
110
|
+
const KST_OFFSET_MS = 9 * 60 * 60 * 1000; // KST 는 일광 절약 시간이 없다
|
|
111
|
+
/**
|
|
112
|
+
* KST 달력 날짜(연, 0부터 세는 월, 일)의 **00:00 KST 순간.** 일·월은 넘쳐도 된다(`Date.UTC` 처럼 — 32일은 다음 달 1일).
|
|
113
|
+
* 서버에 기간을 물을 때(`toISOString()`) 기기 시간대가 아니라 KST 의 하루로 자르려고 쓴다.
|
|
114
|
+
*
|
|
115
|
+
* @public
|
|
116
|
+
*/
|
|
117
|
+
export function kstStartOfDay(year, month0, day) {
|
|
118
|
+
return new Date(Date.UTC(year, month0, day) - KST_OFFSET_MS);
|
|
119
|
+
}
|
|
101
120
|
/**
|
|
102
121
|
* 'YYYY-MM-DD' 날짜 전용 문자열(예: 서버가 KST 기준으로 집계해 내려주는 log_date)을
|
|
103
122
|
* 로컬 타임존 자정 Date로 파싱한다. new Date('YYYY-MM-DD')는 UTC 자정으로 해석되어,
|
|
104
123
|
* UTC보다 서쪽 시간대(예: 미국)에서 getDate()/getMonth() 같은 로컬 getter로 읽으면
|
|
105
124
|
* 하루 전으로 밀린다 — 차트를 날짜별로 버킷팅할 때 특히 문제가 된다.
|
|
125
|
+
*
|
|
126
|
+
* 시각(ISO)에서 KST 의 날을 달력 Date 로 얻으려면 `parseLocalYmd(toKstYmd(시각))`.
|
|
127
|
+
*
|
|
128
|
+
* @public
|
|
106
129
|
*/
|
|
107
130
|
export function parseLocalYmd(ymd) {
|
|
108
131
|
const [y, m, d] = ymd.split('-').map(Number);
|
|
@@ -9,7 +9,8 @@ import { toKstYmd } from './date';
|
|
|
9
9
|
* 입력에 대시가 있든 없든, 어떤 포맷이든 처리.
|
|
10
10
|
*/
|
|
11
11
|
export function formatPhone(raw) {
|
|
12
|
-
|
|
12
|
+
// 전각 숫자(한글 IME 전각 모드 '010')는 \D 에 걸려 통째로 사라졌다 — NFKC 로 반각으로 바꾼 뒤 걸러낸다
|
|
13
|
+
const all = raw.normalize('NFKC').replace(/\D/g, '');
|
|
13
14
|
/* **앞자리가 네 자리인 번호.** 050X 안심번호·0303 착신전환은 열두 자리까지 가고, 앞자리를 세 자리로 보면
|
|
14
15
|
`0507-1234-5678` 이 `050-7123-4567` 이 되어 **마지막 한 자리가 사라진 채** 멀쩡한 모양으로 저장됐다
|
|
15
16
|
(말귀가 formatPhoneKr 로 감싸 두었다 — 이제 걷어도 된다). */
|
|
@@ -38,6 +38,8 @@ export declare function getImageExistenceStats(): {
|
|
|
38
38
|
total: number;
|
|
39
39
|
exists: number;
|
|
40
40
|
} | null;
|
|
41
|
+
/** @internal 동음이의 번호 없이(=1) '이미지 있음' 으로 확인된 낱말 집합 — 캐시 판마다 한 번 만든다. 돌려받은 집합을 고치지 말 것. */
|
|
42
|
+
export declare function getImageOkWordSet(): ReadonlySet<string>;
|
|
41
43
|
/**
|
|
42
44
|
* @public
|
|
43
45
|
* 이미지 존재 여부를 일괄 주입 (storage.list() 결과 등 외부에서 미리 계산한 경우).
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { timeoutSignal } from './timeoutSignal';
|
|
1
2
|
const GLOBAL_KEY = '__ihab_core_image_config__';
|
|
2
3
|
/** @public */
|
|
3
4
|
export function configureImageResolver(resolver) {
|
|
@@ -10,13 +11,23 @@ export function getImageResolver() {
|
|
|
10
11
|
// ─── 로컬 캐싱 (localStorage + 메모리) ───────────────────────────────────────
|
|
11
12
|
const LS_KEY = 'word_image_existence_map_v1';
|
|
12
13
|
let memMap = null;
|
|
14
|
+
/* 캐시가 바뀔 때마다 오르는 판 번호 — 통계·'있음' 낱말 집합을 판마다 한 번만 센다(단어훈련 칩 한 번에 유형 수만큼
|
|
15
|
+
2.3만 칸을 훑었다). 캐시를 바꾸는 곳(확인 결과 담기·일괄 주입·비우기)은 모두 판을 올린다. */
|
|
16
|
+
let 캐시판 = 0;
|
|
17
|
+
let 통계판 = -1;
|
|
18
|
+
let 통계값 = null;
|
|
19
|
+
let 있음판 = -1;
|
|
20
|
+
let 있음집합 = new Set();
|
|
13
21
|
const inflight = new Map(); // 중복 fetch 방지
|
|
14
22
|
function ensureLoaded() {
|
|
15
23
|
if (memMap)
|
|
16
24
|
return memMap;
|
|
17
25
|
try {
|
|
18
26
|
const raw = (typeof localStorage !== 'undefined') ? localStorage.getItem(LS_KEY) : null;
|
|
19
|
-
|
|
27
|
+
const v = raw ? JSON.parse(raw) : null;
|
|
28
|
+
/* 객체가 아닌 JSON(null·"x"·42·배열)이 들어 있으면 `k in map`·Object.values 가 부를 때마다 던지고 memMap 이 null 로 남아
|
|
29
|
+
매번 다시 파싱했다 — 확장 프로그램·손으로 고친 값이라도 캐시 비우기 전까지 이미지 확인·훈련 생성이 막혔다. */
|
|
30
|
+
memMap = v && typeof v === 'object' && !Array.isArray(v) ? v : {};
|
|
20
31
|
}
|
|
21
32
|
catch {
|
|
22
33
|
memMap = {};
|
|
@@ -62,13 +73,14 @@ export async function checkImageExists(word, homonum) {
|
|
|
62
73
|
// 검색 버튼(이미지 있음 필터)이 영구 비활성화된다
|
|
63
74
|
/* **확인이 안 된 것은 캐시하지 않는다.** 망 끊김·타임아웃·5xx 까지 '이미지 없음' 으로 영구 저장해, 한 번 끊긴 단어는 망이
|
|
64
75
|
돌아와도 '이미지 있음' 필터에서 계속 빠졌다(ttsConfig.checkAudioExists 와 같게). 있음(2xx)·없음(4xx)만 담는다. */
|
|
65
|
-
const p = fetch(url, { method: 'HEAD', signal:
|
|
76
|
+
const p = fetch(url, { method: 'HEAD', signal: timeoutSignal(8000) })
|
|
66
77
|
.then(res => (res.status >= 500 ? null : res.ok), () => null)
|
|
67
78
|
.then(ok => {
|
|
68
79
|
inflight.delete(k);
|
|
69
80
|
if (ok === null)
|
|
70
81
|
return false;
|
|
71
82
|
map[k] = ok;
|
|
83
|
+
캐시판++;
|
|
72
84
|
scheduleSave();
|
|
73
85
|
return ok;
|
|
74
86
|
});
|
|
@@ -131,10 +143,25 @@ export async function prewarmImageCache(words, options = {}) {
|
|
|
131
143
|
*/
|
|
132
144
|
export function getImageExistenceStats() {
|
|
133
145
|
const map = ensureLoaded();
|
|
146
|
+
if (통계판 === 캐시판)
|
|
147
|
+
return 통계값 ? { ...통계값 } : null;
|
|
134
148
|
const values = Object.values(map);
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
return {
|
|
149
|
+
통계값 = values.length === 0 ? null : { total: values.length, exists: values.filter(Boolean).length };
|
|
150
|
+
통계판 = 캐시판;
|
|
151
|
+
return 통계값 ? { ...통계값 } : null;
|
|
152
|
+
}
|
|
153
|
+
/** @internal 동음이의 번호 없이(=1) '이미지 있음' 으로 확인된 낱말 집합 — 캐시 판마다 한 번 만든다. 돌려받은 집합을 고치지 말 것. */
|
|
154
|
+
export function getImageOkWordSet() {
|
|
155
|
+
const map = ensureLoaded();
|
|
156
|
+
if (있음판 === 캐시판)
|
|
157
|
+
return 있음집합;
|
|
158
|
+
const s = new Set();
|
|
159
|
+
for (const k in map)
|
|
160
|
+
if (map[k] === true && k.endsWith('_1'))
|
|
161
|
+
s.add(k.slice(0, -2));
|
|
162
|
+
있음집합 = s;
|
|
163
|
+
있음판 = 캐시판;
|
|
164
|
+
return s;
|
|
138
165
|
}
|
|
139
166
|
/**
|
|
140
167
|
* @public
|
|
@@ -144,6 +171,7 @@ export function getImageExistenceStats() {
|
|
|
144
171
|
export function bulkSetImageExistence(entries) {
|
|
145
172
|
const map = ensureLoaded();
|
|
146
173
|
Object.assign(map, entries);
|
|
174
|
+
캐시판++;
|
|
147
175
|
try {
|
|
148
176
|
if (typeof localStorage !== 'undefined') {
|
|
149
177
|
localStorage.setItem(LS_KEY, JSON.stringify(map));
|
|
@@ -154,6 +182,7 @@ export function bulkSetImageExistence(entries) {
|
|
|
154
182
|
/** @public 캐시 초기화 (이미지 데이터 변경 시 호출) */
|
|
155
183
|
export function clearImageExistenceCache() {
|
|
156
184
|
memMap = {};
|
|
185
|
+
캐시판++;
|
|
157
186
|
try {
|
|
158
187
|
if (typeof localStorage !== 'undefined')
|
|
159
188
|
localStorage.removeItem(LS_KEY);
|
|
@@ -5,6 +5,8 @@ const VOWEL_JAMO = /[ㅏ-ㅣ]/;
|
|
|
5
5
|
// 문자열 끝의 실제 한글(음절 또는 낱자모) 글자를 찾는다 — 따옴표 등 뒤에 붙는
|
|
6
6
|
// 문장부호는 건너뛴다(예: "'ㄹ'"의 마지막 문자는 "'"지만 판정에 쓸 글자는 "ㄹ").
|
|
7
7
|
function lastHangulChar(text) {
|
|
8
|
+
// 조각 한글(NFD) 끝 받침(U+11A8~)은 음절 범위에도 호환 자모에도 안 들어 기본값('가'·'를')으로 떨어졌다
|
|
9
|
+
text = text.normalize('NFC');
|
|
8
10
|
for (let i = text.length - 1; i >= 0; i--) {
|
|
9
11
|
const ch = text[i];
|
|
10
12
|
const code = ch.charCodeAt(0);
|
|
@@ -50,6 +50,11 @@ export declare function matchesChoseongSearch(text: string, search: string): boo
|
|
|
50
50
|
* 검색어에 종성 겹자모·비한글 등 위 종류가 아닌 문자가 있으면 일반 부분 문자열로 폴백한다.
|
|
51
51
|
*/
|
|
52
52
|
export declare function matchesWordQuery(word: string, query: string): boolean;
|
|
53
|
+
/**
|
|
54
|
+
* @internal matchesWordQuery 의 검색어 쪽 준비(NFC·글자 분류)를 **한 번만** 해 두고 낱말마다 부르는 판별기.
|
|
55
|
+
* 목록 전체(2.3만 낱말)를 거를 때 낱말마다 검색어를 다시 펼치고 분류해, 키를 누를 때마다 14–36ms 걸렸다.
|
|
56
|
+
*/
|
|
57
|
+
export declare function makeWordQueryMatcher(query: string): (word: string) => boolean;
|
|
53
58
|
/** @public 복합 종성 index → 구성 자모 배열 (예: 9(ㄺ) → ['ㄹ','ㄱ']) */
|
|
54
59
|
export declare const JONG_DECOMPOSE: Record<number, string[]>;
|
|
55
60
|
/** @public 단어가 특정 자모(음소)를 어디든 (초성/종성/복합종성) 포함하는지 */
|
|
@@ -100,6 +100,9 @@ export function isAllChoseong(search) {
|
|
|
100
100
|
export function matchesChoseongSearch(text, search) {
|
|
101
101
|
if (!search)
|
|
102
102
|
return true;
|
|
103
|
+
/* 맥·PDF 에서 붙여넣은 글은 조각 한글(NFD, '가' = ᄀ+ᅡ)이라 완성 음절과 한 글자도 같지 않다 — 둘 다 NFC 로 맞춘다. */
|
|
104
|
+
text = text.normalize('NFC');
|
|
105
|
+
search = search.normalize('NFC');
|
|
103
106
|
if (isAllChoseong(search))
|
|
104
107
|
return toChoseong(text).includes(search);
|
|
105
108
|
return text.toLowerCase().includes(search.toLowerCase());
|
|
@@ -112,55 +115,69 @@ export function matchesChoseongSearch(text, search) {
|
|
|
112
115
|
* 검색어에 종성 겹자모·비한글 등 위 종류가 아닌 문자가 있으면 일반 부분 문자열로 폴백한다.
|
|
113
116
|
*/
|
|
114
117
|
export function matchesWordQuery(word, query) {
|
|
118
|
+
return makeWordQueryMatcher(query)(word);
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* @internal matchesWordQuery 의 검색어 쪽 준비(NFC·글자 분류)를 **한 번만** 해 두고 낱말마다 부르는 판별기.
|
|
122
|
+
* 목록 전체(2.3만 낱말)를 거를 때 낱말마다 검색어를 다시 펼치고 분류해, 키를 누를 때마다 14–36ms 걸렸다.
|
|
123
|
+
*/
|
|
124
|
+
export function makeWordQueryMatcher(query) {
|
|
115
125
|
if (!query)
|
|
116
|
-
return true;
|
|
126
|
+
return () => true;
|
|
127
|
+
// 조각 한글(NFD) 붙여넣기는 '기타 문자' 로 분류돼 부분 문자열 비교로 새고 0건이 됐다 — 둘 다 NFC 로
|
|
128
|
+
query = query.normalize('NFC');
|
|
117
129
|
const q = [...query];
|
|
118
130
|
const isCho = (ch) => CHOSEONG.includes(ch);
|
|
119
131
|
const isJung = (ch) => JUNGSEONG.includes(ch);
|
|
120
132
|
// 각 글자 분류: 'cho'(자음/초성) | 'jung'(모음/중성) | 'syl'(완성 음절) | 'other'(그 외)
|
|
121
133
|
const kinds = q.map(ch => (isCho(ch) ? 'cho' : isJung(ch) ? 'jung' : parseSyllable(ch) ? 'syl' : 'other'));
|
|
134
|
+
const qSyl = q.map(ch => parseSyllable(ch));
|
|
122
135
|
// 영문은 대소문자를 가리지 않는다 — 옆 matchesChoseongSearch 와 같게('apple' 로 'Apple' 을 찾는다)
|
|
123
|
-
if (kinds.includes('other'))
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
if (!s || JUNGSEONG[s.jung] !== q[j]) {
|
|
140
|
-
ok = false;
|
|
141
|
-
break;
|
|
136
|
+
if (kinds.includes('other')) {
|
|
137
|
+
const lower = query.toLowerCase();
|
|
138
|
+
return (word) => word.normalize('NFC').toLowerCase().includes(lower);
|
|
139
|
+
}
|
|
140
|
+
return (word) => {
|
|
141
|
+
const w = [...word.normalize('NFC')];
|
|
142
|
+
for (let i = 0; i + q.length <= w.length; i++) {
|
|
143
|
+
let ok = true;
|
|
144
|
+
for (let j = 0; j < q.length; j++) {
|
|
145
|
+
const wc = w[i + j];
|
|
146
|
+
const k = kinds[j];
|
|
147
|
+
if (k === 'cho') {
|
|
148
|
+
if (firstChoseongOf(wc) !== q[j]) {
|
|
149
|
+
ok = false;
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
142
152
|
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
if (qs && qs.jong === 0) {
|
|
147
|
-
// 받침 없는 글자는 초성+중성만 비교(받침 무관): '수' → 수·술·숙·순 …
|
|
148
|
-
const ws = parseSyllable(wc);
|
|
149
|
-
if (!ws || ws.cho !== qs.cho || ws.jung !== qs.jung) {
|
|
153
|
+
else if (k === 'jung') {
|
|
154
|
+
const s = parseSyllable(wc);
|
|
155
|
+
if (!s || JUNGSEONG[s.jung] !== q[j]) {
|
|
150
156
|
ok = false;
|
|
151
157
|
break;
|
|
152
158
|
}
|
|
153
159
|
}
|
|
154
|
-
else
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
160
|
+
else { // 'syl' — 완성 음절
|
|
161
|
+
const qs = qSyl[j];
|
|
162
|
+
if (qs && qs.jong === 0) {
|
|
163
|
+
// 받침 없는 글자는 초성+중성만 비교(받침 무관): '수' → 수·술·숙·순 …
|
|
164
|
+
const ws = parseSyllable(wc);
|
|
165
|
+
if (!ws || ws.cho !== qs.cho || ws.jung !== qs.jung) {
|
|
166
|
+
ok = false;
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
else if (wc !== q[j]) {
|
|
171
|
+
ok = false;
|
|
172
|
+
break;
|
|
173
|
+
} // 받침 있으면 정확 일치
|
|
174
|
+
}
|
|
158
175
|
}
|
|
176
|
+
if (ok)
|
|
177
|
+
return true;
|
|
159
178
|
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
}
|
|
163
|
-
return false;
|
|
179
|
+
return false;
|
|
180
|
+
};
|
|
164
181
|
}
|
|
165
182
|
// 복합 종성 자모 분해 (예: ㄺ → ㄹ+ㄱ)
|
|
166
183
|
/** @public 복합 종성 index → 구성 자모 배열 (예: 9(ㄺ) → ['ㄹ','ㄱ']) */
|
|
@@ -418,6 +435,8 @@ function matchesPatternAtom(ch, atom) {
|
|
|
418
435
|
* 0건이 된다(조용히 무관 처리하지 않는다 — 제약이 무시된 채 결과가 나오는 오해를 막기 위해).
|
|
419
436
|
*/
|
|
420
437
|
export function matchesSyllablePattern(word, pattern) {
|
|
438
|
+
word = word.normalize('NFC');
|
|
439
|
+
pattern = pattern.normalize('NFC'); // 조각 한글(NFD) 붙여넣기
|
|
421
440
|
const atoms = pattern.split(',').flatMap(chunk => parseChunkToAtoms(chunk.trim()));
|
|
422
441
|
if (atoms.length === 0)
|
|
423
442
|
return true;
|
|
@@ -497,7 +516,8 @@ function isSearchAtomChar(ch) {
|
|
|
497
516
|
* @returns 유효하면 `{ ok: true }`, 아니면 `{ ok: false }`(호출부가 안내 문구를 표시).
|
|
498
517
|
*/
|
|
499
518
|
export function validateSyllableSearchInput(input) {
|
|
500
|
-
|
|
519
|
+
// 조각 한글(NFD) 붙여넣기가 '해석할 수 없는 글자' 로 입력 오류 안내를 띄웠다
|
|
520
|
+
const trimmed = input.normalize('NFC').trim();
|
|
501
521
|
if (!trimmed)
|
|
502
522
|
return { ok: true };
|
|
503
523
|
for (const raw of trimmed.split(',')) {
|
|
@@ -643,16 +663,22 @@ export function countPhonemeDiff(a, b) {
|
|
|
643
663
|
* @public
|
|
644
664
|
*/
|
|
645
665
|
export function searchAndRankWords(items, query) {
|
|
646
|
-
const q = query.trim();
|
|
666
|
+
const q = query.normalize('NFC').trim(); // 조각 한글(NFD) 붙여넣기
|
|
647
667
|
if (!q || !/[^\s,_]/.test(q))
|
|
648
668
|
return items;
|
|
649
669
|
if (isSyllablePattern(q))
|
|
650
670
|
return items.filter(w => matchesSyllablePattern(w.word, q));
|
|
651
|
-
|
|
671
|
+
// 검색어 준비는 한 번 — 낱말마다 다시 하면 키 한 번에 2.3만 번 펼치고 분류했다
|
|
672
|
+
const m = makeWordQueryMatcher(q);
|
|
652
673
|
const headLen = [...q].length;
|
|
653
|
-
const startsWith = (s) => !!s &&
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
674
|
+
const startsWith = (s) => !!s && m(s.normalize('NFC').slice(0, headLen));
|
|
675
|
+
// 순위는 두 갈래로 나눠 잇는다(안정 정렬과 같은 순서)
|
|
676
|
+
const 앞 = [];
|
|
677
|
+
const 뒤 = [];
|
|
678
|
+
for (const w of items) {
|
|
679
|
+
if (!(m(w.word) || m(w.pronunciation ?? '')))
|
|
680
|
+
continue;
|
|
681
|
+
(startsWith(w.word) || startsWith(w.pronunciation ?? '') ? 앞 : 뒤).push(w);
|
|
682
|
+
}
|
|
683
|
+
return 앞.concat(뒤);
|
|
658
684
|
}
|
|
@@ -484,9 +484,26 @@ preferAsTarget) {
|
|
|
484
484
|
for (let p = 0; p < target.syls.length; p++) {
|
|
485
485
|
const key = makeKey(target.syls, p);
|
|
486
486
|
const candidates = groups.get(key) ?? [];
|
|
487
|
+
const ts = target.syls[p];
|
|
487
488
|
for (const candidate of candidates) {
|
|
488
489
|
if (candidate.entry.word === target.entry.word)
|
|
489
490
|
continue;
|
|
491
|
+
/* 같은 묶음은 p 자리 밖이 모두 같다 — p 자리에서 정확히 한 성분만 달라야 최소대립쌍이다. 1음절 낱말은 전부 한 묶음이라
|
|
492
|
+
묶음이 커서, 쌍 문자열·발음 분해(analyzeMinimalPair) 전에 먼저 거른다(음소를 고를 때마다 110–320ms 걸렸다). */
|
|
493
|
+
{
|
|
494
|
+
const cs = candidate.syls[p];
|
|
495
|
+
let d = 0;
|
|
496
|
+
if (ts.초성 !== cs.초성)
|
|
497
|
+
d++;
|
|
498
|
+
if (ts.모음 !== cs.모음)
|
|
499
|
+
d++;
|
|
500
|
+
if (ts.종성 !== cs.종성)
|
|
501
|
+
d++;
|
|
502
|
+
if ((ts.활음 ?? '') !== (cs.활음 ?? ''))
|
|
503
|
+
d++;
|
|
504
|
+
if (d !== 1)
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
490
507
|
const dedupKey = target.entry.word < candidate.entry.word
|
|
491
508
|
? `${target.entry.word}|${candidate.entry.word}`
|
|
492
509
|
: `${candidate.entry.word}|${target.entry.word}`;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ms` 뒤 끊기는 AbortSignal. `AbortSignal.timeout` 은 Safari 16 부터라 iOS/iPadOS 15 이하(치료실 구형 iPad)에서는
|
|
3
|
+
* 없어 fetch 를 부르기도 전에 TypeError 로 거절됐다 — 없으면 AbortController + setTimeout 으로 만든다.
|
|
4
|
+
*/
|
|
5
|
+
export function timeoutSignal(ms) {
|
|
6
|
+
const native = AbortSignal.timeout;
|
|
7
|
+
if (typeof native === 'function')
|
|
8
|
+
return native.call(AbortSignal, ms);
|
|
9
|
+
const c = new AbortController();
|
|
10
|
+
setTimeout(() => c.abort(), ms);
|
|
11
|
+
return c.signal;
|
|
12
|
+
}
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
*
|
|
21
21
|
* @public
|
|
22
22
|
*/
|
|
23
|
+
import { timeoutSignal } from './timeoutSignal';
|
|
23
24
|
const GLOBAL_KEY = '__ihab_core_tts_config__';
|
|
24
25
|
/**
|
|
25
26
|
* TTS URL 리졸버를 전역으로 설정합니다.
|
|
@@ -84,7 +85,7 @@ export async function checkAudioExists(word) {
|
|
|
84
85
|
audioExistsCache.delete(url);
|
|
85
86
|
return false;
|
|
86
87
|
};
|
|
87
|
-
const promise = fetch(url, { method: 'HEAD', signal:
|
|
88
|
+
const promise = fetch(url, { method: 'HEAD', signal: timeoutSignal(8000) })
|
|
88
89
|
.then(res => (res.status >= 500 ? 확인안됨() : res.ok), 확인안됨);
|
|
89
90
|
audioExistsCache.set(url, promise);
|
|
90
91
|
return promise;
|
|
@@ -141,15 +141,17 @@ position) {
|
|
|
141
141
|
answersByPos.set(i, [pw]);
|
|
142
142
|
}
|
|
143
143
|
for (const [pos, answers] of answersByPos) {
|
|
144
|
-
|
|
144
|
+
// 가릴 성분은 대립 자리(pos)의 것뿐 — 낱말·자리·성분마다 'i:part' 문자열을 만들어 Set 에서 찾던 것을 불리언으로(열쇠 모양은 같다)
|
|
145
|
+
const mC = selParts.includes('cho'), mJ = selParts.includes('jung'), mG = selParts.includes('jong');
|
|
145
146
|
// 마스킹 템플릿은 '발음' 분해 기준 (대립 자리 외 발음이 전부 같은 단어끼리 묶음)
|
|
146
147
|
const templateOf = (d) => {
|
|
147
148
|
let key = '';
|
|
148
149
|
for (let i = 0; i < L; i++) {
|
|
149
150
|
const s = d[i];
|
|
150
|
-
|
|
151
|
-
key += (
|
|
152
|
-
|
|
151
|
+
if (i === pos)
|
|
152
|
+
key += (mC ? '*' : s.cho) + '.' + (mJ ? '*' : s.jung) + '.' + (mG ? '*' : s.jong) + '.';
|
|
153
|
+
else
|
|
154
|
+
key += s.cho + '.' + s.jung + '.' + s.jong + '.';
|
|
153
155
|
}
|
|
154
156
|
return key;
|
|
155
157
|
};
|