@ihabdevteam/core 0.38.0 → 0.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/components.cjs.js +15 -15
  2. package/dist/hooks.cjs.js +1 -788
  3. package/dist/ihabdevteam-core.cjs.js +32 -32
  4. package/dist/ihabdevteam-core.d.ts +45 -5
  5. package/dist/packages/core/src/components/BottomNav.js +1 -1
  6. package/dist/packages/core/src/components/DatePicker.js +1 -1
  7. package/dist/packages/core/src/components/Dropdown.js +1 -1
  8. package/dist/packages/core/src/components/FilterPanel.js +2 -1
  9. package/dist/packages/core/src/components/Header.js +1 -1
  10. package/dist/packages/core/src/components/Panel.d.ts +3 -133
  11. package/dist/packages/core/src/components/Panel.js +2 -140
  12. package/dist/packages/core/src/components/PanelRow.d.ts +145 -0
  13. package/dist/packages/core/src/components/PanelRow.js +144 -0
  14. package/dist/packages/core/src/components/Training/AudioPlayerWaveForm.js +1 -54
  15. package/dist/packages/core/src/components/Training/NoisePlayerContext.d.ts +2 -15
  16. package/dist/packages/core/src/components/useAnchoredMenu.d.ts +8 -26
  17. package/dist/packages/core/src/components/useAnchoredMenu.js +7 -70
  18. package/dist/packages/core/src/components/useSoundCompassExam.d.ts +8 -51
  19. package/dist/packages/core/src/components/useSoundCompassExam.js +10 -128
  20. package/dist/packages/core/src/components.d.ts +4 -4
  21. package/dist/packages/core/src/components.js +2 -2
  22. package/dist/packages/core/src/hooks/useAnchoredMenu.d.ts +28 -0
  23. package/dist/packages/core/src/hooks/useAnchoredMenu.js +72 -0
  24. package/dist/packages/core/src/hooks/useSoundCompassExam.d.ts +54 -0
  25. package/dist/packages/core/src/hooks/useSoundCompassExam.js +128 -0
  26. package/dist/packages/core/src/hooks/useWordAttributeFilter.d.ts +2 -0
  27. package/dist/packages/core/src/hooks/useWordAttributeFilter.js +2 -1
  28. package/dist/packages/core/src/hooks/useWordTrainingDataGeneration.d.ts +2 -8
  29. package/dist/packages/core/src/hooks/useWordTrainingFilter.js +7 -6
  30. package/dist/packages/core/src/hooks.d.ts +4 -0
  31. package/dist/packages/core/src/hooks.js +6 -0
  32. package/dist/packages/core/src/types/filterPanel.d.ts +15 -0
  33. package/dist/packages/core/src/types/training.d.ts +20 -0
  34. package/dist/packages/core/src/types/training.js +1 -0
  35. package/dist/packages/core/src/utils/audioPeaks.d.ts +2 -0
  36. package/dist/packages/core/src/utils/audioPeaks.js +67 -0
  37. package/dist/packages/core/src/utils/noiseSettingsStore.d.ts +1 -1
  38. package/dist/packages/core/src/utils/wordTrainingGroups.d.ts +1 -1
  39. package/dist/packages/core/src/utils/wordTrainingRuntimeTypes.d.ts +14 -0
  40. package/dist/packages/core/src/version.d.ts +1 -1
  41. package/dist/packages/core/src/version.js +1 -1
  42. package/dist/utils.cjs.js +1 -1
  43. package/package.json +1 -1
@@ -0,0 +1,72 @@
1
+ 'use client';
2
+ import { useCallback, useEffect, useRef, useState } from 'react';
3
+ /**
4
+ * 트리거 요소에 fixed 포지션으로 앵커링되는 메뉴/드롭다운/팝오버의 공용 로직.
5
+ * 열림상태 + 위치 계산 + 바깥클릭/스크롤/리사이즈 처리를 담당하고, 렌더링(포탈 등)은
6
+ * 사용하는 쪽에서 담당한다. Dropdown, DatePicker, BottomNav의 "더보기" 메뉴가 이 훅을 쓴다.
7
+ * @public
8
+ */
9
+ export function useAnchoredMenu({ computeStyle, onScroll = 'close', onResize = 'close', ignoreOutsideClickSelector, }) {
10
+ const [open, setOpen] = useState(false);
11
+ const [style, setStyle] = useState({});
12
+ const triggerRef = useRef(null);
13
+ const menuRef = useRef(null);
14
+ const recalc = useCallback(() => {
15
+ if (!triggerRef.current)
16
+ return;
17
+ setStyle(computeStyle(triggerRef.current.getBoundingClientRect()));
18
+ }, [computeStyle]);
19
+ useEffect(() => {
20
+ if (!open)
21
+ return;
22
+ recalc();
23
+ const handleOutsideClick = (e) => {
24
+ const target = e.target;
25
+ if (ignoreOutsideClickSelector && target.closest?.(ignoreOutsideClickSelector))
26
+ return;
27
+ if (triggerRef.current?.contains(target))
28
+ return;
29
+ if (menuRef.current?.contains(target))
30
+ return;
31
+ setOpen(false);
32
+ };
33
+ document.addEventListener('mousedown', handleOutsideClick);
34
+ const handleScroll = (e) => {
35
+ if (menuRef.current?.contains(e.target))
36
+ return;
37
+ if (onScroll === 'reposition')
38
+ recalc();
39
+ else if (onScroll === 'close')
40
+ setOpen(false);
41
+ };
42
+ if (onScroll !== 'ignore')
43
+ window.addEventListener('scroll', handleScroll, true);
44
+ const handleResize = () => {
45
+ if (onResize === 'reposition')
46
+ recalc();
47
+ else if (onResize === 'close')
48
+ setOpen(false);
49
+ };
50
+ if (onResize !== 'ignore')
51
+ window.addEventListener('resize', handleResize);
52
+ // ESC로 닫기 — 이 훅을 쓰는 컴포넌트가 자체 키보드 핸들링(예: Dropdown의 화살표 탐색)을
53
+ // 따로 두더라도 무해하게 중복 호출될 뿐이므로, 여기서 공통으로 처리해 DatePicker/BottomNav
54
+ // 더보기 메뉴처럼 자체 ESC 처리가 없던 소비자도 함께 혜택을 받게 한다.
55
+ const handleKeyDown = (e) => {
56
+ if (e.key === 'Escape')
57
+ setOpen(false);
58
+ };
59
+ document.addEventListener('keydown', handleKeyDown);
60
+ return () => {
61
+ document.removeEventListener('mousedown', handleOutsideClick);
62
+ if (onScroll !== 'ignore')
63
+ window.removeEventListener('scroll', handleScroll, true);
64
+ if (onResize !== 'ignore')
65
+ window.removeEventListener('resize', handleResize);
66
+ document.removeEventListener('keydown', handleKeyDown);
67
+ };
68
+ }, [open, recalc, onScroll, onResize, ignoreOutsideClickSelector]);
69
+ const toggle = useCallback(() => setOpen(o => !o), []);
70
+ const close = useCallback(() => setOpen(false), []);
71
+ return { open, setOpen, toggle, close, style, triggerRef, menuRef };
72
+ }
@@ -0,0 +1,54 @@
1
+ import type { SpeakerPos } from '../types/audio';
2
+ /**
3
+ * @public
4
+ * SoundCompass 오케스트레이션 참조 훅.
5
+ *
6
+ * SoundCompass 자체는 제어형(표현) 컴포넌트라 순서·타이밍·점수·진행을 갖지 않는다.
7
+ * 이 훅이 그 "부모(검사 컨트롤러)" 역할의 기본 구현을 제공한다 —
8
+ * 큐 셔플, 문제 재생 타이밍, 선택 수집, 피드백, 진행/점수, 완료 콜백.
9
+ *
10
+ * 반환한 `bind`를 `<SoundCompass {...exam.bind} />`처럼 펼쳐 넣으면 된다.
11
+ * 다른 동작이 필요하면 이 훅을 복사·수정하거나, SoundCompass의 제어 props를
12
+ * 직접 구동해도 된다.
13
+ */
14
+ export type SoundCompassMode = 'playback' | 'practice' | 'exam';
15
+ export interface UseSoundCompassExamOptions {
16
+ /** 동작 모드. 기본 'playback' */
17
+ mode?: SoundCompassMode;
18
+ /** 마운트/리셋 시 자동 시작. 기본 false */
19
+ autoStart?: boolean;
20
+ /** 스피커 소리를 재생해야 할 때 호출 (오디오는 호출자 담당) */
21
+ onPlaySpeaker?: (speaker: SpeakerPos) => void;
22
+ /** 모든 문제/재생 완료 시 호출 */
23
+ onComplete?: (result: {
24
+ score: number;
25
+ total: number;
26
+ }) => void;
27
+ /** 문제당 소리 재생 후 선택 대기까지 지연(ms). 기본 1100 */
28
+ playMs?: number;
29
+ /** practice 모드 피드백 표시 시간(ms). 기본 1500 */
30
+ feedbackMs?: number;
31
+ }
32
+ export type SoundCompassPhase = 'idle' | 'playing' | 'waiting' | 'feedback' | 'done';
33
+ export interface SoundCompassExam {
34
+ /** `<SoundCompass {...bind} />` 로 펼쳐 넣는 제어 props */
35
+ bind: {
36
+ activeSpeakerId: string | null;
37
+ playedIds: string[];
38
+ selectedId: string | null;
39
+ answerId: string | null;
40
+ showFeedback: boolean;
41
+ clickable: boolean;
42
+ onSelectSpeaker: (id: string) => void;
43
+ };
44
+ index: number;
45
+ total: number;
46
+ score: number;
47
+ phase: SoundCompassPhase;
48
+ done: boolean;
49
+ /** 시작(또는 재시작) */
50
+ start: () => void;
51
+ /** idle 상태로 리셋 */
52
+ reset: () => void;
53
+ }
54
+ export declare function useSoundCompassExam(speakers: SpeakerPos[], { mode, autoStart, onPlaySpeaker, onComplete, playMs, feedbackMs, }?: UseSoundCompassExamOptions): SoundCompassExam;
@@ -0,0 +1,128 @@
1
+ 'use client';
2
+ import { useCallback, useEffect, useRef, useState } from 'react';
3
+ import { shuffle } from '../utils/array';
4
+ export function useSoundCompassExam(speakers, { mode = 'playback', autoStart = false, onPlaySpeaker, onComplete, playMs = 1100, feedbackMs = 1500, } = {}) {
5
+ const [queue, setQueue] = useState([]);
6
+ const [index, setIndex] = useState(0);
7
+ const [phase, setPhase] = useState('idle');
8
+ const [activeId, setActiveId] = useState(null);
9
+ const [playedIds, setPlayedIds] = useState([]);
10
+ const [selectedId, setSelectedId] = useState(null);
11
+ const [score, setScore] = useState(0);
12
+ // updater 밖에서 최신 score를 읽기 위한 미러 — setState updater 안에서 onComplete를
13
+ // 호출하면(부수효과) StrictMode에서 updater가 2회 실행돼 완료 콜백(점수 기록)이 중복된다.
14
+ const scoreRef = useRef(0);
15
+ const setScoreTracked = useCallback((next) => {
16
+ scoreRef.current = typeof next === 'function' ? next(scoreRef.current) : next;
17
+ setScore(scoreRef.current);
18
+ }, []);
19
+ // 최신 콜백/타이머 참조 (effect 재실행 없이 사용)
20
+ const onPlayRef = useRef(onPlaySpeaker);
21
+ const onDoneRef = useRef(onComplete);
22
+ useEffect(() => { onPlayRef.current = onPlaySpeaker; onDoneRef.current = onComplete; }, [onPlaySpeaker, onComplete]);
23
+ const timers = useRef([]);
24
+ const clearTimers = () => { timers.current.forEach(clearTimeout); timers.current = []; };
25
+ const after = (ms, fn) => { timers.current.push(setTimeout(fn, ms)); };
26
+ useEffect(() => () => clearTimers(), []);
27
+ const answerId = queue[index]?.id ?? null;
28
+ // ── playback: 모든 스피커를 순서대로 ──────────────────────────────────
29
+ const runPlayback = useCallback(() => {
30
+ clearTimers();
31
+ setPhase('playing');
32
+ setPlayedIds([]);
33
+ setSelectedId(null);
34
+ setScoreTracked(0);
35
+ let acc = 0;
36
+ speakers.forEach((sp, i) => {
37
+ after(acc, () => { setIndex(i); setActiveId(sp.id); onPlayRef.current?.(sp); });
38
+ after(acc + 1000, () => { setActiveId(null); setPlayedIds(prev => [...prev, sp.id]); });
39
+ acc += 1300;
40
+ });
41
+ after(acc, () => { setPhase('done'); onDoneRef.current?.({ score: 0, total: speakers.length }); });
42
+ }, [speakers, setScoreTracked]);
43
+ // ── practice / exam: 문제 재생 → 선택 대기 ────────────────────────────
44
+ const playQuestion = useCallback((q, i) => {
45
+ if (i >= q.length) {
46
+ setPhase('done');
47
+ onDoneRef.current?.({ score: scoreRef.current, total: q.length });
48
+ return;
49
+ }
50
+ setIndex(i);
51
+ setSelectedId(null);
52
+ setPhase('playing');
53
+ setActiveId(q[i].id);
54
+ onPlayRef.current?.(q[i]);
55
+ after(playMs, () => { setActiveId(null); setPhase('waiting'); });
56
+ }, [playMs]);
57
+ const runExam = useCallback(() => {
58
+ clearTimers();
59
+ const q = shuffle(speakers);
60
+ setQueue(q);
61
+ setScoreTracked(0);
62
+ setPlayedIds([]);
63
+ playQuestion(q, 0);
64
+ }, [speakers, playQuestion, setScoreTracked]);
65
+ const start = useCallback(() => {
66
+ if (mode === 'playback') {
67
+ setQueue(speakers);
68
+ runPlayback();
69
+ }
70
+ else
71
+ runExam();
72
+ }, [mode, speakers, runPlayback, runExam]);
73
+ const reset = useCallback(() => {
74
+ clearTimers();
75
+ setPhase('idle');
76
+ setActiveId(null);
77
+ setPlayedIds([]);
78
+ setSelectedId(null);
79
+ setIndex(0);
80
+ setScoreTracked(0);
81
+ setQueue([]);
82
+ }, [setScoreTracked]);
83
+ // autoStart / 입력 변경 시 재시작 — start/reset이 여러 상태를 동기 초기화하지만 의도된 동작이다
84
+ /* eslint-disable react-hooks/set-state-in-effect */
85
+ useEffect(() => {
86
+ if (autoStart)
87
+ start();
88
+ else
89
+ reset();
90
+ // eslint-disable-next-line react-hooks/exhaustive-deps
91
+ }, [mode, speakers.length, autoStart]);
92
+ /* eslint-enable react-hooks/set-state-in-effect */
93
+ const onSelectSpeaker = useCallback((id) => {
94
+ if (phase !== 'waiting' || mode === 'playback')
95
+ return;
96
+ setSelectedId(id);
97
+ const correct = id === queue[index]?.id;
98
+ if (correct)
99
+ setScoreTracked(s => s + 1);
100
+ if (mode === 'exam') {
101
+ setPhase('playing');
102
+ after(400, () => playQuestion(queue, index + 1));
103
+ }
104
+ else {
105
+ setPhase('feedback');
106
+ after(feedbackMs, () => playQuestion(queue, index + 1));
107
+ }
108
+ }, [phase, mode, queue, index, playQuestion, feedbackMs, setScoreTracked]);
109
+ const total = mode === 'playback' ? speakers.length : queue.length;
110
+ return {
111
+ bind: {
112
+ activeSpeakerId: activeId,
113
+ playedIds,
114
+ selectedId,
115
+ answerId,
116
+ showFeedback: phase === 'feedback',
117
+ clickable: phase === 'waiting',
118
+ onSelectSpeaker,
119
+ },
120
+ index,
121
+ total,
122
+ score,
123
+ phase,
124
+ done: phase === 'done',
125
+ start,
126
+ reset,
127
+ };
128
+ }
@@ -26,6 +26,7 @@ export declare function useWordAttributeFilterKeys(): WordAttributeFilterKeys;
26
26
  export declare function buildGradeSection(t: TFunction, keys: WordAttributeFilterKeys, opts?: {
27
27
  allLabel?: string;
28
28
  labelAction?: ReactNode;
29
+ labelInfo?: FilterPanelSection['labelInfo'];
29
30
  }): FilterPanelSection;
30
31
  /** @public 품사 FilterPanelSection — 세 화면 공용. */
31
32
  export declare function buildPartOfSpeechSection(keys: WordAttributeFilterKeys): FilterPanelSection;
@@ -42,6 +43,7 @@ export declare function buildSyllableSection(t: TFunction, keys: WordAttributeFi
42
43
  export declare function buildSelectSection(t: TFunction, opts?: {
43
44
  includeStructure?: boolean;
44
45
  labelAction?: ReactNode;
46
+ labelInfo?: FilterPanelSection['labelInfo'];
45
47
  }): FilterPanelSection;
46
48
  /**
47
49
  * @public 위치(어두/어중/어말) FilterPanelSection 빌더 — WordSearchPage·단어훈련 만들기(메인 앱)가 공용.
@@ -37,6 +37,7 @@ export function buildGradeSection(t, keys, opts) {
37
37
  allOption: true,
38
38
  allLabel: opts?.allLabel,
39
39
  labelAction: opts?.labelAction,
40
+ labelInfo: opts?.labelInfo,
40
41
  };
41
42
  }
42
43
  /** @public 품사 FilterPanelSection — 세 화면 공용. */
@@ -72,7 +73,7 @@ export function buildSelectSection(t, opts) {
72
73
  const items = [t('wordPairs.filter.onlyHasImage', '이미지 있는 단어만')];
73
74
  if (opts?.includeStructure)
74
75
  items.push(t('wordPairs.filter.onlySameStructure', '음소 구조 같은 단어만'));
75
- return { label: t('wordPairs.filter.selectLabel', '선택'), items, multiple: true, labelAction: opts?.labelAction };
76
+ return { label: t('wordPairs.filter.selectLabel', '선택'), items, multiple: true, labelAction: opts?.labelAction, labelInfo: opts?.labelInfo };
76
77
  }
77
78
  /**
78
79
  * @public 위치(어두/어중/어말) FilterPanelSection 빌더 — WordSearchPage·단어훈련 만들기(메인 앱)가 공용.
@@ -1,13 +1,8 @@
1
1
  import { type WordTrainingTypeKey } from '../utils/wordTrainingRuntime';
2
2
  import type { DifficultyLevel } from '../utils/koreanSyllable';
3
3
  import { type WordComposition } from './useWordTrainingFilter';
4
- export interface WordTrainingType {
5
- key: WordTrainingTypeKey;
6
- label: string;
7
- icon: string;
8
- color: string;
9
- subTitle: string;
10
- }
4
+ import type { WordTrainingType } from '../utils/wordTrainingRuntimeTypes';
5
+ export type { WordTrainingType } from '../utils/wordTrainingRuntimeTypes';
11
6
  interface WordTrainingDataEntry {
12
7
  id: string;
13
8
  name: string;
@@ -31,4 +26,3 @@ export declare function useWordTrainingDataGeneration(types: WordTrainingType[],
31
26
  perTypeData: Record<string, WordTrainingDataEntry | null>;
32
27
  generating: boolean;
33
28
  };
34
- export {};
@@ -1,10 +1,9 @@
1
1
  'use client';
2
- import { useCallback, useEffect, useMemo, useRef, useState, createElement } from 'react';
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
3
  import { useTranslation } from 'react-i18next';
4
4
  import { getViableComposition, countUsableWords } from '../utils/wordTrainingRuntime';
5
5
  import { FILTER_ALL_VALUE } from '../types/filterPanel';
6
6
  import { useWindowEvent } from './useWindowEvent';
7
- import FilterSectionInfo from '../components/FilterSectionInfo';
8
7
  import { useWordAttributeFilterKeys, buildGradeSection, buildPartOfSpeechSection, buildWordTypeSection } from './useWordAttributeFilter';
9
8
  import { matchesFilters } from '../utils/phonemePairSearch';
10
9
  import { hasSel } from '../utils/wordContrast';
@@ -327,7 +326,9 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
327
326
  const partByLabel = useMemo(() => ({
328
327
  [LABELS.trainOnset]: 'cho', [LABELS.trainVowel]: 'jung', [LABELS.trainCoda]: 'jong',
329
328
  }), [LABELS]);
330
- const infoAction = useCallback((items, title) => createElement(FilterSectionInfo, { title, items }), []);
329
+ /* 요소가 아니라 **서술**을 넘긴다 — 그리는 일은 FilterPanel 이 한다.
330
+ 예전에는 여기서 createElement 로 만들었고, 그 탓에 훅이 컴포넌트를 올려다봤다. */
331
+ const infoAction = useCallback((items, title) => ({ title, items }), []);
331
332
  const selectInfoItems = useMemo(() => [
332
333
  t('training.filter.excludeImageInfo', { defaultValue: '이미지 있는 단어만: 단어 중에 이미지가 있는 단어만 결과에 보여줍니다.' }),
333
334
  t('training.filter.sameStructureInfo', { defaultValue: '음소 구조 같은 단어만: 정답과 오답의 음절 구조(글자 수·받침 유무 등)가 같은 결과만 남겨요.' }),
@@ -383,7 +384,7 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
383
384
  // label만 오버라이드하고 나머지(items/allOption 등)는 공용 빌더 그대로 사용.
384
385
  sections.push({
385
386
  ...buildGradeSection(t, attrKeys, {
386
- labelAction: infoAction([t('wordPairs.filter.difficultyInfo', { defaultValue: '국립국어원의 기초 어휘 등급이 결과에 반영됩니다.' })]),
387
+ labelInfo: infoAction([t('wordPairs.filter.difficultyInfo', { defaultValue: '국립국어원의 기초 어휘 등급이 결과에 반영됩니다.' })]),
387
388
  }),
388
389
  label: LABELS.gradeLabel,
389
390
  });
@@ -423,10 +424,10 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
423
424
  // 선택 — choseong 타입은 긍정형 다중 토글(이미지 있는 단어만 / 음소 구조 같은 단어만),
424
425
  // 아니면 이미지 제외만 마지막에 노출. (받침 제외는 종성 섹션의 '제외' 버튼)
425
426
  if (caps.choseong) {
426
- sections.push({ label: LABELS.exclude, displayLabel: LABELS.selectLabel, multiple: true, items: [LABELS.excludeImage, LABELS.sameStructure], labelAction: infoAction(selectInfoItems) });
427
+ sections.push({ label: LABELS.exclude, displayLabel: LABELS.selectLabel, multiple: true, items: [LABELS.excludeImage, LABELS.sameStructure], labelInfo: infoAction(selectInfoItems) });
427
428
  }
428
429
  else {
429
- sections.push({ label: LABELS.exclude, displayLabel: LABELS.selectLabel, multiple: true, items: [LABELS.excludeImage], labelAction: infoAction([selectInfoItems[0]]) });
430
+ sections.push({ label: LABELS.exclude, displayLabel: LABELS.selectLabel, multiple: true, items: [LABELS.excludeImage], labelInfo: infoAction([selectInfoItems[0]]) });
430
431
  }
431
432
  return sections;
432
433
  }, [caps.choseong, caps.partOfSpeech, caps.wordType, caps.requireSyllable, caps.fixedSyllable, caps.fixedNoCoda, caps.requireCoda, minSyllables, partViable, viable, phonemeView, syllableViable, LABELS, t, infoAction, selectInfoItems, attrKeys]);
@@ -26,3 +26,7 @@ export * from './hooks/usePagedList';
26
26
  export * from './hooks/useToastState';
27
27
  export { useTrainingTypeLabels } from './hooks/useTrainingTypeLabels';
28
28
  export { CORE_VERSION } from './version';
29
+ export { useAnchoredMenu } from './hooks/useAnchoredMenu';
30
+ export type { UseAnchoredMenuOptions, UseAnchoredMenuResult } from './hooks/useAnchoredMenu';
31
+ export { useSoundCompassExam } from './hooks/useSoundCompassExam';
32
+ export type { SoundCompassMode, SoundCompassPhase, SoundCompassExam, UseSoundCompassExamOptions } from './hooks/useSoundCompassExam';
@@ -27,3 +27,9 @@ export * from './hooks/usePagedList';
27
27
  export * from './hooks/useToastState';
28
28
  export { useTrainingTypeLabels } from './hooks/useTrainingTypeLabels';
29
29
  export { CORE_VERSION } from './version';
30
+ /* 컴포넌트 곁에 있다가 이리로 온 둘 — 옛 경로(`components/…`)도 껍데기로 살아 있다.
31
+ 기준은 "몇 곳이 쓰는가" 다: useAnchoredMenu 는 네 컴포넌트가 함께 쓰고,
32
+ useSoundCompassExam 은 어느 컴포넌트도 안 쓴다(소비자가 쓰는 머리 없는 훅이다).
33
+ 한 컴포넌트만 쓰는 훅 다섯은 그 곁에 그대로 두었다 — 옮기면 응집만 떨어진다. */
34
+ export { useAnchoredMenu } from './hooks/useAnchoredMenu';
35
+ export { useSoundCompassExam } from './hooks/useSoundCompassExam';
@@ -45,6 +45,21 @@ export interface FilterPanelSection {
45
45
  /** 가로 배치에서 이 섹션을 한 줄 전체(자체 줄)로 배치 — 인라인 흐름에서 분리 */
46
46
  block?: boolean;
47
47
  labelAction?: ReactNode;
48
+ /**
49
+ * 라벨 옆 "?" 설명 버튼을 **서술로** 지정한다 — `labelAction` 에 요소를 직접 넣는 대신
50
+ * 내용만 준다. FilterPanel 이 `FilterSectionInfo` 로 그린다.
51
+ *
52
+ * 왜 두 갈래인가: 훅은 화면을 그리면 안 된다. 섹션을 만들어 주는 훅
53
+ * (`useWordTrainingFilter`)이 예전에는 `createElement(FilterSectionInfo, …)` 로
54
+ * 요소를 만들었는데, 그러려면 **훅이 컴포넌트를 올려다봐야** 했다. 이 슬롯을 쓰면
55
+ * 훅은 내용만 넘기고 그리는 일은 컴포넌트가 한다.
56
+ * `labelAction` 은 그대로 둔다 — 호출부가 제 요소를 넣고 싶을 때가 있다.
57
+ * 둘 다 있으면 `labelAction` 이 이긴다.
58
+ */
59
+ labelInfo?: {
60
+ title?: string;
61
+ items: string[];
62
+ };
48
63
  items?: (FilterPanelItem | string)[];
49
64
  groups?: (FilterPanelItem | string)[][];
50
65
  /** 'note': 선택 불가능한 안내 텍스트 섹션 — content를 그대로 렌더링한다(예: 입력 구문 사용법). */
@@ -1,3 +1,23 @@
1
+ /** 소음 음원 갈래. @public */
2
+ export type CoreNoiseType = 'defaultNoise' | 'street' | 'restaurant' | 'buzz';
3
+ /**
4
+ * 훈련 화면의 소음/재생 설정. `NoisePlayerContext` 가 들고 다니고
5
+ * `utils/noiseSettingsStore` 가 앱이 주입한 콜백으로 영속화한다.
6
+ * 두 계층이 함께 아는 모양이라 여기(아래 계층)에 둔다.
7
+ * @public
8
+ */
9
+ export type CoreNoiseSettings = {
10
+ enabled: boolean;
11
+ type: CoreNoiseType;
12
+ /** 0–9. 0이면 소음 비활성화. (소음 음원을 +7dB 증폭한 뒤 상한을 4→9로 확장 — 실제 volume = level/9) */
13
+ level: number;
14
+ /** 소음 재생 속도(playbackRate). 0.75–1.25, 기본 1. */
15
+ speed: number;
16
+ /** 문장(메인) 재생 소리(볼륨). 0–1, 기본 1. 계정 설정("훈련" 패널)과 훈련 화면 툴팁이 함께 영속화한다. */
17
+ mainVolume: number;
18
+ /** 문장(메인) 재생 속도(playbackRate). 0.5–1.5, 기본 1. 계정 설정("훈련" 패널)과 훈련 화면 툴팁이 함께 영속화한다. */
19
+ mainSpeed: number;
20
+ };
1
21
  /** @public */
2
22
  export interface Choice {
3
23
  id?: string | number;
@@ -1 +1,2 @@
1
+ // Central domain types for training-related data
1
2
  export {};
@@ -0,0 +1,2 @@
1
+ /** 주소에서 소리를 받아 파형 막대 높이 배열을 낸다. 실패하면 null. @public */
2
+ export declare function computePeaksFromUrl(url: string, bars: number, windowSeconds?: number): Promise<number[] | null>;
@@ -0,0 +1,67 @@
1
+ import { getErrorMessage } from './errorMessage';
2
+ /**
3
+ * 소리 파일에서 **파형 막대 높이**를 뽑는다 — 내려받기 · 디코딩 · 봉우리 계산.
4
+ *
5
+ * 왜 여기 있나: 이건 화면이 아니다. 예전에는 `components/Training/AudioPlayerWaveForm.tsx`
6
+ * 안에 있었는데, 그 파일은 그림을 그리는 컴포넌트다. 거기 있으면
7
+ * **컴포넌트가 직접 `fetch` 를 부르는** 꼴이 되고, 이 계산만 따로 시험하려 해도
8
+ * React 를 세워야 한다. 컴포넌트는 이제 결과만 받아 그린다.
9
+ *
10
+ * 값은 **절대 진폭(0~1, full-scale 기준)** 이다 — 문장과 소음이 같은 잣대를 써야
11
+ * dBFS 미터와 눈금이 맞는다.
12
+ */
13
+ // windowSeconds: 지정 시 파일의 처음 N초만 사용(소음을 문장 재생시간만큼만 그릴 때).
14
+ // 컴포넌트 상태를 참조하지 않는 순수 계산이라 모듈 스코프로 분리 — 매 렌더 재생성되지 않는다.
15
+ /** 내려받은 바이트를 디코딩해 봉우리를 센다 — 아래 `computePeaksFromUrl` 만 쓴다. */
16
+ async function decodeAndComputePeaks(arrayBuffer, bars, windowSeconds) {
17
+ try {
18
+ const AudioCtxConstructor = window.AudioContext || window.webkitAudioContext;
19
+ const audioCtx = new AudioCtxConstructor();
20
+ const decoded = await audioCtx.decodeAudioData(arrayBuffer.slice(0));
21
+ const channelData = decoded.numberOfChannels > 0 ? decoded.getChannelData(0) : null;
22
+ if (!channelData)
23
+ return null;
24
+ const total = (windowSeconds && windowSeconds > 0)
25
+ ? Math.min(channelData.length, Math.floor(windowSeconds * decoded.sampleRate))
26
+ : channelData.length;
27
+ const usableLen = Math.max(bars, total);
28
+ const blockSize = Math.floor(usableLen / bars) || 1;
29
+ const peaks = new Array(bars).fill(0).map((_, i) => {
30
+ const start = i * blockSize;
31
+ const end = Math.min(start + blockSize, usableLen);
32
+ let max = 0;
33
+ for (let j = start; j < end; j++) {
34
+ const v = Math.abs(channelData[j]);
35
+ if (v > max)
36
+ max = v;
37
+ }
38
+ return max;
39
+ });
40
+ try {
41
+ audioCtx.close && audioCtx.close();
42
+ }
43
+ catch (e) { }
44
+ // 절대 진폭(0~1, full-scale 기준) 반환 — 문장/소음이 같은 기준을 쓰게 해 dBFS 미터와 일치.
45
+ return peaks;
46
+ }
47
+ catch (e) {
48
+ return null;
49
+ }
50
+ }
51
+ /** 주소에서 소리를 받아 파형 막대 높이 배열을 낸다. 실패하면 null. @public */
52
+ export async function computePeaksFromUrl(url, bars, windowSeconds) {
53
+ try {
54
+ const resp = await fetch(url);
55
+ const arrayBuffer = await resp.arrayBuffer();
56
+ return await decodeAndComputePeaks(arrayBuffer, bars, windowSeconds);
57
+ }
58
+ catch (e) {
59
+ try {
60
+ const errMsg = getErrorMessage(e);
61
+ if (import.meta.env?.DEV)
62
+ console.log('WaveForm: computePeaksFromUrl failed', { url, err: errMsg });
63
+ }
64
+ catch (ee) { }
65
+ return null;
66
+ }
67
+ }
@@ -6,7 +6,7 @@
6
6
  * core는 사용자/고객 식별자를 모른다 — 본인(profiles) vs 고객(managed_user) 분기는
7
7
  * 앱이 주입한 콜백 내부에서 현재 훈련 대상에 따라 결정한다.
8
8
  */
9
- import type { CoreNoiseSettings } from '../components/Training/NoisePlayerContext';
9
+ import type { CoreNoiseSettings } from '../types/training';
10
10
  /** @public 소음설정 영속 저장소. 앱에서 Supabase 연결을 주입한다. */
11
11
  export interface NoiseSettingsStore {
12
12
  /** 현재 훈련 대상(본인 또는 고객)의 저장된 소음설정. 없으면 null. */
@@ -1,4 +1,4 @@
1
- import { type WordTrainingType } from '../hooks/useWordTrainingDataGeneration';
1
+ import { type WordTrainingType } from './wordTrainingRuntimeTypes';
2
2
  export interface TrainingGroup {
3
3
  key: string;
4
4
  label: string;
@@ -87,3 +87,17 @@ export declare function hasQuestionWordConstraint(typeKey: WordTrainingTypeKey):
87
87
  export declare function isValidQuestionWord(typeKey: WordTrainingTypeKey, word: string): boolean;
88
88
  /** @public 자동 문제생성 불가(수동 구성 전용) 타입인가. */
89
89
  export declare function isManualOnlyType(typeKey: WordTrainingTypeKey): boolean;
90
+ /**
91
+ * 훈련 갈래 한 줄 — 열쇠와 화면에 보일 이름표.
92
+ * `hooks/useWordTrainingDataGeneration` 이 만들어 내보내고
93
+ * `utils/wordTrainingGroups` 가 묶을 때 읽는다. 두 계층이 함께 아는 모양이라
94
+ * 열쇠 타입이 있는 여기(아래 계층)에 둔다.
95
+ * @public
96
+ */
97
+ export interface WordTrainingType {
98
+ key: WordTrainingTypeKey;
99
+ label: string;
100
+ icon: string;
101
+ color: string;
102
+ subTitle: string;
103
+ }
@@ -19,4 +19,4 @@
19
19
  *
20
20
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
21
21
  */
22
- export declare const CORE_VERSION = "0.38.0";
22
+ export declare const CORE_VERSION = "0.39.0";
@@ -19,4 +19,4 @@
19
19
  *
20
20
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
21
21
  */
22
- export const CORE_VERSION = '0.38.0';
22
+ export const CORE_VERSION = '0.39.0';