@ihabdevteam/core 0.322.0 → 0.324.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 +557 -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 +55 -17
- 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 +14 -2
- package/dist/packages/core/src/i18n/locales/ko.json +13 -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 +129 -105
- package/dist/packages/core/src/utils/phonemePairSearchTypes.d.ts +2 -0
- 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.d.ts +1 -1
- package/dist/packages/core/src/utils/wordAttributeFilterCodes.js +65 -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
|
@@ -8,15 +8,20 @@ function decompose(char) {
|
|
|
8
8
|
if (code < 0 || code > 11171)
|
|
9
9
|
return null;
|
|
10
10
|
return {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
onset: CHO[Math.floor(code / 588)],
|
|
12
|
+
vowel: JUNG[Math.floor((code % 588) / 28)],
|
|
13
|
+
coda: JONG[code % 28] || '',
|
|
14
14
|
};
|
|
15
15
|
}
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
}
|
|
16
|
+
/* 음절 자리 코드는 **아래 모듈**에 있다 — `phonemeConvert` 도 함께 쓰는데, 여기 두면
|
|
17
|
+
`phonemePairSearch ↔ phonemeConvert` 돌림 import 가 된다(한 번 그렇게 해 시험 338 꾸러미가
|
|
18
|
+
무너졌다, 2026-10-13). */
|
|
19
|
+
import { toPhonemeCodes, toLegacyPhonemeKey, isPhonemeSectionCode, PHONEME_SECTION_CODES, PHONEME_NONE } from './phonemeSectionCodes';
|
|
20
|
+
export { toPhonemeCodes, PHONEME_NONE } from './phonemeSectionCodes';
|
|
21
|
+
const SECTION_KEYS = PHONEME_SECTION_CODES;
|
|
22
|
+
const NONE = PHONEME_NONE;
|
|
23
|
+
const isSectionKey = isPhonemeSectionCode;
|
|
24
|
+
const toCodes = toPhonemeCodes;
|
|
20
25
|
// 발음형 분해: 모음을 활음 + 핵모음으로 분리
|
|
21
26
|
const VOWEL_GLIDE = {
|
|
22
27
|
ㅑ: 'j', ㅒ: 'j', ㅕ: 'j', ㅖ: 'j', ㅛ: 'j', ㅠ: 'j',
|
|
@@ -38,10 +43,10 @@ function decomposePhonemicSyllable(char) {
|
|
|
38
43
|
const rawJung = JUNG[jungIdx];
|
|
39
44
|
const rawJong = JONG[jongIdx];
|
|
40
45
|
return {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
46
|
+
onset: rawCho === 'ㅇ' ? NONE : rawCho,
|
|
47
|
+
glide: VOWEL_GLIDE[rawJung] ?? NONE,
|
|
48
|
+
vowel: VOWEL_NUCLEUS[rawJung] ?? rawJung,
|
|
49
|
+
coda: rawJong || NONE,
|
|
45
50
|
};
|
|
46
51
|
}
|
|
47
52
|
function analyzeMinimalPair(word1, word2, phonemic = false) {
|
|
@@ -94,8 +99,8 @@ function diffHasEmptyPair(section, vals) {
|
|
|
94
99
|
// '활음' 섹션의 '없음'은 비움으로 보지 않는다 — 발음 모드에서 사용자가 활음 자리를 **직접 골라** 찾는 대립(ㅐ↔ㅙ = 없음↔w)이라
|
|
95
100
|
// 그 검색 결과를 이 필터가 통째로 지우면 안 된다.
|
|
96
101
|
const isEmpty = (v) => v === '' ||
|
|
97
|
-
(section === '
|
|
98
|
-
(section === '
|
|
102
|
+
(section === 'coda' && v === NONE) ||
|
|
103
|
+
(section === 'onset' && (v === 'ㅇ' || v === NONE));
|
|
99
104
|
if (isEmpty(vals[0]) || isEmpty(vals[1]))
|
|
100
105
|
return true;
|
|
101
106
|
// 모음(글자 모드) 활음 구조 차이: 두 모음의 반모음(활음)이 다르면 음절 '구조' 차이로 본다.
|
|
@@ -104,7 +109,7 @@ function diffHasEmptyPair(section, vals) {
|
|
|
104
109
|
// 발음 모드는 모음=핵모음이라 둘 다 활음 없음으로 읽혀 영향 없음. 활음 대립은
|
|
105
110
|
// 별도 '활음' 섹션에서 처리되므로 여기(모음)에서 걸러도 활음 지정 대립과 충돌하지 않는다.
|
|
106
111
|
// 단어훈련 런타임(wordContrast.hasStructureContrast)도 **같은 함수**로 판정한다.
|
|
107
|
-
if (section === '
|
|
112
|
+
if (section === 'vowel' && vowelGlidesDiffer(vals[0], vals[1]))
|
|
108
113
|
return true;
|
|
109
114
|
return false;
|
|
110
115
|
}
|
|
@@ -122,29 +127,31 @@ const DEFAULT_PAIR_FILTER_KEYS = {
|
|
|
122
127
|
syllable5plus: '5음절 이상',
|
|
123
128
|
partOfSpeech: '품사',
|
|
124
129
|
wordType: '유형',
|
|
130
|
+
field: '의미범주',
|
|
125
131
|
posMap: POS_GROUP_MAP_KO,
|
|
126
132
|
typeMap: WORD_TYPE_MAP_KO,
|
|
127
133
|
};
|
|
128
|
-
const
|
|
129
|
-
function
|
|
130
|
-
const
|
|
131
|
-
if (
|
|
132
|
-
return
|
|
133
|
-
const
|
|
134
|
-
const
|
|
134
|
+
const collected = new WeakMap();
|
|
135
|
+
function collect(filterSelected, keys) {
|
|
136
|
+
const hit = collected.get(filterSelected);
|
|
137
|
+
if (hit && hit.keys === keys)
|
|
138
|
+
return hit.value;
|
|
139
|
+
const value = { grades: [], syls: [], poses: [], types: [], fields: [] };
|
|
140
|
+
const bySection = { grade: value.grades, syllables: value.syls, pos: value.poses, wordType: value.types, field: value.fields };
|
|
135
141
|
for (const [label, raw] of Object.entries(filterSelected ?? {})) {
|
|
136
142
|
/* 섹션은 넘긴 keys 의 라벨이면 그것, 아니면 어느 언어·옛 라벨·코드든 읽는다(wordAttributeSectionOf) */
|
|
137
143
|
const s = label === keys.difficulty ? 'grade' : label === keys.syllables ? 'syllables'
|
|
138
144
|
: label === keys.partOfSpeech ? 'pos' : label === keys.wordType ? 'wordType'
|
|
139
|
-
:
|
|
145
|
+
: label === keys.field ? 'field'
|
|
146
|
+
: wordAttributeSectionOf(label);
|
|
140
147
|
if (!s)
|
|
141
148
|
continue;
|
|
142
149
|
for (const v of Array.isArray(raw) ? raw : typeof raw === 'string' ? [raw] : [])
|
|
143
|
-
if (
|
|
144
|
-
|
|
150
|
+
if (!bySection[s].includes(v))
|
|
151
|
+
bySection[s].push(v);
|
|
145
152
|
}
|
|
146
|
-
|
|
147
|
-
return
|
|
153
|
+
collected.set(filterSelected, { keys, value });
|
|
154
|
+
return value;
|
|
148
155
|
}
|
|
149
156
|
/**
|
|
150
157
|
* 난이도/음절수/품사/유형 필터를 단어 하나에 대해 판정(의미범주 제외 — WordEntry 전용이라 applyFilters에서 별도 처리).
|
|
@@ -154,7 +161,7 @@ function 모으기(filterSelected, keys) {
|
|
|
154
161
|
* **풀리고** 품사·어종 값은 posMap 에 없어 **모든 단어가 떨어졌다**.
|
|
155
162
|
*/
|
|
156
163
|
export function matchesFilters(word, filterSelected, keys = DEFAULT_PAIR_FILTER_KEYS) {
|
|
157
|
-
const { grades, syls, poses, types } =
|
|
164
|
+
const { grades, syls, poses, types } = collect(filterSelected, keys);
|
|
158
165
|
if (grades.length) {
|
|
159
166
|
/* **`없음` 은 값이 아니라 빔이다**((205), 2026-10-12) — 거르는 결정은 `matchesGradeCodes` **하나**다
|
|
160
167
|
(단어훈련 풀도 같은 것을 부른다). 여기서는 라벨을 코드로만 바꾼다.
|
|
@@ -188,8 +195,14 @@ export function applyFilters(filterSelected, words, keys = DEFAULT_PAIR_FILTER_K
|
|
|
188
195
|
return words.filter(entry => {
|
|
189
196
|
if (!matchesFilters({ grade: entry.grade, syllables: [...entry.word].length, pos: entry.pos, wordType: entry.wordType }, filterSelected, keys))
|
|
190
197
|
return false;
|
|
191
|
-
|
|
192
|
-
|
|
198
|
+
/* **의미범주도 다른 넷과 같은 길로 읽는다**(사장님 지시 2026-10-14, 말귀 실측으로 다리는
|
|
199
|
+
필요 없음이 확인됐다 — 앱 소스 0건·저장 구성 416줄 0건).
|
|
200
|
+
|
|
201
|
+
전에는 `filterSelected['의미범주']` 로 **그 다섯 글자만** 찾았다. 다른 절은 어느 언어
|
|
202
|
+
라벨이든 읽는데 이 하나만 안 그랬고, 영어 라벨로 부르면 **이 필터만 조용히 안 걸렸다**
|
|
203
|
+
(에러도 없다). 같은 꼴을 등급·음절에서 이미 겪어 고쳤던 자리다. */
|
|
204
|
+
const { fields } = collect(filterSelected, keys);
|
|
205
|
+
if (fields.length) {
|
|
193
206
|
const normalized = (entry.field ?? '').replace(/전문\(|\)/g, '');
|
|
194
207
|
if (!fields.some(f => normalized.includes(f)))
|
|
195
208
|
return false;
|
|
@@ -278,9 +291,15 @@ function searchPairsCore(selectedList, words, debug, enforcePosition, phonemic =
|
|
|
278
291
|
const uniqueWordsCount = new Set(words.map(w => w.word)).size;
|
|
279
292
|
const bySection = {};
|
|
280
293
|
const bySectionValueOrder = {};
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
294
|
+
/* **들어오는 자리에서 한 번 옮긴다** — 저장된 단어표의 `phonemeSelected` 는 옛 한글 열쇠일
|
|
295
|
+
수 있다(`{ 초성: 'ㄴ' }`). 안쪽은 코드만 보므로 여기서 가른다. */
|
|
296
|
+
const codedList = selectedList.map(toCodes);
|
|
297
|
+
for (let i = 0; i < codedList.length; i++) {
|
|
298
|
+
for (const [section, value] of Object.entries(codedList[i])) {
|
|
299
|
+
/* **`!value` 로 거르지 않는다.** `{ 종성: '' }` 은 "받침 없음을 골랐다" 는 뜻이고,
|
|
300
|
+
옮기며 그 줄을 더했다가 `받침 있음↔없음` 검색이 빈손이 됐다(2026-10-13, 시험이 잡았다).
|
|
301
|
+
*옮기기에 거르는 줄을 끼워 넣으면 뜻이 바뀐다.* */
|
|
302
|
+
if (!isSectionKey(section) || value === undefined)
|
|
284
303
|
continue;
|
|
285
304
|
if (!bySection[section])
|
|
286
305
|
bySection[section] = new Set();
|
|
@@ -315,16 +334,16 @@ function searchPairsCore(selectedList, words, debug, enforcePosition, phonemic =
|
|
|
315
334
|
const targetKeys = new Set(sectionEntries.map(([s]) => s));
|
|
316
335
|
const contrastSections = new Set(sectionEntries.filter(([, vals]) => vals.size >= 2).map(([s]) => s));
|
|
317
336
|
const encodeAll = phonemic
|
|
318
|
-
? (s) => s
|
|
319
|
-
: (s) => s
|
|
337
|
+
? (s) => s.onset + (s.glide ?? '') + s.vowel + s.coda
|
|
338
|
+
: (s) => s.onset + s.vowel + s.coda;
|
|
320
339
|
const encodeWild = phonemic
|
|
321
|
-
? (s) => (targetKeys.has('
|
|
322
|
-
(targetKeys.has('
|
|
323
|
-
(targetKeys.has('
|
|
324
|
-
(targetKeys.has('
|
|
325
|
-
: (s) => (targetKeys.has('
|
|
326
|
-
(targetKeys.has('
|
|
327
|
-
(targetKeys.has('
|
|
340
|
+
? (s) => (targetKeys.has('onset') ? '*' : s.onset) +
|
|
341
|
+
(targetKeys.has('glide') ? '*' : (s.glide ?? '')) +
|
|
342
|
+
(targetKeys.has('vowel') ? '*' : s.vowel) +
|
|
343
|
+
(targetKeys.has('coda') ? '*' : s.coda)
|
|
344
|
+
: (s) => (targetKeys.has('onset') ? '*' : s.onset) +
|
|
345
|
+
(targetKeys.has('vowel') ? '*' : s.vowel) +
|
|
346
|
+
(targetKeys.has('coda') ? '*' : s.coda);
|
|
328
347
|
const makeKeyForPosition = (syllables, diffPos) => syllables.map((s, i) => (i === diffPos ? encodeWild(s) : encodeAll(s))).join('|');
|
|
329
348
|
const gradeOrder = (g) => {
|
|
330
349
|
const parsed = Number.parseInt(g ?? '', 10);
|
|
@@ -335,9 +354,9 @@ function searchPairsCore(selectedList, words, debug, enforcePosition, phonemic =
|
|
|
335
354
|
한 묶음의 낱말은 모두 같은 자리를 비운 것이다. 분해한 음절도 함께 든다(견줄 때마다 다시 분해하지 않게). */
|
|
336
355
|
const groups = new Map();
|
|
337
356
|
/* 표기가 같아도 뜻마다 칸이 하나다 — 판정은 대표로 한 번만 하고 결과에서 뜻을 펼친다 */
|
|
338
|
-
const {
|
|
357
|
+
const { heads, senses } = groupBySense(words);
|
|
339
358
|
const seenWords = new Set();
|
|
340
|
-
for (const entry of
|
|
359
|
+
for (const entry of heads) {
|
|
341
360
|
seenWords.add(entry.word);
|
|
342
361
|
const decomposeFn = phonemic ? decomposePhonemicSyllable : decompose;
|
|
343
362
|
const syllables = [...cleanPron(entry.pronunciation)].map(decomposeFn);
|
|
@@ -346,25 +365,25 @@ function searchPairsCore(selectedList, words, debug, enforcePosition, phonemic =
|
|
|
346
365
|
const syls = syllables;
|
|
347
366
|
for (let p = 0; p < syls.length; p++) {
|
|
348
367
|
const key = makeKeyForPosition(syls, p);
|
|
349
|
-
let
|
|
350
|
-
if (
|
|
351
|
-
|
|
352
|
-
groups.set(key,
|
|
368
|
+
let group = groups.get(key);
|
|
369
|
+
if (!group) {
|
|
370
|
+
group = { emptyAt: p, members: [] };
|
|
371
|
+
groups.set(key, group);
|
|
353
372
|
}
|
|
354
|
-
|
|
373
|
+
group.members.push({ entry, syls });
|
|
355
374
|
}
|
|
356
375
|
}
|
|
357
376
|
const results = [];
|
|
358
377
|
const rejectCounts = createRejectCounter();
|
|
359
378
|
const rejectSamples = [];
|
|
360
379
|
let comparedPairs = 0;
|
|
361
|
-
for (const {
|
|
362
|
-
if (
|
|
380
|
+
for (const { emptyAt, members } of groups.values()) {
|
|
381
|
+
if (members.length < 2)
|
|
363
382
|
continue;
|
|
364
|
-
for (let i = 0; i <
|
|
365
|
-
for (let j = i + 1; j <
|
|
366
|
-
const w1 =
|
|
367
|
-
const w2 =
|
|
383
|
+
for (let i = 0; i < members.length; i++) {
|
|
384
|
+
for (let j = i + 1; j < members.length; j++) {
|
|
385
|
+
const w1 = members[i].entry;
|
|
386
|
+
const w2 = members[j].entry;
|
|
368
387
|
/* 같은 표기가 발음만 다르게 두 번 들어온 자료(김밥[김밥]·김밥[김빱])는 짝으로 세지 않는다 —
|
|
369
388
|
칸 둘의 이름이 같은 '김밥/김밥' 쌍이 된다. 견준 쌍을 다 기억하던 전에는 이것이
|
|
370
389
|
덤으로 막히고 있었다(같은 표기면 열쇠가 하나라 한 번만 지나갔다). */
|
|
@@ -383,15 +402,15 @@ function searchPairsCore(selectedList, words, debug, enforcePosition, phonemic =
|
|
|
383
402
|
남은 것은 '한 군데만 다른가' 를 쌍 문자열·발음 분해 전에 여기서 재는 일이다 —
|
|
384
403
|
analyzeMinimalPair 와 같은 잣대(SECTION_KEYS)를 그 한 자리에만 쓴다. 포함 검색
|
|
385
404
|
(searchWordsByPhonemes)이 전부터 하던 것과 같은 걸름이다. */
|
|
386
|
-
const s1 =
|
|
387
|
-
const s2 =
|
|
388
|
-
let
|
|
405
|
+
const s1 = members[i].syls[emptyAt];
|
|
406
|
+
const s2 = members[j].syls[emptyAt];
|
|
407
|
+
let diffCount = 0;
|
|
389
408
|
for (const sec of SECTION_KEYS)
|
|
390
409
|
if (s1[sec] !== s2[sec])
|
|
391
|
-
|
|
410
|
+
diffCount += 1;
|
|
392
411
|
comparedPairs += 1;
|
|
393
|
-
if (
|
|
394
|
-
const reason =
|
|
412
|
+
if (diffCount !== 1) {
|
|
413
|
+
const reason = diffCount === 0 ? 'no_difference' : 'multiple_differences';
|
|
395
414
|
rejectCounts[reason] += 1;
|
|
396
415
|
if (debug && rejectSamples.length < 20)
|
|
397
416
|
rejectSamples.push({ id: `${w1.word}|${w2.word}`, reason });
|
|
@@ -421,13 +440,15 @@ function searchPairsCore(selectedList, words, debug, enforcePosition, phonemic =
|
|
|
421
440
|
const sDiffVals = analysis.diff.diffs[sDiffSection];
|
|
422
441
|
const sDiffHasEmpty = diffHasEmptyPair(sDiffSection, sDiffVals);
|
|
423
442
|
// 뜻마다 한 칸 — '눈'(신체)·'눈'(날씨) 가 각각 '문' 과 쌍을 이룬다
|
|
424
|
-
for (const f of
|
|
425
|
-
for (const sec of
|
|
443
|
+
for (const f of senses.get(first) ?? [first]) {
|
|
444
|
+
for (const sec of senses.get(second) ?? [second]) {
|
|
426
445
|
results.push({
|
|
427
|
-
id: `${
|
|
428
|
-
|
|
446
|
+
id: `${wordKey(f)}|${wordKey(sec)}`,
|
|
447
|
+
/* 옛 이름을 내보내고 코드를 나란히 — 소비자가 옛 값으로 묶음을 가른다 */
|
|
448
|
+
diffSection: toLegacyPhonemeKey(sDiffSection),
|
|
449
|
+
diffSectionCode: sDiffSection,
|
|
429
450
|
diffHasEmpty: sDiffHasEmpty,
|
|
430
|
-
words: [
|
|
451
|
+
words: [wordCells(f), wordCells(sec)],
|
|
431
452
|
});
|
|
432
453
|
}
|
|
433
454
|
}
|
|
@@ -469,38 +490,38 @@ export function searchPairs(selectedList, words, enforcePosition, phonemic = fal
|
|
|
469
490
|
// 슬롯 i → 단어의 i번째 음절에 조건 매칭 (순서 보장)
|
|
470
491
|
/** 표기가 같아도 뜻이 다르면 다른 칸 — 칸에 붙는 그림이 뜻마다 다르다(사용자 결정 2026-09-16).
|
|
471
492
|
* 동음이의 번호가 없는 자료는 예전처럼 표기뿐이다. */
|
|
472
|
-
function
|
|
493
|
+
function wordKey(entry) {
|
|
473
494
|
return entry.homonum != null ? `${entry.word}#${entry.homonum}` : entry.word;
|
|
474
495
|
}
|
|
475
496
|
/** 표기·발음이 같은 뜻들을 한 대표로 묶는다 — 최소대립쌍 판정은 표기와 발음만 보므로 뜻마다 다시 셀 까닭이 없다.
|
|
476
497
|
* (뜻을 모두 후보로 넣으면 묶음이 커져 23,000 낱말에서 검색이 2–4배 느려졌다.) 결과를 만들 때 뜻마다 칸을 펼친다. */
|
|
477
498
|
/** 결과 칸 하나 */
|
|
478
|
-
function
|
|
499
|
+
function wordCells(e) {
|
|
479
500
|
return {
|
|
480
501
|
label: e.word, subLabel: e.pronunciation, colloquialLabel: e.colloquialPronunciation, homonum: e.homonum,
|
|
481
502
|
grade: e.grade, syllables: [...e.word].length, pos: e.pos, wordType: e.wordType, isCustom: e.isCustom,
|
|
482
503
|
};
|
|
483
504
|
}
|
|
484
|
-
function
|
|
485
|
-
const
|
|
486
|
-
const
|
|
487
|
-
const
|
|
505
|
+
function groupBySense(words) {
|
|
506
|
+
const byKey = new Map();
|
|
507
|
+
const senses = new Map();
|
|
508
|
+
const heads = [];
|
|
488
509
|
for (const entry of words) {
|
|
489
|
-
const
|
|
490
|
-
const
|
|
491
|
-
if (
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
510
|
+
const key = `${entry.word}\u0000${cleanPron(entry.pronunciation)}`;
|
|
511
|
+
const head = byKey.get(key);
|
|
512
|
+
if (head === undefined) {
|
|
513
|
+
byKey.set(key, entry);
|
|
514
|
+
heads.push(entry);
|
|
515
|
+
senses.set(entry, [entry]);
|
|
495
516
|
continue;
|
|
496
517
|
}
|
|
497
|
-
const
|
|
518
|
+
const list = senses.get(head);
|
|
498
519
|
// 같은 번호가 두 번 오면(자료 중복) 한 번만
|
|
499
|
-
if (
|
|
520
|
+
if (list.some(e => wordKey(e) === wordKey(entry)))
|
|
500
521
|
continue;
|
|
501
|
-
|
|
522
|
+
list.push(entry);
|
|
502
523
|
}
|
|
503
|
-
return {
|
|
524
|
+
return { heads, senses };
|
|
504
525
|
}
|
|
505
526
|
function wordContainsAllPhonemes(syllables, requirements) {
|
|
506
527
|
for (let i = 0; i < requirements.length; i++) {
|
|
@@ -534,18 +555,20 @@ export function searchWordsByPhonemes(requiredPhonemes, words, ordered = true, p
|
|
|
534
555
|
* 이 조건을 만족하는 쪽을 우선한다(예: "검색 단어만" 필터 통과 여부). 지정하지
|
|
535
556
|
* 않으면 기존 동작(먼저 발견된 방향을 그대로 채택)과 동일하다. */
|
|
536
557
|
preferAsTarget) {
|
|
537
|
-
|
|
558
|
+
/* 들어오는 자리에서 코드로 — 옛 한글 열쇠로 저장된 표도 그대로 읽힌다 */
|
|
559
|
+
const coded = requiredPhonemes.map(toCodes);
|
|
560
|
+
const reqs = coded.filter(r => Object.keys(r).some(k => isSectionKey(k)));
|
|
538
561
|
if (reqs.length === 0)
|
|
539
562
|
return [];
|
|
540
563
|
const encodeAll = phonemic
|
|
541
|
-
? (s) => s
|
|
542
|
-
: (s) => s
|
|
564
|
+
? (s) => s.onset + (s.glide ?? '') + s.vowel + s.coda
|
|
565
|
+
: (s) => s.onset + s.vowel + s.coda;
|
|
543
566
|
const makeKey = (syls, pos) => syls.map((s, i) => (i === pos ? '*' : encodeAll(s))).join('|');
|
|
544
567
|
const decomposeFn = phonemic ? decomposePhonemicSyllable : decompose;
|
|
545
568
|
const allWords = [];
|
|
546
|
-
const {
|
|
569
|
+
const { heads, senses } = groupBySense(words);
|
|
547
570
|
const seenWords = new Set();
|
|
548
|
-
for (const entry of
|
|
571
|
+
for (const entry of heads) {
|
|
549
572
|
seenWords.add(entry.word);
|
|
550
573
|
const syls = [...cleanPron(entry.pronunciation)].map(decomposeFn);
|
|
551
574
|
if (syls.some(s => s === null))
|
|
@@ -563,7 +586,7 @@ preferAsTarget) {
|
|
|
563
586
|
/* 일치(ordered) 는 조건의 자리가 곧 음절 자리다 — 빈 슬롯을 걸러 낸 reqs 를 쓰면 [{}, {초성:ㄴ}] 의 ㄴ 이 첫 음절에 걸렸다.
|
|
564
587
|
wordContainsAllPhonemes 는 빈 슬롯을 스스로 건너뛴다. */
|
|
565
588
|
return ordered
|
|
566
|
-
? wordContainsAllPhonemes(filterSyls,
|
|
589
|
+
? wordContainsAllPhonemes(filterSyls, coded)
|
|
567
590
|
: wordContainsAllPhonemesUnordered(filterSyls, reqs);
|
|
568
591
|
});
|
|
569
592
|
const groups = new Map();
|
|
@@ -575,7 +598,7 @@ preferAsTarget) {
|
|
|
575
598
|
groups.get(key).push(w);
|
|
576
599
|
}
|
|
577
600
|
}
|
|
578
|
-
const
|
|
601
|
+
const pairs = [];
|
|
579
602
|
// dedupKey → 쌍들 배열 내 인덱스. 같은 두 단어가 서로 minimal pair이면서
|
|
580
603
|
// 둘 다 독립적으로 targetWords에 속할 수 있어(예: 다른 음절에 요구 음소가 하나 더 있는 경우),
|
|
581
604
|
// 어느 쪽을 target(1번/words[0])으로 채택할지가 순회 순서에 좌우될 수 있다.
|
|
@@ -594,13 +617,13 @@ preferAsTarget) {
|
|
|
594
617
|
{
|
|
595
618
|
const cs = candidate.syls[p];
|
|
596
619
|
let d = 0;
|
|
597
|
-
if (ts
|
|
620
|
+
if (ts.onset !== cs.onset)
|
|
598
621
|
d++;
|
|
599
|
-
if (ts
|
|
622
|
+
if (ts.vowel !== cs.vowel)
|
|
600
623
|
d++;
|
|
601
|
-
if (ts
|
|
624
|
+
if (ts.coda !== cs.coda)
|
|
602
625
|
d++;
|
|
603
|
-
if ((ts
|
|
626
|
+
if ((ts.glide ?? '') !== (cs.glide ?? ''))
|
|
604
627
|
d++;
|
|
605
628
|
if (d !== 1)
|
|
606
629
|
continue;
|
|
@@ -612,7 +635,7 @@ preferAsTarget) {
|
|
|
612
635
|
if (existingIdx !== undefined) {
|
|
613
636
|
if (!preferAsTarget)
|
|
614
637
|
continue;
|
|
615
|
-
if (preferAsTarget(
|
|
638
|
+
if (preferAsTarget(wordCells(pairs[existingIdx].target)))
|
|
616
639
|
continue;
|
|
617
640
|
if (!preferAsTarget({ grade: target.entry.grade, syllables: [...target.entry.word].length, pos: target.entry.pos, wordType: target.entry.wordType }))
|
|
618
641
|
continue;
|
|
@@ -635,12 +658,12 @@ preferAsTarget) {
|
|
|
635
658
|
if (tv !== cv)
|
|
636
659
|
diffValues = [tv, cv];
|
|
637
660
|
}
|
|
638
|
-
const
|
|
661
|
+
const matches = (req) => {
|
|
639
662
|
const reqVal = req?.[diffSection];
|
|
640
663
|
return reqVal !== undefined && (diffValues[0] === reqVal || diffValues[1] === reqVal);
|
|
641
664
|
};
|
|
642
665
|
// 일치(ordered): "첫번째 음절에서 초성 ㄱ 이 대립" — 대립한 자리의 조건이어야 한다(다른 음절에서 대립한 쌍이 섞였다)
|
|
643
|
-
const isRelevant = ordered ?
|
|
666
|
+
const isRelevant = ordered ? matches(coded[analysis.diff.idx]) : reqs.some(matches);
|
|
644
667
|
if (!isRelevant)
|
|
645
668
|
continue;
|
|
646
669
|
}
|
|
@@ -649,26 +672,27 @@ preferAsTarget) {
|
|
|
649
672
|
const diffHasEmpty = diffHasEmptyPair(diffSection, diffVals);
|
|
650
673
|
const item = { target: target.entry, candidate: candidate.entry, diffSection, diffHasEmpty };
|
|
651
674
|
if (existingIdx !== undefined) {
|
|
652
|
-
|
|
675
|
+
pairs[existingIdx] = item;
|
|
653
676
|
}
|
|
654
677
|
else {
|
|
655
|
-
seenPairs.set(dedupKey,
|
|
656
|
-
|
|
678
|
+
seenPairs.set(dedupKey, pairs.length);
|
|
679
|
+
pairs.push(item);
|
|
657
680
|
}
|
|
658
681
|
}
|
|
659
682
|
}
|
|
660
683
|
}
|
|
661
684
|
/* 뜻마다 한 칸 — 기준·짝의 뜻을 모두 펼친다('눈'(신체)·'눈'(날씨) 가 각각 '문' 과 쌍). id 는 정렬 꼴에 번호까지. */
|
|
662
685
|
const results = [];
|
|
663
|
-
for (const
|
|
664
|
-
for (const t of
|
|
665
|
-
for (const c of
|
|
666
|
-
const kt =
|
|
686
|
+
for (const item of pairs) {
|
|
687
|
+
for (const t of senses.get(item.target) ?? [item.target]) {
|
|
688
|
+
for (const c of senses.get(item.candidate) ?? [item.candidate]) {
|
|
689
|
+
const kt = wordKey(t), kc = wordKey(c);
|
|
667
690
|
results.push({
|
|
668
691
|
id: kt < kc ? `${kt}|${kc}` : `${kc}|${kt}`,
|
|
669
|
-
diffSection:
|
|
670
|
-
|
|
671
|
-
|
|
692
|
+
diffSection: toLegacyPhonemeKey(item.diffSection),
|
|
693
|
+
diffSectionCode: item.diffSection,
|
|
694
|
+
diffHasEmpty: item.diffHasEmpty,
|
|
695
|
+
words: [wordCells(t), wordCells(c)],
|
|
672
696
|
});
|
|
673
697
|
}
|
|
674
698
|
}
|
|
@@ -25,6 +25,8 @@ export interface PairFilterKeys {
|
|
|
25
25
|
syllable5plus: string;
|
|
26
26
|
partOfSpeech: string;
|
|
27
27
|
wordType: string;
|
|
28
|
+
/** 의미범주 절의 이름표. **안 줘도 된다** — 안 주면 어느 언어 라벨이든 사전으로 읽는다(0.324.0). */
|
|
29
|
+
field?: string;
|
|
28
30
|
posMap: Record<string, string[]>;
|
|
29
31
|
typeMap: Record<string, string>;
|
|
30
32
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* **음절 자리의 코드** — `onset`·`vowel`·`coda`·`glide`, 그리고 빔(`none`).
|
|
3
|
+
*
|
|
4
|
+
* ## 왜 제 모듈인가
|
|
5
|
+
*
|
|
6
|
+
* 처음에는 이 가름을 `phonemePairSearch` 에 두고 `phonemeConvert` 가 들여왔다 — 그런데
|
|
7
|
+
* `phonemePairSearch` 도 `phonemeConvert`(`VOWEL_TO_PHONEME`)를 들여온다. **돌림 import** 가
|
|
8
|
+
* 되어 시험 **338 꾸러미**가 한꺼번에 무너졌다(2026-10-13). 두 쪽이 함께 쓰는 것은 **어느
|
|
9
|
+
* 한쪽이 아니라 그 아래**에 둔다.
|
|
10
|
+
*
|
|
11
|
+
* ## 왜 코드인가
|
|
12
|
+
*
|
|
13
|
+
* 전에는 열쇠가 한글(`'초성'`)이었고 그것이 **저장된다** — 단어표의
|
|
14
|
+
* `searchConfig.phonemeSelected` 가 그 꼴이다. 저장되는 값이 표시 언어에 묶이면 말을 고치려
|
|
15
|
+
* 해도 자료를 밀어야 한다(사장님 지시 2026-10-13 "동작에 한글이 끼지 말라").
|
|
16
|
+
*
|
|
17
|
+
* **값을 바꾸지 않고 읽는 쪽을 너그럽게 했다** — 표준의 *lenient reader · strict writer*.
|
|
18
|
+
* 저장된 표를 밀지 않아도 되고 소비자 코드도 안 바뀐다. 안쪽 로직에서만 한글이 사라진다.
|
|
19
|
+
*
|
|
20
|
+
* 어휘는 **훈련 필터가 이미 쓰던 것**과 같게 뒀다(`useWordTrainingFilter` 의 `onset`·`vowel`·
|
|
21
|
+
* `coda`) — 같은 뜻에 어휘가 둘이던 것을 모았다.
|
|
22
|
+
*
|
|
23
|
+
* **값(자모 `'ㄴ'`)은 그대로다** — 그것은 한국어 글자 그 자체이고 바꿀 것은 열쇠와 빔 값이다.
|
|
24
|
+
*/
|
|
25
|
+
/** 음절 자리 코드 @public */
|
|
26
|
+
export type PhonemeSectionCode = 'onset' | 'vowel' | 'coda' | 'glide';
|
|
27
|
+
/** @public */
|
|
28
|
+
export declare const PHONEME_SECTION_CODES: PhonemeSectionCode[];
|
|
29
|
+
/** 자리가 비었음 — 초성 ㅇ · 받침 없음 · 활음 없음을 한 값으로 적는다 @public */
|
|
30
|
+
export declare const PHONEME_NONE = "none";
|
|
31
|
+
/** @public */
|
|
32
|
+
export declare function isPhonemeSectionCode(value: string): value is PhonemeSectionCode;
|
|
33
|
+
/**
|
|
34
|
+
* **들어오는 고른값 하나를 코드 꼴로.** 옛 한글 열쇠·옛 빔 값도 받는다.
|
|
35
|
+
*
|
|
36
|
+
* 공개 함수(`searchPairs`·`searchWordsByPhonemes`·`convertToPhoneme`)가 받는
|
|
37
|
+
* `Record<string, string>` 은 **저장된 검색 구성에서 온다** — 그 꼴을 바꾸면 이미 저장된
|
|
38
|
+
* 단어표가 깨진다. 그래서 들어오는 자리에서 한 번 옮기고, 안쪽은 코드만 본다.
|
|
39
|
+
*
|
|
40
|
+
* @public
|
|
41
|
+
*/
|
|
42
|
+
export declare function toPhonemeCodes(sel: Record<string, string>): Partial<Record<PhonemeSectionCode, string>>;
|
|
43
|
+
/**
|
|
44
|
+
* 코드를 **옛 열쇠 글자로** — 밖으로 나가는 `diffSection` 이 그 꼴이다.
|
|
45
|
+
*
|
|
46
|
+
* 소비자가 그 값으로 묶음을 가른다(말귀 `WordPairsPage` 의 `GROUP_ORDER`·`GROUP_LABEL`).
|
|
47
|
+
* 혼자 코드로 바꾸면 **그쪽 묶음이 조용히 빈다** — 그래서 옛 이름을 내보내고, 코드는
|
|
48
|
+
* `diffSectionCode` 로 **나란히** 둔다(2026-10-13). 소비자가 옮긴 뒤 옛 쪽을 걷는다.
|
|
49
|
+
*
|
|
50
|
+
* @public
|
|
51
|
+
*/
|
|
52
|
+
export declare function toLegacyPhonemeKey(code: PhonemeSectionCode): string;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* **음절 자리의 코드** — `onset`·`vowel`·`coda`·`glide`, 그리고 빔(`none`).
|
|
3
|
+
*
|
|
4
|
+
* ## 왜 제 모듈인가
|
|
5
|
+
*
|
|
6
|
+
* 처음에는 이 가름을 `phonemePairSearch` 에 두고 `phonemeConvert` 가 들여왔다 — 그런데
|
|
7
|
+
* `phonemePairSearch` 도 `phonemeConvert`(`VOWEL_TO_PHONEME`)를 들여온다. **돌림 import** 가
|
|
8
|
+
* 되어 시험 **338 꾸러미**가 한꺼번에 무너졌다(2026-10-13). 두 쪽이 함께 쓰는 것은 **어느
|
|
9
|
+
* 한쪽이 아니라 그 아래**에 둔다.
|
|
10
|
+
*
|
|
11
|
+
* ## 왜 코드인가
|
|
12
|
+
*
|
|
13
|
+
* 전에는 열쇠가 한글(`'초성'`)이었고 그것이 **저장된다** — 단어표의
|
|
14
|
+
* `searchConfig.phonemeSelected` 가 그 꼴이다. 저장되는 값이 표시 언어에 묶이면 말을 고치려
|
|
15
|
+
* 해도 자료를 밀어야 한다(사장님 지시 2026-10-13 "동작에 한글이 끼지 말라").
|
|
16
|
+
*
|
|
17
|
+
* **값을 바꾸지 않고 읽는 쪽을 너그럽게 했다** — 표준의 *lenient reader · strict writer*.
|
|
18
|
+
* 저장된 표를 밀지 않아도 되고 소비자 코드도 안 바뀐다. 안쪽 로직에서만 한글이 사라진다.
|
|
19
|
+
*
|
|
20
|
+
* 어휘는 **훈련 필터가 이미 쓰던 것**과 같게 뒀다(`useWordTrainingFilter` 의 `onset`·`vowel`·
|
|
21
|
+
* `coda`) — 같은 뜻에 어휘가 둘이던 것을 모았다.
|
|
22
|
+
*
|
|
23
|
+
* **값(자모 `'ㄴ'`)은 그대로다** — 그것은 한국어 글자 그 자체이고 바꿀 것은 열쇠와 빔 값이다.
|
|
24
|
+
*/
|
|
25
|
+
/** @public */
|
|
26
|
+
export const PHONEME_SECTION_CODES = ['onset', 'vowel', 'coda', 'glide'];
|
|
27
|
+
/** 자리가 비었음 — 초성 ㅇ · 받침 없음 · 활음 없음을 한 값으로 적는다 @public */
|
|
28
|
+
export const PHONEME_NONE = 'none';
|
|
29
|
+
/** 저장된 옛 열쇠 → 코드. 옛 표를 읽을 때만 쓰는 다리다(새로 쓰는 것은 코드뿐) */
|
|
30
|
+
const LEGACY_SECTION_KEY = {
|
|
31
|
+
초성: 'onset', 모음: 'vowel', 종성: 'coda', 활음: 'glide',
|
|
32
|
+
};
|
|
33
|
+
/** 저장된 옛 '빔' 값 */
|
|
34
|
+
const LEGACY_NONE = '없음';
|
|
35
|
+
/** @public */
|
|
36
|
+
export function isPhonemeSectionCode(value) {
|
|
37
|
+
return PHONEME_SECTION_CODES.includes(value);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* **들어오는 고른값 하나를 코드 꼴로.** 옛 한글 열쇠·옛 빔 값도 받는다.
|
|
41
|
+
*
|
|
42
|
+
* 공개 함수(`searchPairs`·`searchWordsByPhonemes`·`convertToPhoneme`)가 받는
|
|
43
|
+
* `Record<string, string>` 은 **저장된 검색 구성에서 온다** — 그 꼴을 바꾸면 이미 저장된
|
|
44
|
+
* 단어표가 깨진다. 그래서 들어오는 자리에서 한 번 옮기고, 안쪽은 코드만 본다.
|
|
45
|
+
*
|
|
46
|
+
* @public
|
|
47
|
+
*/
|
|
48
|
+
export function toPhonemeCodes(sel) {
|
|
49
|
+
const out = {};
|
|
50
|
+
for (const [k, v] of Object.entries(sel)) {
|
|
51
|
+
const key = isPhonemeSectionCode(k) ? k : LEGACY_SECTION_KEY[k];
|
|
52
|
+
/* **빈 값은 버리지 않는다.** `{ 종성: '' }` 은 "받침 없음을 골랐다" 는 **뜻 있는 고름**이다 —
|
|
53
|
+
한 번 버렸다가 `받침 있음↔없음` 대립 검색이 빈손이 됐다(2026-10-13, 시험이 잡았다).
|
|
54
|
+
거르는 것은 **열쇠를 못 알아볼 때**뿐이다. */
|
|
55
|
+
if (!key)
|
|
56
|
+
continue;
|
|
57
|
+
out[key] = v === LEGACY_NONE ? PHONEME_NONE : v;
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
/** 코드 → 저장된 옛 열쇠. 밖으로 내보내는 값이 옛 꼴이어야 할 때만 쓴다 */
|
|
62
|
+
const CODE_TO_LEGACY = {
|
|
63
|
+
onset: '초성', vowel: '모음', coda: '종성', glide: '활음',
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* 코드를 **옛 열쇠 글자로** — 밖으로 나가는 `diffSection` 이 그 꼴이다.
|
|
67
|
+
*
|
|
68
|
+
* 소비자가 그 값으로 묶음을 가른다(말귀 `WordPairsPage` 의 `GROUP_ORDER`·`GROUP_LABEL`).
|
|
69
|
+
* 혼자 코드로 바꾸면 **그쪽 묶음이 조용히 빈다** — 그래서 옛 이름을 내보내고, 코드는
|
|
70
|
+
* `diffSectionCode` 로 **나란히** 둔다(2026-10-13). 소비자가 옮긴 뒤 옛 쪽을 걷는다.
|
|
71
|
+
*
|
|
72
|
+
* @public
|
|
73
|
+
*/
|
|
74
|
+
export function toLegacyPhonemeKey(code) {
|
|
75
|
+
return CODE_TO_LEGACY[code];
|
|
76
|
+
}
|
|
@@ -26,19 +26,19 @@
|
|
|
26
26
|
* @public
|
|
27
27
|
*/
|
|
28
28
|
export function prefetchChunk(...loads) {
|
|
29
|
-
let
|
|
30
|
-
const
|
|
29
|
+
let cancelled = false;
|
|
30
|
+
const load = () => { if (!cancelled)
|
|
31
31
|
for (const load of loads)
|
|
32
32
|
void load().catch(() => { }); };
|
|
33
33
|
const idle = globalThis.requestIdleCallback;
|
|
34
34
|
if (idle) {
|
|
35
|
-
const id = idle(
|
|
35
|
+
const id = idle(load, { timeout: 2000 });
|
|
36
36
|
return () => {
|
|
37
|
-
|
|
37
|
+
cancelled = true;
|
|
38
38
|
const cancel = globalThis.cancelIdleCallback;
|
|
39
39
|
cancel?.(id);
|
|
40
40
|
};
|
|
41
41
|
}
|
|
42
|
-
const id = setTimeout(
|
|
43
|
-
return () => {
|
|
42
|
+
const id = setTimeout(load, 300);
|
|
43
|
+
return () => { cancelled = true; clearTimeout(id); };
|
|
44
44
|
}
|
|
@@ -8,16 +8,16 @@
|
|
|
8
8
|
export function isSafeOpenHref(href) {
|
|
9
9
|
if (typeof href !== 'string')
|
|
10
10
|
return false;
|
|
11
|
-
let
|
|
11
|
+
let stripped = '';
|
|
12
12
|
for (const ch of href) {
|
|
13
13
|
const c = ch.charCodeAt(0);
|
|
14
14
|
if (c <= 0x20 || (c >= 0x7f && c <= 0x9f))
|
|
15
15
|
continue;
|
|
16
|
-
|
|
16
|
+
stripped += ch;
|
|
17
17
|
}
|
|
18
|
-
if (
|
|
18
|
+
if (!stripped)
|
|
19
19
|
return false;
|
|
20
|
-
const m = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(
|
|
20
|
+
const m = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(stripped);
|
|
21
21
|
if (!m)
|
|
22
22
|
return true; // 스킴이 없다 — 상대 경로(`/help`·`./a`·`?q`·`#x`)·`//host` 는 지금 문서의 스킴을 따른다
|
|
23
23
|
return ['http', 'https', 'mailto', 'tel'].includes(m[1].toLowerCase());
|