@ihabdevteam/core 0.292.0 → 0.294.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 +69 -0
- package/dist/components.cjs.js +11 -11
- package/dist/hooks.cjs.js +3 -3
- package/dist/i18n.cjs.js +1 -1
- package/dist/ihabdevteam-core.cjs.js +186 -186
- package/dist/ihabdevteam-core.d.ts +34 -1
- package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +49 -9
- package/dist/packages/core/src/components/Training/usePlaybackSequencer.d.ts +20 -9
- package/dist/packages/core/src/components/Training/usePlaybackSequencer.js +11 -5
- package/dist/packages/core/src/utils/koreanSyllable.d.ts +32 -0
- package/dist/packages/core/src/utils/koreanSyllable.js +66 -0
- package/dist/packages/core/src/utils.d.ts +1 -1
- package/dist/packages/core/src/utils.js +1 -1
- package/dist/packages/core/src/version.d.ts +1 -1
- package/dist/packages/core/src/version.js +1 -1
- package/dist/utils.cjs.js +2 -2
- package/package.json +1 -1
|
@@ -1651,7 +1651,7 @@ export declare function copyText(text: string): Promise<boolean>;
|
|
|
1651
1651
|
*
|
|
1652
1652
|
* 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
|
|
1653
1653
|
*/
|
|
1654
|
-
export declare const CORE_VERSION = "0.
|
|
1654
|
+
export declare const CORE_VERSION = "0.294.0";
|
|
1655
1655
|
|
|
1656
1656
|
/** @public */
|
|
1657
1657
|
declare const coreDefault: Record<string, unknown>;
|
|
@@ -10483,6 +10483,39 @@ export declare interface WordFilterSnapshot {
|
|
|
10483
10483
|
wordTypeFilter?: string[];
|
|
10484
10484
|
}
|
|
10485
10485
|
|
|
10486
|
+
/**
|
|
10487
|
+
* @public **자리(어두·어중·어말)를 음절이 아니라 자음에 매긴다** — 말귀 요청 (93), 2026-10-06.
|
|
10488
|
+
*
|
|
10489
|
+
* `wordHasSyllableMatching` 의 위치 제약은 **음절 색인**으로 가른다(0번=어두 · 마지막=어말 ·
|
|
10490
|
+
* 그 사이=어중). 그러면 **2음절 낱말에는 어중이 아예 없다** — `선물` 의 ㄴ받침·ㅁ초성을
|
|
10491
|
+
* 뽑을 길이 없어 검색이 자료의 일부에 못 닿았다(말귀 두 화면이 2음절 이하면 어중 단추를
|
|
10492
|
+
* 잠가 두고 있었다).
|
|
10493
|
+
*
|
|
10494
|
+
* 임상에서 쓰는 모델은 자리를 **자음에만** 매긴다(김동민 팀장·사공정희 선생, 2026-10-06):
|
|
10495
|
+
*
|
|
10496
|
+
* 선물 → ㅅ 어두초성 · ㄴ 어중종성 · ㅁ 어중초성 · ㄹ 어말종성
|
|
10497
|
+
* 아기 → ㄱ 어중초성 (어두초성 없음 — `ㅇ` 은 음가가 없다 · 어말종성 없음)
|
|
10498
|
+
*
|
|
10499
|
+
* 어두 = 첫 음절의 **초성**(`ㅇ` 이면 **없는 것**으로 본다)
|
|
10500
|
+
* 어말 = 마지막 음절의 **받침**(없으면 없는 것)
|
|
10501
|
+
* 어중 = 그 밖의 **모든 초성·종성** — **2음절에도 생긴다**
|
|
10502
|
+
* 중성(모음)에는 자리를 **안 매긴다** — 그래서 이 함수는 `jung` 을 안 받는다
|
|
10503
|
+
*
|
|
10504
|
+
* **`mid` 에서 `cho` 와 `jong` 을 둘 다 주면 둘 다 있어야 한다**(AND, 사용자 결정). `선물`
|
|
10505
|
+
* 처럼 **받침+다음 초성**이 맞물리는 자음동화 꼴을 잡으려는 것이다. 다만 **붙어 있어야
|
|
10506
|
+
* 하는 것은 아니다** — 낱말 어디에든 하나씩 있으면 된다. 하나만 주면 그것만 본다.
|
|
10507
|
+
*
|
|
10508
|
+
* `wordHasSyllableMatching` 은 **그대로 둔다** — 위치가 `무관` 일 때는 초성·중성·종성을
|
|
10509
|
+
* **한 음절 안에서** 묶어 보는 것이 맞다(`ㄱ+ㅏ+ㅇ` = `강`). 두 함수는 묻는 것이 다르다.
|
|
10510
|
+
*/
|
|
10511
|
+
export declare function wordHasConsonantAt(word: string, parts: {
|
|
10512
|
+
cho?: string[] | null;
|
|
10513
|
+
jong?: string[] | null;
|
|
10514
|
+
jongNone?: boolean;
|
|
10515
|
+
}, position: 'head' | 'mid' | 'tail',
|
|
10516
|
+
/** 종성을 무엇으로 견줄지 — `wordHasSyllableMatching` 과 같다(`JongMatch` 참고) */
|
|
10517
|
+
jongMatch?: JongMatch): boolean;
|
|
10518
|
+
|
|
10486
10519
|
/** @public 단어의 모든 한글 음절에 종성(받침)이 없는지 (열린 음절로만 구성) */
|
|
10487
10520
|
export declare function wordHasNoCoda(word: string): boolean;
|
|
10488
10521
|
|
|
@@ -339,7 +339,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
339
339
|
}
|
|
340
340
|
setFillCompletionPhase('idle');
|
|
341
341
|
}, []);
|
|
342
|
-
const tryPlayWithRetry = useCallback(async (a, attempts = 3, delayMs = 50) => {
|
|
342
|
+
const tryPlayWithRetry = useCallback(async (a, attempts = 3, delayMs = 50, 앞뜸흐른 = 0) => {
|
|
343
343
|
/* 지난 완료의 접는 연출을 먼저 걷는다 — 위 설명 참고 */
|
|
344
344
|
완료연출걷기();
|
|
345
345
|
const 내세대 = 진행기.generation();
|
|
@@ -357,7 +357,10 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
357
357
|
> 있다. 그러면 시간표를 *앞뜸 + 남은 소리 + 뒤뜸* 으로 다시 그려야 어긋나지 않는다.
|
|
358
358
|
> 그것은 **사장님께 여쭐 일**이라 지금은 "앞뜸은 소리 앞" 이라는 뜻 그대로 둔다. */
|
|
359
359
|
const 이어틀기 = !!a && a.currentTime > 0 && !a.ended;
|
|
360
|
-
|
|
360
|
+
/* 앞뜸에서 멈췄다 이으면 **남은 만큼만** 채운다 — 뜸도 시간표의 일부라 처음부터 다시
|
|
361
|
+
세면 그 자리에서 이어지지 않는다(사용자 결정, 2026-10-06). */
|
|
362
|
+
const 남은앞뜸 = Math.max(0, leadInMsRef.current - Math.max(0, 앞뜸흐른));
|
|
363
|
+
if (남은앞뜸 > 0 && !이어틀기) {
|
|
361
364
|
/* **스피커는 뜸이 시작될 때 잡는다**((85) 2b, 2026-10-06). 전에는 `await a.play()` 바로
|
|
362
365
|
앞에서 잡았는데, 그러면 **앞뜸 내내 주인이 없다** — 그 사이 남이 소리를 내도 이쪽을
|
|
363
366
|
못 멈추고, 뜸이 끝나면 둘이 함께 울린다. 소음은 이미 뜸부터 깔리니(0.268.0)
|
|
@@ -380,7 +383,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
380
383
|
}
|
|
381
384
|
catch (e) { }
|
|
382
385
|
}
|
|
383
|
-
if (!(await 진행기.waitPad(
|
|
386
|
+
if (!(await 진행기.waitPad(남은앞뜸, 'lead', 앞뜸흐른)))
|
|
384
387
|
return false;
|
|
385
388
|
if (!mountedRef.current || !진행기.isCurrent(내세대))
|
|
386
389
|
return false;
|
|
@@ -422,6 +425,39 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
422
425
|
catch (_) { }
|
|
423
426
|
return false;
|
|
424
427
|
}, [t, startTicker, 진행기, fadeInNoise, noiseSettingsRef, 완료연출걷기]);
|
|
428
|
+
/**
|
|
429
|
+
* **뒤뜸(사이 뜸)에서 멈췄다 이을 때** — 남은 만큼 돌고 하던 일을 잇는다.
|
|
430
|
+
*
|
|
431
|
+
* 전에는 그 뜸을 **건너뛰고** 바로 다음 소리로 갔다(마지막이면 즉시 완료). 그러면 사이가
|
|
432
|
+
* 짧아지고 재생바가 그 순간 튄다 — 사용자가 짚은 것이 그 자리다. `onEnded` 의 사슬과
|
|
433
|
+
* **같은 모양**이어야 하므로(진행바 켜기 · 소음 · 세대 가림 · `leave()`) 여기도 그대로 둔다.
|
|
434
|
+
*/
|
|
435
|
+
const 뒤뜸이어가기 = useCallback((흐른) => {
|
|
436
|
+
const 내세대 = 진행기.generation();
|
|
437
|
+
const 남은 = Math.max(0, tailMsRef.current - Math.max(0, 흐른));
|
|
438
|
+
startTicker();
|
|
439
|
+
const ns = noiseSettingsRef.current;
|
|
440
|
+
if (ns?.enabled && (ns.volume ?? 0) > 0) {
|
|
441
|
+
try {
|
|
442
|
+
fadeInNoise(ns.volume);
|
|
443
|
+
}
|
|
444
|
+
catch (e) { }
|
|
445
|
+
}
|
|
446
|
+
const 잇기 = () => { try {
|
|
447
|
+
이어가기RefBox.current?.();
|
|
448
|
+
}
|
|
449
|
+
catch (e) { } };
|
|
450
|
+
if (남은 <= 0) {
|
|
451
|
+
진행기.leave();
|
|
452
|
+
잇기();
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
진행기.waitPad(남은, 'tail', 흐른).then((이어도되나) => {
|
|
456
|
+
진행기.leave();
|
|
457
|
+
if (이어도되나 && 진행기.isCurrent(내세대))
|
|
458
|
+
잇기();
|
|
459
|
+
}).catch(() => { });
|
|
460
|
+
}, [진행기, startTicker, fadeInNoise, noiseSettingsRef]);
|
|
425
461
|
const attachAudioListeners = useCallback(() => {
|
|
426
462
|
const a = audioRef.current;
|
|
427
463
|
if (!a)
|
|
@@ -926,19 +962,23 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
926
962
|
/* 뒤뜸에서 멈췄던 것이면 그 소리를 다시 틀지 않고 **그다음으로** 잇는다 — 끝난 요소에
|
|
927
963
|
`play()` 를 걸면 방금 들은 소리가 처음부터 또 난다. 뜸은 이미 한 번 기다렸으니
|
|
928
964
|
다시 세지 않는다. */
|
|
929
|
-
/* **멈춘 자리에서 이어 간다** — 어디서
|
|
930
|
-
|
|
931
|
-
const
|
|
932
|
-
if (
|
|
965
|
+
/* **멈춘 자리에서 이어 간다** — 어디서 몇 ms 였는지는 진행기가 안다.
|
|
966
|
+
뜸도 시간표의 일부라 **남은 만큼만** 채운다(사용자 결정, 2026-10-06). */
|
|
967
|
+
const 멈췄던곳 = 진행기.resume();
|
|
968
|
+
if (멈췄던곳) {
|
|
933
969
|
setPlaybackState("playing");
|
|
934
|
-
|
|
970
|
+
if (멈췄던곳.phase === 'tail') {
|
|
971
|
+
뒤뜸이어가기(멈췄던곳.흐른);
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
await tryPlayWithRetry(a, 3, 50, 멈췄던곳.흐른);
|
|
935
975
|
return;
|
|
936
976
|
}
|
|
937
977
|
await tryPlayWithRetry(a);
|
|
938
978
|
setPlaybackState("playing");
|
|
939
979
|
}
|
|
940
980
|
else {
|
|
941
|
-
진행기.pause(
|
|
981
|
+
진행기.pause();
|
|
942
982
|
멈춤의도();
|
|
943
983
|
a.pause();
|
|
944
984
|
setPlaybackState("paused");
|
|
@@ -28,6 +28,11 @@ export interface SequencerPosition {
|
|
|
28
28
|
/** 그 구간이 시작한 때(`Date.now()`). `sound` 이거나 안 돌면 0 */
|
|
29
29
|
at: number;
|
|
30
30
|
}
|
|
31
|
+
/** 뜸에서 멈춘 자리 — **어느 뜸의 몇 ms 지점**이었나. 이을 때 남은 만큼만 채우는 데 쓴다 */
|
|
32
|
+
export interface SequencerPause {
|
|
33
|
+
phase: Exclude<PadPhase, 'sound'>;
|
|
34
|
+
흐른: number;
|
|
35
|
+
}
|
|
31
36
|
export interface PlaybackSequencer {
|
|
32
37
|
/** 틱·소음·토글이 읽는다 — 매 틀 읽히므로 ref 다 */
|
|
33
38
|
positionRef: React.RefObject<SequencerPosition>;
|
|
@@ -56,25 +61,31 @@ export interface PlaybackSequencer {
|
|
|
56
61
|
/**
|
|
57
62
|
* **멈춘 자리를 적어 둔다 — 다시 누르면 어디서부터인가.**
|
|
58
63
|
*
|
|
59
|
-
* 멈출 때 `pause(
|
|
64
|
+
* 멈출 때 `pause()` 로 적고, 다시 누를 때 `resume()` 으로 묻는다. 답은 **어느 뜸의 몇 ms
|
|
65
|
+
* 자리였나** 다 — 그러면 부르는 쪽이 **남은 만큼만** 채우고 하던 일을 잇는다.
|
|
60
66
|
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
67
|
+
* **뜸을 포함한 시간표가 한 시계다**(사용자 결정, 2026-10-06). 전에는 앞뜸이면 처음부터 다시,
|
|
68
|
+
* 사이·뒤뜸이면 그 뜸을 **건너뛰고** 다음으로였다 — 소리 구간만 제자리에서 이어지고 뜸
|
|
69
|
+
* 구간은 "다시" 아니면 "건너뜀" 이었다. 사용자가 짚은 것이 그 어긋남이다: *"앞 뒤 뜸을
|
|
70
|
+
* 고려한 시간으로 계산이 가능하도록 해야 앞 뒤에서 멈추더라도 그 자리에서 동일하게
|
|
71
|
+
* 재생될 것 같은데"*.
|
|
65
72
|
*
|
|
66
73
|
* 전에는 재생기가 ref 둘(`뒤뜸에서멈춤Ref` · `이어가기Ref`)로 들고 **멈추는 자리마다** 그
|
|
67
74
|
* 둘을 맞춰 적어야 했다 — 네 자리 중 한 곳만 빠뜨려도 틀린 데서 이어 간다.
|
|
68
75
|
* 어느 구간에서 멈췄는지는 **자리를 쥔 쪽**이 안다.
|
|
69
76
|
*/
|
|
70
|
-
pause: (
|
|
71
|
-
/** 멈춘
|
|
72
|
-
resume: () =>
|
|
77
|
+
pause: () => void;
|
|
78
|
+
/** 멈춘 뜸 자리. 뜸에서 안 멈췄으면 `null` */
|
|
79
|
+
resume: () => SequencerPause | null;
|
|
73
80
|
/**
|
|
74
81
|
* 뜸만큼 기다린다. 그 사이 세대가 바뀌거나 화면을 떠나면 **거짓**을 준다 —
|
|
75
82
|
* 부르는 쪽은 거짓이면 아무 일도 하지 않고 돌아간다.
|
|
83
|
+
*
|
|
84
|
+
* `이미흐른` 은 **이 뜸에서 이미 지나간 만큼**이다(이어 틀 때). 기다리는 시간은 `ms` 그대로고
|
|
85
|
+
* 자리만 그만큼 **뒤로 밀어 둔다** — 그래야 재생바가 멈춘 값에서 이어진다. 안 밀면 남은
|
|
86
|
+
* 시간만 기다리면서 바는 0 부터 다시 긴다(그 어긋남이 (90)·(91) 의 튐이었다).
|
|
76
87
|
*/
|
|
77
|
-
waitPad: (ms: number, phase: Exclude<PadPhase, 'sound'
|
|
88
|
+
waitPad: (ms: number, phase: Exclude<PadPhase, 'sound'>, 이미흐른?: number) => Promise<boolean>;
|
|
78
89
|
/** 소리 구간으로 들어간다 */
|
|
79
90
|
enterSound: () => void;
|
|
80
91
|
/**
|
|
@@ -18,16 +18,21 @@ export function usePlaybackSequencer({ mountedRef }) {
|
|
|
18
18
|
* (처음에는 걷는 쪽으로 썼다가, 그 때문에 **세대 비교가 죽은 코드**가 된 것을 뒤늦게 봤다 —
|
|
19
19
|
* 걷어 버리면 세대를 볼 일이 없다. 둘 중 하나를 골라야 하는 자리였다.)
|
|
20
20
|
*/
|
|
21
|
-
/** 멈출 때의 자리 —
|
|
21
|
+
/** 멈출 때의 자리 — 어느 뜸의 몇 ms 지점이었나. 뜸에서 안 멈췄으면 `null` */
|
|
22
22
|
const 멈춘자리Ref = useRef(null);
|
|
23
23
|
/* **`abort()` 보다 먼저 부른다** — `abort()` 는 자리를 비우므로 뒤에 부르면 "어느 구간에서
|
|
24
24
|
멈췄나" 를 이미 잃는다. `pause()` 가 그 자리를 보고 적고, 그다음에 `abort()` 가 걷는다.
|
|
25
25
|
(`abort()` 가 이 적바림까지 지우게 했다가 못이 울렸다 — 멈춘 자리를 바로 지워 버려
|
|
26
26
|
이을 때 앞 소리가 또 났다. `멈춤의도()` 안에서 둘이 잇따라 불린다.) */
|
|
27
|
-
const pause = useCallback((
|
|
27
|
+
const pause = useCallback(() => {
|
|
28
28
|
const p = positionRef.current;
|
|
29
29
|
const 뜸중 = p.phase !== 'sound' && p.at > 0;
|
|
30
|
-
|
|
30
|
+
/* **타이머는 안 걷는다** — 위 긴 설명 그대로다. 걷으면 약속이 안 풀려 뒷정리가 통째로
|
|
31
|
+
안 돈다. 바로 뒤따르는 `abort()` 가 세대를 올리므로 돌던 뜸은 깨어나 거짓을 받고
|
|
32
|
+
조용히 물러난다. 여기서는 **어디까지 갔었나**만 적는다. */
|
|
33
|
+
멈춘자리Ref.current = 뜸중
|
|
34
|
+
? { phase: p.phase, 흐른: Math.max(0, Date.now() - p.at) }
|
|
35
|
+
: null;
|
|
31
36
|
}, []);
|
|
32
37
|
const resume = useCallback(() => {
|
|
33
38
|
const v = 멈춘자리Ref.current;
|
|
@@ -38,11 +43,12 @@ export function usePlaybackSequencer({ mountedRef }) {
|
|
|
38
43
|
세대Ref.current += 1;
|
|
39
44
|
positionRef.current = { phase: 'sound', at: 0 };
|
|
40
45
|
}, []);
|
|
41
|
-
const waitPad = useCallback((ms, phase) => {
|
|
46
|
+
const waitPad = useCallback((ms, phase, 이미흐른 = 0) => {
|
|
42
47
|
if (!(ms > 0))
|
|
43
48
|
return Promise.resolve(true);
|
|
44
49
|
const 내세대 = 세대Ref.current;
|
|
45
|
-
|
|
50
|
+
/* 자리를 **이미 흐른 만큼 뒤로 밀어 둔다** — 재생바가 그 값에서 이어진다(위 설명) */
|
|
51
|
+
positionRef.current = { phase, at: Date.now() - Math.max(0, 이미흐른) };
|
|
46
52
|
return new Promise((res) => {
|
|
47
53
|
const 내타이머 = setTimeout(() => {
|
|
48
54
|
if (뜸타이머Ref.current === 내타이머)
|
|
@@ -93,6 +93,38 @@ export declare function wordHasSyllableMatching(word: string, parts: {
|
|
|
93
93
|
/** 종성을 무엇으로 견줄지 — 기본은 **글자 그대로**(`'glyph'`). 받침 **소리**로 모으려면 `'phoneme'`(`JongMatch` 참고).
|
|
94
94
|
* 발음 입력 화면은 넘기는 낱말 자체가 표준발음이라 겹받침이 안 나온다 — 기본 그대로 두면 된다. */
|
|
95
95
|
jongMatch?: JongMatch): boolean;
|
|
96
|
+
/**
|
|
97
|
+
* @public **자리(어두·어중·어말)를 음절이 아니라 자음에 매긴다** — 말귀 요청 (93), 2026-10-06.
|
|
98
|
+
*
|
|
99
|
+
* `wordHasSyllableMatching` 의 위치 제약은 **음절 색인**으로 가른다(0번=어두 · 마지막=어말 ·
|
|
100
|
+
* 그 사이=어중). 그러면 **2음절 낱말에는 어중이 아예 없다** — `선물` 의 ㄴ받침·ㅁ초성을
|
|
101
|
+
* 뽑을 길이 없어 검색이 자료의 일부에 못 닿았다(말귀 두 화면이 2음절 이하면 어중 단추를
|
|
102
|
+
* 잠가 두고 있었다).
|
|
103
|
+
*
|
|
104
|
+
* 임상에서 쓰는 모델은 자리를 **자음에만** 매긴다(김동민 팀장·사공정희 선생, 2026-10-06):
|
|
105
|
+
*
|
|
106
|
+
* 선물 → ㅅ 어두초성 · ㄴ 어중종성 · ㅁ 어중초성 · ㄹ 어말종성
|
|
107
|
+
* 아기 → ㄱ 어중초성 (어두초성 없음 — `ㅇ` 은 음가가 없다 · 어말종성 없음)
|
|
108
|
+
*
|
|
109
|
+
* 어두 = 첫 음절의 **초성**(`ㅇ` 이면 **없는 것**으로 본다)
|
|
110
|
+
* 어말 = 마지막 음절의 **받침**(없으면 없는 것)
|
|
111
|
+
* 어중 = 그 밖의 **모든 초성·종성** — **2음절에도 생긴다**
|
|
112
|
+
* 중성(모음)에는 자리를 **안 매긴다** — 그래서 이 함수는 `jung` 을 안 받는다
|
|
113
|
+
*
|
|
114
|
+
* **`mid` 에서 `cho` 와 `jong` 을 둘 다 주면 둘 다 있어야 한다**(AND, 사용자 결정). `선물`
|
|
115
|
+
* 처럼 **받침+다음 초성**이 맞물리는 자음동화 꼴을 잡으려는 것이다. 다만 **붙어 있어야
|
|
116
|
+
* 하는 것은 아니다** — 낱말 어디에든 하나씩 있으면 된다. 하나만 주면 그것만 본다.
|
|
117
|
+
*
|
|
118
|
+
* `wordHasSyllableMatching` 은 **그대로 둔다** — 위치가 `무관` 일 때는 초성·중성·종성을
|
|
119
|
+
* **한 음절 안에서** 묶어 보는 것이 맞다(`ㄱ+ㅏ+ㅇ` = `강`). 두 함수는 묻는 것이 다르다.
|
|
120
|
+
*/
|
|
121
|
+
export declare function wordHasConsonantAt(word: string, parts: {
|
|
122
|
+
cho?: string[] | null;
|
|
123
|
+
jong?: string[] | null;
|
|
124
|
+
jongNone?: boolean;
|
|
125
|
+
}, position: 'head' | 'mid' | 'tail',
|
|
126
|
+
/** 종성을 무엇으로 견줄지 — `wordHasSyllableMatching` 과 같다(`JongMatch` 참고) */
|
|
127
|
+
jongMatch?: JongMatch): boolean;
|
|
96
128
|
/**
|
|
97
129
|
* @public 문자열이 음절 패턴 DSL 문법인지 — 쉼표(,) 포함 여부로 판별한다.
|
|
98
130
|
* 일반 단어 검색어엔 쉼표가 쓰이지 않으므로 충돌 없이 자동 분기할 수 있다.
|
|
@@ -337,6 +337,72 @@ jongMatch = 'glyph') {
|
|
|
337
337
|
return true;
|
|
338
338
|
return false;
|
|
339
339
|
}
|
|
340
|
+
/**
|
|
341
|
+
* @public **자리(어두·어중·어말)를 음절이 아니라 자음에 매긴다** — 말귀 요청 (93), 2026-10-06.
|
|
342
|
+
*
|
|
343
|
+
* `wordHasSyllableMatching` 의 위치 제약은 **음절 색인**으로 가른다(0번=어두 · 마지막=어말 ·
|
|
344
|
+
* 그 사이=어중). 그러면 **2음절 낱말에는 어중이 아예 없다** — `선물` 의 ㄴ받침·ㅁ초성을
|
|
345
|
+
* 뽑을 길이 없어 검색이 자료의 일부에 못 닿았다(말귀 두 화면이 2음절 이하면 어중 단추를
|
|
346
|
+
* 잠가 두고 있었다).
|
|
347
|
+
*
|
|
348
|
+
* 임상에서 쓰는 모델은 자리를 **자음에만** 매긴다(김동민 팀장·사공정희 선생, 2026-10-06):
|
|
349
|
+
*
|
|
350
|
+
* 선물 → ㅅ 어두초성 · ㄴ 어중종성 · ㅁ 어중초성 · ㄹ 어말종성
|
|
351
|
+
* 아기 → ㄱ 어중초성 (어두초성 없음 — `ㅇ` 은 음가가 없다 · 어말종성 없음)
|
|
352
|
+
*
|
|
353
|
+
* 어두 = 첫 음절의 **초성**(`ㅇ` 이면 **없는 것**으로 본다)
|
|
354
|
+
* 어말 = 마지막 음절의 **받침**(없으면 없는 것)
|
|
355
|
+
* 어중 = 그 밖의 **모든 초성·종성** — **2음절에도 생긴다**
|
|
356
|
+
* 중성(모음)에는 자리를 **안 매긴다** — 그래서 이 함수는 `jung` 을 안 받는다
|
|
357
|
+
*
|
|
358
|
+
* **`mid` 에서 `cho` 와 `jong` 을 둘 다 주면 둘 다 있어야 한다**(AND, 사용자 결정). `선물`
|
|
359
|
+
* 처럼 **받침+다음 초성**이 맞물리는 자음동화 꼴을 잡으려는 것이다. 다만 **붙어 있어야
|
|
360
|
+
* 하는 것은 아니다** — 낱말 어디에든 하나씩 있으면 된다. 하나만 주면 그것만 본다.
|
|
361
|
+
*
|
|
362
|
+
* `wordHasSyllableMatching` 은 **그대로 둔다** — 위치가 `무관` 일 때는 초성·중성·종성을
|
|
363
|
+
* **한 음절 안에서** 묶어 보는 것이 맞다(`ㄱ+ㅏ+ㅇ` = `강`). 두 함수는 묻는 것이 다르다.
|
|
364
|
+
*/
|
|
365
|
+
export function wordHasConsonantAt(word, parts, position,
|
|
366
|
+
/** 종성을 무엇으로 견줄지 — `wordHasSyllableMatching` 과 같다(`JongMatch` 참고) */
|
|
367
|
+
jongMatch = 'glyph') {
|
|
368
|
+
if (!word)
|
|
369
|
+
return false;
|
|
370
|
+
const 음절 = [...word].map(parseSyllable).filter((s) => !!s);
|
|
371
|
+
if (음절.length === 0)
|
|
372
|
+
return false;
|
|
373
|
+
const 끝 = 음절.length - 1;
|
|
374
|
+
if (position === 'head') {
|
|
375
|
+
/* 어두는 **첫 음절의 초성**뿐이다. `ㅇ` 은 음가가 없어 자리를 안 가진다 —
|
|
376
|
+
`아기` 의 어두초성은 **없다**(`cho: ['ㅇ']` 로 물으면 거짓). */
|
|
377
|
+
if (!parts.cho)
|
|
378
|
+
return false;
|
|
379
|
+
const c = CHOSEONG[음절[0].cho];
|
|
380
|
+
return c !== 'ㅇ' && parts.cho.includes(c);
|
|
381
|
+
}
|
|
382
|
+
if (position === 'tail') {
|
|
383
|
+
/* 어말은 **마지막 받침**뿐이다. 받침이 없으면 자리가 없다. */
|
|
384
|
+
if (parts.jongNone)
|
|
385
|
+
return 음절[끝].jong === 0;
|
|
386
|
+
if (!parts.jong)
|
|
387
|
+
return false;
|
|
388
|
+
return syllableJongMatches(음절[끝], parts.jong, jongMatch);
|
|
389
|
+
}
|
|
390
|
+
/* 어중 — 첫 초성과 마지막 종성을 **뺀** 나머지 자음 전부. 2음절 `선물` 이면
|
|
391
|
+
ㄴ(0번 종성) · ㅁ(1번 초성) 둘이 어중이다. */
|
|
392
|
+
const 초성맞나 = !parts.cho || 음절.some((s, i) => {
|
|
393
|
+
if (i === 0)
|
|
394
|
+
return false; // 첫 초성은 어두다
|
|
395
|
+
const c = CHOSEONG[s.cho];
|
|
396
|
+
return c !== 'ㅇ' && parts.cho.includes(c); // 음가 없는 ㅇ 은 자리를 안 가진다
|
|
397
|
+
});
|
|
398
|
+
const 종성맞나 = parts.jongNone
|
|
399
|
+
? 음절.some((s, i) => i !== 끝 && s.jong === 0)
|
|
400
|
+
: !parts.jong || 음절.some((s, i) => i !== 끝 && syllableJongMatches(s, parts.jong, jongMatch));
|
|
401
|
+
/* 둘 다 줬으면 **둘 다** 있어야 한다(AND) — 하나만 줬으면 그쪽만 참이면 된다 */
|
|
402
|
+
if (!parts.cho && !parts.jong && !parts.jongNone)
|
|
403
|
+
return false;
|
|
404
|
+
return 초성맞나 && 종성맞나;
|
|
405
|
+
}
|
|
340
406
|
/**
|
|
341
407
|
* @public 문자열이 음절 패턴 DSL 문법인지 — 쉼표(,) 포함 여부로 판별한다.
|
|
342
408
|
* 일반 단어 검색어엔 쉼표가 쓰이지 않으므로 충돌 없이 자동 분기할 수 있다.
|
|
@@ -51,7 +51,7 @@ export type { TrainingCounts, ComputeDailySelectionOptions } from './utils/getTr
|
|
|
51
51
|
export { TRAINING_TYPES, TOPIC_TYPES, TRAINING_TYPE_I18N_KEY } from './utils/CategoryData';
|
|
52
52
|
export type { WordEntry } from './utils/phonemePairSearch';
|
|
53
53
|
export { CAPABILITY, isValidQuestionWord, hasQuestionWordConstraint, isManualOnlyType } from './utils/wordTrainingRuntime';
|
|
54
|
-
export { wordContainsChoseong, wordContainsJungseong, wordContainsJongseong, wordHasNoCoda, wordHasSyllableMatching, syllableLength } from './utils/koreanSyllable';
|
|
54
|
+
export { wordContainsChoseong, wordContainsJungseong, wordContainsJongseong, wordHasNoCoda, wordHasSyllableMatching, wordHasConsonantAt, syllableLength } from './utils/koreanSyllable';
|
|
55
55
|
export { isSyllablePattern, matchesSyllablePattern, validateSyllableSearchInput, patternToComposition, matchesWordQuery, searchAndRankWords } from './utils/koreanSyllable';
|
|
56
56
|
export { PLAN_FEATURES, PLAN_LIMITS, TIER_RANK, getPlanTier, isManagerTier, type PlanTier, type DataLevel, type PlanFeatures, type PlanLimits, } from './utils/planFeatures';
|
|
57
57
|
export { WORD_SUGGESTION_WEEKLY_QUOTA, WORD_SUGGESTION_ACCOUNT_LIMIT, WORD_SUGGESTION_EXISTING_ERROR, isThisWeekPick, } from './utils/wordSuggestionPolicy';
|
|
@@ -34,7 +34,7 @@ export { classifyPairDifficulty, firstChoseongOf, toChoseong, isAllChoseong, mat
|
|
|
34
34
|
export { ensureTrainingDataLoaded, getTrainingPools, loadTrainingData, isTrainingDataLoaded, getDailySelection, computeDailySelection, NOISE_DATAS, SHORT_STORIES, LONG_STORIES, ORDER_SETS } from './utils/getTrainingData';
|
|
35
35
|
export { TRAINING_TYPES, TOPIC_TYPES, TRAINING_TYPE_I18N_KEY } from './utils/CategoryData';
|
|
36
36
|
export { CAPABILITY, isValidQuestionWord, hasQuestionWordConstraint, isManualOnlyType } from './utils/wordTrainingRuntime';
|
|
37
|
-
export { wordContainsChoseong, wordContainsJungseong, wordContainsJongseong, wordHasNoCoda, wordHasSyllableMatching, syllableLength } from './utils/koreanSyllable';
|
|
37
|
+
export { wordContainsChoseong, wordContainsJungseong, wordContainsJongseong, wordHasNoCoda, wordHasSyllableMatching, wordHasConsonantAt, syllableLength } from './utils/koreanSyllable';
|
|
38
38
|
export { isSyllablePattern, matchesSyllablePattern, validateSyllableSearchInput, patternToComposition, matchesWordQuery, searchAndRankWords } from './utils/koreanSyllable';
|
|
39
39
|
/* 요금제 정의 — 회원앱과 말귀어드민이 둘 다 본다. 예전에는 회원앱(src/libs) 안에 있었고
|
|
40
40
|
어드민이 '@app/libs/planFeatures' 로 끌어다 썼는데, 그 한 줄 때문에 어드민이 회원앱
|