@ihabdevteam/core 0.141.0 → 0.143.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.
Files changed (76) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/dist/components.cjs.js +176 -176
  3. package/dist/hooks.cjs.js +1 -1
  4. package/dist/i18n.cjs.js +1 -1
  5. package/dist/ihabdevteam-core.cjs.js +182 -182
  6. package/dist/ihabdevteam-core.d.ts +81 -29
  7. package/dist/packages/core/src/components/BarChart.d.ts +1 -1
  8. package/dist/packages/core/src/components/BarChart.js +4 -3
  9. package/dist/packages/core/src/components/BottomNav.js +4 -0
  10. package/dist/packages/core/src/components/BulkBar.js +15 -4
  11. package/dist/packages/core/src/components/Button.js +10 -4
  12. package/dist/packages/core/src/components/DatePicker.js +5 -1
  13. package/dist/packages/core/src/components/Dialog.js +3 -1
  14. package/dist/packages/core/src/components/Dropdown.js +6 -2
  15. package/dist/packages/core/src/components/FileUpload.js +3 -1
  16. package/dist/packages/core/src/components/FilterPanel.js +3 -2
  17. package/dist/packages/core/src/components/PanelRow.js +5 -0
  18. package/dist/packages/core/src/components/PaymentPlan.d.ts +2 -2
  19. package/dist/packages/core/src/components/Popup/DeviceSideTable.js +3 -1
  20. package/dist/packages/core/src/components/Popup/LoadTablePopup.js +5 -2
  21. package/dist/packages/core/src/components/Popup/Popup.d.ts +6 -6
  22. package/dist/packages/core/src/components/Popup/Popup.js +11 -9
  23. package/dist/packages/core/src/components/Popup/customerDetailUtils.d.ts +1 -1
  24. package/dist/packages/core/src/components/Popup/customerDetailUtils.js +1 -1
  25. package/dist/packages/core/src/components/TextField.js +3 -1
  26. package/dist/packages/core/src/components/Tooltip/Tooltip.js +7 -1
  27. package/dist/packages/core/src/components/Training/TrainingBlock.js +2 -1
  28. package/dist/packages/core/src/components/Training/TrainingPagePopup.d.ts +1 -1
  29. package/dist/packages/core/src/components/Training/TrainingPagePopup.js +7 -1
  30. package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +52 -2
  31. package/dist/packages/core/src/components/Training/TrainingStepTextBox.js +3 -1
  32. package/dist/packages/core/src/components/Training/WordTablePopup.js +9 -1
  33. package/dist/packages/core/src/components/Training/useAudioPreview.js +28 -3
  34. package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +19 -5
  35. package/dist/packages/core/src/components/Word/WordTableBlock.js +5 -2
  36. package/dist/packages/core/src/components/Word/WordTableButton.d.ts +3 -3
  37. package/dist/packages/core/src/components/Word/WordTableButton.js +1 -1
  38. package/dist/packages/core/src/components/Word/useWordTableDisplaySettings.js +1 -1
  39. package/dist/packages/core/src/components/Word/wordTableBlockTypes.d.ts +2 -2
  40. package/dist/packages/core/src/components/barChartData.d.ts +3 -5
  41. package/dist/packages/core/src/components/barChartData.js +7 -3
  42. package/dist/packages/core/src/components/barChartTypes.d.ts +16 -3
  43. package/dist/packages/core/src/components/barChartTypes.js +27 -13
  44. package/dist/packages/core/src/components.d.ts +1 -0
  45. package/dist/packages/core/src/hooks/focusReturn.d.ts +4 -0
  46. package/dist/packages/core/src/hooks/focusReturn.js +27 -0
  47. package/dist/packages/core/src/hooks/inputModality.d.ts +2 -0
  48. package/dist/packages/core/src/hooks/inputModality.js +31 -0
  49. package/dist/packages/core/src/hooks/useDocumentTitle.d.ts +12 -0
  50. package/dist/packages/core/src/hooks/useDocumentTitle.js +7 -6
  51. package/dist/packages/core/src/hooks/useEditableList.js +3 -3
  52. package/dist/packages/core/src/hooks/useWordMeanings.js +6 -3
  53. package/dist/packages/core/src/hooks/useWordTrainingFilter.d.ts +2 -2
  54. package/dist/packages/core/src/hooks/useWordTrainingFilter.js +54 -17
  55. package/dist/packages/core/src/utils/date.d.ts +18 -0
  56. package/dist/packages/core/src/utils/date.js +23 -0
  57. package/dist/packages/core/src/utils/format.js +2 -1
  58. package/dist/packages/core/src/utils/imageConfig.d.ts +2 -0
  59. package/dist/packages/core/src/utils/imageConfig.js +34 -5
  60. package/dist/packages/core/src/utils/josa.js +2 -0
  61. package/dist/packages/core/src/utils/koreanSyllable.d.ts +5 -0
  62. package/dist/packages/core/src/utils/koreanSyllable.js +69 -43
  63. package/dist/packages/core/src/utils/phonemePairSearch.js +17 -0
  64. package/dist/packages/core/src/utils/textSort.d.ts +10 -0
  65. package/dist/packages/core/src/utils/textSort.js +16 -11
  66. package/dist/packages/core/src/utils/timeoutSignal.d.ts +5 -0
  67. package/dist/packages/core/src/utils/timeoutSignal.js +12 -0
  68. package/dist/packages/core/src/utils/ttsConfig.js +2 -1
  69. package/dist/packages/core/src/utils/wordContrast.js +6 -4
  70. package/dist/packages/core/src/utils/wordTrainingPool.js +14 -5
  71. package/dist/packages/core/src/utils.d.ts +1 -1
  72. package/dist/packages/core/src/utils.js +1 -1
  73. package/dist/packages/core/src/version.d.ts +1 -1
  74. package/dist/packages/core/src/version.js +1 -1
  75. package/dist/utils.cjs.js +2 -2
  76. package/package.json +1 -1
@@ -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
- return word.toLowerCase().includes(query.toLowerCase());
125
- const w = [...word];
126
- for (let i = 0; i + q.length <= w.length; i++) {
127
- let ok = true;
128
- for (let j = 0; j < q.length; j++) {
129
- const wc = w[i + j];
130
- const k = kinds[j];
131
- if (k === 'cho') {
132
- if (firstChoseongOf(wc) !== q[j]) {
133
- ok = false;
134
- break;
135
- }
136
- }
137
- else if (k === 'jung') {
138
- const s = parseSyllable(wc);
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
- else { // 'syl' — 완성 음절
145
- const qs = parseSyllable(q[j]);
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 if (wc !== q[j]) {
155
- ok = false;
156
- break;
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
- if (ok)
161
- return true;
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
- const trimmed = input.trim();
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
- const hit = items.filter(w => matchesWordQuery(w.word, q) || matchesWordQuery(w.pronunciation ?? '', q));
671
+ // 검색어 준비는 한 번 — 낱말마다 다시 하면 키 한 번에 2.3만 번 펼치고 분류했다
672
+ const m = makeWordQueryMatcher(q);
652
673
  const headLen = [...q].length;
653
- const startsWith = (s) => !!s && matchesWordQuery(s.slice(0, headLen), q);
654
- return hit
655
- .map(w => ({ w, rank: (startsWith(w.word) || startsWith(w.pronunciation ?? '')) ? 0 : 1 }))
656
- .sort((a, b) => a.rank - b.rank)
657
- .map(s => s.w);
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}`;
@@ -1,3 +1,13 @@
1
1
  /** 글자 둘을 한국어 차례로 견준다 */
2
2
  export declare function 한글순(a: string, b: string): number;
3
+ /**
4
+ * 표의 한 칸끼리 견준다 — **숫자는 숫자로, 나머지는 글자 속 숫자까지 수로.**
5
+ *
6
+ * 표의 칸은 무엇이든 올 수 있어서 늘 글자로 견주면 **숫자 칸이 망가진다**
7
+ * (`10` 이 `9` 앞에 선다). 그래서 둘 다 온전한 숫자(수, 또는 숫자만 든 글자)면 숫자로 견주고,
8
+ * 그 밖에는 한국어 차례로 견주되 글자 안의 숫자는 수로 읽는다(`회차 9` 가 `회차 10` 앞).
9
+ *
10
+ * 비어 있는 값(`null`·`undefined`)은 빈 글자로 본다 — 부르는 쪽이 이미
11
+ * `?? ''` 로 채워 넘기던 것과 같은 뜻이다.
12
+ */
3
13
  export declare function 칸순(a: unknown, b: unknown): number;
@@ -22,16 +22,6 @@ const 콜레이터 = (() => {
22
22
  export function 한글순(a, b) {
23
23
  return 콜레이터().compare(a, b);
24
24
  }
25
- /**
26
- * 표의 한 칸끼리 견준다 — **숫자는 숫자로, 나머지는 글자로.**
27
- *
28
- * 표의 칸은 무엇이든 올 수 있어서 늘 글자로 견주면 **숫자 칸이 망가진다**
29
- * (`10` 이 `9` 앞에 선다). 그래서 둘 다 진짜 숫자일 때만 숫자로 견주고,
30
- * 그 밖에는 한국어 차례를 쓴다.
31
- *
32
- * 비어 있는 값(`null`·`undefined`)은 빈 글자로 본다 — 부르는 쪽이 이미
33
- * `?? ''` 로 채워 넘기던 것과 같은 뜻이다.
34
- */
35
25
  const 숫자글자 = /^-?\d+(\.\d+)?$/;
36
26
  /* 칸의 글자 비교는 **안의 숫자도 수로** 본다 — 숫자 칸끼리는 수로, 섞인 칸은 글자로 견주면 '9'<'10'(수)·'10'<'10분'(글자)·
37
27
  '10분'<'9'(글자) 로 돌고 돌아, 같은 행을 들어온 차례만 바꿔도 정렬 결과가 달랐다. 수를 수로 읽는 콜레이터는
@@ -44,6 +34,16 @@ const 칸콜레이터 = (() => {
44
34
  return 것;
45
35
  };
46
36
  })();
37
+ /**
38
+ * 표의 한 칸끼리 견준다 — **숫자는 숫자로, 나머지는 글자 속 숫자까지 수로.**
39
+ *
40
+ * 표의 칸은 무엇이든 올 수 있어서 늘 글자로 견주면 **숫자 칸이 망가진다**
41
+ * (`10` 이 `9` 앞에 선다). 그래서 둘 다 온전한 숫자(수, 또는 숫자만 든 글자)면 숫자로 견주고,
42
+ * 그 밖에는 한국어 차례로 견주되 글자 안의 숫자는 수로 읽는다(`회차 9` 가 `회차 10` 앞).
43
+ *
44
+ * 비어 있는 값(`null`·`undefined`)은 빈 글자로 본다 — 부르는 쪽이 이미
45
+ * `?? ''` 로 채워 넘기던 것과 같은 뜻이다.
46
+ */
47
47
  export function 칸순(a, b) {
48
48
  /* NaN 은 숫자가 아닌 것으로 — a - b 가 NaN 이면 정렬 비교가 어긋나 줄이 뒤섞였다. 글자로 온 숫자('10'·'9')도
49
49
  둘 다 온전한 숫자면 숫자로 견준다(전에는 '10' 이 '9' 앞에 섰다). */
@@ -51,5 +51,10 @@ export function 칸순(a, b) {
51
51
  const na = 수(a), nb = 수(b);
52
52
  if (na !== null && nb !== null)
53
53
  return na - nb;
54
- return 칸콜레이터().compare(a == null ? '' : String(a), b == null ? '' : String(b));
54
+ const sa = a == null ? '' : String(a), sb = b == null ? '' : String(b);
55
+ /* 소수점·음수가 든 글자('12.5%'·'-10 dB')는 수로 읽는 콜레이터가 틀린다 — 점 뒤를 따로 떼어 '12.5' 가 '12.25' 뒤에,
56
+ 빼기표를 버려 '-5' 가 '-10' 뒤에 섰다. 그런 칸은 한국어 글자 차례로 둔다. 선행 0 도 수로 읽으면 'A007'='A7' 이 되어 글자로. */
57
+ if (/\d\.\d|-\d|(^|\D)0\d/.test(sa) || /\d\.\d|-\d|(^|\D)0\d/.test(sb))
58
+ return 한글순(sa, sb);
59
+ return 칸콜레이터().compare(sa, sb);
55
60
  }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * `ms` 뒤 끊기는 AbortSignal. `AbortSignal.timeout` 은 Safari 16 부터라 iOS/iPadOS 15 이하(치료실 구형 iPad)에서는
3
+ * 없어 fetch 를 부르기도 전에 TypeError 로 거절됐다 — 없으면 AbortController + setTimeout 으로 만든다.
4
+ */
5
+ export declare function timeoutSignal(ms: number): AbortSignal;
@@ -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: AbortSignal.timeout(8000) })
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
- const maskedSlots = new Set(selParts.map(p => pos + ':' + p));
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
- for (const part of ['cho', 'jung', 'jong']) {
151
- key += (maskedSlots.has(i + ':' + part) ? '*' : s[part]) + '.';
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
  };
@@ -9,7 +9,7 @@
9
9
  * 재료 고르기는 성능이 전부고(캐시·부분 셔플·병렬 이미지 확인), 문제 만들기는 훈련 타입마다
10
10
  * 규칙이 다르다. 한쪽을 볼 때 다른 쪽이 끼어들지 않게 갈라 둔다.
11
11
  */
12
- import { getImageExistsCached, getImageExistenceStats } from './imageConfig';
12
+ import { getImageExistenceStats, getImageOkWordSet } from './imageConfig';
13
13
  import { getWordPool } from './wordPool';
14
14
  import { shuffle } from './array';
15
15
  import { getPairIndex } from './wordPairIndex';
@@ -143,11 +143,12 @@ export function filterByCachedImage(words) {
143
143
  return all;
144
144
  }
145
145
  // prewarm 완료 → 이미지 확인된 단어만 허용
146
+ // 낱말마다 '${w}_1' 열쇠를 만들어 찾던 것을 캐시 판마다 한 번 만든 집합으로(칩 한 번에 유형 수만큼 돌았다)
147
+ const ok = getImageOkWordSet();
146
148
  const result = new Set();
147
- for (const w of words) {
148
- if (getImageExistsCached(w) === true)
149
+ for (const w of words)
150
+ if (ok.has(w))
149
151
  result.add(w);
150
- }
151
152
  return result;
152
153
  }
153
154
  // ─── buildUsablePool 캐시 ──────────────────────────────────────────────────
@@ -440,7 +441,15 @@ async function _buildUsablePool(opts) {
440
441
  else if (useRandomPool) {
441
442
  // 'random' 또는 null(difficulty:false 타입): 음운론적 제약은 없지만,
442
443
  // 정답과 오답의 음절수는 같아야 한다(단어 길이만으로 정답을 짐작하지 못하도록) — 본 단어와 다른 단어 + 음절수(글자수) 동일
443
- usable = distractorPool.filter(other => other !== w.w && other.length === w.w.length && pronOfWord(other) !== wPron);
444
+ const 쓸수있음 = (other) => other !== w.w && other.length === w.w.length && pronOfWord(other) !== wPron;
445
+ /* 셈(fullCount — 가능 단어 수 안내)은 '오답이 하나라도 있나' 만 본다. 2.2만 후보마다 600개를 끝까지 걸러 칩 한 번에
446
+ 300ms 넘게 멈췄다. 셈은 결과 개수만 쓰고(countUsableWords → pool.length) 오답 목록은 쓰지 않는다. */
447
+ if (opts.fullCount) {
448
+ const one = distractorPool.find(쓸수있음);
449
+ usable = one === undefined ? [] : [one];
450
+ }
451
+ else
452
+ usable = distractorPool.filter(쓸수있음);
444
453
  }
445
454
  else {
446
455
  // cho/jung/jong: 발음 기반 pairIndex에서 해당 종류 대립쌍 조회(공용 엔진). 구조 필터는 후처리에서 일괄.
@@ -39,7 +39,7 @@ export { copyText } from './utils/clipboard';
39
39
  export { formatPhone, normalizeDate, dateToDb, formatTimeHM, formatCount } from './utils/format';
40
40
  export { configureTrainingCategories, getTopicTypes, getTrainingTypes } from './utils/categories';
41
41
  export type { TopicType, TopicSubType, TrainingType, TrainingCategoryConfig } from './utils/categories';
42
- export { formatYmd, formatRelativeTime, getTodayKst, toKstYmd, toKstHm, formatDateKo, formatDateLongKo, formatDatetimeKo, formatDateLocale, formatDateLongLocale } from './utils/date';
42
+ export { formatYmd, formatRelativeTime, getTodayKst, getTodayKstDate, kstStartOfDay, parseLocalYmd, toKstYmd, toKstHm, formatDateKo, formatDateLongKo, formatDatetimeKo, formatDateLocale, formatDateLongLocale } from './utils/date';
43
43
  export type { FormatYmdOptions } from './utils/date';
44
44
  export { getSavedTheme, applyTheme, initTheme } from './utils/theme';
45
45
  export type { ThemeMode } from './utils/theme';
@@ -27,7 +27,7 @@ export { toStorageName, toBase64UrlSafe, officialSoundPath } from './utils/encod
27
27
  export { copyText } from './utils/clipboard';
28
28
  export { formatPhone, normalizeDate, dateToDb, formatTimeHM, formatCount } from './utils/format';
29
29
  export { configureTrainingCategories, getTopicTypes, getTrainingTypes } from './utils/categories';
30
- export { formatYmd, formatRelativeTime, getTodayKst, toKstYmd, toKstHm, formatDateKo, formatDateLongKo, formatDatetimeKo, formatDateLocale, formatDateLongLocale } from './utils/date';
30
+ export { formatYmd, formatRelativeTime, getTodayKst, getTodayKstDate, kstStartOfDay, parseLocalYmd, toKstYmd, toKstHm, formatDateKo, formatDateLongKo, formatDatetimeKo, formatDateLocale, formatDateLongLocale } from './utils/date';
31
31
  export { getSavedTheme, applyTheme, initTheme } from './utils/theme';
32
32
  export { getSavedFontScale, applyFontScale, initFontScale } from './utils/fontScale';
33
33
  export { classifyPairDifficulty, firstChoseongOf, toChoseong, isAllChoseong, matchesChoseongSearch } from './utils/koreanSyllable';
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export declare const CORE_VERSION = "0.141.0";
49
+ export declare const CORE_VERSION = "0.143.0";
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export const CORE_VERSION = '0.141.0';
49
+ export const CORE_VERSION = '0.143.0';