@ihabdevteam/core 0.344.0 → 0.346.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 +103 -0
- package/dist/components.cjs.js +182 -182
- package/dist/components.esm.css +2 -2
- package/dist/hooks.cjs.js +5 -5
- package/dist/i18n.cjs.js +3 -3
- package/dist/ihabdevteam-core.cjs.js +187 -187
- package/dist/ihabdevteam-core.d.ts +1 -30
- package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.css +14 -3
- package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +17 -5
- package/dist/packages/core/src/components/Word/WordSearchResultBlock.css +8 -0
- package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +22 -10
- package/dist/packages/core/src/hooks/useWordTrainingFilter.js +2 -0
- package/dist/packages/core/src/i18n/locales/en.json +2 -1
- package/dist/packages/core/src/i18n/locales/ko.json +2 -1
- package/dist/packages/core/src/types/wordSearch.d.ts +0 -8
- package/dist/packages/core/src/utils/dailySelectionUtils.js +4 -2
- package/dist/packages/core/src/utils/date.d.ts +2 -0
- package/dist/packages/core/src/utils/date.js +18 -1
- package/dist/packages/core/src/utils/imageConfig.d.ts +1 -0
- package/dist/packages/core/src/utils/imageConfig.js +27 -1
- package/dist/packages/core/src/utils/phonemePairSearch.js +1 -4
- package/dist/packages/core/src/utils/trainingItemResults.d.ts +0 -17
- package/dist/packages/core/src/utils/trainingItemResults.js +2 -1
- package/dist/packages/core/src/utils/wordPairIndex.js +5 -7
- package/dist/packages/core/src/utils/wordTrainingPool.js +11 -2
- package/dist/packages/core/src/utils/wordTrainingRuntime.js +20 -11
- 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
|
@@ -1754,7 +1754,7 @@ export declare function copyText(text: string): Promise<boolean>;
|
|
|
1754
1754
|
*
|
|
1755
1755
|
* 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
|
|
1756
1756
|
*/
|
|
1757
|
-
export declare const CORE_VERSION = "0.
|
|
1757
|
+
export declare const CORE_VERSION = "0.346.0";
|
|
1758
1758
|
|
|
1759
1759
|
/** @public */
|
|
1760
1760
|
declare const coreDefault: Record<string, unknown>;
|
|
@@ -2693,10 +2693,6 @@ declare interface FooterProps {
|
|
|
2693
2693
|
*/
|
|
2694
2694
|
export declare function formatCount(n: number | null | undefined): string;
|
|
2695
2695
|
|
|
2696
|
-
/**
|
|
2697
|
-
* 날짜 포맷 유틸 — 산재한 Date 파싱 로직을 공유.
|
|
2698
|
-
* 각 화면의 표시 형식 차이는 옵션으로 흡수.
|
|
2699
|
-
*/
|
|
2700
2696
|
export declare function formatDateKo(iso: string): string;
|
|
2701
2697
|
|
|
2702
2698
|
/**
|
|
@@ -8955,23 +8951,6 @@ export declare interface TrainingItem {
|
|
|
8955
8951
|
[k: string]: unknown;
|
|
8956
8952
|
}
|
|
8957
8953
|
|
|
8958
|
-
/**
|
|
8959
|
-
* 훈련 한 판의 **문항 하나하나**를 기록할 수 있는 모양으로 추린다.
|
|
8960
|
-
*
|
|
8961
|
-
* 08-26 회의에서 치료사가 "음소별 오류를 보고 싶다"고 했다. 그러려면 어느 낱말을
|
|
8962
|
-
* 틀렸는지가 남아야 하는데, 지금은 안 남는다 — `customer_training_result` 의
|
|
8963
|
-
* `item_id`·`item_title` 이 전부 null 이다.
|
|
8964
|
-
*
|
|
8965
|
-
* 정보가 없어서가 아니다. 훈련이 끝나는 순간 화면은 문항마다
|
|
8966
|
-
* `{ question, choices, selected, selectedText, correct, answer }` 를 들고 있다
|
|
8967
|
-
* (trainingPopupUtils 의 build*Results). 그걸 `correct/total` 두 숫자로 접어서 버리고
|
|
8968
|
-
* 있었다. 접기 전에 한 번 추려 둔다.
|
|
8969
|
-
*
|
|
8970
|
-
* **음소를 여기서 뽑지 않는다.** 낱말을 남기는 것과 그 낱말에서 음소를 세는 것은 다른
|
|
8971
|
-
* 일이고, 뒤엣것은 아직 규칙이 정해지지 않았다(어두/어중/어말 위치 모델도 바뀌는 중이다).
|
|
8972
|
-
* 지금 섣불리 음소를 박아 두면, 규칙이 바뀔 때 이미 쌓인 기록이 옛 규칙으로 남는다.
|
|
8973
|
-
* 낱말은 사실이고 음소는 해석이다 — 사실만 남긴다.
|
|
8974
|
-
*/
|
|
8975
8954
|
/** @public 훈련 한 문항의 결과 — 서버에 남길 만큼만 추린 것 */
|
|
8976
8955
|
export declare interface TrainingItemResult {
|
|
8977
8956
|
/** 몇 번째 문항인가(0부터) — 같은 낱말이 두 번 나와도 구별된다 */
|
|
@@ -11147,14 +11126,6 @@ export declare interface WordSearchResultGroup {
|
|
|
11147
11126
|
export declare interface WordSearchResultItem {
|
|
11148
11127
|
id: string;
|
|
11149
11128
|
words: WordSearchResultWord[];
|
|
11150
|
-
/**
|
|
11151
|
-
* 갈린 자리 — **옛 한글 이름**(`'초성'`·`'모음'`·`'종성'`·`'활음'`)이다.
|
|
11152
|
-
*
|
|
11153
|
-
* @deprecated `diffSectionCode` 를 쓴다 — 같은 자리를 코드로 가리킨다. 2026-10-13 안쪽을
|
|
11154
|
-
* 코드로 옮겼지만 **소비자가 이 값으로 묶음을 가른다**(말귀 `WordPairsPage` 의 `GROUP_ORDER`).
|
|
11155
|
-
* 혼자 바꾸면 그쪽 묶음이 조용히 빈다. 소비자가 옮긴 뒤 걷는다.
|
|
11156
|
-
*/
|
|
11157
|
-
diffSection?: string;
|
|
11158
11129
|
/** 갈린 자리 — 코드(`'onset'`·`'vowel'`·`'coda'`·`'glide'`). 이쪽이 정본이다. */
|
|
11159
11130
|
diffSectionCode?: PhonemeSectionCode;
|
|
11160
11131
|
diffHasEmpty?: boolean;
|
|
@@ -248,11 +248,22 @@
|
|
|
248
248
|
box-shadow: 0 0 6px rgba(0,0,0,0.4);
|
|
249
249
|
}
|
|
250
250
|
|
|
251
|
-
/*
|
|
252
|
-
|
|
251
|
+
/* **눈에서 감추되 낭독기에는 남긴다**(사장님 결정 (다), 2026-10-19 — (305)).
|
|
252
|
+
전에는 `display: none` 이었다. 글자는 안 보였지만 **접근성 나무에서도 빠져** 기다리는
|
|
253
|
+
동안 아무 말도 안 읽혔다 — 위의 `role="status"`·`aria-live` 가 헛돌았다.
|
|
254
|
+
저장소 처음부터 그랬다(`speechear 복사본`).
|
|
255
|
+
|
|
256
|
+
이 저장소가 쓰는 감추는 규약(`Table.css`·`Board.css`·`WordTable.css` 의 `clip-path: inset(50%)`)
|
|
257
|
+
을 그대로 쓴다 — 자리를 안 차지하고 보이지 않지만 **읽힌다.**
|
|
258
|
+
`display: none` 이나 `visibility: hidden` 으로 바꾸면 읽히는 것이 다시 사라진다. */
|
|
253
259
|
.audio-player__loading .audio-player__loading-text,
|
|
254
260
|
.audio-player__panel.is-loading .audio-player__loading-text {
|
|
255
|
-
|
|
261
|
+
position: absolute;
|
|
262
|
+
width: 1px;
|
|
263
|
+
height: 1px;
|
|
264
|
+
overflow: hidden;
|
|
265
|
+
clip-path: inset(50%);
|
|
266
|
+
white-space: nowrap;
|
|
256
267
|
}
|
|
257
268
|
|
|
258
269
|
.audio-player__loading-spinner {
|
|
@@ -18,7 +18,9 @@ import Toast from '../Toast';
|
|
|
18
18
|
import Button from '../Button';
|
|
19
19
|
import Icon from '../Icon';
|
|
20
20
|
import InfoTooltip from '../Tooltip/InfoTooltip';
|
|
21
|
-
|
|
21
|
+
/* `STATUS_MESSAGES` 를 더는 안 들여온다 — 걸음마다 다른 글을 화면에 쓰지 않기 때문이다
|
|
22
|
+
(사장님 결정 (다), 2026-10-19). 그 표는 **지우지 않았다**: 개발 판의 자취와 색이 쓴다. */
|
|
23
|
+
import { ANNOUNCED_STATUSES } from './trainingStepAudioPlayerTypes';
|
|
22
24
|
import { wasKeyboardPress } from '../../hooks/inputModality';
|
|
23
25
|
import { measureAudioDuration, waitForCanPlay } from './audioWaits';
|
|
24
26
|
import { useWaveReady } from './useWaveReady';
|
|
@@ -1418,10 +1420,20 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
1418
1420
|
const fillLeftPercent = Math.max(0, Math.min(100, (progress || 0) * 100));
|
|
1419
1421
|
const fillClass = cx('audio-player__fill', fillVisibleFlag ? 'is-visible' : '', fillExpandedFlag ? 'is-expanded' : '', fillPausedFlag ? 'is-paused' : '', isFillOutAnimating ? 'is-out' : '');
|
|
1420
1422
|
return (_jsxs("div", { className: `audio-player ${isDisabled ? 'is-disabled' : ''}${className ? ` ${className}` : ''}`, role: "group", "aria-label": typeof label === 'string' ? label : undefined, children: [_jsxs("div", { className: `audio-player__panel ${displayedPlaying ? "is-playing" : ""} ${isFetchingUrl ? "is-loading" : ""} ${stateLocal !== "default" ? "is-revealed" : ""}`, children: [isFetchingUrl ? (
|
|
1421
|
-
/*
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1423
|
+
/* **기다리는 동안 글자를 안 띄운다**(사장님 결정 (다), 2026-10-19 — (305)).
|
|
1424
|
+
전에는 걸음마다 글이 바뀌었다: `소리를 만들고 있습니다…` 가 **2ms 뒤에**
|
|
1425
|
+
`리소스 매핑 중…` 으로 덮이고 `서명 URL 조회…` · `다운로드 중…` 까지 늘어섰다
|
|
1426
|
+
(말귀 실측 2026-10-19: 캐시가 다 찬 가장 짧은 판에서도 94ms · 746ms).
|
|
1427
|
+
사장님 규칙은 **개발자의 말을 화면에 쓰지 않는다**다.
|
|
1428
|
+
|
|
1429
|
+
골라 둔 길 (가)(사람 말 한 마디로 묶기)가 아니라 **글을 아예 없애는** 쪽으로
|
|
1430
|
+
정하셨다 — 깜박이는 글은 한 마디로 묶어도 읽을 틈이 없다. 눈에는 동그라미만,
|
|
1431
|
+
**낭독기에는 한 마디**를 남긴다(그쪽은 깜박임이 없다).
|
|
1432
|
+
|
|
1433
|
+
`ANNOUNCED_STATUSES` 는 그대로 쓴다(사장님 결정 (나), 2026-10-05) — `준비 완료`
|
|
1434
|
+
처럼 일이 끝난 뒤의 알림은 읽히지 않는다. 걸음 이름과 그 글(`STATUS_MESSAGES`)은
|
|
1435
|
+
**지우지 않는다**: 개발 판의 자취와 색이 그것을 쓴다. */
|
|
1436
|
+
_jsxs("div", { className: "audio-player__loading", role: ANNOUNCED_STATUSES[fetchDebugStatus] ? 'status' : undefined, "aria-live": ANNOUNCED_STATUSES[fetchDebugStatus] ? 'polite' : 'off', children: [_jsx("span", { className: "audio-player__loading-spinner", "aria-hidden": "true" }), _jsx("span", { className: "audio-player__loading-text", children: t('training.audioStatus.preparing', { defaultValue: '소리를 준비하고 있습니다…' }) })] })) : null, stateLocal === "active" ? (_jsx("button", { className: "audio-player__button", type: "button",
|
|
1425
1437
|
/* 막힌 단계(아직 차례가 아닌 듣기·합성 주소를 받는 중)는 CSS pointer-events 로만 막아, Tab 으로 와서 Enter 를
|
|
1426
1438
|
누르면 소리를 받으러 가거나 "가져오지 못했습니다" 를 띄웠다 — 마우스와 같게 키보드도 막는다 */
|
|
1427
1439
|
disabled: isDisabled,
|
|
@@ -135,6 +135,14 @@
|
|
|
135
135
|
gap: 10px;
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
+
/* 묶음 이름 머리(말귀 청 (308)) — 앱이 `초성 대비`·`모음 대비` 처럼 넘긴다.
|
|
139
|
+
**글자 크기는 여기서 안 정한다** — 이 저장소는 `styles.css` 의 공용 클래스(`caption-1b`)로
|
|
140
|
+
준다(`date-picker__dow caption-1` 따위가 그 꼴이다). 세우다 `--font-label-m` 이라는
|
|
141
|
+
**없는 토큰**을 적었는데, 없는 변수는 조용히 아무 꼴도 안 내서 깨진 줄 몰랐다. */
|
|
142
|
+
.word-search-result-block__group-label {
|
|
143
|
+
color: var(--color-gray);
|
|
144
|
+
}
|
|
145
|
+
|
|
138
146
|
.word-search-result-block__group + .word-search-result-block__group {
|
|
139
147
|
margin-top: var(--spacing-l);
|
|
140
148
|
padding-top: var(--spacing-l);
|
|
@@ -3,7 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
3
3
|
import { cx } from '../../utils/cx';
|
|
4
4
|
import { compareKorean } from '../../utils/textSort';
|
|
5
5
|
import { useEscToClose } from '../../hooks/useEscToClose';
|
|
6
|
-
import { useState, useCallback, useMemo, useEffect } from 'react';
|
|
6
|
+
import { useState, useCallback, useMemo, useEffect, useId } from 'react';
|
|
7
7
|
import { useTranslation } from 'react-i18next';
|
|
8
8
|
import './WordSearchResultBlock.css';
|
|
9
9
|
import Button from '../Button';
|
|
@@ -126,6 +126,9 @@ export default function WordSearchResultBlock({ titleIcon, resultCount, resultUn
|
|
|
126
126
|
const pageClamped = Math.min(page, totalPages);
|
|
127
127
|
const pagedItems = useMemo(() => sortedItems.slice((pageClamped - 1) * pageSize, pageClamped * pageSize), [sortedItems, pageClamped, pageSize]);
|
|
128
128
|
// 현재 페이지의 그룹 항목을 그룹 순서대로 재그룹핑한다(그룹 헤더 유지; 페이지 경계에서 한 그룹이 나뉠 수 있음).
|
|
129
|
+
/* 묶음 이름표의 id 뿌리 — 한 쪽에 이 묶음이 둘 이상 설 수 있어 `useId()` 로 가른다.
|
|
130
|
+
이름 자체를 꼬리로 붙인다(한 쪽 안에서 `Map` 열쇠라 겹치지 않는다). */
|
|
131
|
+
const groupLabelBaseId = useId();
|
|
129
132
|
const pagedGroups = useMemo(() => {
|
|
130
133
|
if (!groups)
|
|
131
134
|
return null;
|
|
@@ -180,15 +183,24 @@ export default function WordSearchResultBlock({ titleIcon, resultCount, resultUn
|
|
|
180
183
|
과 아주 같은 h4 로 맞췄는데, 그러니 결과 덩이의 머리가 그 위 판 제목들(`검색 옵션` ·
|
|
181
184
|
`단어 검색 설정`)과 같은 급이 되어 **무엇이 무엇의 머리인지 안 보였다.** 거르개 판은
|
|
182
185
|
결과를 고르는 도구고 이 덩이는 그 결과라, 나란한 것이 아니라 아래 딸린 것이다. */
|
|
183
|
-
_jsxs(Section, { type: sectionType, className: cx('word-search-result-block', className), children: [_jsxs("div", { className: "word-search-result-block__inner", children: [(titleLabel || toolbar || discNode) && (_jsxs("div", { className: "word-search-result-block__head", children: [_jsxs("div", { className: "word-search-result-block__head-main", children: [titleIcon && _jsx(Icon, { icon: titleIcon, className: "word-search-result-block__head-icon" }), titleLabel && _jsx("span", { className: "word-search-result-block__title h3", children: titleLabel })] }), toolbar && _jsx("div", { className: "word-search-result-block__head-actions", children: toolbar }), discNode && _jsx("div", { className: "word-search-result-block__head-disc body-3", children: discNode })] })), (groups ? groups.length === 0 : items.length === 0) && (_jsxs("div", { className: "word-search-result-block__empty-wrap", children: [_jsx("p", { className: "word-search-result-block__empty body-3", children: t('wordSearch.emptyResult', '검색 결과가 없습니다.') }), emptyHint && _jsx("p", { className: "word-search-result-block__empty-hint body-3", children: emptyHint })] })), groups ? ((pagedGroups ?? []).map((group) => (
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
186
|
+
_jsxs(Section, { type: sectionType, className: cx('word-search-result-block', className), children: [_jsxs("div", { className: "word-search-result-block__inner", children: [(titleLabel || toolbar || discNode) && (_jsxs("div", { className: "word-search-result-block__head", children: [_jsxs("div", { className: "word-search-result-block__head-main", children: [titleIcon && _jsx(Icon, { icon: titleIcon, className: "word-search-result-block__head-icon" }), titleLabel && _jsx("span", { className: "word-search-result-block__title h3", children: titleLabel })] }), toolbar && _jsx("div", { className: "word-search-result-block__head-actions", children: toolbar }), discNode && _jsx("div", { className: "word-search-result-block__head-disc body-3", children: discNode })] })), (groups ? groups.length === 0 : items.length === 0) && (_jsxs("div", { className: "word-search-result-block__empty-wrap", children: [_jsx("p", { className: "word-search-result-block__empty body-3", children: t('wordSearch.emptyResult', '검색 결과가 없습니다.') }), emptyHint && _jsx("p", { className: "word-search-result-block__empty-hint body-3", children: emptyHint })] })), groups ? ((pagedGroups ?? []).map((group) => (
|
|
187
|
+
/* **묶음 이름을 머리로 그린다**(말귀 청 (308), 2026-10-19).
|
|
188
|
+
`pagedGroups` 가 `label` 을 들고 왔는데 **그리는 자리에서 안 썼다** — 받은 이름이
|
|
189
|
+
`key` 로만 쓰이고 사라져, 보는 사람은 어느 자리가 갈린 쌍인지 모른 채 한 덩어리로
|
|
190
|
+
봤다(말귀 실측: 묶음 네 개를 넘겼는데 `__group` 이 1 개, 이름 글자 0 건, 1,159쌍).
|
|
191
|
+
`pagedGroups` 가 쪽마다 다시 묶으므로 **쪽 경계에서 나뉘면 나뉜 쪽에도 다시 선다.**
|
|
192
|
+
|
|
193
|
+
이름을 `aria-labelledby` 로 가리킨다 — `IconGridMenu` 가 쓰는 규약이다.
|
|
194
|
+
`role="group"` 은 **이름이 없으면 낭독기가 묶음을 안 알린다**(`ColorPicker` 의 주석). */
|
|
195
|
+
_jsxs("div", { className: "word-search-result-block__group", role: "group", "aria-labelledby": `${groupLabelBaseId}-${group.label}`, children: [_jsx("div", { className: "word-search-result-block__group-label caption-1b", id: `${groupLabelBaseId}-${group.label}`, children: group.label }), _jsx("div", { className: "word-search-result-block__items", children: group.items.map((item) => {
|
|
196
|
+
const isSelected = selected.includes(item.id);
|
|
197
|
+
return (_jsx("div", { onMouseEnter: e => {
|
|
198
|
+
openHover(e.currentTarget);
|
|
199
|
+
setHoverWords(item.words ?? []);
|
|
200
|
+
}, onMouseLeave: handleHoverLeave, children: _jsx(Button, { htmlType: "button", size: "word", align: "grid", label: (item.words ?? []).map(w => w.label), subLabel: (item.words ?? []).some(w => w.subLabel)
|
|
201
|
+
? (item.words ?? []).map(w => w.subLabel)
|
|
202
|
+
: undefined, selected: isSelected, onClick: () => handleClick(item.id), minWidth: 200 }) }, item.id));
|
|
203
|
+
}) })] }, group.label)))) : (_jsx("div", { className: "word-search-result-block__items", children: pagedItems.map((item) => {
|
|
192
204
|
const isSelected = selected.includes(item.id);
|
|
193
205
|
return (_jsx("div", { onMouseEnter: e => {
|
|
194
206
|
openHover(e.currentTarget);
|
|
@@ -2,6 +2,7 @@
|
|
|
2
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
|
+
import { IMAGE_EXISTENCE_CACHE_UPDATED } from '../utils/imageConfig';
|
|
5
6
|
import { FILTER_ALL_VALUE } from '../types/filterPanel';
|
|
6
7
|
import { useWindowEvent } from './useWindowEvent';
|
|
7
8
|
import { useWordAttributeFilterKeys, buildGradeSection, buildPartOfSpeechSection, buildWordTypeSection } from './useWordAttributeFilter';
|
|
@@ -236,6 +237,7 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
|
|
|
236
237
|
const [poolVersion, setPoolVersion] = useState(0);
|
|
237
238
|
// 단어 풀 갱신 이벤트 구독
|
|
238
239
|
useWindowEvent('wordpool:update', () => setPoolVersion(v => v + 1));
|
|
240
|
+
useWindowEvent(IMAGE_EXISTENCE_CACHE_UPDATED, () => setPoolVersion(v => v + 1));
|
|
239
241
|
// 언어 전환 시에도 poolVersion을 올려 재생성을 트리거한다.
|
|
240
242
|
// useWordTrainingDataGeneration의 batch 생성 effect는 (다른 렌더에서 매번 새 배열이 되는)
|
|
241
243
|
// types를 의존성에서 의도적으로 제외하므로, types의 label/subTitle이 바뀌는 유일한 외부
|
|
@@ -470,7 +470,8 @@
|
|
|
470
470
|
"measuring_duration": "Measuring length…",
|
|
471
471
|
"ready": "Ready",
|
|
472
472
|
"error": "Error",
|
|
473
|
-
"synthesizing": "Preparing the sound…"
|
|
473
|
+
"synthesizing": "Preparing the sound…",
|
|
474
|
+
"preparing": "Preparing sound…"
|
|
474
475
|
}
|
|
475
476
|
},
|
|
476
477
|
"trainingSettings": {
|
|
@@ -19,14 +19,6 @@ export interface WordSearchResultWord {
|
|
|
19
19
|
export interface WordSearchResultItem {
|
|
20
20
|
id: string;
|
|
21
21
|
words: WordSearchResultWord[];
|
|
22
|
-
/**
|
|
23
|
-
* 갈린 자리 — **옛 한글 이름**(`'초성'`·`'모음'`·`'종성'`·`'활음'`)이다.
|
|
24
|
-
*
|
|
25
|
-
* @deprecated `diffSectionCode` 를 쓴다 — 같은 자리를 코드로 가리킨다. 2026-10-13 안쪽을
|
|
26
|
-
* 코드로 옮겼지만 **소비자가 이 값으로 묶음을 가른다**(말귀 `WordPairsPage` 의 `GROUP_ORDER`).
|
|
27
|
-
* 혼자 바꾸면 그쪽 묶음이 조용히 빈다. 소비자가 옮긴 뒤 걷는다.
|
|
28
|
-
*/
|
|
29
|
-
diffSection?: string;
|
|
30
22
|
/** 갈린 자리 — 코드(`'onset'`·`'vowel'`·`'coda'`·`'glide'`). 이쪽이 정본이다. */
|
|
31
23
|
diffSectionCode?: import('../utils/phonemeSectionCodes').PhonemeSectionCode;
|
|
32
24
|
diffHasEmpty?: boolean;
|
|
@@ -83,8 +83,10 @@ const DEFAULT_TRAINING_COUNTS = {
|
|
|
83
83
|
// localStorage 캐시 없이 매번 결정적으로 재계산한다(저장 없음). 이웃한 두 날의 세트는 후보가 충분하면 겹치지 않는다(유형세트).
|
|
84
84
|
export function resolveSelection(_forceRefresh = false, counts = DEFAULT_TRAINING_COUNTS, userId) {
|
|
85
85
|
// 서버 완료집계와 동일하게 KST 기준 날짜. userId 없어도 날짜로 결정적(Math.random 비결정성 제거)
|
|
86
|
-
|
|
87
|
-
const
|
|
86
|
+
// 한 순간에서 둘 다 계산한다. 두 번 시계를 읽는 사이 자정이 오면 오늘=어제가 된다.
|
|
87
|
+
const now = Date.now();
|
|
88
|
+
const today = toKstYmd(now);
|
|
89
|
+
const yesterday = toKstYmd(now - 86400000);
|
|
88
90
|
const u = userId ?? '';
|
|
89
91
|
const hasSource = NOISE_DATAS.length > 0 || SHORT_STORIES.length > 0 ||
|
|
90
92
|
LONG_STORIES.length > 0 || ORDER_SETS.length > 0;
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
* 날짜 포맷 유틸 — 산재한 Date 파싱 로직을 공유.
|
|
3
3
|
* 각 화면의 표시 형식 차이는 옵션으로 흡수.
|
|
4
4
|
*/
|
|
5
|
+
/** @internal 저장할 시각 문자열도 화면 표시와 같은 달력 검증을 사용한다. */
|
|
6
|
+
export declare function isValidDateValue(when: string | number | Date): boolean;
|
|
5
7
|
export declare function formatDateKo(iso: string): string;
|
|
6
8
|
/** "2024년 1월 15일 오후 03:30" (long month + time) */
|
|
7
9
|
export declare function formatDateLongKo(iso: string): string;
|
|
@@ -34,6 +34,14 @@ function toInstant(when) {
|
|
|
34
34
|
d = when;
|
|
35
35
|
else if (typeof when === 'string') {
|
|
36
36
|
const s = when.trim();
|
|
37
|
+
// Date.parse는 없는 달력날도 다음 달로 굴린다(2월 30일 → 3월 2일).
|
|
38
|
+
// 표시 함수가 잘못된 원본을 실제 날짜처럼 보여 주지 않도록 날짜 부분을 먼저 확인한다.
|
|
39
|
+
const datePart = /^(\d{4}-\d{2}-\d{2})(?:$|[T ])/.exec(s)?.[1];
|
|
40
|
+
if (datePart) {
|
|
41
|
+
const calendarDate = new Date(`${datePart}T00:00:00Z`);
|
|
42
|
+
if (Number.isNaN(calendarDate.getTime()) || calendarDate.toISOString().slice(0, 10) !== datePart)
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
37
45
|
const m = WALL_CLOCK.exec(s);
|
|
38
46
|
/* 소수 초는 세 자리까지만 — 여섯 자리(Postgres)는 엔진마다 읽기가 갈린다 */
|
|
39
47
|
d = new Date(m ? `${m[1]}T${m[2]}:${m[3] ?? '00'}${m[4] ? `.${m[4].slice(0, 3).padEnd(3, '0')}` : ''}+09:00` : s);
|
|
@@ -42,6 +50,10 @@ function toInstant(when) {
|
|
|
42
50
|
d = new Date(when);
|
|
43
51
|
return Number.isNaN(d.getTime()) ? null : d;
|
|
44
52
|
}
|
|
53
|
+
/** @internal 저장할 시각 문자열도 화면 표시와 같은 달력 검증을 사용한다. */
|
|
54
|
+
export function isValidDateValue(when) {
|
|
55
|
+
return toInstant(when) !== null;
|
|
56
|
+
}
|
|
45
57
|
/* 못 읽는 값(빈 문자열·깨진 날짜)이면 null — 아래 형식 함수들이 화면에 "Invalid Date" 를 그대로 내던 것을 빈 글자로 막는다
|
|
46
58
|
(같은 파일의 toKstYmd·formatYmd 는 이미 막고 있었다) */
|
|
47
59
|
const parsed = (iso) => toInstant(iso);
|
|
@@ -164,7 +176,12 @@ export function getTodayKst() {
|
|
|
164
176
|
* 공개 export 아님(utils.ts 가 다시 내보내지 않는다).
|
|
165
177
|
*/
|
|
166
178
|
export function todayYmdOr(today) {
|
|
167
|
-
|
|
179
|
+
if (today && /^\d{4}-\d{2}-\d{2}$/.test(today)) {
|
|
180
|
+
const date = toInstant(today);
|
|
181
|
+
if (date?.toISOString().slice(0, 10) === today)
|
|
182
|
+
return today;
|
|
183
|
+
}
|
|
184
|
+
return getTodayKst();
|
|
168
185
|
}
|
|
169
186
|
/**
|
|
170
187
|
* KST 오늘을 **기기 달력의 자정 Date** 로 — 연·월·일 칸을 `getFullYear()/getMonth()/getDate()` 로 세는 곳(BarChart)의
|
|
@@ -5,6 +5,7 @@ declare global {
|
|
|
5
5
|
export declare function configureImageResolver(resolver: (word: string, homonum?: number) => string): void;
|
|
6
6
|
/** @public */
|
|
7
7
|
export declare function getImageResolver(): ((word: string, homonum?: number) => string) | null;
|
|
8
|
+
export declare const IMAGE_EXISTENCE_CACHE_UPDATED = "ihab:image-existence-updated";
|
|
8
9
|
/**
|
|
9
10
|
* @public
|
|
10
11
|
* 단어 이미지 존재 여부 확인 — localStorage 캐시 우선, 없으면 HEAD 요청 후 캐시 저장.
|
|
@@ -10,6 +10,21 @@ export function getImageResolver() {
|
|
|
10
10
|
}
|
|
11
11
|
// ─── 로컬 캐싱 (localStorage + 메모리) ───────────────────────────────────────
|
|
12
12
|
const LS_KEY = 'word_image_existence_map_v1';
|
|
13
|
+
export const IMAGE_EXISTENCE_CACHE_UPDATED = 'ihab:image-existence-updated';
|
|
14
|
+
let updatePending = null;
|
|
15
|
+
function notifyCacheUpdated() {
|
|
16
|
+
if (updatePending !== null) {
|
|
17
|
+
clearTimeout(updatePending);
|
|
18
|
+
updatePending = null;
|
|
19
|
+
}
|
|
20
|
+
if (typeof window !== 'undefined')
|
|
21
|
+
window.dispatchEvent(new Event(IMAGE_EXISTENCE_CACHE_UPDATED));
|
|
22
|
+
}
|
|
23
|
+
function scheduleCacheUpdated() {
|
|
24
|
+
if (updatePending !== null)
|
|
25
|
+
return;
|
|
26
|
+
updatePending = setTimeout(notifyCacheUpdated, 200);
|
|
27
|
+
}
|
|
13
28
|
let memMap = null;
|
|
14
29
|
/* 캐시가 바뀔 때마다 오르는 판 번호 — 통계·'있음' 낱말 집합을 판마다 한 번만 센다(단어훈련 칩 한 번에 유형 수만큼
|
|
15
30
|
2.3만 칸을 훑었다). 캐시를 바꾸는 곳(확인 결과 담기·일괄 주입·비우기)은 모두 판을 올린다. */
|
|
@@ -111,7 +126,12 @@ async function probeImageExists(word, homonum, force = false) {
|
|
|
111
126
|
* 한 번 확인된 단어는 영구 캐싱되어 다음 접속 시에도 즉시 반환.
|
|
112
127
|
*/
|
|
113
128
|
export async function checkImageExists(word, homonum) {
|
|
114
|
-
|
|
129
|
+
const before = cacheVersion;
|
|
130
|
+
const exists = await probeImageExists(word, homonum);
|
|
131
|
+
// 여러 단어를 동시에 확인하는 표에서도 이벤트를 한 번으로 모은다.
|
|
132
|
+
if (cacheVersion !== before)
|
|
133
|
+
scheduleCacheUpdated();
|
|
134
|
+
return exists;
|
|
115
135
|
}
|
|
116
136
|
/** @public 단어 이미지 존재 여부 동기 조회 (캐시에 있을 때만). 없으면 undefined. */
|
|
117
137
|
export function getImageExistsCached(word, homonum) {
|
|
@@ -129,6 +149,7 @@ export function getImageExistsCached(word, homonum) {
|
|
|
129
149
|
export async function prewarmImageCache(words, options = {}) {
|
|
130
150
|
const { force = false, concurrency = 16, onProgress } = options;
|
|
131
151
|
const map = ensureLoaded();
|
|
152
|
+
const before = cacheVersion;
|
|
132
153
|
const todo = force ? Array.from(new Set(words)) : Array.from(new Set(words)).filter(w => !(keyOf(w) in map));
|
|
133
154
|
let done = 0;
|
|
134
155
|
if (todo.length > 0) {
|
|
@@ -161,6 +182,9 @@ export async function prewarmImageCache(words, options = {}) {
|
|
|
161
182
|
}
|
|
162
183
|
}
|
|
163
184
|
catch { }
|
|
185
|
+
// 병렬 HEAD마다 알리면 대량 사전 확인 중 필터가 수천 번 재계산된다.
|
|
186
|
+
if (cacheVersion !== before)
|
|
187
|
+
notifyCacheUpdated();
|
|
164
188
|
return { checked: todo.length, cached: Object.keys(map).length, total: words.length };
|
|
165
189
|
}
|
|
166
190
|
/**
|
|
@@ -208,6 +232,7 @@ export function bulkSetImageExistence(entries) {
|
|
|
208
232
|
}
|
|
209
233
|
}
|
|
210
234
|
catch { }
|
|
235
|
+
notifyCacheUpdated();
|
|
211
236
|
}
|
|
212
237
|
/** @public 캐시 초기화 (이미지 데이터 변경 시 호출) */
|
|
213
238
|
export function clearImageExistenceCache() {
|
|
@@ -223,4 +248,5 @@ export function clearImageExistenceCache() {
|
|
|
223
248
|
localStorage.removeItem(LS_KEY);
|
|
224
249
|
}
|
|
225
250
|
catch { }
|
|
251
|
+
notifyCacheUpdated();
|
|
226
252
|
}
|
|
@@ -16,7 +16,7 @@ function decompose(char) {
|
|
|
16
16
|
/* 음절 자리 코드는 **아래 모듈**에 있다 — `phonemeConvert` 도 함께 쓰는데, 여기 두면
|
|
17
17
|
`phonemePairSearch ↔ phonemeConvert` 돌림 import 가 된다(한 번 그렇게 해 시험 338 꾸러미가
|
|
18
18
|
무너졌다, 2026-10-13). */
|
|
19
|
-
import { toPhonemeCodes,
|
|
19
|
+
import { toPhonemeCodes, isPhonemeSectionCode, PHONEME_SECTION_CODES, PHONEME_NONE } from './phonemeSectionCodes';
|
|
20
20
|
export { toPhonemeCodes, PHONEME_NONE } from './phonemeSectionCodes';
|
|
21
21
|
const SECTION_KEYS = PHONEME_SECTION_CODES;
|
|
22
22
|
const NONE = PHONEME_NONE;
|
|
@@ -444,8 +444,6 @@ function searchPairsCore(selectedList, words, debug, enforcePosition, phonemic =
|
|
|
444
444
|
for (const sec of senses.get(second) ?? [second]) {
|
|
445
445
|
results.push({
|
|
446
446
|
id: `${wordKey(f)}|${wordKey(sec)}`,
|
|
447
|
-
/* 옛 이름을 내보내고 코드를 나란히 — 소비자가 옛 값으로 묶음을 가른다 */
|
|
448
|
-
diffSection: toLegacyPhonemeKey(sDiffSection),
|
|
449
447
|
diffSectionCode: sDiffSection,
|
|
450
448
|
diffHasEmpty: sDiffHasEmpty,
|
|
451
449
|
words: [wordCells(f), wordCells(sec)],
|
|
@@ -689,7 +687,6 @@ preferAsTarget) {
|
|
|
689
687
|
const kt = wordKey(t), kc = wordKey(c);
|
|
690
688
|
results.push({
|
|
691
689
|
id: kt < kc ? `${kt}|${kc}` : `${kc}|${kt}`,
|
|
692
|
-
diffSection: toLegacyPhonemeKey(item.diffSection),
|
|
693
690
|
diffSectionCode: item.diffSection,
|
|
694
691
|
diffHasEmpty: item.diffHasEmpty,
|
|
695
692
|
words: [wordCells(t), wordCells(c)],
|
|
@@ -1,20 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 훈련 한 판의 **문항 하나하나**를 기록할 수 있는 모양으로 추린다.
|
|
3
|
-
*
|
|
4
|
-
* 08-26 회의에서 치료사가 "음소별 오류를 보고 싶다"고 했다. 그러려면 어느 낱말을
|
|
5
|
-
* 틀렸는지가 남아야 하는데, 지금은 안 남는다 — `customer_training_result` 의
|
|
6
|
-
* `item_id`·`item_title` 이 전부 null 이다.
|
|
7
|
-
*
|
|
8
|
-
* 정보가 없어서가 아니다. 훈련이 끝나는 순간 화면은 문항마다
|
|
9
|
-
* `{ question, choices, selected, selectedText, correct, answer }` 를 들고 있다
|
|
10
|
-
* (trainingPopupUtils 의 build*Results). 그걸 `correct/total` 두 숫자로 접어서 버리고
|
|
11
|
-
* 있었다. 접기 전에 한 번 추려 둔다.
|
|
12
|
-
*
|
|
13
|
-
* **음소를 여기서 뽑지 않는다.** 낱말을 남기는 것과 그 낱말에서 음소를 세는 것은 다른
|
|
14
|
-
* 일이고, 뒤엣것은 아직 규칙이 정해지지 않았다(어두/어중/어말 위치 모델도 바뀌는 중이다).
|
|
15
|
-
* 지금 섣불리 음소를 박아 두면, 규칙이 바뀔 때 이미 쌓인 기록이 옛 규칙으로 남는다.
|
|
16
|
-
* 낱말은 사실이고 음소는 해석이다 — 사실만 남긴다.
|
|
17
|
-
*/
|
|
18
1
|
/** @public 훈련 한 문항의 결과 — 서버에 남길 만큼만 추린 것 */
|
|
19
2
|
export interface TrainingItemResult {
|
|
20
3
|
/** 몇 번째 문항인가(0부터) — 같은 낱말이 두 번 나와도 구별된다 */
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* 지금 섣불리 음소를 박아 두면, 규칙이 바뀔 때 이미 쌓인 기록이 옛 규칙으로 남는다.
|
|
16
16
|
* 낱말은 사실이고 음소는 해석이다 — 사실만 남긴다.
|
|
17
17
|
*/
|
|
18
|
+
import { isValidDateValue } from './date';
|
|
18
19
|
/**
|
|
19
20
|
* 때 하나 — ISO 8601 글자로 남긴다. 못 읽는 값은 `null`(없는 때를 지어내지 않는다).
|
|
20
21
|
*
|
|
@@ -29,7 +30,7 @@ function timeOf(v) {
|
|
|
29
30
|
const s = v.trim();
|
|
30
31
|
if (!s)
|
|
31
32
|
return null;
|
|
32
|
-
return
|
|
33
|
+
return isValidDateValue(s) ? s : null;
|
|
33
34
|
}
|
|
34
35
|
if (v instanceof Date)
|
|
35
36
|
return Number.isNaN(v.getTime()) ? null : v.toISOString();
|
|
@@ -57,16 +57,14 @@ function saveToLocalStorage(index, sig) {
|
|
|
57
57
|
}
|
|
58
58
|
catch { }
|
|
59
59
|
}
|
|
60
|
-
/** 입력의 signature 계산 (단어+발음 해시) —
|
|
60
|
+
/** 입력의 signature 계산 (단어+발음 해시) — 첫 유효 중복 행을 쓰므로 입력 순서도 포함한다. */
|
|
61
61
|
function computeSig(items) {
|
|
62
|
-
const
|
|
62
|
+
const serialized = JSON.stringify(items.map(it => [it.word, it.pron ?? '']));
|
|
63
63
|
let h = 0;
|
|
64
|
-
for (
|
|
65
|
-
|
|
66
|
-
h = ((h << 5) - h + s.charCodeAt(i)) | 0;
|
|
67
|
-
}
|
|
64
|
+
for (let i = 0; i < serialized.length; i++) {
|
|
65
|
+
h = ((h << 5) - h + serialized.charCodeAt(i)) | 0;
|
|
68
66
|
}
|
|
69
|
-
return `${
|
|
67
|
+
return `${items.length}-${h}`;
|
|
70
68
|
}
|
|
71
69
|
async function buildIndexAsync(items, onProgress) {
|
|
72
70
|
// 단어(철자) 기준 중복 제거하되, 대립 비교는 표준발음을 분해해서 수행한다.
|
|
@@ -158,13 +158,18 @@ export function filterByCachedImage(words) {
|
|
|
158
158
|
const _poolCache = new Map();
|
|
159
159
|
let _cachePoolRef = null;
|
|
160
160
|
let _cacheIdxRef = null;
|
|
161
|
+
let _cacheImageOkRef = null;
|
|
161
162
|
function _jamoKey(v) {
|
|
162
163
|
if (v == null)
|
|
163
164
|
return '';
|
|
164
165
|
return Array.isArray(v) ? v.slice().sort().join(',') : v;
|
|
165
166
|
}
|
|
166
167
|
export function _poolCacheKey(opts) {
|
|
167
|
-
|
|
168
|
+
// choseong=[] 은 후보를 전부 제외한다. 미지정(무제약)과 같은 키로 묶으면
|
|
169
|
+
// 먼저 계산한 쪽의 결과가 다른 조건에 재사용된다.
|
|
170
|
+
const chStr = Array.isArray(opts.choseong) && opts.choseong.length === 0
|
|
171
|
+
? '[]'
|
|
172
|
+
: _jamoKey(opts.choseong);
|
|
168
173
|
const c = opts.composition;
|
|
169
174
|
const sylKey = c?.syllable == null ? '' : (Array.isArray(c.syllable) ? c.syllable.slice().sort().join(',') : String(c.syllable));
|
|
170
175
|
const compStr = c ? `${_jamoKey(c.cho)}/${_jamoKey(c.jung)}/${_jamoKey(c.jong)}/${c.jongNone ? 'N' : ''}/${sylKey}` : '';
|
|
@@ -201,11 +206,15 @@ export async function buildUsablePool(opts) {
|
|
|
201
206
|
// wordPool/pairIndex가 교체되면 캐시 전체 무효화
|
|
202
207
|
const pool = getWordPool();
|
|
203
208
|
const idx = getPairIndex();
|
|
204
|
-
|
|
209
|
+
// 이미지 필터의 결과는 풀 참조를 바꾸지 않고도 prewarm/일괄 주입 중 달라진다.
|
|
210
|
+
// getImageOkWordSet은 캐시 판이 바뀔 때 새 Set을 돌려주므로 이를 무효화 기준으로 쓴다.
|
|
211
|
+
const imageOkRef = opts.excludeNoImage ? getImageOkWordSet() : null;
|
|
212
|
+
if (pool !== _cachePoolRef || idx !== _cacheIdxRef || (opts.excludeNoImage && imageOkRef !== _cacheImageOkRef)) {
|
|
205
213
|
_poolCache.clear();
|
|
206
214
|
_posContrastCache.clear();
|
|
207
215
|
_cachePoolRef = pool;
|
|
208
216
|
_cacheIdxRef = idx;
|
|
217
|
+
_cacheImageOkRef = imageOkRef;
|
|
209
218
|
}
|
|
210
219
|
const key = _poolCacheKey(opts);
|
|
211
220
|
if (!_poolCache.has(key))
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* @public
|
|
11
11
|
*/
|
|
12
12
|
import { getWordPool } from './wordPool';
|
|
13
|
+
import { getImageOkWordSet } from './imageConfig';
|
|
13
14
|
import { shuffle } from './array';
|
|
14
15
|
import { getPairIndex } from './wordPairIndex';
|
|
15
16
|
import { wordContainsPhoneme, syllableLength, parseSyllable, CHOSEONG, JUNGSEONG, JONGSEONG, JONG_DECOMPOSE } from './koreanSyllable';
|
|
@@ -165,7 +166,7 @@ async function genStartsWithSame(opts) {
|
|
|
165
166
|
}
|
|
166
167
|
// sourceWord 와 correct 가 모두 필요하므로 같은 첫음절 단어 ≥2개인 그룹만 사용
|
|
167
168
|
const viableFirsts = Object.entries(sameFirstPool)
|
|
168
|
-
.filter(([, ws]) => ws.
|
|
169
|
+
.filter(([, ws]) => new Set(ws.map(w => wordPron.get(w) || w)).size >= 2)
|
|
169
170
|
.map(([f]) => f);
|
|
170
171
|
if (viableFirsts.length === 0)
|
|
171
172
|
return [];
|
|
@@ -176,15 +177,13 @@ async function genStartsWithSame(opts) {
|
|
|
176
177
|
const group = sameFirstPool[targetSyllable];
|
|
177
178
|
if (group.length < 2)
|
|
178
179
|
continue;
|
|
179
|
-
// sourceWord 와 correct 는 같은 그룹에서
|
|
180
|
+
// sourceWord 와 correct 는 같은 그룹에서 소리도 달라야 한다. 앞의 두 낱말이
|
|
181
|
+
// 동음어여도 유효한 다른 짝이 있으면 그 짝을 선택한다.
|
|
180
182
|
const picked = shuffle([...group]);
|
|
181
183
|
const sourceWord = picked[0];
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
if (!sourceWord || !correct || sourceWord === correct)
|
|
186
|
-
continue;
|
|
187
|
-
if ((wordPron.get(sourceWord) || sourceWord) === (wordPron.get(correct) || correct))
|
|
184
|
+
const sourcePron = wordPron.get(sourceWord) || sourceWord;
|
|
185
|
+
const correct = picked.find(w => w !== sourceWord && (wordPron.get(w) || w) !== sourcePron);
|
|
186
|
+
if (!sourceWord || !correct)
|
|
188
187
|
continue;
|
|
189
188
|
// 오답: 첫 음절(발음)이 다르고, 정답(correct)과 음절수(글자수)가 같으며, 발음이 다른 단어 (sourceWord, correct 모두 제외)
|
|
190
189
|
// 철자 첫 글자로 거르면 '학교(학꾜)'가 targetSyllable '하'인 문제의 오답으로 뽑혀
|
|
@@ -579,20 +578,28 @@ function genRemoveConsonant(opts) {
|
|
|
579
578
|
const _resultCache = new Map();
|
|
580
579
|
let _resultCachePoolRef = null;
|
|
581
580
|
let _resultCacheIdxRef = null;
|
|
581
|
+
let _resultCacheImageOkRef = null;
|
|
582
|
+
let _resultCacheEpoch = 0;
|
|
582
583
|
/** @public 훈련 유형별 문제 item 생성 */
|
|
583
584
|
export async function generateRuntimeItems(typeKey, opts = {}) {
|
|
584
585
|
const pool = getWordPool();
|
|
585
586
|
const idx = getPairIndex();
|
|
586
|
-
|
|
587
|
+
const imageOkRef = opts.excludeNoImage ? getImageOkWordSet() : null;
|
|
588
|
+
if (pool !== _resultCachePoolRef || idx !== _resultCacheIdxRef || (opts.excludeNoImage && imageOkRef !== _resultCacheImageOkRef)) {
|
|
587
589
|
_resultCache.clear();
|
|
590
|
+
_resultCacheEpoch++;
|
|
588
591
|
_resultCachePoolRef = pool;
|
|
589
592
|
_resultCacheIdxRef = idx;
|
|
593
|
+
_resultCacheImageOkRef = imageOkRef;
|
|
590
594
|
}
|
|
591
595
|
const cacheKey = `${typeKey}:${_poolCacheKey(opts)}`;
|
|
592
596
|
if (_resultCache.has(cacheKey))
|
|
593
597
|
return _resultCache.get(cacheKey);
|
|
598
|
+
const epoch = _resultCacheEpoch;
|
|
594
599
|
const items = await _generateRuntimeItems(typeKey, opts);
|
|
595
|
-
|
|
600
|
+
// 계산 중 풀이나 이미지 캐시가 교체됐다면 늦게 끝난 옛 문항을 새 캐시에 다시 넣지 않는다.
|
|
601
|
+
if (epoch === _resultCacheEpoch)
|
|
602
|
+
_resultCache.set(cacheKey, items);
|
|
596
603
|
return items;
|
|
597
604
|
}
|
|
598
605
|
const GENERATOR_MAP = {
|
|
@@ -656,7 +663,9 @@ export async function precomputeCommonCombinations(excludeNoImage) {
|
|
|
656
663
|
}
|
|
657
664
|
/** @public 이미지/난이도 조건이 적용된 초성 set — pool 단어 어디든 해당 자모가 있으면 viable */
|
|
658
665
|
export async function getViableChoseongs(opts = {}) {
|
|
659
|
-
|
|
666
|
+
// 선택 가능 여부는 전체 후보 기준이어야 한다. 생성용 샘플 캡(기본 1500)을 쓰면
|
|
667
|
+
// 샘플 밖의 드문 초성이 실제로 가능해도 버튼에서 사라진다.
|
|
668
|
+
const pool = await buildUsablePool({ difficulty: opts.difficulty ?? null, excludeNoImage: opts.excludeNoImage, fullCount: true });
|
|
660
669
|
const set = new Set();
|
|
661
670
|
// 모든 19 초성 자모를 단어와 매칭해서 viable 검사
|
|
662
671
|
const allJamos = ['ㄱ', 'ㄲ', 'ㄴ', 'ㄷ', 'ㄸ', 'ㄹ', 'ㅁ', 'ㅂ', 'ㅃ', 'ㅅ', 'ㅆ', 'ㅇ', 'ㅈ', 'ㅉ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ'];
|