@ihabdevteam/core 0.322.0 → 0.323.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +517 -0
- package/dist/components.cjs.js +183 -183
- package/dist/components.esm.css +1 -1
- package/dist/hooks.cjs.js +3 -3
- package/dist/i18n.cjs.js +3 -3
- package/dist/ihabdevteam-core.cjs.js +187 -187
- package/dist/ihabdevteam-core.d.ts +54 -16
- package/dist/packages/core/src/components/Accordion.js +3 -3
- package/dist/packages/core/src/components/Alert.js +2 -2
- package/dist/packages/core/src/components/BarChart.js +120 -119
- package/dist/packages/core/src/components/BottomNav.js +18 -18
- package/dist/packages/core/src/components/BulkBar.js +25 -25
- package/dist/packages/core/src/components/Button.js +16 -16
- package/dist/packages/core/src/components/Calendar.js +17 -17
- package/dist/packages/core/src/components/ChartSection.js +30 -30
- package/dist/packages/core/src/components/CountTile.js +38 -38
- package/dist/packages/core/src/components/DatePicker.js +59 -59
- package/dist/packages/core/src/components/Dialog.js +14 -14
- package/dist/packages/core/src/components/Effect/EffectPartyPopperHost.js +8 -8
- package/dist/packages/core/src/components/FilterPanel.js +6 -6
- package/dist/packages/core/src/components/FilterSectionInfo.js +6 -6
- package/dist/packages/core/src/components/GameIntro.js +1 -1
- package/dist/packages/core/src/components/Icon.js +2 -2
- package/dist/packages/core/src/components/ItemActions.js +16 -16
- package/dist/packages/core/src/components/LandingHeader.js +6 -6
- package/dist/packages/core/src/components/LineChart.js +24 -24
- package/dist/packages/core/src/components/Loader.js +5 -5
- package/dist/packages/core/src/components/NoticeBox.js +3 -3
- package/dist/packages/core/src/components/PageTitle.js +2 -2
- package/dist/packages/core/src/components/Pagination.js +4 -4
- package/dist/packages/core/src/components/Panel.js +2 -2
- package/dist/packages/core/src/components/PanelRow.js +6 -13
- package/dist/packages/core/src/components/PassCard/PassCard.js +9 -9
- package/dist/packages/core/src/components/PassCard/passCardProduct.js +2 -2
- package/dist/packages/core/src/components/PaymentPlan.js +5 -5
- package/dist/packages/core/src/components/Popup/ConfirmPopup.js +29 -29
- package/dist/packages/core/src/components/Popup/CouponInputPopup.js +2 -2
- package/dist/packages/core/src/components/Popup/DeviceSideTable.js +18 -25
- package/dist/packages/core/src/components/Popup/ListPopup.js +3 -3
- package/dist/packages/core/src/components/Popup/LoadTablePopup.js +7 -7
- package/dist/packages/core/src/components/Popup/Popup.js +27 -27
- package/dist/packages/core/src/components/Popup/SaveTablePopup.js +5 -5
- package/dist/packages/core/src/components/Popup/TrainingResultPopup.js +4 -4
- package/dist/packages/core/src/components/Popup/customerDetailUtils.js +18 -18
- package/dist/packages/core/src/components/Popup/useDiscardGuard.js +4 -4
- package/dist/packages/core/src/components/ProgressIndicator.js +5 -5
- package/dist/packages/core/src/components/PromoBannerBg/promoBannerBg.js +16 -16
- package/dist/packages/core/src/components/Rating.js +20 -20
- package/dist/packages/core/src/components/Report/ReportDocument.js +21 -21
- package/dist/packages/core/src/components/RingChart.js +5 -5
- package/dist/packages/core/src/components/SectionHeadFloat.js +15 -15
- package/dist/packages/core/src/components/SegmentedControl.js +34 -34
- package/dist/packages/core/src/components/SideNav.js +37 -37
- package/dist/packages/core/src/components/Slider.js +2 -2
- package/dist/packages/core/src/components/SoundCompass.js +5 -5
- package/dist/packages/core/src/components/Table.js +257 -98
- package/dist/packages/core/src/components/Tabs.js +12 -12
- package/dist/packages/core/src/components/TextField.js +34 -34
- package/dist/packages/core/src/components/Toast.js +6 -6
- package/dist/packages/core/src/components/Tooltip/MenuTooltip.js +4 -4
- package/dist/packages/core/src/components/Tooltip/Tooltip.js +22 -22
- package/dist/packages/core/src/components/Tooltip/TrainingTooltip.js +4 -4
- package/dist/packages/core/src/components/TopBanner.js +28 -28
- package/dist/packages/core/src/components/Training/AudioPlayerWaveForm.js +11 -11
- package/dist/packages/core/src/components/Training/CategoryIcon.js +2 -2
- package/dist/packages/core/src/components/Training/NoisePlayerContext.js +12 -12
- package/dist/packages/core/src/components/Training/NoiseSettingsTooltip.js +2 -2
- package/dist/packages/core/src/components/Training/TrainingBlock.js +11 -11
- package/dist/packages/core/src/components/Training/TrainingBlockButton.js +10 -10
- package/dist/packages/core/src/components/Training/TrainingBlockGrid.js +21 -21
- package/dist/packages/core/src/components/Training/TrainingCard.css +13 -1
- package/dist/packages/core/src/components/Training/TrainingCard.js +2 -2
- package/dist/packages/core/src/components/Training/TrainingCardGroup.js +6 -6
- package/dist/packages/core/src/components/Training/TrainingPagePopup.js +81 -81
- package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.js +17 -17
- package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +127 -127
- package/dist/packages/core/src/components/Training/TrainingStepCard.js +18 -18
- package/dist/packages/core/src/components/Training/TrainingStepConfirmWithText.js +4 -4
- package/dist/packages/core/src/components/Training/TrainingStepOrderList.js +12 -12
- package/dist/packages/core/src/components/Training/TrainingStepTextBox.js +12 -12
- package/dist/packages/core/src/components/Training/TrainingStepTitle.js +4 -4
- package/dist/packages/core/src/components/Training/TrainingWordCard.js +17 -17
- package/dist/packages/core/src/components/Training/WordTablePopup.js +5 -5
- package/dist/packages/core/src/components/Training/audioPlaybackRules.js +8 -8
- package/dist/packages/core/src/components/Training/audioTimeline.js +25 -25
- package/dist/packages/core/src/components/Training/oneShotSound.js +13 -13
- package/dist/packages/core/src/components/Training/trainingMissStats.js +20 -20
- package/dist/packages/core/src/components/Training/trainingPopupUtils.js +7 -7
- package/dist/packages/core/src/components/Training/useAudioPreview.js +15 -15
- package/dist/packages/core/src/components/Training/useFloatingFocus.js +14 -14
- package/dist/packages/core/src/components/Training/useNoiseFade.js +22 -22
- package/dist/packages/core/src/components/Training/useObjectUrls.js +2 -2
- package/dist/packages/core/src/components/Training/usePlaybackSequencer.d.ts +2 -2
- package/dist/packages/core/src/components/Training/usePlaybackSequencer.js +23 -23
- package/dist/packages/core/src/components/Training/useProgressTicker.js +33 -33
- package/dist/packages/core/src/components/Training/useTTSPlayback.js +17 -17
- package/dist/packages/core/src/components/Training/useWaveReady.js +11 -11
- package/dist/packages/core/src/components/UpdateAlert.js +6 -6
- package/dist/packages/core/src/components/Word/PhonemeSelectBlock.js +7 -1
- package/dist/packages/core/src/components/Word/TableFilterPanel.js +3 -3
- package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +10 -10
- package/dist/packages/core/src/components/Word/WordTableBlock.js +28 -28
- package/dist/packages/core/src/components/Word/WordTableButton.css +16 -2
- package/dist/packages/core/src/components/Word/WordTableThumbnail.css +6 -1
- package/dist/packages/core/src/components/Word/generatedDraft.js +14 -14
- package/dist/packages/core/src/components/Word/useWordTableDisplaySettings.js +7 -7
- package/dist/packages/core/src/components/Word/wordScoreStats.js +15 -15
- package/dist/packages/core/src/components/Word/wordTablePrint.js +10 -10
- package/dist/packages/core/src/components/Word/wordTableRecommend.js +11 -11
- package/dist/packages/core/src/components/Word/wordTableReview.js +28 -28
- package/dist/packages/core/src/components/Word/wordTableSettings.d.ts +8 -11
- package/dist/packages/core/src/components/Word/wordTableSettings.js +48 -36
- package/dist/packages/core/src/components/WordTable.js +130 -130
- package/dist/packages/core/src/components/barChartData.d.ts +16 -1
- package/dist/packages/core/src/components/barChartData.js +49 -29
- package/dist/packages/core/src/components/barChartTypes.js +8 -8
- package/dist/packages/core/src/components/tableTrackList.js +11 -11
- package/dist/packages/core/src/components/updateAlertStack.js +28 -28
- package/dist/packages/core/src/components/useBarChartControl.js +4 -6
- package/dist/packages/core/src/components/useComboboxSuggestions.js +17 -17
- package/dist/packages/core/src/components/useToastLifecycle.js +18 -18
- package/dist/packages/core/src/components/wordTableLayout.d.ts +17 -4
- package/dist/packages/core/src/components/wordTableLayout.js +5 -5
- package/dist/packages/core/src/context/I18nProvider.js +6 -6
- package/dist/packages/core/src/hooks/focusReturn.js +27 -27
- package/dist/packages/core/src/hooks/inputModality.js +13 -13
- package/dist/packages/core/src/hooks/touchToggle.js +39 -39
- package/dist/packages/core/src/hooks/useAnchoredMenu.js +30 -30
- package/dist/packages/core/src/hooks/useDailyTrainingSelection.js +28 -28
- package/dist/packages/core/src/hooks/useDocumentTitle.js +10 -10
- package/dist/packages/core/src/hooks/useEditableList.js +4 -4
- package/dist/packages/core/src/hooks/useFocusTrap.js +2 -2
- package/dist/packages/core/src/hooks/useFontScale.js +6 -6
- package/dist/packages/core/src/hooks/useIcon.js +7 -7
- package/dist/packages/core/src/hooks/useListControls.js +2 -2
- package/dist/packages/core/src/hooks/usePageResetOnChange.js +3 -3
- package/dist/packages/core/src/hooks/usePasswordChange.js +4 -4
- package/dist/packages/core/src/hooks/usePendingPress.js +7 -7
- package/dist/packages/core/src/hooks/useRelativeDay.js +4 -4
- package/dist/packages/core/src/hooks/useSoundCompassExam.js +6 -6
- package/dist/packages/core/src/hooks/useTablePaging.d.ts +0 -11
- package/dist/packages/core/src/hooks/useTablePaging.js +29 -34
- package/dist/packages/core/src/hooks/useTrainingSessionLog.js +35 -35
- package/dist/packages/core/src/hooks/useVisitSchedule.js +4 -4
- package/dist/packages/core/src/hooks/useWordAttributeFilter.js +14 -14
- package/dist/packages/core/src/hooks/useWordMeanings.js +5 -5
- package/dist/packages/core/src/hooks/useWordTrainingFilter.js +7 -7
- package/dist/packages/core/src/i18n/locales/en.json +13 -2
- package/dist/packages/core/src/i18n/locales/ko.json +12 -1
- package/dist/packages/core/src/types/wordSearch.d.ts +9 -0
- package/dist/packages/core/src/utils/audioMeter.js +5 -5
- package/dist/packages/core/src/utils/audioPeaks.d.ts +1 -1
- package/dist/packages/core/src/utils/audioPeaks.js +18 -18
- package/dist/packages/core/src/utils/blockGrid.js +4 -4
- package/dist/packages/core/src/utils/chunkSkew.js +9 -9
- package/dist/packages/core/src/utils/clipboard.js +3 -3
- package/dist/packages/core/src/utils/dailySelectionUtils.js +12 -12
- package/dist/packages/core/src/utils/date.js +12 -12
- package/dist/packages/core/src/utils/devWarn.js +4 -4
- package/dist/packages/core/src/utils/deviceKind.d.ts +37 -7
- package/dist/packages/core/src/utils/deviceKind.js +53 -10
- package/dist/packages/core/src/utils/errorMessage.js +7 -7
- package/dist/packages/core/src/utils/filterPanelSummary.js +14 -14
- package/dist/packages/core/src/utils/format.js +4 -4
- package/dist/packages/core/src/utils/getTrainingData.js +9 -9
- package/dist/packages/core/src/utils/imageConfig.js +17 -17
- package/dist/packages/core/src/utils/koreanSyllable.js +18 -18
- package/dist/packages/core/src/utils/navScroll.js +2 -2
- package/dist/packages/core/src/utils/phonemeConvert.d.ts +7 -0
- package/dist/packages/core/src/utils/phonemeConvert.js +19 -10
- package/dist/packages/core/src/utils/phonemeGroups.js +2 -2
- package/dist/packages/core/src/utils/phonemePairSearch.d.ts +1 -0
- package/dist/packages/core/src/utils/phonemePairSearch.js +118 -102
- package/dist/packages/core/src/utils/phonemeSectionCodes.d.ts +52 -0
- package/dist/packages/core/src/utils/phonemeSectionCodes.js +76 -0
- package/dist/packages/core/src/utils/prefetchChunk.js +6 -6
- package/dist/packages/core/src/utils/safeHref.js +4 -4
- package/dist/packages/core/src/utils/savedWordTables.js +5 -5
- package/dist/packages/core/src/utils/speak.js +12 -12
- package/dist/packages/core/src/utils/textSort.d.ts +0 -10
- package/dist/packages/core/src/utils/textSort.js +38 -48
- package/dist/packages/core/src/utils/theme.js +3 -3
- package/dist/packages/core/src/utils/trainingItemResults.js +3 -3
- package/dist/packages/core/src/utils/ttsConfig.js +2 -2
- package/dist/packages/core/src/utils/wordAttributeFilterCodes.js +45 -45
- package/dist/packages/core/src/utils/wordContrast.js +3 -3
- package/dist/packages/core/src/utils/wordPairIndex.js +4 -4
- package/dist/packages/core/src/utils/wordTrainingGenerators.js +4 -4
- package/dist/packages/core/src/utils/wordTrainingPool.js +15 -15
- package/dist/packages/core/src/utils/wordTrainingRuntime.js +6 -6
- package/dist/packages/core/src/utils/wornDate.d.ts +3 -3
- package/dist/packages/core/src/utils/wornDate.js +14 -14
- package/dist/packages/core/src/version.d.ts +1 -1
- package/dist/packages/core/src/version.js +1 -1
- package/dist/utils.cjs.js +4 -4
- package/package.json +1 -1
|
@@ -11,16 +11,16 @@ import { getTodayKstDate } from '../utils/date';
|
|
|
11
11
|
import DescriptionTooltip from './Tooltip/DescriptionTooltip';
|
|
12
12
|
import ChartSection, { ChartTotal, ChartLegend } from './ChartSection';
|
|
13
13
|
import { devWarnOnce } from '../utils/devWarn';
|
|
14
|
-
import { firstMondayOfMonth,
|
|
14
|
+
import { firstMondayOfMonth, weekLabel } from './barChartData';
|
|
15
15
|
import { useBarChartControl } from './useBarChartControl';
|
|
16
16
|
// ─── 막대 렌더링 원시 컴포넌트 ───────────────────────────────────
|
|
17
17
|
/** 막대 값 — 수가 아니면(undefined·NaN·Infinity) 0 */
|
|
18
|
-
const
|
|
18
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
|
|
19
19
|
/** 아직 오지 않은 칸인가 — 옛 표시(a === -1)와 새 표시(future)를 함께 본다.
|
|
20
20
|
* 음수가 값인 차트(baseline)에서는 −1 이 진짜 값일 수 있어 `future` 를 쓴다. */
|
|
21
|
-
function
|
|
21
|
+
function isFutureBar(d, showPast = true) {
|
|
22
22
|
/* `baseline` 을 쓰는 차트는 −1 이 진짜 값이라 옛 표시를 보지 않는다 — 부르는 쪽이 옛표시=false 로 끈다 */
|
|
23
|
-
return d.future === true || (
|
|
23
|
+
return d.future === true || (showPast && d.a === -1);
|
|
24
24
|
}
|
|
25
25
|
function BarChartCanvas({ data, colorA, colorB, labelA, labelB, barWidth = 10, barGap = 1, height = 140, maxOverride, tightMax, seriesColors, seriesLabels, rateLabels, grouped = false, baseline, focusableBars = false }) {
|
|
26
26
|
// colorB 미지정 시 단일 계열(A) 모드
|
|
@@ -29,24 +29,24 @@ function BarChartCanvas({ data, colorA, colorB, labelA, labelB, barWidth = 10, b
|
|
|
29
29
|
/* 값이 비었거나(undefined) 수가 아니면 0 으로 친다. 두 계열인데 한 항목의 `b` 가 비면 Math.max 가 NaN 이 되고
|
|
30
30
|
`NaN || 10` 으로 y축이 데이터와 상관없이 10 에 박혀 막대가 판 위로 넘쳤다(ihab-core-96 이 짚음). */
|
|
31
31
|
/* 기준선 모드에서는 옛 미래 표시(a === -1)를 보지 않는다 — −1 이 진짜 값이다 */
|
|
32
|
-
const
|
|
32
|
+
const base = baseline;
|
|
33
33
|
const maxVal = useMemo(() => stacked
|
|
34
|
-
? Math.max(0, ...data.filter(d =>
|
|
34
|
+
? Math.max(0, ...data.filter(d => !isFutureBar(d, base == null)).map(d => grouped
|
|
35
35
|
/* 나란히 서면 가장 높은 **낱 막대**가 위를 정한다 — 합으로 잡으면 막대가 판의 몇 분의 일로 눌린다 */
|
|
36
|
-
? Math.max(0, ...(d.parts ?? []).map(
|
|
37
|
-
: (d.parts ?? []).reduce((s, v) => s + Math.max(0,
|
|
38
|
-
: Math.max(0, ...data.filter(d =>
|
|
36
|
+
? Math.max(0, ...(d.parts ?? []).map(num))
|
|
37
|
+
: (d.parts ?? []).reduce((s, v) => s + Math.max(0, num(v)), 0)))
|
|
38
|
+
: Math.max(0, ...data.filter(d => !isFutureBar(d, base == null)).flatMap(d => single ? [num(d.a)] : [num(d.a), num(d.b)])), [data, single, stacked, grouped]);
|
|
39
39
|
/* 기준선 모드: 0 선에서 얼마나 떨어졌는지(|값 − 기준|)가 축을 정한다 — 위아래가 대칭이라야 같은 크기의 오름·내림이 같은 길이로 읽힌다 */
|
|
40
|
-
const
|
|
41
|
-
if (
|
|
40
|
+
const symmetricMax = useMemo(() => {
|
|
41
|
+
if (base == null)
|
|
42
42
|
return 0;
|
|
43
|
-
const
|
|
44
|
-
return Math.max(0,
|
|
45
|
-
}, [data,
|
|
43
|
+
const values = data.filter(d => !isFutureBar(d, base == null)).flatMap(d => stacked ? (d.parts ?? []).map(num) : (single ? [num(d.a)] : [num(d.a), num(d.b)]));
|
|
44
|
+
return Math.max(0, ...values.map(v => Math.abs(v - base)));
|
|
45
|
+
}, [data, base, stacked, single]);
|
|
46
46
|
const padded = useMemo(() => {
|
|
47
|
-
const m =
|
|
47
|
+
const m = base != null ? symmetricMax : maxVal;
|
|
48
48
|
return maxOverride ?? (tightMax ? (m > 0 ? m : 10) : (Math.ceil(m / 10) * 10 || 10));
|
|
49
|
-
}, [maxVal,
|
|
49
|
+
}, [maxVal, symmetricMax, base, maxOverride, tightMax]);
|
|
50
50
|
const h = height;
|
|
51
51
|
// 막대 너비 — 여백과 무관하게 막대 '개수'(컬럼 × 계열)가 늘수록 점진 축소(최대 barWidth)
|
|
52
52
|
const barsPerCol = stacked ? (grouped ? Math.max(1, seriesColors.length) : 1) : (single ? 1 : 2);
|
|
@@ -57,109 +57,109 @@ function BarChartCanvas({ data, colorA, colorB, labelA, labelB, barWidth = 10, b
|
|
|
57
57
|
}, [data.length, barsPerCol, barWidth]);
|
|
58
58
|
/* 굵기는 인라인 flex/maxWidth 가 아니라 CSS 변수로 박는다 — 소비자가 `!important`
|
|
59
59
|
없이 CSS 로 덮을 수 있게. 읽는 규칙은 BarChart.css 의 `.manager-bar-chart-bar`·`-stack`. */
|
|
60
|
-
const
|
|
60
|
+
const barWidthStyle = { '--bar-width': `${effBarWidth}px` };
|
|
61
61
|
/* 나란히 선 막대 사이 간격 — 칸(.manager-bar-chart-col--group)의 gap 으로 내려보낸다 */
|
|
62
|
-
const
|
|
62
|
+
const groupGapStyle = { '--bar-group-gap': `${Math.max(0, barGap)}px` };
|
|
63
63
|
const ticks = useMemo(() => {
|
|
64
64
|
const mid = Math.round(padded / 2);
|
|
65
|
-
if (
|
|
65
|
+
if (base != null) {
|
|
66
66
|
/* 위 → 0 → 아래. 맨 아래 값은 x축 자리라 아래 렌더에서 뺀다(기존 규칙과 같다) */
|
|
67
|
-
const
|
|
68
|
-
return [
|
|
67
|
+
const topTicks = [base + padded, ...(mid !== padded ? [base + mid] : []), base];
|
|
68
|
+
return [...topTicks, ...(mid !== padded ? [base - mid] : []), base - padded];
|
|
69
69
|
}
|
|
70
70
|
return mid !== padded ? [padded, mid, 0] : [padded, 0];
|
|
71
|
-
}, [padded,
|
|
71
|
+
}, [padded, base]);
|
|
72
72
|
/** 값 → 판 위에서의 높이 비율(0~1) 자리 — 기준선 모드는 0 선이 한가운데 */
|
|
73
|
-
const
|
|
73
|
+
const tickPos = (t) => (base != null ? (1 - (t - (base - padded)) / (2 * padded)) : (1 - t / padded));
|
|
74
74
|
const [tooltip, setTooltip] = useState(null);
|
|
75
75
|
const handleBarLeave = useCallback(() => setTooltip(null), []);
|
|
76
76
|
/* **한 칸이 말하는 것을 한 곳에서 짓는다**(말귀 요청 (52), 2026-10-02).
|
|
77
77
|
전에는 이 셈이 `onMouseEnter` 손잡이 안에만 있어, **얹는 사람만** 수를 읽을 수 있었다.
|
|
78
78
|
같은 글을 풍선·초점·`aria-label` 셋이 쓰도록 밖으로 뺀다 — 보는 말과 들리는 말이
|
|
79
79
|
갈리지 않는다. */
|
|
80
|
-
const
|
|
80
|
+
const tooltipContent = useCallback((d, parts) => {
|
|
81
81
|
const title = [d.label, d.sublabel].filter(Boolean).join(' ');
|
|
82
82
|
let items;
|
|
83
83
|
if (stacked && grouped && d.rawPartsB) {
|
|
84
84
|
/* 계열별 비율 — 잰 것이 없는 계열은 0% 가 아니라 '-' 다(0% 는 "다 틀렸다" 로 읽힌다) */
|
|
85
85
|
items = (seriesLabels ?? []).map((lb, k) => {
|
|
86
|
-
const
|
|
87
|
-
return { text:
|
|
86
|
+
const denominator = d.rawPartsB[k] ?? 0;
|
|
87
|
+
return { text: denominator > 0 ? `${lb}: ${parts[k] ?? 0}% (${d.rawParts?.[k] ?? 0}/${denominator})` : `${lb}: -`, color: (seriesColors ?? [])[k] };
|
|
88
88
|
});
|
|
89
89
|
}
|
|
90
90
|
else if (stacked && grouped) {
|
|
91
91
|
/* 자리가 고정이라 0 도 적는다 — 빈 자리가 무엇인지 알 수 있게 */
|
|
92
|
-
items = (seriesLabels ?? []).map((lb, k) => ({ text: `${lb}: ${
|
|
92
|
+
items = (seriesLabels ?? []).map((lb, k) => ({ text: `${lb}: ${withSign(parts[k] ?? 0)}`, color: (seriesColors ?? [])[k] }));
|
|
93
93
|
}
|
|
94
94
|
else if (stacked) {
|
|
95
|
-
const
|
|
96
|
-
const
|
|
97
|
-
items =
|
|
95
|
+
const allItems = (seriesLabels ?? []).map((lb, k) => ({ text: `${lb}: ${parts[k] ?? 0}`, color: (seriesColors ?? [])[k] }));
|
|
96
|
+
const presentItems = allItems.filter((_, k) => (parts[k] ?? 0) > 0);
|
|
97
|
+
items = presentItems.length ? presentItems : allItems;
|
|
98
98
|
}
|
|
99
99
|
else if (rateLabels && !single) {
|
|
100
|
-
items = [{ text: `${labelA} ${
|
|
100
|
+
items = [{ text: `${labelA} ${num(d.a)}%`, color: colorA }, { text: `${labelB} ${num(d.b)}%`, color: colorB }];
|
|
101
101
|
}
|
|
102
102
|
else if (rateLabels && d.rawB !== undefined) {
|
|
103
103
|
items = [{ text: `${rateLabels.total} ${d.rawB}` }, { text: `${rateLabels.done} ${d.rawA ?? 0}`, color: colorA }];
|
|
104
104
|
}
|
|
105
105
|
else if (single) {
|
|
106
|
-
items = [{ text: `${labelA}: ${
|
|
106
|
+
items = [{ text: `${labelA}: ${num(d.a)}`, color: colorA }];
|
|
107
107
|
}
|
|
108
108
|
else {
|
|
109
|
-
items = [{ text: `${labelA}: ${
|
|
109
|
+
items = [{ text: `${labelA}: ${num(d.a)}`, color: colorA }, { text: `${labelB}: ${num(d.b)}`, color: colorB }];
|
|
110
110
|
}
|
|
111
111
|
return { title, items };
|
|
112
112
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
113
|
-
}, [stacked, grouped, seriesLabels, seriesColors, rateLabels, single, labelA, labelB, colorA, colorB,
|
|
114
|
-
const
|
|
113
|
+
}, [stacked, grouped, seriesLabels, seriesColors, rateLabels, single, labelA, labelB, colorA, colorB, base]);
|
|
114
|
+
const openTooltip = useCallback((el, d, parts) => {
|
|
115
115
|
const r = el.getBoundingClientRect();
|
|
116
|
-
const { title, items } =
|
|
116
|
+
const { title, items } = tooltipContent(d, parts);
|
|
117
117
|
setTooltip({ rect: { left: r.left, top: r.top, right: r.right, bottom: r.bottom, width: r.width, height: r.height }, title, items });
|
|
118
|
-
}, [
|
|
118
|
+
}, [tooltipContent]);
|
|
119
119
|
/* 낭독기가 읽을 한 칸의 이름 — 풍선과 같은 글을 한 줄로 잇는다 */
|
|
120
|
-
const
|
|
121
|
-
const { title, items } =
|
|
120
|
+
const barLabel = useCallback((d, parts) => {
|
|
121
|
+
const { title, items } = tooltipContent(d, parts);
|
|
122
122
|
return [title, ...items.map(i => i.text)].filter(Boolean).join(', ');
|
|
123
|
-
}, [
|
|
123
|
+
}, [tooltipContent]);
|
|
124
124
|
/* 기준선 모드에서는 오름·내림이 뜻이라 양수에도 + 를 붙인다(부호가 없으면 툴팁만 보고 방향을 알 수 없다) */
|
|
125
|
-
const
|
|
125
|
+
const withSign = (v) => (base != null && v > base ? `+${v}` : `${v}`);
|
|
126
126
|
/* 떠 있는 동안 **스크롤하거나 막대 밖을 누르면** 닫는다 — 터치에서는 톡이 호환 mouseenter 로 띄우고 mouseleave 는 다른 곳을
|
|
127
127
|
눌러야 와서, 스크롤해도 제자리에 떠 있었고 누를 수 없는 곳을 톡해도 안 닫혔다(말귀 훈련 현황·매니저 화면, 태블릿). */
|
|
128
|
-
const
|
|
128
|
+
const isOpen = tooltip != null;
|
|
129
129
|
useEffect(() => {
|
|
130
|
-
if (
|
|
130
|
+
if (!isOpen)
|
|
131
131
|
return;
|
|
132
132
|
/* **안-모음: 닫기만 한다**(코어 자체 조사 2026-10-10에서 세어 보고 적음).
|
|
133
133
|
굴림에 하는 일이 `setTooltip(null)` 하나뿐이라 레이아웃을 안 읽는다. 그리고 **미루면 안 된다** —
|
|
134
134
|
풍선은 막대에 붙어 떠 있어서, 한 틀 미루면 막대에서 떨어진 풍선이 그만큼 더 허공에 남는다
|
|
135
135
|
(Tooltip·useAnchoredMenu 의 `close` 길과 같은 가름: *자리 재기는 미뤄도 되고 닫기는 안 된다*). */
|
|
136
|
-
const
|
|
137
|
-
const
|
|
136
|
+
const close = () => setTooltip(null);
|
|
137
|
+
const onOutsidePress = (e) => {
|
|
138
138
|
const t = e.target;
|
|
139
139
|
if (t && typeof t.closest === 'function' && t.closest('.manager-bar-chart-col'))
|
|
140
140
|
return;
|
|
141
141
|
setTooltip(null);
|
|
142
142
|
};
|
|
143
|
-
window.addEventListener('scroll',
|
|
144
|
-
document.addEventListener('pointerdown',
|
|
143
|
+
window.addEventListener('scroll', close, true);
|
|
144
|
+
document.addEventListener('pointerdown', onOutsidePress, true);
|
|
145
145
|
return () => {
|
|
146
|
-
window.removeEventListener('scroll',
|
|
147
|
-
document.removeEventListener('pointerdown',
|
|
146
|
+
window.removeEventListener('scroll', close, true);
|
|
147
|
+
document.removeEventListener('pointerdown', onOutsidePress, true);
|
|
148
148
|
};
|
|
149
|
-
}, [
|
|
150
|
-
return (_jsxs("div", { className: "manager-bar-chart-wrap", style:
|
|
149
|
+
}, [isOpen]);
|
|
150
|
+
return (_jsxs("div", { className: "manager-bar-chart-wrap", style: groupGapStyle, children: [_jsx("div", { className: "manager-bar-chart-inner", children: _jsx("div", { className: "manager-bar-chart-scroll", children: _jsxs("div", { className: "manager-bar-chart-area", children: [_jsxs("div", { className: "manager-bar-chart-plot", style: { height: h }, children: [ticks.slice(0, -1).map((t, i) => (
|
|
151
151
|
/* 눈금선은 **적힌 값의 높이**에 둔다 — 가운데 값을 반올림(최대 3 → '2')하면서 자리는 절반(1.5)에 두어 선과 숫자가 어긋났다 */
|
|
152
|
-
_jsx("div", { className: `manager-bar-chart-gridline${
|
|
153
|
-
const isFuture =
|
|
152
|
+
_jsx("div", { className: `manager-bar-chart-gridline${base != null && t === base ? ' manager-bar-chart-gridline--baseline' : ''}`, style: { top: tickPos(t) * h }, children: _jsx("span", { className: "manager-bar-chart-gridline__label caption-1", children: t }) }, i))), data.map((d, i) => {
|
|
153
|
+
const isFuture = isFutureBar(d, base == null);
|
|
154
154
|
/* 판 높이를 넘지 않는다 — maxOverride(진행률 100)보다 큰 값이 와도 막대가 눈금 위로 삐져나가지 않게 */
|
|
155
155
|
/* 기준선 모드는 반쪽(h/2)을 쓴다 — 0 선에서 위나 아래로 자란다 */
|
|
156
|
-
const
|
|
157
|
-
const
|
|
158
|
-
? Math.min(
|
|
159
|
-
: Math.min(h, Math.max(0, (
|
|
160
|
-
const ha = isFuture ? 0 :
|
|
161
|
-
const hb = isFuture ? 0 :
|
|
162
|
-
const parts = (d.parts ?? []).map(
|
|
156
|
+
const half = h / 2;
|
|
157
|
+
const heightOf = (v) => (base != null
|
|
158
|
+
? Math.min(half, (Math.abs(num(v) - base) / padded) * half)
|
|
159
|
+
: Math.min(h, Math.max(0, (num(v) / padded) * h)));
|
|
160
|
+
const ha = isFuture ? 0 : heightOf(d.a);
|
|
161
|
+
const hb = isFuture ? 0 : heightOf(d.b);
|
|
162
|
+
const parts = (d.parts ?? []).map(num);
|
|
163
163
|
return (_jsx("div", { className: `manager-bar-chart-col${grouped && stacked ? ' manager-bar-chart-col--group' : ''}`,
|
|
164
164
|
/* **손가락은 그대로 둔다 — 걷으면 아이폰에서 풍선이 아예 안 뜬다.**
|
|
165
165
|
말귀가 "`onClick` 이 없는데 손가락이 뜬다"(요청 (52))고 짚은 것이 맞다.
|
|
@@ -169,7 +169,7 @@ function BarChartCanvas({ data, colorA, colorB, labelA, labelB, barWidth = 10, b
|
|
|
169
169
|
걷으면 데스크톱의 거짓 손가락 하나를 없애는 대신 **손가락으로 쓰는
|
|
170
170
|
기기에서 수를 읽을 길이 통째로 사라진다.** 얹으면 값이 뜨니 아주 거짓도
|
|
171
171
|
아니다. 자판 쪽은 아래 `focusableBars` 가 따로 연다. */
|
|
172
|
-
style: { cursor: isFuture ? 'default' : 'pointer' }, tabIndex: focusableBars && !isFuture ? 0 : undefined, role: focusableBars && !isFuture ? 'img' : undefined, "aria-label": focusableBars && !isFuture ?
|
|
172
|
+
style: { cursor: isFuture ? 'default' : 'pointer' }, tabIndex: focusableBars && !isFuture ? 0 : undefined, role: focusableBars && !isFuture ? 'img' : undefined, "aria-label": focusableBars && !isFuture ? barLabel(d, parts) : undefined, onMouseEnter: isFuture ? undefined : (e) => openTooltip(e.currentTarget, d, parts), onFocus: isFuture || !focusableBars ? undefined : (e) => openTooltip(e.currentTarget, d, parts), onBlur: focusableBars ? handleBarLeave : undefined,
|
|
173
173
|
/* **수를 자판으로도 읽는다**(말귀 요청 (52), 2026-10-02). 전에는 값이
|
|
174
174
|
얹어야 뜨는 풍선에만 있어, 자판·낭독기로는 막대 하나하나의 수를 읽을
|
|
175
175
|
길이 **아예 없었다**. 이름은 풍선과 같은 글로 짓는다 — 보는 말과
|
|
@@ -180,26 +180,26 @@ function BarChartCanvas({ data, colorA, colorB, labelA, labelB, barWidth = 10, b
|
|
|
180
180
|
/* 나란히 — 계열 k 는 늘 k 번째 자리. 0 이어도 자리를 두어 칸마다 같은 색이 같은 곳에 선다 */
|
|
181
181
|
(seriesColors ?? []).map((c, k) => {
|
|
182
182
|
const v = parts[k] ?? 0;
|
|
183
|
-
if (
|
|
184
|
-
return _jsx("div", { className: "manager-bar-chart-bar", style: {
|
|
183
|
+
if (base == null)
|
|
184
|
+
return _jsx("div", { className: "manager-bar-chart-bar", style: { ...barWidthStyle, height: heightOf(v), background: c } }, k);
|
|
185
185
|
/* 0 선 위·아래 — 반쪽 둘을 쌓아 둔 자리(slot)에 그려, 나란히 선 칸의 폭 나눔은 그대로 둔다 */
|
|
186
|
-
const
|
|
187
|
-
return (_jsxs("div", { className: "manager-bar-chart-slot", style:
|
|
186
|
+
const below = num(v) < base;
|
|
187
|
+
return (_jsxs("div", { className: "manager-bar-chart-slot", style: barWidthStyle, children: [_jsx("div", { className: "manager-bar-chart-slot__half manager-bar-chart-slot__half--up", children: !below && _jsx("div", { className: "manager-bar-chart-bar manager-bar-chart-bar--full", style: { height: heightOf(v), background: c } }) }), _jsx("div", { className: "manager-bar-chart-slot__half manager-bar-chart-slot__half--down", children: below && _jsx("div", { className: "manager-bar-chart-bar manager-bar-chart-bar--full manager-bar-chart-bar--down", style: { height: heightOf(v), background: c } }) })] }, k));
|
|
188
188
|
})) : stacked ? (
|
|
189
189
|
// 계열별 stacked — 아래부터 위로 쌓고, 맨 위 segment만 상단 라운드
|
|
190
|
-
_jsx("div", { className: "manager-bar-chart-stack", style:
|
|
190
|
+
_jsx("div", { className: "manager-bar-chart-stack", style: barWidthStyle, children: (() => {
|
|
191
191
|
const topK = parts.reduce((acc, v, k) => (v > 0 ? k : acc), -1); // 맨 위(최상단) 세그먼트
|
|
192
|
-
let
|
|
192
|
+
let remaining = h; // 쌓은 합도 판 높이를 넘지 않게 위 조각부터 깎는다
|
|
193
193
|
return parts.map((v, k) => {
|
|
194
194
|
if (v <= 0)
|
|
195
195
|
return null;
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
return _jsx("div", { className: "manager-bar-chart-stack-segment", style: { height:
|
|
196
|
+
const piece = Math.min(remaining, (v / padded) * h);
|
|
197
|
+
remaining -= piece;
|
|
198
|
+
return _jsx("div", { className: "manager-bar-chart-stack-segment", style: { height: piece, background: (seriesColors ?? [])[k], borderRadius: k === topK ? '6px 6px 0 0' : undefined } }, k);
|
|
199
199
|
});
|
|
200
|
-
})() })) : (
|
|
200
|
+
})() })) : (base != null ? (_jsx(_Fragment, { children: [{ v: num(d.a), c: colorA }, ...(single ? [] : [{ v: num(d.b), c: colorB }])].map(({ v, c }, k) => (_jsxs("div", { className: "manager-bar-chart-slot", style: barWidthStyle, children: [_jsx("div", { className: "manager-bar-chart-slot__half manager-bar-chart-slot__half--up", children: v >= base && _jsx("div", { className: "manager-bar-chart-bar manager-bar-chart-bar--full", style: { height: heightOf(v), background: c } }) }), _jsx("div", { className: "manager-bar-chart-slot__half manager-bar-chart-slot__half--down", children: v < base && _jsx("div", { className: "manager-bar-chart-bar manager-bar-chart-bar--full manager-bar-chart-bar--down", style: { height: heightOf(v), background: c } }) })] }, k))) })) : (_jsxs(_Fragment, { children: [_jsx("div", { className: "manager-bar-chart-bar", style: { ...barWidthStyle, height: ha, background: colorA } }), !single && _jsx("div", { className: "manager-bar-chart-bar", style: { ...barWidthStyle, height: hb, background: colorB } })] })))) }, i));
|
|
201
201
|
})] }), _jsx("div", { className: "manager-bar-chart-x-axis", children: data.map((d, i) => {
|
|
202
|
-
const isFuture =
|
|
202
|
+
const isFuture = isFutureBar(d, base == null); // 아직 도래하지 않은 날 — 글씨 흐리게
|
|
203
203
|
return (_jsxs("span", { className: `manager-bar-chart-x-axis__label${isFuture ? ' manager-bar-chart-x-axis__label--future' : ''}`, children: [_jsxs("span", { className: "caption-1 manager-bar-chart-x-axis__main", children: [d.isToday && _jsx("span", { className: "manager-bar-chart-x-axis__today-dot", style: { background: colorA } }), d.label] }), d.sublabel && _jsx("span", { className: "caption-1", children: d.sublabel })] }, i));
|
|
204
204
|
}) })] }) }) }), tooltip && (_jsx(DescriptionTooltip, { anchorRect: tooltip.rect, title: tooltip.title, items: tooltip.items, showIndex: false, closeOnOutsideClick: false }))] }));
|
|
205
205
|
}
|
|
@@ -220,20 +220,21 @@ export function BarChartControls({ control }) {
|
|
|
220
220
|
* 주 보기의 막대·합계와 월 보기 완료율 주 막대가 **이 한 벌**을 쓴다 — 같은 주가 두 보기에서
|
|
221
221
|
* 다른 수로 서지 않게(2026-09-12 말귀 검수: 주 보기 25% · 월 보기 38%).
|
|
222
222
|
*/
|
|
223
|
-
function
|
|
224
|
-
const
|
|
223
|
+
function daysOfWeek(data, monday, now, showPast = true) {
|
|
224
|
+
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
225
225
|
return Array.from({ length: 7 }, (_, i) => {
|
|
226
226
|
const dt = new Date(monday);
|
|
227
227
|
dt.setDate(monday.getDate() + i);
|
|
228
228
|
const entry = (data[`${dt.getFullYear()}년 ${dt.getMonth() + 1}월`]?.daily ?? [])[dt.getDate() - 1];
|
|
229
|
-
const isFuture = (entry != null &&
|
|
229
|
+
const isFuture = (entry != null && isFutureBar(entry, showPast)) || dt > today; // 미래 날 — 평균/막대에서 제외
|
|
230
230
|
return { dt, entry, isFuture };
|
|
231
231
|
});
|
|
232
232
|
}
|
|
233
233
|
/** control(기간)에 따라 data를 막대 배열로 변환 */
|
|
234
|
-
|
|
234
|
+
/* **언어를 인자로 받지 않는다.** 주 라벨을 사전(`weekLabel`)이 쥐게 되자 `lang` 이 할 일이
|
|
235
|
+
없어졌다 — 가름이 한 자리로 모이면 그 자리로 흘러들던 인자도 함께 사라진다(2026-10-13). */
|
|
236
|
+
function barsFromControl(data, control, t, showPast = true) {
|
|
235
237
|
const { period, monthRange, weekRange, weekRanges, DOW } = control;
|
|
236
|
-
const isKo = lang?.startsWith('ko');
|
|
237
238
|
const now = getTodayKstDate(); // 칸은 KST 달력 — 오늘·이번 주 표시도 KST 로
|
|
238
239
|
const nowY = now.getFullYear(), nowM = now.getMonth() + 1, nowD = now.getDate();
|
|
239
240
|
if (period === 'month') {
|
|
@@ -255,19 +256,19 @@ function barsFromControl(data, control, t, lang, 옛표시 = true) {
|
|
|
255
256
|
label = t('manager.chart.weekRange', { start: startDay, end: endTxt });
|
|
256
257
|
// 오늘이 이 주 범위(시작일~+6)에 들어가면 표시 — 마지막 주는 다음 달로 넘어가므로
|
|
257
258
|
// 날짜로 견준다(달이 같은지로 보면 9/3 이 8월 마지막 주 8/31~9/6 에서 빠진다)
|
|
258
|
-
const
|
|
259
|
-
isToday =
|
|
259
|
+
const today = new Date(nowY, nowM - 1, nowD).getTime();
|
|
260
|
+
isToday = today >= new Date(year, month - 1, startDay).getTime() && today <= end.getTime();
|
|
260
261
|
// 시작일이 오늘 이후(또는 미래 달)면 아직 도래하지 않은 주 → 흐리게/막대 미표시
|
|
261
262
|
isFutureWeek = year > nowY || (year === nowY && (month > nowM || (month === nowM && startDay > nowD)));
|
|
262
263
|
}
|
|
263
|
-
return { ...d, label, sublabel:
|
|
264
|
+
return { ...d, label, sublabel: weekLabel(t, n - 1), isToday, future: (d.future === true || isFutureWeek) || undefined, a: isFutureWeek ? -1 : d.a, b: isFutureWeek ? 0 : d.b };
|
|
264
265
|
});
|
|
265
266
|
}
|
|
266
267
|
const opt = weekRanges.find(o => o.value === weekRange);
|
|
267
268
|
if (!opt)
|
|
268
269
|
return [];
|
|
269
270
|
const monday = new Date(opt.year, opt.month - 1, firstMondayOfMonth(opt.year, opt.month - 1) + opt.week * 7);
|
|
270
|
-
return
|
|
271
|
+
return daysOfWeek(data, monday, now, showPast).map(({ dt, entry, isFuture }) => {
|
|
271
272
|
const dM = dt.getMonth() + 1, dD = dt.getDate(), dY = dt.getFullYear();
|
|
272
273
|
const sameMonth = dM === opt.month && dY === opt.year;
|
|
273
274
|
return {
|
|
@@ -295,44 +296,44 @@ const BAR_SIZE_PRESET = {
|
|
|
295
296
|
* 원시 렌더링은 내부 BarChartCanvas가 담당한다.
|
|
296
297
|
*/
|
|
297
298
|
export default function BarChart({ focusableBars, size = 'medium', barWidth, titleKey, titleLevel, data, colorA, colorB, labelAKey, labelBKey, showTotal = false, totalUnitKey, totalLabelKey, control: controlProp, hideControls = false, rate = false, rateAgg = 'dayMean', tightMax = false, avgMode = false, totalFormat, series, seriesMode = 'stack', loading = false, onDetail, headExtra, headSegment, icon, disc, onClick, cardAriaLabel, cardTooltip, detailSize = 32, barGap, baseline, className, inGrid = false, }) {
|
|
298
|
-
const { t
|
|
299
|
+
const { t } = useTranslation();
|
|
299
300
|
const sizePreset = BAR_SIZE_PRESET[size];
|
|
300
301
|
// 외부 control 미지정 시 내부 컨트롤 사용 (단독 차트 하위 호환)
|
|
301
302
|
const internalControl = useBarChartControl(data);
|
|
302
303
|
const control = controlProp ?? internalControl;
|
|
303
|
-
const rawBars = useMemo(() => barsFromControl(data, control, t,
|
|
304
|
+
const rawBars = useMemo(() => barsFromControl(data, control, t, baseline == null), [data, control, t, baseline]);
|
|
304
305
|
// 완료율 모드: a = 진행률(a/b×100). labelBKey 지정 시 b = 정답률(parts[0]/parts[1]×100) 2계열.
|
|
305
306
|
// 월 보기의 주 막대는 **그 주 경과한 날의 일별 진행률 평균(미기록일 0%)** — 아래 total 과
|
|
306
307
|
// 주 보기가 쓰는 같은 셈. 주 합 a/b 로 두면 기록 없는 날(b=0)이 빠져 같은 주가 두 보기에서
|
|
307
308
|
// 다른 수로 선다(주 보기 25% · 월 보기 38%). 다음 달로 넘친 날도 그 주에 든다(주의날들).
|
|
308
309
|
// 툴팁의 전체/완료(rawA·rawB)는 합 그대로 — 둘 다 사실이다.
|
|
309
|
-
const
|
|
310
|
+
const grouped = seriesMode === 'group' && !!series && series.length > 0;
|
|
310
311
|
/* barGap 은 나란히 선 막대 **사이** 값이라 그 밖에서는 그릴 자리가 없다 — 조용히 먹으면 "간격을 줬는데 안 변한다" 가 된다 */
|
|
311
|
-
if (barGap != null &&
|
|
312
|
+
if (barGap != null && !grouped) {
|
|
312
313
|
devWarnOnce('bar-chart-bargap-without-group', "[BarChart] barGap 은 seriesMode='group' + series 에서만 쓰인다 — 그 밖에서는 아무 일도 하지 않는다.");
|
|
313
314
|
}
|
|
314
315
|
const bars = useMemo(() => {
|
|
315
316
|
if (!rate)
|
|
316
317
|
return rawBars;
|
|
317
|
-
const
|
|
318
|
+
const monthMatch = control.period === 'month' ? control.monthRange.match(/^(\d+)년 (\d+)월$/) : null;
|
|
318
319
|
const now = getTodayKstDate();
|
|
319
|
-
const
|
|
320
|
+
const seriesCount = series?.length ?? 0;
|
|
320
321
|
/** 계열 k 의 비율과 원래 수 — rateAgg 를 계열마다 따로. 날들은 막대 하나가 품는 경과한 날(주 보기는 그 하루) */
|
|
321
|
-
const
|
|
322
|
+
const seriesRatio = (days) => {
|
|
322
323
|
const pct = [], ra = [], rb = [];
|
|
323
|
-
for (let k = 0; k <
|
|
324
|
-
let sa = 0, sb = 0,
|
|
325
|
-
for (const
|
|
326
|
-
const x =
|
|
324
|
+
for (let k = 0; k < seriesCount; k++) {
|
|
325
|
+
let sa = 0, sb = 0, ratioSum = 0;
|
|
326
|
+
for (const day of days) {
|
|
327
|
+
const x = num(day.a[k]), y = num(day.b[k]);
|
|
327
328
|
if (y <= 0)
|
|
328
329
|
continue; // 그 계열을 안 잰 날 — 분자가 남아 있어도 뺀다(단일 계열 weighted 와 같은 규칙)
|
|
329
330
|
sa += x;
|
|
330
331
|
sb += y;
|
|
331
|
-
|
|
332
|
+
ratioSum += (x / y) * 100;
|
|
332
333
|
}
|
|
333
334
|
pct.push(rateAgg === 'weighted'
|
|
334
335
|
? (sb > 0 ? Math.round((sa / sb) * 100) : 0) // 잰 날만 — Σa/Σb
|
|
335
|
-
: (
|
|
336
|
+
: (days.length ? Math.round(ratioSum / days.length) : 0)); // 안 잰 날은 0% 로 평균
|
|
336
337
|
ra.push(sa);
|
|
337
338
|
rb.push(sb);
|
|
338
339
|
}
|
|
@@ -340,13 +341,13 @@ export default function BarChart({ focusableBars, size = 'medium', barWidth, tit
|
|
|
340
341
|
};
|
|
341
342
|
return rawBars.map((d, i) => {
|
|
342
343
|
let a = d.a === -1 ? -1 : (d.b > 0 ? Math.round((d.a / d.b) * 100) : 0);
|
|
343
|
-
if (
|
|
344
|
-
const y = Number(
|
|
345
|
-
const
|
|
344
|
+
if (monthMatch && !isFutureBar(d)) {
|
|
345
|
+
const y = Number(monthMatch[1]), m0 = Number(monthMatch[2]) - 1;
|
|
346
|
+
const pastDays = daysOfWeek(data, new Date(y, m0, firstMondayOfMonth(y, m0) + i * 7), now).filter(x => !x.isFuture);
|
|
346
347
|
if (rateAgg === 'weighted') {
|
|
347
348
|
/* 잰 것이 있는 날만 — 안 한 날을 0% 로 섞으면 "맞힌 비율" 이 내려간다(정답률) */
|
|
348
349
|
let sa = 0, sb = 0;
|
|
349
|
-
for (const { entry: e } of
|
|
350
|
+
for (const { entry: e } of pastDays)
|
|
350
351
|
if (e && e.b > 0) {
|
|
351
352
|
sa += e.a;
|
|
352
353
|
sb += e.b;
|
|
@@ -354,35 +355,35 @@ export default function BarChart({ focusableBars, size = 'medium', barWidth, tit
|
|
|
354
355
|
a = sb > 0 ? Math.round((sa / sb) * 100) : 0;
|
|
355
356
|
}
|
|
356
357
|
else {
|
|
357
|
-
a =
|
|
358
|
-
? Math.round(
|
|
358
|
+
a = pastDays.length
|
|
359
|
+
? Math.round(pastDays.reduce((s, { entry: e }) => s + (e && e.b > 0 ? (e.a / e.b) * 100 : 0), 0) / pastDays.length)
|
|
359
360
|
: 0;
|
|
360
361
|
}
|
|
361
362
|
}
|
|
362
|
-
let
|
|
363
|
-
if (
|
|
364
|
-
let
|
|
365
|
-
if (
|
|
366
|
-
const y = Number(
|
|
367
|
-
|
|
363
|
+
let seriesParts = null;
|
|
364
|
+
if (grouped && !isFutureBar(d)) {
|
|
365
|
+
let days;
|
|
366
|
+
if (monthMatch) {
|
|
367
|
+
const y = Number(monthMatch[1]), m0 = Number(monthMatch[2]) - 1;
|
|
368
|
+
days = daysOfWeek(data, new Date(y, m0, firstMondayOfMonth(y, m0) + i * 7), now)
|
|
368
369
|
.filter(x => !x.isFuture)
|
|
369
370
|
.map(({ entry: e }) => ({ a: e?.parts ?? [], b: e?.partsB ?? [] }));
|
|
370
371
|
}
|
|
371
372
|
else {
|
|
372
|
-
|
|
373
|
+
days = [{ a: d.parts ?? [], b: d.partsB ?? [] }];
|
|
373
374
|
}
|
|
374
|
-
const r =
|
|
375
|
-
|
|
375
|
+
const r = seriesRatio(days);
|
|
376
|
+
seriesParts = { parts: r.pct, rawParts: r.ra, rawPartsB: r.rb };
|
|
376
377
|
}
|
|
377
378
|
return {
|
|
378
379
|
...d,
|
|
379
380
|
a,
|
|
380
381
|
b: d.a === -1 ? 0 : (labelBKey && d.parts && d.parts[1] > 0 ? Math.round((d.parts[0] / d.parts[1]) * 100) : 0),
|
|
381
382
|
rawA: d.a === -1 ? -1 : d.a, rawB: d.b,
|
|
382
|
-
...(
|
|
383
|
+
...(seriesParts ?? {}),
|
|
383
384
|
};
|
|
384
385
|
});
|
|
385
|
-
}, [rawBars, rate, rateAgg, labelBKey, control.period, control.monthRange, data,
|
|
386
|
+
}, [rawBars, rate, rateAgg, labelBKey, control.period, control.monthRange, data, grouped, series?.length]);
|
|
386
387
|
// 합계 — 완료율 모드는 주/월 모두 "경과한 모든 날의 일별 진행률 평균"(미기록일=0% 포함)으로 통일.
|
|
387
388
|
// 주: 선택 주의 일별, 월: 선택 월의 일별(주별 집계가 아니라 일 단위라 주↔월 일관성 유지). 미래(a=-1)만 제외.
|
|
388
389
|
const total = useMemo(() => {
|
|
@@ -410,13 +411,13 @@ export default function BarChart({ focusableBars, size = 'medium', barWidth, tit
|
|
|
410
411
|
/* 월 보기 합계는 **그 달 날짜만** — 주 막대는 월요일 시작 7일이라 달 경계를 넘는다(마지막 주는 다음 달 날, 첫 월요일 앞 날은
|
|
411
412
|
앞 달 막대). 막대 합으로 내면 8월 합계에 9/1~9/6 이 들고 8/1~8/2 가 빠져, 상세 목록(달력 달)의 건수와 어긋났다.
|
|
412
413
|
주 보기는 그 주 막대의 합 그대로. */
|
|
413
|
-
const
|
|
414
|
+
const toMerge = control.period === 'month' ? (data[control.monthRange]?.daily ?? []) : bars;
|
|
414
415
|
/* 기준선 모드는 부호를 살려 더한다 — 오르내림의 **순변화** 가 합계다(양수만 더하면 내린 주가 사라진다).
|
|
415
416
|
미래 표시(a === -1)는 여기서도 뺀다 — 기준선 모드에서도 -1 은 '아직 오지 않은 칸' 이다(실제 -1 이 나올 수 있으면 그 칸을 자료에서 뺀다). */
|
|
416
417
|
if (baseline != null) {
|
|
417
|
-
return
|
|
418
|
+
return toMerge.reduce((s, d) => s + (isFutureBar(d, false) ? 0 : num(d.a) + (labelBKey ? num(d.b) : 0)), 0);
|
|
418
419
|
}
|
|
419
|
-
return
|
|
420
|
+
return toMerge.reduce((s, d) => s + (d.a > 0 ? d.a : 0) + (labelBKey && d.b > 0 ? d.b : 0), 0);
|
|
420
421
|
}, [bars, rawBars, rate, rateAgg, avgMode, baseline, labelBKey, control.period, control.monthRange, data]);
|
|
421
422
|
const legendItems = series && series.length > 0
|
|
422
423
|
? series.map(s => ({ color: s.color, label: s.label }))
|
|
@@ -441,13 +442,13 @@ export default function BarChart({ focusableBars, size = 'medium', barWidth, tit
|
|
|
441
442
|
* 이름은 바깥에서 더 받지 않는다 — **이미 `titleKey` 를 받고 있다.** 제목이 없을 때만 예전
|
|
442
443
|
* 글자로 떨어뜨려 쓰던 자리가 안 깨지게 한다.
|
|
443
444
|
*/
|
|
444
|
-
const
|
|
445
|
-
const
|
|
446
|
-
? t('trainingStatus.detailFor', { name:
|
|
445
|
+
const cardTitle = titleKey ? t(titleKey) : '';
|
|
446
|
+
const detailTitle = cardTitle
|
|
447
|
+
? t('trainingStatus.detailFor', { name: cardTitle, defaultValue: '{{name}} 상세보기' })
|
|
447
448
|
: t('trainingStatus.detail', '상세보기');
|
|
448
449
|
return (_jsx(ChartSection, { className: sectionClassName, inGrid: inGrid, icon: icon, disc: disc, onClick: onClick, ariaLabel: cardAriaLabel, tooltip: cardTooltip, headSegment: headSegment,
|
|
449
450
|
/* 좁아 고르개가 '⋯' 로 접히면 상세보기도 그 메뉴로 들어간다 — 머리에 단추 둘이 남지 않게 */
|
|
450
|
-
headMenuItems: onDetail ? [{ label:
|
|
451
|
+
headMenuItems: onDetail ? [{ label: detailTitle, icon: 'list', onClick: onDetail, closeOnSelect: true }] : undefined, title: t(titleKey), titleLevel: titleLevel, titleClassName: isSmall ? 'h5 chart-section-title--small' : undefined, headRight: isSmall ? (headExtra ?? undefined) : ({ collapsed }) => _jsxs(_Fragment, { children: [!hideControls && _jsx(BarChartControls, { control: control }), headExtra, onDetail && !collapsed && (_jsx("span", { className: "manager-bar-chart-detail-btn", children: _jsx(Button, { htmlType: "button", size: detailSize, type: "dot", icon: "list", ariaLabel: detailTitle, showTooltipInfo: detailTitle, onClick: onDetail }) }))] }), subRow: _jsxs(_Fragment, { children: [showTotal ? ((rate || avgMode || (!!totalLabelKey && !!totalFormat)) ? (
|
|
451
452
|
// 진행률/평균 모드 또는 totalLabelKey+totalFormat 조합: 라벨(윗줄) + 값(아랫줄). size='small'은 라벨 생략
|
|
452
453
|
_jsx(ChartTotal, { label: isSmall ? undefined : `${t(control.period === 'week' ? 'manager.chart.weeklyPrefix' : 'manager.chart.monthlyPrefix')} ${t(totalLabelKey ?? labelAKey)}`, children: totalFormat ? (
|
|
453
454
|
// 숫자는 h0, 단위(시간/분/초 등)는 h5로 분리
|
|
@@ -460,5 +461,5 @@ export default function BarChart({ focusableBars, size = 'medium', barWidth, tit
|
|
|
460
461
|
→ 막대 156px 로 카드마다 30px, 섹션 372 → 342px.
|
|
461
462
|
그래서 **그려질 것과 같은 뼈대**(칸 자리 + 축 한 줄)를 세우고 그 안에 스피너를 둔다.
|
|
462
463
|
키는 막대 자리(`sizePreset.height`)에서 오므로 크기 보기(size)가 달라도 따라온다. */
|
|
463
|
-
_jsx("div", { className: "manager-bar-chart-wrap manager-bar-chart-loading", "aria-busy": "true", children: _jsx("div", { className: "manager-bar-chart-inner", children: _jsxs("div", { className: "manager-bar-chart-area manager-bar-chart-area--loading", children: [_jsx("div", { className: "manager-bar-chart-plot manager-bar-chart-plot--loading", style: { height: sizePreset.height }, children: _jsx(Loader, { size: 32 }) }), _jsx("div", { className: "manager-bar-chart-x-axis", "aria-hidden": "true", children: _jsxs("span", { className: "manager-bar-chart-x-axis__label", children: [_jsx("span", { className: "caption-1 manager-bar-chart-x-axis__main", children: "\u00A0" }), bars.some(b => !!b.sublabel) && _jsx("span", { className: "caption-1", children: "\u00A0" })] }) })] }) }) })) : (_jsx(BarChartCanvas, { focusableBars: focusableBars, data: bars, colorA: colorA, colorB: labelBKey ? colorB : undefined, labelA: t(labelAKey), labelB: labelBKey ? t(labelBKey) : undefined, barWidth: barWidth ?? sizePreset.barWidth, barGap: barGap, baseline: baseline, height: sizePreset.height, maxOverride: rate ? 100 : undefined, tightMax: tightMax, seriesColors: series?.map(s => s.color), seriesLabels: series?.map(s => s.label), rateLabels: rate ? { total: t('trainingStatus.today.tooltipTotal', '전체'), done: t('trainingStatus.today.tooltipDone', '완료') } : undefined, grouped:
|
|
464
|
+
_jsx("div", { className: "manager-bar-chart-wrap manager-bar-chart-loading", "aria-busy": "true", children: _jsx("div", { className: "manager-bar-chart-inner", children: _jsxs("div", { className: "manager-bar-chart-area manager-bar-chart-area--loading", children: [_jsx("div", { className: "manager-bar-chart-plot manager-bar-chart-plot--loading", style: { height: sizePreset.height }, children: _jsx(Loader, { size: 32 }) }), _jsx("div", { className: "manager-bar-chart-x-axis", "aria-hidden": "true", children: _jsxs("span", { className: "manager-bar-chart-x-axis__label", children: [_jsx("span", { className: "caption-1 manager-bar-chart-x-axis__main", children: "\u00A0" }), bars.some(b => !!b.sublabel) && _jsx("span", { className: "caption-1", children: "\u00A0" })] }) })] }) }) })) : (_jsx(BarChartCanvas, { focusableBars: focusableBars, data: bars, colorA: colorA, colorB: labelBKey ? colorB : undefined, labelA: t(labelAKey), labelB: labelBKey ? t(labelBKey) : undefined, barWidth: barWidth ?? sizePreset.barWidth, barGap: barGap, baseline: baseline, height: sizePreset.height, maxOverride: rate ? 100 : undefined, tightMax: tightMax, seriesColors: series?.map(s => s.color), seriesLabels: series?.map(s => s.label), rateLabels: rate ? { total: t('trainingStatus.today.tooltipTotal', '전체'), done: t('trainingStatus.today.tooltipDone', '완료') } : undefined, grouped: grouped })) }));
|
|
464
465
|
}
|
|
@@ -38,23 +38,23 @@ export default function BottomNav({ items = [], activeKey, onChange, appMode, on
|
|
|
38
38
|
const root = rootRef.current;
|
|
39
39
|
if (!root)
|
|
40
40
|
return;
|
|
41
|
-
const
|
|
41
|
+
const measure = () => {
|
|
42
42
|
const cs = typeof getComputedStyle === 'function' ? getComputedStyle(root) : null;
|
|
43
|
-
const
|
|
43
|
+
const innerWidth = root.clientWidth - (parseFloat(cs?.paddingLeft ?? '') || 0) - (parseFloat(cs?.paddingRight ?? '') || 0);
|
|
44
44
|
// 왼쪽 묶음은 항목 수와 무관하게 늘 같은 폭이다(모드 전환을 안 쓰면 아예 없다)
|
|
45
|
-
const
|
|
46
|
-
const
|
|
47
|
-
const
|
|
48
|
-
+ (
|
|
49
|
-
setAvail(Math.max(0,
|
|
45
|
+
const leftEl = root.querySelector('.bottom-nav__left');
|
|
46
|
+
const leftDivider = leftEl?.nextElementSibling;
|
|
47
|
+
const leftWidth = (leftEl?.offsetWidth ?? 0)
|
|
48
|
+
+ (leftEl && leftDivider?.classList.contains('bottom-nav__divider') ? leftDivider.offsetWidth : 0);
|
|
49
|
+
setAvail(Math.max(0, innerWidth - leftWidth));
|
|
50
50
|
};
|
|
51
|
-
|
|
51
|
+
measure();
|
|
52
52
|
// ResizeObserver 가 없는 환경(구형 브라우저·SSR·테스트 러너)에서는 생성자 호출이
|
|
53
53
|
// 그대로 던져 렌더가 통째로 깨진다. 위에서 초기값은 이미 쟀으므로 추적만 포기한다
|
|
54
54
|
// (Popup·CrosswordGame·Tooltip 과 같은 처리).
|
|
55
55
|
if (typeof ResizeObserver === 'undefined')
|
|
56
56
|
return;
|
|
57
|
-
const ro = new ResizeObserver(
|
|
57
|
+
const ro = new ResizeObserver(measure);
|
|
58
58
|
ro.observe(root);
|
|
59
59
|
/* **왼쪽 묶음도 본다**(말귀 요청 (71), 2026-10-02).
|
|
60
60
|
`avail` 은 `바 안쪽 - 왼쪽 묶음` 이라 **왼쪽이 바뀌면 값이 바뀐다.** 그런데 보개는
|
|
@@ -65,21 +65,21 @@ export default function BottomNav({ items = [], activeKey, onChange, appMode, on
|
|
|
65
65
|
없고, `/manager` 에서는 지금 보고 있는 화면의 아이콘이 반쪽이었다).
|
|
66
66
|
놀이터에서 바 폭은 그대로 두고 왼쪽만 52 → 160px 로 넓혀 봤더니 **칸 수가 그대로**였다
|
|
67
67
|
— 보개가 그 변화를 안 본다는 뜻이다. */
|
|
68
|
-
const
|
|
69
|
-
if (
|
|
70
|
-
ro.observe(
|
|
68
|
+
const leftEl = root.querySelector('.bottom-nav__left');
|
|
69
|
+
if (leftEl)
|
|
70
|
+
ro.observe(leftEl);
|
|
71
71
|
return () => ro.disconnect();
|
|
72
72
|
/* 왼쪽 묶음은 `onAppModeChange` 가 있을 때만 선다 — 그 유무가 바뀌면 **볼 대상이 바뀌므로**
|
|
73
73
|
보개를 다시 건다. 항목 수는 `avail` 에 안 들어가므로(그게 이 셈의 요점이다) 안 넣는다. */
|
|
74
74
|
}, [onAppModeChange]);
|
|
75
75
|
const MORE_W = BTN_SIZE + 1;
|
|
76
76
|
/** 폭 w 에 들어가는 칸 수 — n칸은 n*52 + (n-1)*10 을 쓰므로 n <= (w + 10) / 62 */
|
|
77
|
-
const
|
|
78
|
-
const
|
|
77
|
+
const fitCount = (w) => Math.max(0, Math.floor((w + BTN_GAP) / (BTN_SIZE + BTN_GAP)));
|
|
78
|
+
const allFit = itemsLen * (BTN_SIZE + BTN_GAP) - BTN_GAP <= avail;
|
|
79
79
|
const n = avail <= 0 ? itemsLen
|
|
80
|
-
: (moreItems.length === 0 &&
|
|
80
|
+
: (moreItems.length === 0 && allFit) ? itemsLen
|
|
81
81
|
// "…" 가 설 자리를 뺀 폭으로 센다 — 유틸이 있으면 "…" 는 늘 선다
|
|
82
|
-
: Math.min(itemsLen,
|
|
82
|
+
: Math.min(itemsLen, fitCount(avail - MORE_W));
|
|
83
83
|
const overflow = avail > 0 && n < itemsLen;
|
|
84
84
|
const maxVisible = overflow ? n : itemsLen;
|
|
85
85
|
const visibleItems = items.slice(0, maxVisible);
|
|
@@ -106,7 +106,7 @@ export default function BottomNav({ items = [], activeKey, onChange, appMode, on
|
|
|
106
106
|
넘친 항목·유틸 항목의 배지를 **합한** 것이라 그 단추의 수가 아니다 — `더보기, 3개` 는
|
|
107
107
|
"더보기에 3개가 들어 있다" 로 읽히는데, 안에 든 항목 수와도 다르다. `activeMore` 가
|
|
108
108
|
있을 때는 더 어긋났다: `설정, 3개` 의 3 은 알림의 것이었다. */
|
|
109
|
-
const
|
|
109
|
+
const visibleCount = useCallback((label, count) => count > 0 ? t('nav.badgeInsideLabel', { label, count, defaultValue: '{{label}}, 안에 알림 {{count}}개' }) : label, [t]);
|
|
110
110
|
const handleModeChange = useCallback(() => onAppModeChange?.(nextMode), [onAppModeChange, nextMode]);
|
|
111
111
|
// 넘친 nav 항목 또는 유틸 항목이 현재 활성 상태면 … 버튼에 그 아이콘/선택표시를 반영
|
|
112
112
|
const activeMore = useMemo(() => hiddenItems.find(it => !!it.key && it.key === activeKey) ?? moreItems.find(it => it.selected || (it.key && it.key === activeKey)), [hiddenItems, activeKey, moreItems]);
|
|
@@ -154,5 +154,5 @@ export default function BottomNav({ items = [], activeKey, onChange, appMode, on
|
|
|
154
154
|
onChange?.(it.key);
|
|
155
155
|
scrollNavTargetToTop(scrollContainer, 'BottomNav');
|
|
156
156
|
} } }), count > 0 && (_jsx("span", { "aria-hidden": "true", children: _jsx(Badge, { type: "dot", position: "top-right", number: Math.min(count, 99), size: 24 }) }))] }, it.key ?? `__slot-${i}`));
|
|
157
|
-
}) }), showMore && (_jsxs(_Fragment, { children: [_jsx("div", { className: "bottom-nav__divider", "aria-hidden": "true" }), _jsx("div", { className: "bottom-nav__right", children: _jsxs("div", { ref: moreRef, className: "bottom-nav__slot", children: [_jsx(Button, { htmlType: "button", size: 52, type: "dot", icon: activeMore?.icon ?? 'ellipsis', ariaLabel:
|
|
157
|
+
}) }), showMore && (_jsxs(_Fragment, { children: [_jsx("div", { className: "bottom-nav__divider", "aria-hidden": "true" }), _jsx("div", { className: "bottom-nav__right", children: _jsxs("div", { ref: moreRef, className: "bottom-nav__slot", children: [_jsx(Button, { htmlType: "button", size: 52, type: "dot", icon: activeMore?.icon ?? 'ellipsis', ariaLabel: visibleCount(activeMore?.label ?? t('nav.more', '더보기'), moreBadge), selected: !!activeMore, className: "bottom-nav-item", onClick: handleToggleMore, "aria-expanded": moreOpen, "aria-controls": moreOpen ? moreMenuId : undefined }), moreBadge > 0 && (_jsx("span", { "aria-hidden": "true", children: _jsx(Badge, { type: "dot", position: "top-right", number: Math.min(moreBadge, 99), size: 24 }) })), moreOpen && createPortal(_jsx("div", { id: moreMenuId, role: "group", "aria-label": t('nav.moreMenu', '더보기 메뉴'), className: "tooltip-content", style: menuStyle, ref: menuRef, children: _jsx(MenuTooltip, { columns: moreColumns, onClose: handleCloseMore }) }), document.body)] }) })] }))] }));
|
|
158
158
|
}
|