@ihabdevteam/core 0.322.0 → 0.323.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 +517 -0
- package/dist/components.cjs.js +183 -183
- package/dist/components.esm.css +1 -1
- package/dist/hooks.cjs.js +3 -3
- package/dist/i18n.cjs.js +3 -3
- package/dist/ihabdevteam-core.cjs.js +187 -187
- package/dist/ihabdevteam-core.d.ts +54 -16
- package/dist/packages/core/src/components/Accordion.js +3 -3
- package/dist/packages/core/src/components/Alert.js +2 -2
- package/dist/packages/core/src/components/BarChart.js +120 -119
- package/dist/packages/core/src/components/BottomNav.js +18 -18
- package/dist/packages/core/src/components/BulkBar.js +25 -25
- package/dist/packages/core/src/components/Button.js +16 -16
- package/dist/packages/core/src/components/Calendar.js +17 -17
- package/dist/packages/core/src/components/ChartSection.js +30 -30
- package/dist/packages/core/src/components/CountTile.js +38 -38
- package/dist/packages/core/src/components/DatePicker.js +59 -59
- package/dist/packages/core/src/components/Dialog.js +14 -14
- package/dist/packages/core/src/components/Effect/EffectPartyPopperHost.js +8 -8
- package/dist/packages/core/src/components/FilterPanel.js +6 -6
- package/dist/packages/core/src/components/FilterSectionInfo.js +6 -6
- package/dist/packages/core/src/components/GameIntro.js +1 -1
- package/dist/packages/core/src/components/Icon.js +2 -2
- package/dist/packages/core/src/components/ItemActions.js +16 -16
- package/dist/packages/core/src/components/LandingHeader.js +6 -6
- package/dist/packages/core/src/components/LineChart.js +24 -24
- package/dist/packages/core/src/components/Loader.js +5 -5
- package/dist/packages/core/src/components/NoticeBox.js +3 -3
- package/dist/packages/core/src/components/PageTitle.js +2 -2
- package/dist/packages/core/src/components/Pagination.js +4 -4
- package/dist/packages/core/src/components/Panel.js +2 -2
- package/dist/packages/core/src/components/PanelRow.js +6 -13
- package/dist/packages/core/src/components/PassCard/PassCard.js +9 -9
- package/dist/packages/core/src/components/PassCard/passCardProduct.js +2 -2
- package/dist/packages/core/src/components/PaymentPlan.js +5 -5
- package/dist/packages/core/src/components/Popup/ConfirmPopup.js +29 -29
- package/dist/packages/core/src/components/Popup/CouponInputPopup.js +2 -2
- package/dist/packages/core/src/components/Popup/DeviceSideTable.js +18 -25
- package/dist/packages/core/src/components/Popup/ListPopup.js +3 -3
- package/dist/packages/core/src/components/Popup/LoadTablePopup.js +7 -7
- package/dist/packages/core/src/components/Popup/Popup.js +27 -27
- package/dist/packages/core/src/components/Popup/SaveTablePopup.js +5 -5
- package/dist/packages/core/src/components/Popup/TrainingResultPopup.js +4 -4
- package/dist/packages/core/src/components/Popup/customerDetailUtils.js +18 -18
- package/dist/packages/core/src/components/Popup/useDiscardGuard.js +4 -4
- package/dist/packages/core/src/components/ProgressIndicator.js +5 -5
- package/dist/packages/core/src/components/PromoBannerBg/promoBannerBg.js +16 -16
- package/dist/packages/core/src/components/Rating.js +20 -20
- package/dist/packages/core/src/components/Report/ReportDocument.js +21 -21
- package/dist/packages/core/src/components/RingChart.js +5 -5
- package/dist/packages/core/src/components/SectionHeadFloat.js +15 -15
- package/dist/packages/core/src/components/SegmentedControl.js +34 -34
- package/dist/packages/core/src/components/SideNav.js +37 -37
- package/dist/packages/core/src/components/Slider.js +2 -2
- package/dist/packages/core/src/components/SoundCompass.js +5 -5
- package/dist/packages/core/src/components/Table.js +257 -98
- package/dist/packages/core/src/components/Tabs.js +12 -12
- package/dist/packages/core/src/components/TextField.js +34 -34
- package/dist/packages/core/src/components/Toast.js +6 -6
- package/dist/packages/core/src/components/Tooltip/MenuTooltip.js +4 -4
- package/dist/packages/core/src/components/Tooltip/Tooltip.js +22 -22
- package/dist/packages/core/src/components/Tooltip/TrainingTooltip.js +4 -4
- package/dist/packages/core/src/components/TopBanner.js +28 -28
- package/dist/packages/core/src/components/Training/AudioPlayerWaveForm.js +11 -11
- package/dist/packages/core/src/components/Training/CategoryIcon.js +2 -2
- package/dist/packages/core/src/components/Training/NoisePlayerContext.js +12 -12
- package/dist/packages/core/src/components/Training/NoiseSettingsTooltip.js +2 -2
- package/dist/packages/core/src/components/Training/TrainingBlock.js +11 -11
- package/dist/packages/core/src/components/Training/TrainingBlockButton.js +10 -10
- package/dist/packages/core/src/components/Training/TrainingBlockGrid.js +21 -21
- package/dist/packages/core/src/components/Training/TrainingCard.css +13 -1
- package/dist/packages/core/src/components/Training/TrainingCard.js +2 -2
- package/dist/packages/core/src/components/Training/TrainingCardGroup.js +6 -6
- package/dist/packages/core/src/components/Training/TrainingPagePopup.js +81 -81
- package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.js +17 -17
- package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +127 -127
- package/dist/packages/core/src/components/Training/TrainingStepCard.js +18 -18
- package/dist/packages/core/src/components/Training/TrainingStepConfirmWithText.js +4 -4
- package/dist/packages/core/src/components/Training/TrainingStepOrderList.js +12 -12
- package/dist/packages/core/src/components/Training/TrainingStepTextBox.js +12 -12
- package/dist/packages/core/src/components/Training/TrainingStepTitle.js +4 -4
- package/dist/packages/core/src/components/Training/TrainingWordCard.js +17 -17
- package/dist/packages/core/src/components/Training/WordTablePopup.js +5 -5
- package/dist/packages/core/src/components/Training/audioPlaybackRules.js +8 -8
- package/dist/packages/core/src/components/Training/audioTimeline.js +25 -25
- package/dist/packages/core/src/components/Training/oneShotSound.js +13 -13
- package/dist/packages/core/src/components/Training/trainingMissStats.js +20 -20
- package/dist/packages/core/src/components/Training/trainingPopupUtils.js +7 -7
- package/dist/packages/core/src/components/Training/useAudioPreview.js +15 -15
- package/dist/packages/core/src/components/Training/useFloatingFocus.js +14 -14
- package/dist/packages/core/src/components/Training/useNoiseFade.js +22 -22
- package/dist/packages/core/src/components/Training/useObjectUrls.js +2 -2
- package/dist/packages/core/src/components/Training/usePlaybackSequencer.d.ts +2 -2
- package/dist/packages/core/src/components/Training/usePlaybackSequencer.js +23 -23
- package/dist/packages/core/src/components/Training/useProgressTicker.js +33 -33
- package/dist/packages/core/src/components/Training/useTTSPlayback.js +17 -17
- package/dist/packages/core/src/components/Training/useWaveReady.js +11 -11
- package/dist/packages/core/src/components/UpdateAlert.js +6 -6
- package/dist/packages/core/src/components/Word/PhonemeSelectBlock.js +7 -1
- package/dist/packages/core/src/components/Word/TableFilterPanel.js +3 -3
- package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +10 -10
- package/dist/packages/core/src/components/Word/WordTableBlock.js +28 -28
- package/dist/packages/core/src/components/Word/WordTableButton.css +16 -2
- package/dist/packages/core/src/components/Word/WordTableThumbnail.css +6 -1
- package/dist/packages/core/src/components/Word/generatedDraft.js +14 -14
- package/dist/packages/core/src/components/Word/useWordTableDisplaySettings.js +7 -7
- package/dist/packages/core/src/components/Word/wordScoreStats.js +15 -15
- package/dist/packages/core/src/components/Word/wordTablePrint.js +10 -10
- package/dist/packages/core/src/components/Word/wordTableRecommend.js +11 -11
- package/dist/packages/core/src/components/Word/wordTableReview.js +28 -28
- package/dist/packages/core/src/components/Word/wordTableSettings.d.ts +8 -11
- package/dist/packages/core/src/components/Word/wordTableSettings.js +48 -36
- package/dist/packages/core/src/components/WordTable.js +130 -130
- package/dist/packages/core/src/components/barChartData.d.ts +16 -1
- package/dist/packages/core/src/components/barChartData.js +49 -29
- package/dist/packages/core/src/components/barChartTypes.js +8 -8
- package/dist/packages/core/src/components/tableTrackList.js +11 -11
- package/dist/packages/core/src/components/updateAlertStack.js +28 -28
- package/dist/packages/core/src/components/useBarChartControl.js +4 -6
- package/dist/packages/core/src/components/useComboboxSuggestions.js +17 -17
- package/dist/packages/core/src/components/useToastLifecycle.js +18 -18
- package/dist/packages/core/src/components/wordTableLayout.d.ts +17 -4
- package/dist/packages/core/src/components/wordTableLayout.js +5 -5
- package/dist/packages/core/src/context/I18nProvider.js +6 -6
- package/dist/packages/core/src/hooks/focusReturn.js +27 -27
- package/dist/packages/core/src/hooks/inputModality.js +13 -13
- package/dist/packages/core/src/hooks/touchToggle.js +39 -39
- package/dist/packages/core/src/hooks/useAnchoredMenu.js +30 -30
- package/dist/packages/core/src/hooks/useDailyTrainingSelection.js +28 -28
- package/dist/packages/core/src/hooks/useDocumentTitle.js +10 -10
- package/dist/packages/core/src/hooks/useEditableList.js +4 -4
- package/dist/packages/core/src/hooks/useFocusTrap.js +2 -2
- package/dist/packages/core/src/hooks/useFontScale.js +6 -6
- package/dist/packages/core/src/hooks/useIcon.js +7 -7
- package/dist/packages/core/src/hooks/useListControls.js +2 -2
- package/dist/packages/core/src/hooks/usePageResetOnChange.js +3 -3
- package/dist/packages/core/src/hooks/usePasswordChange.js +4 -4
- package/dist/packages/core/src/hooks/usePendingPress.js +7 -7
- package/dist/packages/core/src/hooks/useRelativeDay.js +4 -4
- package/dist/packages/core/src/hooks/useSoundCompassExam.js +6 -6
- package/dist/packages/core/src/hooks/useTablePaging.d.ts +0 -11
- package/dist/packages/core/src/hooks/useTablePaging.js +29 -34
- package/dist/packages/core/src/hooks/useTrainingSessionLog.js +35 -35
- package/dist/packages/core/src/hooks/useVisitSchedule.js +4 -4
- package/dist/packages/core/src/hooks/useWordAttributeFilter.js +14 -14
- package/dist/packages/core/src/hooks/useWordMeanings.js +5 -5
- package/dist/packages/core/src/hooks/useWordTrainingFilter.js +7 -7
- package/dist/packages/core/src/i18n/locales/en.json +13 -2
- package/dist/packages/core/src/i18n/locales/ko.json +12 -1
- package/dist/packages/core/src/types/wordSearch.d.ts +9 -0
- package/dist/packages/core/src/utils/audioMeter.js +5 -5
- package/dist/packages/core/src/utils/audioPeaks.d.ts +1 -1
- package/dist/packages/core/src/utils/audioPeaks.js +18 -18
- package/dist/packages/core/src/utils/blockGrid.js +4 -4
- package/dist/packages/core/src/utils/chunkSkew.js +9 -9
- package/dist/packages/core/src/utils/clipboard.js +3 -3
- package/dist/packages/core/src/utils/dailySelectionUtils.js +12 -12
- package/dist/packages/core/src/utils/date.js +12 -12
- package/dist/packages/core/src/utils/devWarn.js +4 -4
- package/dist/packages/core/src/utils/deviceKind.d.ts +37 -7
- package/dist/packages/core/src/utils/deviceKind.js +53 -10
- package/dist/packages/core/src/utils/errorMessage.js +7 -7
- package/dist/packages/core/src/utils/filterPanelSummary.js +14 -14
- package/dist/packages/core/src/utils/format.js +4 -4
- package/dist/packages/core/src/utils/getTrainingData.js +9 -9
- package/dist/packages/core/src/utils/imageConfig.js +17 -17
- package/dist/packages/core/src/utils/koreanSyllable.js +18 -18
- package/dist/packages/core/src/utils/navScroll.js +2 -2
- package/dist/packages/core/src/utils/phonemeConvert.d.ts +7 -0
- package/dist/packages/core/src/utils/phonemeConvert.js +19 -10
- package/dist/packages/core/src/utils/phonemeGroups.js +2 -2
- package/dist/packages/core/src/utils/phonemePairSearch.d.ts +1 -0
- package/dist/packages/core/src/utils/phonemePairSearch.js +118 -102
- package/dist/packages/core/src/utils/phonemeSectionCodes.d.ts +52 -0
- package/dist/packages/core/src/utils/phonemeSectionCodes.js +76 -0
- package/dist/packages/core/src/utils/prefetchChunk.js +6 -6
- package/dist/packages/core/src/utils/safeHref.js +4 -4
- package/dist/packages/core/src/utils/savedWordTables.js +5 -5
- package/dist/packages/core/src/utils/speak.js +12 -12
- package/dist/packages/core/src/utils/textSort.d.ts +0 -10
- package/dist/packages/core/src/utils/textSort.js +38 -48
- package/dist/packages/core/src/utils/theme.js +3 -3
- package/dist/packages/core/src/utils/trainingItemResults.js +3 -3
- package/dist/packages/core/src/utils/ttsConfig.js +2 -2
- package/dist/packages/core/src/utils/wordAttributeFilterCodes.js +45 -45
- package/dist/packages/core/src/utils/wordContrast.js +3 -3
- package/dist/packages/core/src/utils/wordPairIndex.js +4 -4
- package/dist/packages/core/src/utils/wordTrainingGenerators.js +4 -4
- package/dist/packages/core/src/utils/wordTrainingPool.js +15 -15
- package/dist/packages/core/src/utils/wordTrainingRuntime.js +6 -6
- package/dist/packages/core/src/utils/wornDate.d.ts +3 -3
- package/dist/packages/core/src/utils/wornDate.js +14 -14
- package/dist/packages/core/src/version.d.ts +1 -1
- package/dist/packages/core/src/version.js +1 -1
- package/dist/utils.cjs.js +4 -4
- package/package.json +1 -1
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/* 목소리 목록을 기다리는 말 — 하나만 둔다. 전에는 기다리는 리스너가 부를 때마다 줄을 서고 stopSpeaking 이 치우지 못해,
|
|
2
2
|
`speak('하나'); speak('둘'); stopSpeaking()` 뒤 목소리가 뜨는 순간 멈췄던 말이 모두 나왔다. */
|
|
3
|
-
let
|
|
3
|
+
let pendingWait = null;
|
|
4
4
|
/** voiceschanged 가 끝내 안 오는 환경(목소리 없음)에서도 말은 건다 — 안 그러면 onEnd 가 영영 안 불렸다 */
|
|
5
|
-
const
|
|
6
|
-
function
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
const VOICE_WAIT_MS = 1500;
|
|
6
|
+
function clearPendingWait() {
|
|
7
|
+
pendingWait?.clear();
|
|
8
|
+
pendingWait = null;
|
|
9
9
|
}
|
|
10
10
|
/** @public */
|
|
11
11
|
export function speak(text, { onEnd, rate = 0.9 } = {}) {
|
|
@@ -14,9 +14,9 @@ export function speak(text, { onEnd, rate = 0.9 } = {}) {
|
|
|
14
14
|
return;
|
|
15
15
|
}
|
|
16
16
|
const synth = window.speechSynthesis;
|
|
17
|
-
|
|
17
|
+
clearPendingWait();
|
|
18
18
|
const doSpeak = () => {
|
|
19
|
-
|
|
19
|
+
clearPendingWait();
|
|
20
20
|
synth.cancel();
|
|
21
21
|
const u = new SpeechSynthesisUtterance(text);
|
|
22
22
|
const voices = synth.getVoices();
|
|
@@ -47,18 +47,18 @@ export function speak(text, { onEnd, rate = 0.9 } = {}) {
|
|
|
47
47
|
doSpeak();
|
|
48
48
|
return;
|
|
49
49
|
}
|
|
50
|
-
const
|
|
50
|
+
const timer = setTimeout(doSpeak, VOICE_WAIT_MS);
|
|
51
51
|
synth.addEventListener('voiceschanged', doSpeak, { once: true });
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
clearTimeout(
|
|
52
|
+
pendingWait = {
|
|
53
|
+
clear: () => {
|
|
54
|
+
clearTimeout(timer);
|
|
55
55
|
synth.removeEventListener('voiceschanged', doSpeak);
|
|
56
56
|
},
|
|
57
57
|
};
|
|
58
58
|
}
|
|
59
59
|
/** @public */
|
|
60
60
|
export function stopSpeaking() {
|
|
61
|
-
|
|
61
|
+
clearPendingWait();
|
|
62
62
|
if (typeof window !== 'undefined' && 'speechSynthesis' in window)
|
|
63
63
|
window.speechSynthesis.cancel();
|
|
64
64
|
}
|
|
@@ -14,16 +14,6 @@ export declare function compareKorean(a: string, b: string): number;
|
|
|
14
14
|
* `?? ''` 로 채워 넘기던 것과 같은 뜻이다.
|
|
15
15
|
*/
|
|
16
16
|
export declare function compareCell(a: unknown, b: unknown): number;
|
|
17
|
-
/**
|
|
18
|
-
* @deprecated **`compareCell` 을 쓴다 — 같은 함수다.**
|
|
19
|
-
*
|
|
20
|
-
* 2026-10-13 공개 이름을 영어로 옮기며(사장님 지시) 옛 이름을 남겼다. 말귀가
|
|
21
|
-
* `import { 칸순 } from '@ihabdevteam/core/utils/textSort'` 를 **다섯 줄**에서
|
|
22
|
-
* 쓰고 있어 한 번에 걷으면 그쪽이 통째로 빨개진다. 옮긴 뒤 알려 주면 걷는다.
|
|
23
|
-
*
|
|
24
|
-
* 같은 꼴로 남겨 둔 것이 또 있다 — `useTableSort()` 의 `정렬`·`칸`.
|
|
25
|
-
*/
|
|
26
|
-
export declare const 칸순: typeof compareCell;
|
|
27
17
|
/**
|
|
28
18
|
* 표 정렬 한 번의 비교 — compareCell에 방향을 입히되 **빈 값은 오름·내림 모두 맨 뒤.**
|
|
29
19
|
* 방향을 `-compareCell` 으로 뒤집기만 하면 빈 값이 오름차순 맨 앞·내림차순 맨 뒤로 오가, 표마다 빈 줄이 다른 끝에 섰다.
|
|
@@ -10,44 +10,44 @@
|
|
|
10
10
|
*
|
|
11
11
|
* 콜레이터는 만드는 값이 비싸다 — 한 번만 만들어 쓴다.
|
|
12
12
|
*/
|
|
13
|
-
const
|
|
14
|
-
let
|
|
13
|
+
const collator = (() => {
|
|
14
|
+
let it = null;
|
|
15
15
|
return () => {
|
|
16
|
-
if (
|
|
17
|
-
|
|
18
|
-
return
|
|
16
|
+
if (!it)
|
|
17
|
+
it = new Intl.Collator('ko');
|
|
18
|
+
return it;
|
|
19
19
|
};
|
|
20
20
|
})();
|
|
21
21
|
/** 글자 둘을 한국어 차례로 견준다 */
|
|
22
22
|
export function compareKorean(a, b) {
|
|
23
|
-
return
|
|
23
|
+
return collator().compare(a, b);
|
|
24
24
|
}
|
|
25
|
-
const
|
|
26
|
-
function
|
|
27
|
-
const
|
|
28
|
-
if (
|
|
29
|
-
return
|
|
25
|
+
const pieceCache = new Map();
|
|
26
|
+
function toPieces(s) {
|
|
27
|
+
const hit = pieceCache.get(s);
|
|
28
|
+
if (hit)
|
|
29
|
+
return hit;
|
|
30
30
|
const out = [];
|
|
31
31
|
// 뒤돌아보기(?<=) 는 iOS 15 Safari 가 못 읽어 모듈째 깨지므로 쓰지 않는다 — 빼기표 앞 글자를 손으로 본다
|
|
32
32
|
const re = /-?\d+(?:\.\d+)?/g;
|
|
33
|
-
let
|
|
33
|
+
let end = 0;
|
|
34
34
|
for (let m = re.exec(s); m; m = re.exec(s)) {
|
|
35
|
-
let
|
|
36
|
-
let
|
|
37
|
-
if (
|
|
38
|
-
|
|
39
|
-
|
|
35
|
+
let start = m.index;
|
|
36
|
+
let numText = m[0];
|
|
37
|
+
if (numText[0] === '-' && start > 0 && !/\s/.test(s[start - 1])) {
|
|
38
|
+
start += 1;
|
|
39
|
+
numText = numText.slice(1);
|
|
40
40
|
}
|
|
41
|
-
if (
|
|
42
|
-
out.push(s.slice(
|
|
43
|
-
out.push(Number(
|
|
44
|
-
|
|
41
|
+
if (start > end)
|
|
42
|
+
out.push(s.slice(end, start));
|
|
43
|
+
out.push(Number(numText));
|
|
44
|
+
end = m.index + m[0].length;
|
|
45
45
|
}
|
|
46
|
-
if (
|
|
47
|
-
out.push(s.slice(
|
|
48
|
-
if (
|
|
49
|
-
|
|
50
|
-
|
|
46
|
+
if (end < s.length)
|
|
47
|
+
out.push(s.slice(end));
|
|
48
|
+
if (pieceCache.size > 5000)
|
|
49
|
+
pieceCache.clear();
|
|
50
|
+
pieceCache.set(s, out);
|
|
51
51
|
return out;
|
|
52
52
|
}
|
|
53
53
|
/* **전화번호 꼴**(숫자·하이픈·빈칸만, 숫자 9–11자리, 0 으로 시작 — 1 로 시작하면 하이픈·빈칸이 있을 때만)은 숫자만 뽑은 **글자 조각 하나**가 열쇠다.
|
|
@@ -55,14 +55,14 @@ function 조각내기(s) {
|
|
|
55
55
|
'010-…' 뒤에 서서, 같은 번호가 하이픈 유무로 표의 다른 자리로 갔다.
|
|
56
56
|
두 값이 모두 전화 꼴이면 숫자 글자끼리 견주는 셈이 된다. 한쪽만 전화 꼴일 때도 **값마다 열쇠가 하나로 정해져** 있어야
|
|
57
57
|
차례가 돌지 않는다(값 쌍마다 규칙을 고르면 A<B<C<A 가 생긴다) — 글자 조각이라 수 조각 뒤, 다른 글자와는 한국어 차례. */
|
|
58
|
-
function
|
|
58
|
+
function phoneKey(s) {
|
|
59
59
|
if (!/^[\d\s-]+$/.test(s))
|
|
60
60
|
return null;
|
|
61
|
-
const
|
|
62
|
-
if (
|
|
61
|
+
const digits = s.replace(/\D/g, '');
|
|
62
|
+
if (digits.length < 9 || digits.length > 11)
|
|
63
63
|
return null;
|
|
64
64
|
// 0 으로 시작하면 전화. 1 로 시작하면 하이픈·빈칸이 있을 때만 — 하이픈 없는 '1234567890' 같은 번호 ID 는 수로 둔다
|
|
65
|
-
return
|
|
65
|
+
return digits[0] === '0' || (digits[0] === '1' && s !== digits) ? digits : null;
|
|
66
66
|
}
|
|
67
67
|
/**
|
|
68
68
|
* 표의 한 칸끼리 견준다 — **숫자는 숫자로, 글자 속 숫자도 수로.**
|
|
@@ -78,18 +78,18 @@ function 전화열쇠(s) {
|
|
|
78
78
|
* `?? ''` 로 채워 넘기던 것과 같은 뜻이다.
|
|
79
79
|
*/
|
|
80
80
|
export function compareCell(a, b) {
|
|
81
|
-
const
|
|
82
|
-
const sa =
|
|
81
|
+
const text = (v) => (v == null || (typeof v === 'number' && Number.isNaN(v)) ? '' : String(v).trim());
|
|
82
|
+
const sa = text(a), sb = text(b);
|
|
83
83
|
if (sa === sb)
|
|
84
84
|
return 0;
|
|
85
85
|
/* 수(number)는 글로 바꿔 쪼개지 않고 **수 조각 하나** 로 — '1e+21'·'1e-7' 로 쪼개면 1e21 이 5 앞에, -Infinity 가 끝에 섰다 */
|
|
86
|
-
const
|
|
86
|
+
const keyOf = (v, s) => {
|
|
87
87
|
if (typeof v === 'number' && !Number.isNaN(v))
|
|
88
88
|
return [v];
|
|
89
|
-
const
|
|
90
|
-
return
|
|
89
|
+
const phone = phoneKey(s);
|
|
90
|
+
return phone !== null ? [phone] : toPieces(s);
|
|
91
91
|
};
|
|
92
|
-
const ka =
|
|
92
|
+
const ka = keyOf(a, sa), kb = keyOf(b, sb);
|
|
93
93
|
const n = Math.min(ka.length, kb.length);
|
|
94
94
|
for (let i = 0; i < n; i++) {
|
|
95
95
|
const x = ka[i], y = kb[i];
|
|
@@ -110,25 +110,15 @@ export function compareCell(a, b) {
|
|
|
110
110
|
return ka.length - kb.length;
|
|
111
111
|
return compareKorean(sa, sb);
|
|
112
112
|
}
|
|
113
|
-
/**
|
|
114
|
-
* @deprecated **`compareCell` 을 쓴다 — 같은 함수다.**
|
|
115
|
-
*
|
|
116
|
-
* 2026-10-13 공개 이름을 영어로 옮기며(사장님 지시) 옛 이름을 남겼다. 말귀가
|
|
117
|
-
* `import { 칸순 } from '@ihabdevteam/core/utils/textSort'` 를 **다섯 줄**에서
|
|
118
|
-
* 쓰고 있어 한 번에 걷으면 그쪽이 통째로 빨개진다. 옮긴 뒤 알려 주면 걷는다.
|
|
119
|
-
*
|
|
120
|
-
* 같은 꼴로 남겨 둔 것이 또 있다 — `useTableSort()` 의 `정렬`·`칸`.
|
|
121
|
-
*/
|
|
122
|
-
export const 칸순 = compareCell;
|
|
123
113
|
/* 빈 값 — null·undefined·NaN·빈 글자(빈칸만 있는 글자 포함). compareCell이 '' 로 보는 것과 같은 범위. */
|
|
124
|
-
const
|
|
114
|
+
const isBlank = (v) => v == null || (typeof v === 'number' && Number.isNaN(v)) || (typeof v === 'string' && v.trim() === '');
|
|
125
115
|
/**
|
|
126
116
|
* 표 정렬 한 번의 비교 — compareCell에 방향을 입히되 **빈 값은 오름·내림 모두 맨 뒤.**
|
|
127
117
|
* 방향을 `-compareCell` 으로 뒤집기만 하면 빈 값이 오름차순 맨 앞·내림차순 맨 뒤로 오가, 표마다 빈 줄이 다른 끝에 섰다.
|
|
128
118
|
* ListPopup 과 useTableSort().sortRows 가 함께 쓴다.
|
|
129
119
|
*/
|
|
130
120
|
export function compareCellBlankLast(a, b, asc) {
|
|
131
|
-
const ea =
|
|
121
|
+
const ea = isBlank(a), eb = isBlank(b);
|
|
132
122
|
if (ea || eb)
|
|
133
123
|
return ea === eb ? 0 : ea ? 1 : -1;
|
|
134
124
|
const c = compareCell(a, b);
|
|
@@ -18,10 +18,10 @@ export function getSavedTheme() {
|
|
|
18
18
|
export function applyTheme(mode) {
|
|
19
19
|
/* 세 값만 받는다 — 설정은 서버(JSON)에서 오기도 해서 타입만 믿고 그대로 박으면, 이 속성을 글자로 옮겨 짓는 인쇄 문서에
|
|
20
20
|
`"><script>` 가 끼어들었다. 모르는 값은 기본(light)으로. */
|
|
21
|
-
const
|
|
21
|
+
const value = mode === 'light' || mode === 'dark' || mode === 'auto' ? mode : DEFAULT_THEME;
|
|
22
22
|
try {
|
|
23
|
-
document.documentElement.setAttribute('data-theme',
|
|
24
|
-
localStorage.setItem(STORAGE_KEY,
|
|
23
|
+
document.documentElement.setAttribute('data-theme', value);
|
|
24
|
+
localStorage.setItem(STORAGE_KEY, value);
|
|
25
25
|
}
|
|
26
26
|
catch { }
|
|
27
27
|
}
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* 여기서 멋대로 UTC 로 돌리면 그 뜻이 깎인다. 읽히는지만 보고 **그대로** 둔다.
|
|
25
25
|
* `Date`·숫자(epoch)로 오면 그때는 쓸 글자가 없으니 ISO 로 짓는다.
|
|
26
26
|
*/
|
|
27
|
-
function
|
|
27
|
+
function timeOf(v) {
|
|
28
28
|
if (typeof v === 'string') {
|
|
29
29
|
const s = v.trim();
|
|
30
30
|
if (!s)
|
|
@@ -87,8 +87,8 @@ export function toItemResults(raw) {
|
|
|
87
87
|
correct: itemCorrectness(r),
|
|
88
88
|
/* 칸 이름은 **camelCase 와 snake_case 둘 다** 본다 — 화면이 서버 꼴(`shown_at`)을 그대로
|
|
89
89
|
들고 있는 자리가 있다. 이름이 하나라고 믿었다가 조용히 떨어뜨리는 것이 이 청의 뿌리다. */
|
|
90
|
-
shownAt:
|
|
91
|
-
answeredAt:
|
|
90
|
+
shownAt: timeOf(r.shownAt) ?? timeOf(r.shown_at),
|
|
91
|
+
answeredAt: timeOf(r.answeredAt) ?? timeOf(r.answered_at),
|
|
92
92
|
};
|
|
93
93
|
});
|
|
94
94
|
}
|
|
@@ -80,13 +80,13 @@ export async function checkAudioExists(word) {
|
|
|
80
80
|
// 한 번의 통신 끊김·서버 장애가 그 단어를 세션 내내 '음성 없음'으로 고정해, '음성 있음' 필터에서
|
|
81
81
|
// 통째로 빠진다. 실패는 지워서 다음 호출이 다시 물어보게 한다 — 단 **이 요청이 담은 칸일 때만**:
|
|
82
82
|
// clearAudioExistsCache() 뒤 새로 담긴 확인을 늦게 끝난 옛 요청이 지우면 같은 주소를 또 묻는다.
|
|
83
|
-
const
|
|
83
|
+
const onUnconfirmed = () => {
|
|
84
84
|
if (audioExistsCache.get(url) === promise)
|
|
85
85
|
audioExistsCache.delete(url);
|
|
86
86
|
return false;
|
|
87
87
|
};
|
|
88
88
|
const promise = fetch(url, { method: 'HEAD', signal: timeoutSignal(8000) })
|
|
89
|
-
.then(res => (res.status >= 500 ?
|
|
89
|
+
.then(res => (res.status >= 500 ? onUnconfirmed() : res.ok), onUnconfirmed);
|
|
90
90
|
audioExistsCache.set(url, promise);
|
|
91
91
|
return promise;
|
|
92
92
|
}
|
|
@@ -36,7 +36,7 @@ export function isBlankGrade(grade) {
|
|
|
36
36
|
}
|
|
37
37
|
/** 등급 코드인가 — `'1'`~`'4'`·`'none'`. 저장된 값을 되읽을 때 **아닌 것은 버린다**(한때 `''` 가 저장됐다). */
|
|
38
38
|
export function isGradeCode(v) {
|
|
39
|
-
return typeof v === 'string' &&
|
|
39
|
+
return typeof v === 'string' && gradeValues.includes(v);
|
|
40
40
|
}
|
|
41
41
|
/**
|
|
42
42
|
* **등급 코드 집합으로 단어 하나를 거른다 — 이 결정은 여기 하나다.**
|
|
@@ -59,42 +59,42 @@ export function matchesGradeCodes(codes, grade) {
|
|
|
59
59
|
return set.has(GRADE_NONE);
|
|
60
60
|
return set.has(String(grade));
|
|
61
61
|
}
|
|
62
|
-
const
|
|
62
|
+
const sectionCodes = ['grade', 'syllables', 'pos', 'wordType'];
|
|
63
63
|
/** 등급 코드 — `'none'` 은 **등급이 빈 단어**다(값이 아니라 **빔**이다. GRADE_NONE 주석을 볼 것) */
|
|
64
|
-
const
|
|
65
|
-
const
|
|
66
|
-
const
|
|
67
|
-
const
|
|
68
|
-
const
|
|
64
|
+
const gradeValues = ['1', '2', '3', '4', 'none'];
|
|
65
|
+
const syllableValues = ['1', '2', '3', '4', '5+'];
|
|
66
|
+
const posValues = ['noun', 'verb', 'modifier'];
|
|
67
|
+
const wordTypeValues = ['native', 'sino', 'foreign', 'mixed'];
|
|
68
|
+
const valuesBySection = { grade: gradeValues, syllables: syllableValues, pos: posValues, wordType: wordTypeValues };
|
|
69
69
|
/** 품사 코드 → 사전 품사 약자(단어의 pos 에 들어 있는지 본다) · 어종 코드 → 사전 어종 값 */
|
|
70
70
|
export const POS_CODE_TARGETS = { noun: ['명', '수'], verb: ['동', '형'], modifier: ['부', '관'] };
|
|
71
71
|
export const WORD_TYPE_CODE_VALUES = { native: '고유', sino: '한자', foreign: '외래', mixed: '혼종' };
|
|
72
72
|
/* 사전 밖의 옛 라벨 — phonemePairSearch 의 DEFAULT_PAIR_FILTER_KEYS 가 쓰던 것(쉼표 뒤 빈칸 없음·'난이도 등급') */
|
|
73
|
-
const
|
|
74
|
-
const
|
|
73
|
+
const legacySectionLabels = { grade: ['난이도 등급'] };
|
|
74
|
+
const legacyValueLabels = {
|
|
75
75
|
pos: { noun: ['체언(명사,수사)'], verb: ['용언(동사,형용사)'], modifier: ['수식언(부사,관형사)'] },
|
|
76
76
|
};
|
|
77
77
|
/* 언어마다 한 번 만든다 — matchesFilters 는 낱말(2만여 개)마다 불린다. 사전이 더해지면(addResourceBundle) 비운다 */
|
|
78
|
-
const
|
|
78
|
+
const dictCache = new Map();
|
|
79
79
|
try {
|
|
80
|
-
i18n.store?.on?.('added', () =>
|
|
80
|
+
i18n.store?.on?.('added', () => dictCache.clear());
|
|
81
81
|
}
|
|
82
82
|
catch { /* 없는 환경 */ }
|
|
83
|
-
function
|
|
84
|
-
const
|
|
85
|
-
if (
|
|
86
|
-
return
|
|
83
|
+
function dictFor(lng) {
|
|
84
|
+
const hit = dictCache.get(lng);
|
|
85
|
+
if (hit)
|
|
86
|
+
return hit;
|
|
87
87
|
const t = i18n.getFixedT(lng);
|
|
88
88
|
const d = {
|
|
89
|
-
|
|
89
|
+
section: {
|
|
90
90
|
grade: t('wordSearch.filter.difficulty', '등급'),
|
|
91
91
|
syllables: t('wordSearch.filter.syllables', '음절 수'),
|
|
92
92
|
pos: t('wordSearch.filter.partOfSpeech', '품사'),
|
|
93
93
|
wordType: t('wordSearch.filter.wordType', '유형'),
|
|
94
94
|
},
|
|
95
|
-
|
|
95
|
+
value: {
|
|
96
96
|
grade: {
|
|
97
|
-
...Object.fromEntries(
|
|
97
|
+
...Object.fromEntries(gradeValues.filter(n => n !== GRADE_NONE).map((n) => [n, t('wordSearch.filter.grade', { n: Number(n), defaultValue: `${n}등급` })])),
|
|
98
98
|
/* **`없음` 은 숫자 틀을 안 쓴다** — `t('…grade', { n: Number('none') })` 은 `NaN등급` 이 된다 */
|
|
99
99
|
[GRADE_NONE]: t('wordSearch.filter.gradeNone', '없음'),
|
|
100
100
|
},
|
|
@@ -115,36 +115,36 @@ function 사전(lng) {
|
|
|
115
115
|
},
|
|
116
116
|
},
|
|
117
117
|
};
|
|
118
|
-
|
|
118
|
+
dictCache.set(lng, d);
|
|
119
119
|
return d;
|
|
120
120
|
}
|
|
121
|
-
const
|
|
121
|
+
const languages = () => Array.from(new Set([i18n.language, 'ko', 'en'].filter((x) => !!x)));
|
|
122
122
|
/** 섹션 라벨(어느 언어든·옛 라벨·코드) → 섹션 코드. 속성 섹션이 아니면 null. */
|
|
123
123
|
export function wordAttributeSectionOf(label) {
|
|
124
|
-
if (
|
|
124
|
+
if (sectionCodes.includes(label))
|
|
125
125
|
return label;
|
|
126
|
-
for (const lng of
|
|
127
|
-
const d =
|
|
128
|
-
for (const s of
|
|
129
|
-
if (d
|
|
126
|
+
for (const lng of languages()) {
|
|
127
|
+
const d = dictFor(lng);
|
|
128
|
+
for (const s of sectionCodes)
|
|
129
|
+
if (d.section[s] === label)
|
|
130
130
|
return s;
|
|
131
131
|
}
|
|
132
|
-
for (const s of
|
|
133
|
-
if (
|
|
132
|
+
for (const s of sectionCodes)
|
|
133
|
+
if (legacySectionLabels[s]?.includes(label))
|
|
134
134
|
return s;
|
|
135
135
|
return null;
|
|
136
136
|
}
|
|
137
137
|
/** 한 섹션의 값(코드·어느 언어 라벨·옛 라벨) → 값 코드. 못 읽으면 null. */
|
|
138
138
|
export function wordAttributeValueCodeOf(section, value) {
|
|
139
|
-
if (
|
|
139
|
+
if (valuesBySection[section].includes(value))
|
|
140
140
|
return value;
|
|
141
|
-
for (const lng of
|
|
142
|
-
const
|
|
143
|
-
for (const [code, label] of Object.entries(
|
|
141
|
+
for (const lng of languages()) {
|
|
142
|
+
const table = dictFor(lng).value[section];
|
|
143
|
+
for (const [code, label] of Object.entries(table))
|
|
144
144
|
if (label === value)
|
|
145
145
|
return code;
|
|
146
146
|
}
|
|
147
|
-
for (const [code, labels] of Object.entries(
|
|
147
|
+
for (const [code, labels] of Object.entries(legacyValueLabels[section] ?? {}))
|
|
148
148
|
if (labels.includes(value))
|
|
149
149
|
return code;
|
|
150
150
|
/* 등급·음절은 숫자로도 읽는다('Grade 3'·'3등급'·'6음절') — 사전에 없는 꼴이 와도 풀리지 않게 */
|
|
@@ -160,9 +160,9 @@ export function wordAttributeValueCodeOf(section, value) {
|
|
|
160
160
|
}
|
|
161
161
|
/** 코드 → 지금 언어의 라벨. `'none'` 도 사전 꼴로 나온다(숫자 틀 `{{n}}등급` 에 넣으면 `NaN등급` 이 된다). 모르는 코드는 그대로. */
|
|
162
162
|
export function wordAttributeValueLabelOf(section, code, lng = i18n.language) {
|
|
163
|
-
return
|
|
163
|
+
return dictFor(lng).value[section][code] ?? code;
|
|
164
164
|
}
|
|
165
|
-
const
|
|
165
|
+
const toArray = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === 'string') : typeof v === 'string' && v ? [v] : []);
|
|
166
166
|
/**
|
|
167
167
|
* @public filterSelected(라벨 열쇠·라벨 값, 어느 언어든 섞여 있어도) → **코드 모양**. 저장할 때 쓴다.
|
|
168
168
|
* 속성 섹션(등급·음절 수·품사·어종)만 담고, 그 밖의 섹션(초성·선택·단어 입력 등)은 뺀다. 못 읽는 값은 버린다.
|
|
@@ -173,7 +173,7 @@ export function toWordAttributeFilterCodes(selected) {
|
|
|
173
173
|
const s = wordAttributeSectionOf(k);
|
|
174
174
|
if (!s)
|
|
175
175
|
continue;
|
|
176
|
-
for (const raw of
|
|
176
|
+
for (const raw of toArray(v)) {
|
|
177
177
|
const c = wordAttributeValueCodeOf(s, raw);
|
|
178
178
|
if (!c)
|
|
179
179
|
continue;
|
|
@@ -188,12 +188,12 @@ export function toWordAttributeFilterCodes(selected) {
|
|
|
188
188
|
* @public 코드 모양 → 지정한 언어(기본: 지금 i18n 언어)의 **라벨 filterSelected**. FilterPanel 의 selected 로 그대로 쓴다.
|
|
189
189
|
*/
|
|
190
190
|
export function wordAttributeFilterFromCodes(codes, lng = i18n.language) {
|
|
191
|
-
const d =
|
|
191
|
+
const d = dictFor(lng || 'ko');
|
|
192
192
|
const out = {};
|
|
193
|
-
for (const s of
|
|
194
|
-
const labels = (codes?.[s] ?? []).map((c) => d
|
|
193
|
+
for (const s of sectionCodes) {
|
|
194
|
+
const labels = (codes?.[s] ?? []).map((c) => d.value[s][c]).filter((x) => !!x);
|
|
195
195
|
if (labels.length)
|
|
196
|
-
out[d
|
|
196
|
+
out[d.section[s]] = labels;
|
|
197
197
|
}
|
|
198
198
|
return out;
|
|
199
199
|
}
|
|
@@ -203,17 +203,17 @@ export function wordAttributeFilterFromCodes(codes, lng = i18n.language) {
|
|
|
203
203
|
* 들어 있으면 합친다.
|
|
204
204
|
*/
|
|
205
205
|
export function normalizeWordAttributeFilter(selected, lng = i18n.language) {
|
|
206
|
-
const
|
|
206
|
+
const kept = {};
|
|
207
207
|
for (const [k, v] of Object.entries(selected ?? {}))
|
|
208
208
|
if (!wordAttributeSectionOf(k))
|
|
209
|
-
|
|
210
|
-
return {
|
|
209
|
+
kept[k] = v;
|
|
210
|
+
return { ...kept, ...wordAttributeFilterFromCodes(toWordAttributeFilterCodes(selected), lng) };
|
|
211
211
|
}
|
|
212
212
|
/** 라벨이 어느 언어의 섹션 라벨인가 — 토글이 그 언어로 값을 적게 */
|
|
213
213
|
export function wordAttributeLabelLanguageOf(label) {
|
|
214
|
-
for (const lng of
|
|
215
|
-
const d =
|
|
216
|
-
if (
|
|
214
|
+
for (const lng of languages()) {
|
|
215
|
+
const d = dictFor(lng);
|
|
216
|
+
if (sectionCodes.some((s) => d.section[s] === label))
|
|
217
217
|
return lng;
|
|
218
218
|
}
|
|
219
219
|
return i18n.language || 'ko';
|
|
@@ -147,10 +147,10 @@ position) {
|
|
|
147
147
|
else if (position === 'tail')
|
|
148
148
|
i = matchesAt(pw, L - 1) ? L - 1 : -1;
|
|
149
149
|
else if (position === 'mid') {
|
|
150
|
-
const
|
|
151
|
-
const
|
|
150
|
+
const first = selParts.includes('cho') ? 1 : 0; // 첫 초성은 어두다
|
|
151
|
+
const last = selParts.includes('jong') ? L - 2 : L - 1; // 마지막 받침은 어말이다
|
|
152
152
|
i = -1;
|
|
153
|
-
for (let k =
|
|
153
|
+
for (let k = first; k <= last; k++)
|
|
154
154
|
if (matchesAt(pw, k)) {
|
|
155
155
|
i = k;
|
|
156
156
|
break;
|
|
@@ -145,7 +145,7 @@ async function buildIndexAsync(items, onProgress) {
|
|
|
145
145
|
return result;
|
|
146
146
|
}
|
|
147
147
|
/** 캐시를 비우거나 새 빌드를 시작할 때 오른다 — 늦게 끝난 옛 빌드가 캐시에 쓰지 못하게 */
|
|
148
|
-
let
|
|
148
|
+
let buildGeneration = 0;
|
|
149
149
|
/**
|
|
150
150
|
* @public
|
|
151
151
|
* 단어 리스트로부터 대립쌍 인덱스를 빌드 (또는 캐시된 결과 반환).
|
|
@@ -166,9 +166,9 @@ export async function buildPairIndex(items, options = {}) {
|
|
|
166
166
|
}
|
|
167
167
|
/* 세대가 그대로일 때만 캐시에 담는다 — 빌드 중 clearPairIndexCache() 가 불렸거나 더 새 빌드가 시작됐으면, 늦게 끝난 옛
|
|
168
168
|
빌드가 방금 지운 캐시를 되살리거나 새 인덱스를 옛 것으로 덮었다. 부른 쪽에는 만든 것을 그대로 돌려준다. */
|
|
169
|
-
const
|
|
169
|
+
const myGeneration = ++buildGeneration;
|
|
170
170
|
const built = await buildIndexAsync(items, options.onProgress);
|
|
171
|
-
if (
|
|
171
|
+
if (myGeneration === buildGeneration) {
|
|
172
172
|
memIndex = built;
|
|
173
173
|
memSig = sig;
|
|
174
174
|
saveToLocalStorage(built, sig);
|
|
@@ -189,7 +189,7 @@ export function getPairIndex() {
|
|
|
189
189
|
}
|
|
190
190
|
/** @public 캐시 초기화 */
|
|
191
191
|
export function clearPairIndexCache() {
|
|
192
|
-
|
|
192
|
+
buildGeneration += 1;
|
|
193
193
|
memIndex = null;
|
|
194
194
|
memSig = null;
|
|
195
195
|
try {
|
|
@@ -141,14 +141,14 @@ export function getChangeCardTitle(typeKey, item) {
|
|
|
141
141
|
if (change) {
|
|
142
142
|
// 1음절: "옆"에서 … / 2음절: "사과"의 "과"에서 … ('더하면'은 에서 대신 에)
|
|
143
143
|
const head = one ? `"${word}"` : `"${word}"의 "${from}"`;
|
|
144
|
-
const
|
|
144
|
+
const particle = change.kind === 'add' ? '에' : '에서';
|
|
145
145
|
const f = `"${change.from}"${josaEulReul(josaBaseOf(change.from))}`;
|
|
146
146
|
const t = `"${change.to}"${josaEuRo(josaBaseOf(change.to))}`;
|
|
147
147
|
if (change.kind === 'add')
|
|
148
|
-
return `${head}${
|
|
148
|
+
return `${head}${particle} 받침 "${change.to}"${josaEulReul(josaBaseOf(change.to))} 더하면 무엇이 되나요?`;
|
|
149
149
|
if (change.kind === 'remove')
|
|
150
|
-
return `${head}${
|
|
151
|
-
return `${head}${
|
|
150
|
+
return `${head}${particle} 받침 ${f} 빼면 무엇이 되나요?`;
|
|
151
|
+
return `${head}${particle} ${PART_LABEL[change.part]} ${f} ${t} 바꾸면 무엇이 되나요?`;
|
|
152
152
|
}
|
|
153
153
|
}
|
|
154
154
|
if (typeKey === 'CHANGE_1_PHONEME_IN_1SYLL') {
|
|
@@ -417,12 +417,12 @@ async function _buildUsablePool(opts) {
|
|
|
417
417
|
let posContrastMap = null;
|
|
418
418
|
if (posContrast) {
|
|
419
419
|
const mapKey = _posContrastCacheKey(opts);
|
|
420
|
-
const
|
|
421
|
-
const
|
|
422
|
-
posContrastMap =
|
|
423
|
-
?
|
|
420
|
+
const imageOkSet = excludeNoImage ? getImageOkWordSet() : null;
|
|
421
|
+
const cached = _posContrastCache.get(mapKey);
|
|
422
|
+
posContrastMap = cached && cached.imageOkSet === imageOkSet
|
|
423
|
+
? cached.map
|
|
424
424
|
: buildMultiPartPosContrast(composition, allOkArr, decompOf, sameStructureOnly, phonemeView, pronOfWord);
|
|
425
|
-
_posContrastCache.set(mapKey, {
|
|
425
|
+
_posContrastCache.set(mapKey, { imageOkSet, map: posContrastMap });
|
|
426
426
|
}
|
|
427
427
|
// candidates 크기 제한 — 이미지 필터 이후이므로 okCandidates 기반
|
|
428
428
|
const RANDOM_CAND_CAP = 500; // random: classifyPairDifficulty 없으나 루프 자체 제한
|
|
@@ -471,28 +471,28 @@ async function _buildUsablePool(opts) {
|
|
|
471
471
|
else if (useRandomPool) {
|
|
472
472
|
// 'random' 또는 null(difficulty:false 타입): 음운론적 제약은 없지만,
|
|
473
473
|
// 정답과 오답의 음절수는 같아야 한다(단어 길이만으로 정답을 짐작하지 못하도록) — 본 단어와 다른 단어 + 음절수(글자수) 동일
|
|
474
|
-
const
|
|
474
|
+
const canUse = (other) => other !== w.w && other.length === w.w.length && pronOfWord(other) !== wPron;
|
|
475
475
|
/* 셈(fullCount — 가능 단어 수 안내)은 **서로 소리가 다른 오답이 보기 수 − 1 개** 있는지까지만 본다(countUsableWords 가 그
|
|
476
476
|
기준으로 센다). 2.2만 후보마다 600개를 끝까지 걸러 칩 한 번에 300ms 넘게 멈췄다. */
|
|
477
477
|
if (opts.fullCount) {
|
|
478
478
|
const need = Math.max(1, (opts.choicesPerItem ?? 3) - 1);
|
|
479
|
-
const
|
|
480
|
-
const
|
|
479
|
+
const picked = [];
|
|
480
|
+
const prons = new Set();
|
|
481
481
|
for (const other of distractorPool) {
|
|
482
|
-
if (
|
|
482
|
+
if (!canUse(other))
|
|
483
483
|
continue;
|
|
484
484
|
const p = pronOfWord(other);
|
|
485
|
-
if (
|
|
485
|
+
if (prons.has(p))
|
|
486
486
|
continue;
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
if (
|
|
487
|
+
prons.add(p);
|
|
488
|
+
picked.push(other);
|
|
489
|
+
if (picked.length >= need)
|
|
490
490
|
break;
|
|
491
491
|
}
|
|
492
|
-
usable =
|
|
492
|
+
usable = picked;
|
|
493
493
|
}
|
|
494
494
|
else
|
|
495
|
-
usable = distractorPool.filter(
|
|
495
|
+
usable = distractorPool.filter(canUse);
|
|
496
496
|
}
|
|
497
497
|
else {
|
|
498
498
|
// cho/jung/jong: 발음 기반 pairIndex에서 해당 종류 대립쌍 조회(공용 엔진). 구조 필터는 후처리에서 일괄.
|
|
@@ -73,10 +73,10 @@ async function genIncluded(opts) {
|
|
|
73
73
|
const syllables = [...pron]; // 한 글자 = 한 음절
|
|
74
74
|
/* 음소를 골랐으면 **그 음소를 가진 음절**을 들려준다 — 전에는 아무 음절이나 골라 초성 ㄱ 을 골라도 '가방' 의 '방' 을
|
|
75
75
|
들려주었다(고른 음소와 무관한 훈련). 들리는 음절(발음)에 그 음소가 없으면 전처럼 아무 음절. */
|
|
76
|
-
const
|
|
76
|
+
const pickedPart = comp && primarySelPart(comp)
|
|
77
77
|
? syllables.filter(s => { const p = parseSyllable(s); return !!p && syllMatchesComp(p, comp); })
|
|
78
78
|
: [];
|
|
79
|
-
const targetSyllable = pickOne(
|
|
79
|
+
const targetSyllable = pickOne(pickedPart.length ? pickedPart : syllables);
|
|
80
80
|
if (!targetSyllable)
|
|
81
81
|
continue;
|
|
82
82
|
// 오답: targetSyllable이 발음에 포함되지 않고, 정답과 음절수(글자수)가 같으며, 발음이 다른 단어.
|
|
@@ -357,7 +357,7 @@ async function genMerge3Syll(opts) {
|
|
|
357
357
|
}
|
|
358
358
|
/* 탈락 문제의 '실단어' 집합 — '이미지 있는 단어만' 이면 이미지 있는 낱말로. 전에는 늘 전체 낱말이라, 이미지만 켠(앱 기본) 탈락 카드의
|
|
359
359
|
정답·오답이 이미지 없는 낱말(관상 → 상·땅·탕)로 나와 글자를 숨긴 카드가 빈칸이 됐다. 음절 바꾸기와 같은 방식. */
|
|
360
|
-
function
|
|
360
|
+
function droppedWordSet(opts) {
|
|
361
361
|
const all = MVP.map(w => w.w);
|
|
362
362
|
return opts.excludeNoImage ? filterByCachedImage(all) : new Set(all);
|
|
363
363
|
}
|
|
@@ -368,7 +368,7 @@ async function genDrop1From2Syll(opts) {
|
|
|
368
368
|
// 편집기와 동일한 대립쌍 오답을 item에 심는다(공유 코어 buildDropContrastProblem).
|
|
369
369
|
// 러너(WordManipulationTrainingPagePopup)가 preset distractors를 그대로 쓰므로 자동생성도 편집기 품질이 된다.
|
|
370
370
|
const pronMap = getPronMap();
|
|
371
|
-
const wordSet =
|
|
371
|
+
const wordSet = droppedWordSet(opts);
|
|
372
372
|
const phonemeView = opts.phonemeView ?? 'char';
|
|
373
373
|
const out = [];
|
|
374
374
|
for (const e of shuffle(pool)) {
|
|
@@ -398,7 +398,7 @@ async function genDrop1From3Syll(opts) {
|
|
|
398
398
|
const pool = (await buildUsablePool({ ...opts })).filter(e => e.word.s === 3);
|
|
399
399
|
// 편집기와 동일한 대립쌍 오답을 item에 심는다(공유 코어 buildDropContrast3Problem).
|
|
400
400
|
const pronMap = getPronMap();
|
|
401
|
-
const wordSet =
|
|
401
|
+
const wordSet = droppedWordSet(opts);
|
|
402
402
|
const phonemeView = opts.phonemeView ?? 'char';
|
|
403
403
|
const out = [];
|
|
404
404
|
for (const e of shuffle(pool)) {
|
|
@@ -436,7 +436,7 @@ async function genDropCompound(opts) {
|
|
|
436
436
|
// 2음절 낱말은 1음절 조각 둘로만 나뉘어 합성어가 될 수 없다(buildDropCompoundProblem) — 3음절부터
|
|
437
437
|
const pool = (await buildUsablePool({ ...opts })).filter(e => e.word.s >= 3);
|
|
438
438
|
const pronMap = getPronMap();
|
|
439
|
-
const wordSet =
|
|
439
|
+
const wordSet = droppedWordSet(opts);
|
|
440
440
|
const phonemeView = opts.phonemeView ?? 'char';
|
|
441
441
|
const out = [];
|
|
442
442
|
for (const e of shuffle(pool)) {
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* 타입은 문자열이지만 서버에서는 **월·일이 null 로 오기도 한다**(연도만 적은 옛 기록). 그대로 끼우면 화면에
|
|
5
5
|
* `2026.null.null` 이 찍혔고, 정렬 열쇠를 만들 때는 `null.padStart` 에서 표가 통째로 터졌다. 빈 값은 '' 로 다룬다.
|
|
6
6
|
*/
|
|
7
|
-
type
|
|
7
|
+
type CellValue = string | number | null | undefined;
|
|
8
8
|
/**
|
|
9
9
|
* 화면에 적을 착용일.
|
|
10
10
|
*
|
|
@@ -18,7 +18,7 @@ type 칸값 = string | number | null | undefined;
|
|
|
18
18
|
* `lang` 을 안 주면 **예전 그대로** 점 꼴이다(`2026.09.09`) — 부르는 자리가 말을 모르는 곳도 있을
|
|
19
19
|
* 수 있어 깨지지 않게 둔다. 화면에 그리는 자리는 주는 쪽이 맞다.
|
|
20
20
|
*/
|
|
21
|
-
export declare function formatWornDate(year:
|
|
21
|
+
export declare function formatWornDate(year: CellValue, month: CellValue, day: CellValue, lang?: string): string;
|
|
22
22
|
/** 착용일 정렬 열쇠 — 자리수를 채워(`2026-05-02`) 글자 비교가 날짜 차례와 같게. 연도가 없으면 ''. */
|
|
23
|
-
export declare function wornDateSortKey(year:
|
|
23
|
+
export declare function wornDateSortKey(year: CellValue, month: CellValue, day: CellValue): string;
|
|
24
24
|
export {};
|