@ihabdevteam/core 0.156.0 → 0.158.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 +35 -0
- package/dist/components.cjs.js +173 -173
- package/dist/components.esm.css +1 -1
- package/dist/hooks.cjs.js +1 -1
- package/dist/i18n.cjs.js +1 -1
- package/dist/ihabdevteam-core.cjs.js +177 -177
- package/dist/ihabdevteam-core.d.ts +26 -6
- package/dist/packages/core/src/components/BarChart.css +8 -0
- package/dist/packages/core/src/components/BarChart.d.ts +1 -1
- package/dist/packages/core/src/components/BarChart.js +67 -11
- package/dist/packages/core/src/components/SegmentedControl.css +3 -0
- package/dist/packages/core/src/components/SegmentedControl.d.ts +3 -2
- package/dist/packages/core/src/components/barChartData.d.ts +4 -1
- package/dist/packages/core/src/components/barChartData.js +19 -6
- package/dist/packages/core/src/components/barChartTypes.d.ts +17 -1
- package/dist/packages/core/src/version.d.ts +1 -1
- package/dist/packages/core/src/version.js +1 -1
- package/dist/utils.cjs.js +1 -1
- package/package.json +1 -1
|
@@ -309,7 +309,7 @@ export declare type BadgeType = 'bar' | 'dot';
|
|
|
309
309
|
* - 주(week): 'YYYY년 M월 N째주' 드롭다운 + 그 주의 요일(월~일) 막대
|
|
310
310
|
* 원시 렌더링은 내부 BarChartCanvas가 담당한다.
|
|
311
311
|
*/
|
|
312
|
-
export declare function BarChart({ size, barWidth, titleKey, data, colorA, colorB, labelAKey, labelBKey, showTotal, totalUnitKey, totalLabelKey, control: controlProp, hideControls, rate, rateAgg, tightMax, avgMode, totalFormat, series, loading, onDetail, headExtra, className, inGrid, }: BarChartProps): JSX.Element;
|
|
312
|
+
export declare function BarChart({ size, barWidth, titleKey, data, colorA, colorB, labelAKey, labelBKey, showTotal, totalUnitKey, totalLabelKey, control: controlProp, hideControls, rate, rateAgg, tightMax, avgMode, totalFormat, series, seriesMode, loading, onDetail, headExtra, detailSize, className, inGrid, }: BarChartProps): JSX.Element;
|
|
313
313
|
|
|
314
314
|
/** @public */
|
|
315
315
|
export declare interface BarChartControl {
|
|
@@ -392,11 +392,24 @@ export declare interface BarChartProps {
|
|
|
392
392
|
avgMode?: boolean;
|
|
393
393
|
/** 헤더 합계/평균 값 포맷터(예: 분 → 'X시간 Y분'). 지정 시 단위 대신 포맷 문자열 표시. */
|
|
394
394
|
totalFormat?: (n: number) => string;
|
|
395
|
-
/** 다계열
|
|
395
|
+
/** 다계열 정의 — data의 parts[index]와 1:1. 지정 시 막대를 계열별로 그리고 범례도 계열별. 그리는 법은 `seriesMode`. */
|
|
396
396
|
series?: {
|
|
397
397
|
label: string;
|
|
398
398
|
color: string;
|
|
399
399
|
}[];
|
|
400
|
+
/**
|
|
401
|
+
* 다계열(`series`)을 어떻게 세울지 — 기본 `'stack'`.
|
|
402
|
+
*
|
|
403
|
+
* - `'stack'`: 한 칸 안에 계열을 **쌓는다**. 더해지는 값(분·회)에 쓴다.
|
|
404
|
+
* - `'group'`: 한 칸 안에 계열을 **나란히** 세운다. 포갤 수 없는 값(백분율)에 쓴다.
|
|
405
|
+
* 계열 k 는 늘 k 번째 자리에 선다 — 값이 0 이어도 자리를 비워 두어 칸마다 같은 색이 같은 자리에 있다.
|
|
406
|
+
*
|
|
407
|
+
* `rate` 와 함께 쓰면 계열마다 **따로** 비율을 낸다 — 분자는 `parts[k]`, 분모는 `partsB[k]`
|
|
408
|
+
* (`ChartPoint.partsB`). `rateAgg` 도 계열마다 따로 적용된다(`'weighted'` 면 그 계열의 분모가 0 인 날은 뺀다).
|
|
409
|
+
* 머리의 합계는 지금처럼 `a`/`b`(전체의 분자·분모)로 낸다 — 계열 합을 `a`·`b` 에 넣어 주면 된다.
|
|
410
|
+
* 막대 수가 늘면 굵기는 `barWidth` 를 위 한계로 두고 줄어든다(계열 수만큼 센다).
|
|
411
|
+
*/
|
|
412
|
+
seriesMode?: 'stack' | 'group';
|
|
400
413
|
/** 로딩 중 — 차트 영역에 스피너 표시(헤더/레이아웃은 유지) */
|
|
401
414
|
loading?: boolean;
|
|
402
415
|
/** 지정 시 헤더 우측에 '상세보기' 버튼 표시 → 클릭 콜백 */
|
|
@@ -410,6 +423,9 @@ export declare interface BarChartProps {
|
|
|
410
423
|
* (말귀 실측: 같은 자료가 dayMean 54% · weighted 80% = 229/286문항.)
|
|
411
424
|
*/
|
|
412
425
|
rateAgg?: 'dayMean' | 'weighted';
|
|
426
|
+
/** '상세보기' 단추 크기(기본 32). `headExtra` 에 SegmentedControl(size 32, 겉높이 40px)처럼 큰 것을 얹으면
|
|
427
|
+
* 32 단추와 높이가 안 맞는다 — 그 카드만 40 을 준다. */
|
|
428
|
+
detailSize?: 32 | 40;
|
|
413
429
|
/** 머리 오른쪽 묶음에서 기간 컨트롤과 '상세보기' **사이** 에 놓을 것 — 앱이 카드 머리에 얹는 작은 조작(예: 시간/정답률 SegmentedControl size 32).
|
|
414
430
|
* size='small' 에서도 이것만은 그린다(기간 컨트롤은 그대로 숨김). */
|
|
415
431
|
headExtra?: ReactNode;
|
|
@@ -423,7 +439,8 @@ export declare interface BarChartProps {
|
|
|
423
439
|
export declare type BarChartSize = 'small' | 'medium' | 'large';
|
|
424
440
|
|
|
425
441
|
/** 막대 하나의 데이터 — a/b 두 계열. a === -1 이면 미래(데이터 없음)로 표시하지 않음.
|
|
426
|
-
* parts:
|
|
442
|
+
* parts: 다계열용 계열별 값(있으면 a는 합계로 사용).
|
|
443
|
+
* partsB: `parts` 와 1:1 인 계열별 **분모** — `rate` 에서 계열마다 parts[k]/partsB[k] 를 낸다(a/b 의 계열판). */
|
|
427
444
|
export declare interface BarData {
|
|
428
445
|
label: string;
|
|
429
446
|
sublabel?: string;
|
|
@@ -433,6 +450,7 @@ export declare interface BarData {
|
|
|
433
450
|
rawB?: number;
|
|
434
451
|
isToday?: boolean;
|
|
435
452
|
parts?: number[];
|
|
453
|
+
partsB?: number[];
|
|
436
454
|
}
|
|
437
455
|
|
|
438
456
|
/**
|
|
@@ -987,6 +1005,7 @@ export declare interface ChartPoint {
|
|
|
987
1005
|
a: number;
|
|
988
1006
|
b: number;
|
|
989
1007
|
parts?: number[];
|
|
1008
|
+
partsB?: number[];
|
|
990
1009
|
}
|
|
991
1010
|
|
|
992
1011
|
/**
|
|
@@ -1474,7 +1493,7 @@ export declare function copyText(text: string): Promise<boolean>;
|
|
|
1474
1493
|
*
|
|
1475
1494
|
* 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
|
|
1476
1495
|
*/
|
|
1477
|
-
export declare const CORE_VERSION = "0.
|
|
1496
|
+
export declare const CORE_VERSION = "0.158.0";
|
|
1478
1497
|
|
|
1479
1498
|
/** @public */
|
|
1480
1499
|
declare const coreDefault: Record<string, unknown>;
|
|
@@ -5585,8 +5604,9 @@ export declare interface SegmentedControlProps {
|
|
|
5585
5604
|
*/
|
|
5586
5605
|
onChange?: (v: string) => void;
|
|
5587
5606
|
className?: string;
|
|
5588
|
-
/** 버튼 높이 — Button과
|
|
5589
|
-
|
|
5607
|
+
/** 버튼 높이 — Button 과 같은 숫자 size (24 / 32 / 40 / 48). 겉높이는 여기에 판 여백 4px 두 겹을 더한 값이다
|
|
5608
|
+
* (24 → 32 · 32 → 40 · 40 → 48 · 48 → 56). `useBG={false}` 면 여백이 없어 겉높이가 곧 size 다. */
|
|
5609
|
+
size?: 24 | 32 | 40 | 48;
|
|
5590
5610
|
shape?: 'rounded' | 'pill';
|
|
5591
5611
|
useBG?: boolean;
|
|
5592
5612
|
/** 외부에서 정해진 폭을 버튼들이 균등 분할해 가득 채움 */
|
|
@@ -147,3 +147,11 @@
|
|
|
147
147
|
.manager-bar-chart-detail-btn {
|
|
148
148
|
margin-left: auto;
|
|
149
149
|
}
|
|
150
|
+
|
|
151
|
+
/* 나란히(seriesMode 'group') — 한 칸에 계열이 네다섯 선다. **칸 사이가 계열 사이보다 늘 넓어야**
|
|
152
|
+
어느 막대가 어느 주·날인지 읽힌다. 계열 사이는 1px 로 붙이고, 칸 양옆에 4px 를 둬 칸 사이가
|
|
153
|
+
적어도 8px 이 되게 한다(막대가 줄어 칸이 꽉 차도 무리가 붙지 않는다). */
|
|
154
|
+
.manager-bar-chart-col--group {
|
|
155
|
+
gap: 1px;
|
|
156
|
+
padding: 0 4px;
|
|
157
|
+
}
|
|
@@ -13,4 +13,4 @@ import type { BarChartProps } from './barChartTypes';
|
|
|
13
13
|
* - 주(week): 'YYYY년 M월 N째주' 드롭다운 + 그 주의 요일(월~일) 막대
|
|
14
14
|
* 원시 렌더링은 내부 BarChartCanvas가 담당한다.
|
|
15
15
|
*/
|
|
16
|
-
export default function BarChart({ size, barWidth, titleKey, data, colorA, colorB, labelAKey, labelBKey, showTotal, totalUnitKey, totalLabelKey, control: controlProp, hideControls, rate, rateAgg, tightMax, avgMode, totalFormat, series, loading, onDetail, headExtra, className, inGrid, }: BarChartProps): import("react").JSX.Element;
|
|
16
|
+
export default function BarChart({ size, barWidth, titleKey, data, colorA, colorB, labelAKey, labelBKey, showTotal, totalUnitKey, totalLabelKey, control: controlProp, hideControls, rate, rateAgg, tightMax, avgMode, totalFormat, series, seriesMode, loading, onDetail, headExtra, detailSize, className, inGrid, }: BarChartProps): import("react").JSX.Element;
|
|
@@ -15,19 +15,22 @@ import { useBarChartControl } from './useBarChartControl';
|
|
|
15
15
|
// ─── 막대 렌더링 원시 컴포넌트 ───────────────────────────────────
|
|
16
16
|
/** 막대 값 — 수가 아니면(undefined·NaN·Infinity) 0 */
|
|
17
17
|
const 수 = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
|
|
18
|
-
function BarChartCanvas({ data, colorA, colorB, labelA, labelB, barWidth = 10, height = 140, maxOverride, tightMax, seriesColors, seriesLabels, rateLabels }) {
|
|
18
|
+
function BarChartCanvas({ data, colorA, colorB, labelA, labelB, barWidth = 10, height = 140, maxOverride, tightMax, seriesColors, seriesLabels, rateLabels, grouped = false }) {
|
|
19
19
|
// colorB 미지정 시 단일 계열(A) 모드
|
|
20
20
|
const single = !colorB;
|
|
21
21
|
const stacked = !!(seriesColors && seriesColors.length > 0);
|
|
22
22
|
/* 값이 비었거나(undefined) 수가 아니면 0 으로 친다. 두 계열인데 한 항목의 `b` 가 비면 Math.max 가 NaN 이 되고
|
|
23
23
|
`NaN || 10` 으로 y축이 데이터와 상관없이 10 에 박혀 막대가 판 위로 넘쳤다(ihab-core-96 이 짚음). */
|
|
24
24
|
const maxVal = useMemo(() => stacked
|
|
25
|
-
? Math.max(0, ...data.filter(d => d.a !== -1).map(d =>
|
|
26
|
-
|
|
25
|
+
? Math.max(0, ...data.filter(d => d.a !== -1).map(d => grouped
|
|
26
|
+
/* 나란히 서면 가장 높은 **낱 막대**가 위를 정한다 — 합으로 잡으면 막대가 판의 몇 분의 일로 눌린다 */
|
|
27
|
+
? Math.max(0, ...(d.parts ?? []).map(수))
|
|
28
|
+
: (d.parts ?? []).reduce((s, v) => s + Math.max(0, 수(v)), 0)))
|
|
29
|
+
: Math.max(0, ...data.filter(d => d.a !== -1).flatMap(d => single ? [수(d.a)] : [수(d.a), 수(d.b)])), [data, single, stacked, grouped]);
|
|
27
30
|
const padded = useMemo(() => maxOverride ?? (tightMax ? (maxVal > 0 ? maxVal : 10) : (Math.ceil(maxVal / 10) * 10 || 10)), [maxVal, maxOverride, tightMax]);
|
|
28
31
|
const h = height;
|
|
29
32
|
// 막대 너비 — 여백과 무관하게 막대 '개수'(컬럼 × 계열)가 늘수록 점진 축소(최대 barWidth)
|
|
30
|
-
const barsPerCol = stacked ? 1 : (single ? 1 : 2);
|
|
33
|
+
const barsPerCol = stacked ? (grouped ? Math.max(1, seriesColors.length) : 1) : (single ? 1 : 2);
|
|
31
34
|
const effBarWidth = useMemo(() => {
|
|
32
35
|
const cols = Math.max(1, data.length);
|
|
33
36
|
/* 하한 2px — 8 이던 때는 barWidth 6(말귀 훈련 현황의 가는 막대)을 넘겨도 8 로 올라갔다. 자동 축소 식은 6 밑으로 안 내려간다 */
|
|
@@ -71,13 +74,24 @@ function BarChartCanvas({ data, colorA, colorB, labelA, labelB, barWidth = 10, h
|
|
|
71
74
|
const ha = isFuture ? 0 : 높이(d.a);
|
|
72
75
|
const hb = isFuture ? 0 : 높이(d.b);
|
|
73
76
|
const parts = (d.parts ?? []).map(수);
|
|
74
|
-
return (_jsx("div", { className:
|
|
77
|
+
return (_jsx("div", { className: `manager-bar-chart-col${grouped && stacked ? ' manager-bar-chart-col--group' : ''}`, style: { cursor: isFuture ? 'default' : 'pointer' }, onMouseEnter: isFuture ? undefined : (e) => {
|
|
75
78
|
const r = e.currentTarget.getBoundingClientRect();
|
|
76
79
|
/* 한 줄에 `A: 3 / B: 5 / …` 로 늘어놓던 것을 줄마다 하나로 — 유형이 넷·다섯이면
|
|
77
80
|
가로로 길어져 읽기 어려웠다(사용자가 짚음). 제목은 날짜, 항목은 색 점·이름·값. */
|
|
78
81
|
const title = [d.label, d.sublabel].filter(Boolean).join(' ');
|
|
79
82
|
let items;
|
|
80
|
-
if (stacked) {
|
|
83
|
+
if (stacked && grouped && d.rawPartsB) {
|
|
84
|
+
/* 계열별 비율 — 잰 것이 없는 계열은 0% 가 아니라 '-' 다(0% 는 "다 틀렸다" 로 읽힌다) */
|
|
85
|
+
items = (seriesLabels ?? []).map((lb, k) => {
|
|
86
|
+
const 분모 = d.rawPartsB[k] ?? 0;
|
|
87
|
+
return { text: 분모 > 0 ? `${lb}: ${parts[k] ?? 0}% (${d.rawParts?.[k] ?? 0}/${분모})` : `${lb}: -`, color: (seriesColors ?? [])[k] };
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
else if (stacked && grouped) {
|
|
91
|
+
/* 자리가 고정이라 0 도 적는다 — 빈 자리가 무엇인지 알 수 있게 */
|
|
92
|
+
items = (seriesLabels ?? []).map((lb, k) => ({ text: `${lb}: ${parts[k] ?? 0}`, color: (seriesColors ?? [])[k] }));
|
|
93
|
+
}
|
|
94
|
+
else if (stacked) {
|
|
81
95
|
const 전부 = (seriesLabels ?? []).map((lb, k) => ({ text: `${lb}: ${parts[k] ?? 0}`, color: (seriesColors ?? [])[k] }));
|
|
82
96
|
const 있는 = 전부.filter((_, k) => (parts[k] ?? 0) > 0);
|
|
83
97
|
items = 있는.length ? 있는 : 전부;
|
|
@@ -95,7 +109,9 @@ function BarChartCanvas({ data, colorA, colorB, labelA, labelB, barWidth = 10, h
|
|
|
95
109
|
items = [{ text: `${labelA}: ${수(d.a)}`, color: colorA }, { text: `${labelB}: ${수(d.b)}`, color: colorB }];
|
|
96
110
|
}
|
|
97
111
|
setTooltip({ rect: { left: r.left, top: r.top, right: r.right, bottom: r.bottom, width: r.width, height: r.height }, title, items });
|
|
98
|
-
}, onMouseLeave: handleBarLeave, children: !isFuture && (stacked ? (
|
|
112
|
+
}, onMouseLeave: handleBarLeave, children: !isFuture && (stacked && grouped ? (
|
|
113
|
+
/* 나란히 — 계열 k 는 늘 k 번째 자리. 0 이어도 자리를 두어 칸마다 같은 색이 같은 곳에 선다 */
|
|
114
|
+
(seriesColors ?? []).map((c, k) => (_jsx("div", { className: "manager-bar-chart-bar", style: { ...막대굵기, height: 높이(parts[k] ?? 0), background: c } }, k)))) : stacked ? (
|
|
99
115
|
// 계열별 stacked — 아래부터 위로 쌓고, 맨 위 segment만 상단 라운드
|
|
100
116
|
_jsx("div", { className: "manager-bar-chart-stack", style: 막대굵기, children: (() => {
|
|
101
117
|
const topK = parts.reduce((acc, v, k) => (v > 0 ? k : acc), -1); // 맨 위(최상단) 세그먼트
|
|
@@ -187,6 +203,7 @@ function barsFromControl(data, control, t, lang) {
|
|
|
187
203
|
b: isFuture ? 0 : (entry && entry.b > 0 ? entry.b : 0),
|
|
188
204
|
isToday: dY === nowY && dM === nowM && dD === nowD,
|
|
189
205
|
...(entry?.parts ? { parts: isFuture ? entry.parts.map(() => 0) : entry.parts } : {}),
|
|
206
|
+
...(entry?.partsB ? { partsB: isFuture ? entry.partsB.map(() => 0) : entry.partsB } : {}),
|
|
190
207
|
};
|
|
191
208
|
});
|
|
192
209
|
}
|
|
@@ -202,7 +219,7 @@ const BAR_SIZE_PRESET = {
|
|
|
202
219
|
* - 주(week): 'YYYY년 M월 N째주' 드롭다운 + 그 주의 요일(월~일) 막대
|
|
203
220
|
* 원시 렌더링은 내부 BarChartCanvas가 담당한다.
|
|
204
221
|
*/
|
|
205
|
-
export default function BarChart({ size = 'medium', barWidth, titleKey, data, colorA, colorB, labelAKey, labelBKey, showTotal = false, totalUnitKey, totalLabelKey, control: controlProp, hideControls = false, rate = false, rateAgg = 'dayMean', tightMax = false, avgMode = false, totalFormat, series, loading = false, onDetail, headExtra, className, inGrid = false, }) {
|
|
222
|
+
export default function BarChart({ size = 'medium', barWidth, titleKey, 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, detailSize = 32, className, inGrid = false, }) {
|
|
206
223
|
const { t, i18n } = useTranslation();
|
|
207
224
|
const sizePreset = BAR_SIZE_PRESET[size];
|
|
208
225
|
// 외부 control 미지정 시 내부 컨트롤 사용 (단독 차트 하위 호환)
|
|
@@ -214,11 +231,34 @@ export default function BarChart({ size = 'medium', barWidth, titleKey, data, co
|
|
|
214
231
|
// 주 보기가 쓰는 같은 셈. 주 합 a/b 로 두면 기록 없는 날(b=0)이 빠져 같은 주가 두 보기에서
|
|
215
232
|
// 다른 수로 선다(주 보기 25% · 월 보기 38%). 다음 달로 넘친 날도 그 주에 든다(주의날들).
|
|
216
233
|
// 툴팁의 전체/완료(rawA·rawB)는 합 그대로 — 둘 다 사실이다.
|
|
234
|
+
const 나란히 = seriesMode === 'group' && !!series && series.length > 0;
|
|
217
235
|
const bars = useMemo(() => {
|
|
218
236
|
if (!rate)
|
|
219
237
|
return rawBars;
|
|
220
238
|
const 월 = control.period === 'month' ? control.monthRange.match(/^(\d+)년 (\d+)월$/) : null;
|
|
221
239
|
const now = getTodayKstDate();
|
|
240
|
+
const 계열수 = series?.length ?? 0;
|
|
241
|
+
/** 계열 k 의 비율과 원래 수 — rateAgg 를 계열마다 따로. 날들은 막대 하나가 품는 경과한 날(주 보기는 그 하루) */
|
|
242
|
+
const 계열비율 = (날들) => {
|
|
243
|
+
const pct = [], ra = [], rb = [];
|
|
244
|
+
for (let k = 0; k < 계열수; k++) {
|
|
245
|
+
let sa = 0, sb = 0, 비율합 = 0;
|
|
246
|
+
for (const 날 of 날들) {
|
|
247
|
+
const x = 수(날.a[k]), y = 수(날.b[k]);
|
|
248
|
+
if (y <= 0)
|
|
249
|
+
continue; // 그 계열을 안 잰 날 — 분자가 남아 있어도 뺀다(단일 계열 weighted 와 같은 규칙)
|
|
250
|
+
sa += x;
|
|
251
|
+
sb += y;
|
|
252
|
+
비율합 += (x / y) * 100;
|
|
253
|
+
}
|
|
254
|
+
pct.push(rateAgg === 'weighted'
|
|
255
|
+
? (sb > 0 ? Math.round((sa / sb) * 100) : 0) // 잰 날만 — Σa/Σb
|
|
256
|
+
: (날들.length ? Math.round(비율합 / 날들.length) : 0)); // 안 잰 날은 0% 로 평균
|
|
257
|
+
ra.push(sa);
|
|
258
|
+
rb.push(sb);
|
|
259
|
+
}
|
|
260
|
+
return { pct, ra, rb };
|
|
261
|
+
};
|
|
222
262
|
return rawBars.map((d, i) => {
|
|
223
263
|
let a = d.a === -1 ? -1 : (d.b > 0 ? Math.round((d.a / d.b) * 100) : 0);
|
|
224
264
|
if (월 && d.a !== -1) {
|
|
@@ -240,14 +280,30 @@ export default function BarChart({ size = 'medium', barWidth, titleKey, data, co
|
|
|
240
280
|
: 0;
|
|
241
281
|
}
|
|
242
282
|
}
|
|
283
|
+
let 계열 = null;
|
|
284
|
+
if (나란히 && d.a !== -1) {
|
|
285
|
+
let 날들;
|
|
286
|
+
if (월) {
|
|
287
|
+
const y = Number(월[1]), m0 = Number(월[2]) - 1;
|
|
288
|
+
날들 = 주의날들(data, new Date(y, m0, firstMondayOfMonth(y, m0) + i * 7), now)
|
|
289
|
+
.filter(x => !x.isFuture)
|
|
290
|
+
.map(({ entry: e }) => ({ a: e?.parts ?? [], b: e?.partsB ?? [] }));
|
|
291
|
+
}
|
|
292
|
+
else {
|
|
293
|
+
날들 = [{ a: d.parts ?? [], b: d.partsB ?? [] }];
|
|
294
|
+
}
|
|
295
|
+
const r = 계열비율(날들);
|
|
296
|
+
계열 = { parts: r.pct, rawParts: r.ra, rawPartsB: r.rb };
|
|
297
|
+
}
|
|
243
298
|
return {
|
|
244
299
|
...d,
|
|
245
300
|
a,
|
|
246
301
|
b: d.a === -1 ? 0 : (labelBKey && d.parts && d.parts[1] > 0 ? Math.round((d.parts[0] / d.parts[1]) * 100) : 0),
|
|
247
302
|
rawA: d.a === -1 ? -1 : d.a, rawB: d.b,
|
|
303
|
+
...(계열 ?? {}),
|
|
248
304
|
};
|
|
249
305
|
});
|
|
250
|
-
}, [rawBars, rate, rateAgg, labelBKey, control.period, control.monthRange, data]);
|
|
306
|
+
}, [rawBars, rate, rateAgg, labelBKey, control.period, control.monthRange, data, 나란히, series?.length]);
|
|
251
307
|
// 합계 — 완료율 모드는 주/월 모두 "경과한 모든 날의 일별 진행률 평균"(미기록일=0% 포함)으로 통일.
|
|
252
308
|
// 주: 선택 주의 일별, 월: 선택 월의 일별(주별 집계가 아니라 일 단위라 주↔월 일관성 유지). 미래(a=-1)만 제외.
|
|
253
309
|
const total = useMemo(() => {
|
|
@@ -290,11 +346,11 @@ export default function BarChart({ size = 'medium', barWidth, titleKey, data, co
|
|
|
290
346
|
const isLarge = size === 'large';
|
|
291
347
|
const sectionClassName = [isSmall ? 'manager-section--compact' : isLarge ? 'manager-section--full' : undefined, className]
|
|
292
348
|
.filter(Boolean).join(' ') || undefined;
|
|
293
|
-
return (_jsx(ChartSection, { className: sectionClassName, inGrid: inGrid, title: t(titleKey), titleClassName: isSmall ? 'h5 chart-section-title--small' : undefined, headRight: isSmall ? (headExtra ?? undefined) : _jsxs(_Fragment, { children: [!hideControls && _jsx(BarChartControls, { control: control }), headExtra, onDetail && (_jsx("span", { className: "manager-bar-chart-detail-btn", children: _jsx(Button, { htmlType: "button", size:
|
|
349
|
+
return (_jsx(ChartSection, { className: sectionClassName, inGrid: inGrid, title: t(titleKey), titleClassName: isSmall ? 'h5 chart-section-title--small' : undefined, headRight: isSmall ? (headExtra ?? undefined) : _jsxs(_Fragment, { children: [!hideControls && _jsx(BarChartControls, { control: control }), headExtra, onDetail && (_jsx("span", { className: "manager-bar-chart-detail-btn", children: _jsx(Button, { htmlType: "button", size: detailSize, type: "dot", icon: "list", ariaLabel: t('trainingStatus.detail', '상세보기'), onClick: onDetail }) }))] }), subRow: _jsxs(_Fragment, { children: [showTotal ? ((rate || avgMode || (!!totalLabelKey && !!totalFormat)) ? (
|
|
294
350
|
// 진행률/평균 모드 또는 totalLabelKey+totalFormat 조합: 라벨(윗줄) + 값(아랫줄). size='small'은 라벨 생략
|
|
295
351
|
_jsx(ChartTotal, { label: isSmall ? undefined : `${t(control.period === 'week' ? 'manager.chart.weeklyPrefix' : 'manager.chart.monthlyPrefix')} ${t(totalLabelKey ?? labelAKey)}`, children: totalFormat ? (
|
|
296
352
|
// 숫자는 h0, 단위(시간/분/초 등)는 h5로 분리
|
|
297
353
|
(totalFormat(total).match(/(\d+|[^\d]+)/g) ?? []).map((part, i) => (/\d/.test(part)
|
|
298
354
|
? _jsx("span", { className: "barchart-total__num h0", children: part }, i)
|
|
299
|
-
: _jsx("span", { className: "barchart-total__unit h5", children: part }, i)))) : (_jsxs(_Fragment, { children: [_jsx("span", { className: "barchart-total__num h0", children: total }), totalUnitKey && _jsx("span", { className: "barchart-total__unit h5", children: t(totalUnitKey) })] })) })) : (_jsxs(ChartTotal, { children: [_jsx("span", { className: "barchart-total__num h0", children: total }), _jsx("span", { className: "barchart-total__unit h5", children: t(labelAKey) }), totalUnitKey && _jsx("span", { className: "barchart-total__unit h5", children: t(totalUnitKey) })] }))) : _jsx("span", {}), _jsx(ChartLegend, { items: legendItems, className: "caption-1" })] }), children: loading ? (_jsx("div", { className: "manager-bar-chart-loading", children: _jsx(Loader, { size: 32 }) })) : (_jsx(BarChartCanvas, { data: bars, colorA: colorA, colorB: labelBKey ? colorB : undefined, labelA: t(labelAKey), labelB: labelBKey ? t(labelBKey) : undefined, barWidth: barWidth ?? sizePreset.barWidth, 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 })) }));
|
|
355
|
+
: _jsx("span", { className: "barchart-total__unit h5", children: part }, i)))) : (_jsxs(_Fragment, { children: [_jsx("span", { className: "barchart-total__num h0", children: total }), totalUnitKey && _jsx("span", { className: "barchart-total__unit h5", children: t(totalUnitKey) })] })) })) : (_jsxs(ChartTotal, { children: [_jsx("span", { className: "barchart-total__num h0", children: total }), _jsx("span", { className: "barchart-total__unit h5", children: t(labelAKey) }), totalUnitKey && _jsx("span", { className: "barchart-total__unit h5", children: t(totalUnitKey) })] }))) : _jsx("span", {}), _jsx(ChartLegend, { items: legendItems, className: "caption-1" })] }), children: loading ? (_jsx("div", { className: "manager-bar-chart-loading", children: _jsx(Loader, { size: 32 }) })) : (_jsx(BarChartCanvas, { data: bars, colorA: colorA, colorB: labelBKey ? colorB : undefined, labelA: t(labelAKey), labelB: labelBKey ? t(labelBKey) : undefined, barWidth: barWidth ?? sizePreset.barWidth, 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: 나란히 })) }));
|
|
300
356
|
}
|
|
@@ -10,9 +10,12 @@
|
|
|
10
10
|
}
|
|
11
11
|
.segmented-control--pill { border-radius: 999px; }
|
|
12
12
|
.segmented-control--rounded { border-radius: 12px; } /* button rounded(8px) + padding(4px) */
|
|
13
|
+
/* 24 는 단추 rounded 반지름이 6px 이라 판도 그만큼 작게 — 큰 크기와 같은 규칙(단추 반지름 + 여백 4px) */
|
|
14
|
+
.segmented-control--24.segmented-control--rounded { border-radius: 10px; }
|
|
13
15
|
|
|
14
16
|
/* 라벨이 짧아도 세그먼트가 지나치게 쪼그라들지 않도록 size별 최소 너비 */
|
|
15
17
|
.segmented-control .button { min-width: 96px; } /* 기본(48) */
|
|
18
|
+
.segmented-control--24 .button { min-width: 48px; }
|
|
16
19
|
.segmented-control--32 .button { min-width: 64px; }
|
|
17
20
|
.segmented-control--40 .button { min-width: 80px; }
|
|
18
21
|
.segmented-control--48 .button { min-width: 96px; }
|
|
@@ -17,8 +17,9 @@ export interface SegmentedControlProps {
|
|
|
17
17
|
*/
|
|
18
18
|
onChange?: (v: string) => void;
|
|
19
19
|
className?: string;
|
|
20
|
-
/** 버튼 높이 — Button과
|
|
21
|
-
|
|
20
|
+
/** 버튼 높이 — Button 과 같은 숫자 size (24 / 32 / 40 / 48). 겉높이는 여기에 판 여백 4px 두 겹을 더한 값이다
|
|
21
|
+
* (24 → 32 · 32 → 40 · 40 → 48 · 48 → 56). `useBG={false}` 면 여백이 없어 겉높이가 곧 size 다. */
|
|
22
|
+
size?: 24 | 32 | 40 | 48;
|
|
22
23
|
shape?: 'rounded' | 'pill';
|
|
23
24
|
useBG?: boolean;
|
|
24
25
|
/** 외부에서 정해진 폭을 버튼들이 균등 분할해 가득 채움 */
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** 막대 하나의 데이터 — a/b 두 계열. a === -1 이면 미래(데이터 없음)로 표시하지 않음.
|
|
2
|
-
* parts:
|
|
2
|
+
* parts: 다계열용 계열별 값(있으면 a는 합계로 사용).
|
|
3
|
+
* partsB: `parts` 와 1:1 인 계열별 **분모** — `rate` 에서 계열마다 parts[k]/partsB[k] 를 낸다(a/b 의 계열판). */
|
|
3
4
|
export interface BarData {
|
|
4
5
|
label: string;
|
|
5
6
|
sublabel?: string;
|
|
@@ -9,6 +10,7 @@ export interface BarData {
|
|
|
9
10
|
rawB?: number;
|
|
10
11
|
isToday?: boolean;
|
|
11
12
|
parts?: number[];
|
|
13
|
+
partsB?: number[];
|
|
12
14
|
}
|
|
13
15
|
/** 한 구간(월 등)의 주별/일별 막대 데이터 묶음 */
|
|
14
16
|
export type MonthBarData = {
|
|
@@ -23,6 +25,7 @@ export interface ChartPoint {
|
|
|
23
25
|
a: number;
|
|
24
26
|
b: number;
|
|
25
27
|
parts?: number[];
|
|
28
|
+
partsB?: number[];
|
|
26
29
|
}
|
|
27
30
|
/** 해당 월(0-based)의 첫 월요일 날짜(1~7). 예: 2026-05(금요일 시작) → 4 */
|
|
28
31
|
export declare function firstMondayOfMonth(year: number, month0: number): number;
|
|
@@ -78,10 +78,13 @@ export function buildMonthChartData(points) {
|
|
|
78
78
|
const year = Number(m[1]);
|
|
79
79
|
const month = Number(m[2]) - 1;
|
|
80
80
|
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
|
81
|
-
|
|
81
|
+
/* partsB(계열별 분모)는 parts 와 1:1 — 길이는 둘 중 긴 쪽으로 맞춘다 */
|
|
82
|
+
const partsLen = pts.reduce((n, p) => Math.max(n, p.parts?.length ?? 0, p.partsB?.length ?? 0), 0);
|
|
83
|
+
const hasPartsB = pts.some(p => p.partsB);
|
|
82
84
|
const dayA = new Array(daysInMonth + 1).fill(0);
|
|
83
85
|
const dayB = new Array(daysInMonth + 1).fill(0);
|
|
84
86
|
const dayParts = Array.from({ length: daysInMonth + 1 }, () => new Array(partsLen).fill(0));
|
|
87
|
+
const dayPartsB = Array.from({ length: daysInMonth + 1 }, () => new Array(partsLen).fill(0));
|
|
85
88
|
for (const p of pts) {
|
|
86
89
|
const d = p.date.getDate();
|
|
87
90
|
dayA[d] += p.a;
|
|
@@ -89,14 +92,17 @@ export function buildMonthChartData(points) {
|
|
|
89
92
|
if (p.parts)
|
|
90
93
|
for (let k = 0; k < partsLen; k++)
|
|
91
94
|
dayParts[d][k] += p.parts[k] ?? 0;
|
|
95
|
+
if (p.partsB)
|
|
96
|
+
for (let k = 0; k < partsLen; k++)
|
|
97
|
+
dayPartsB[d][k] += p.partsB[k] ?? 0;
|
|
92
98
|
}
|
|
93
|
-
칸들.set(key, { year, month, daysInMonth, partsLen, dayA, dayB, dayParts });
|
|
99
|
+
칸들.set(key, { year, month, daysInMonth, partsLen, hasPartsB, dayA, dayB, dayParts, dayPartsB });
|
|
94
100
|
}
|
|
95
101
|
/** 그 날이 오늘 뒤인가 — 다음 달 날을 이어 붙일 때 미래는 세지 않는다 */
|
|
96
102
|
const 미래 = (y, m0, d) => new Date(y, m0, d).getTime() > new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
|
|
97
103
|
const result = {};
|
|
98
104
|
for (const [key, 칸] of 칸들) {
|
|
99
|
-
const { year, month, daysInMonth, dayA, dayB, dayParts } = 칸;
|
|
105
|
+
const { year, month, daysInMonth, dayA, dayB, dayParts, dayPartsB } = 칸;
|
|
100
106
|
const isCurMonth = year === now.getFullYear() && month === now.getMonth();
|
|
101
107
|
const today = now.getDate();
|
|
102
108
|
const 다음 = 칸들.get(달키(month === 11 ? year + 1 : year, (month + 1) % 12));
|
|
@@ -110,6 +116,7 @@ export function buildMonthChartData(points) {
|
|
|
110
116
|
a: isFuture && dayA[d] === 0 && dayB[d] === 0 ? -1 : dayA[d],
|
|
111
117
|
b: dayB[d],
|
|
112
118
|
...(partsLen ? { parts: dayParts[d] } : {}),
|
|
119
|
+
...(partsLen && 칸.hasPartsB ? { partsB: dayPartsB[d] } : {}),
|
|
113
120
|
});
|
|
114
121
|
}
|
|
115
122
|
// 월요일 시작 — 그 달의 '첫 월요일'부터 7일 단위로 버킷. 첫 월요일 이전 날짜는
|
|
@@ -118,14 +125,18 @@ export function buildMonthChartData(points) {
|
|
|
118
125
|
for (let start = firstMondayOfMonth(year, month); start <= daysInMonth; start += 7) {
|
|
119
126
|
const 넘침 = Math.max(0, start + 6 - daysInMonth); // 다음 달에서 이어 붙일 날 수
|
|
120
127
|
const wLen = 넘침 && 다음 ? Math.max(partsLen, 다음.partsLen) : partsLen;
|
|
128
|
+
const wHasB = 칸.hasPartsB || !!(넘침 && 다음?.hasPartsB);
|
|
121
129
|
let a = 0;
|
|
122
130
|
let b = 0;
|
|
123
131
|
const wParts = new Array(wLen).fill(0);
|
|
132
|
+
const wPartsB = new Array(wLen).fill(0);
|
|
124
133
|
for (let d = start; d <= Math.min(start + 6, daysInMonth); d++) {
|
|
125
134
|
a += dayA[d];
|
|
126
135
|
b += dayB[d];
|
|
127
|
-
for (let k = 0; k < partsLen; k++)
|
|
136
|
+
for (let k = 0; k < partsLen; k++) {
|
|
128
137
|
wParts[k] += dayParts[d][k];
|
|
138
|
+
wPartsB[k] += dayPartsB[d][k];
|
|
139
|
+
}
|
|
129
140
|
}
|
|
130
141
|
if (넘침 && 다음) {
|
|
131
142
|
for (let d = 1; d <= 넘침; d++) {
|
|
@@ -133,12 +144,14 @@ export function buildMonthChartData(points) {
|
|
|
133
144
|
break;
|
|
134
145
|
a += 다음.dayA[d];
|
|
135
146
|
b += 다음.dayB[d];
|
|
136
|
-
for (let k = 0; k < 다음.partsLen; k++)
|
|
147
|
+
for (let k = 0; k < 다음.partsLen; k++) {
|
|
137
148
|
wParts[k] += 다음.dayParts[d][k];
|
|
149
|
+
wPartsB[k] += 다음.dayPartsB[d][k];
|
|
150
|
+
}
|
|
138
151
|
}
|
|
139
152
|
}
|
|
140
153
|
const 끝 = 넘침 ? `${(month + 1) % 12 + 1}/${넘침}` : `${start + 6}`;
|
|
141
|
-
weekly.push({ label: `${weekly.length + 1}주`, sublabel: `[${start}일~${끝}일]`, a, b, ...(wLen ? { parts: wParts } : {}) });
|
|
154
|
+
weekly.push({ label: `${weekly.length + 1}주`, sublabel: `[${start}일~${끝}일]`, a, b, ...(wLen ? { parts: wParts } : {}), ...(wLen && wHasB ? { partsB: wPartsB } : {}) });
|
|
142
155
|
}
|
|
143
156
|
result[key] = { weekly, daily };
|
|
144
157
|
}
|
|
@@ -77,11 +77,24 @@ export interface BarChartProps {
|
|
|
77
77
|
avgMode?: boolean;
|
|
78
78
|
/** 헤더 합계/평균 값 포맷터(예: 분 → 'X시간 Y분'). 지정 시 단위 대신 포맷 문자열 표시. */
|
|
79
79
|
totalFormat?: (n: number) => string;
|
|
80
|
-
/** 다계열
|
|
80
|
+
/** 다계열 정의 — data의 parts[index]와 1:1. 지정 시 막대를 계열별로 그리고 범례도 계열별. 그리는 법은 `seriesMode`. */
|
|
81
81
|
series?: {
|
|
82
82
|
label: string;
|
|
83
83
|
color: string;
|
|
84
84
|
}[];
|
|
85
|
+
/**
|
|
86
|
+
* 다계열(`series`)을 어떻게 세울지 — 기본 `'stack'`.
|
|
87
|
+
*
|
|
88
|
+
* - `'stack'`: 한 칸 안에 계열을 **쌓는다**. 더해지는 값(분·회)에 쓴다.
|
|
89
|
+
* - `'group'`: 한 칸 안에 계열을 **나란히** 세운다. 포갤 수 없는 값(백분율)에 쓴다.
|
|
90
|
+
* 계열 k 는 늘 k 번째 자리에 선다 — 값이 0 이어도 자리를 비워 두어 칸마다 같은 색이 같은 자리에 있다.
|
|
91
|
+
*
|
|
92
|
+
* `rate` 와 함께 쓰면 계열마다 **따로** 비율을 낸다 — 분자는 `parts[k]`, 분모는 `partsB[k]`
|
|
93
|
+
* (`ChartPoint.partsB`). `rateAgg` 도 계열마다 따로 적용된다(`'weighted'` 면 그 계열의 분모가 0 인 날은 뺀다).
|
|
94
|
+
* 머리의 합계는 지금처럼 `a`/`b`(전체의 분자·분모)로 낸다 — 계열 합을 `a`·`b` 에 넣어 주면 된다.
|
|
95
|
+
* 막대 수가 늘면 굵기는 `barWidth` 를 위 한계로 두고 줄어든다(계열 수만큼 센다).
|
|
96
|
+
*/
|
|
97
|
+
seriesMode?: 'stack' | 'group';
|
|
85
98
|
/** 로딩 중 — 차트 영역에 스피너 표시(헤더/레이아웃은 유지) */
|
|
86
99
|
loading?: boolean;
|
|
87
100
|
/** 지정 시 헤더 우측에 '상세보기' 버튼 표시 → 클릭 콜백 */
|
|
@@ -95,6 +108,9 @@ export interface BarChartProps {
|
|
|
95
108
|
* (말귀 실측: 같은 자료가 dayMean 54% · weighted 80% = 229/286문항.)
|
|
96
109
|
*/
|
|
97
110
|
rateAgg?: 'dayMean' | 'weighted';
|
|
111
|
+
/** '상세보기' 단추 크기(기본 32). `headExtra` 에 SegmentedControl(size 32, 겉높이 40px)처럼 큰 것을 얹으면
|
|
112
|
+
* 32 단추와 높이가 안 맞는다 — 그 카드만 40 을 준다. */
|
|
113
|
+
detailSize?: 32 | 40;
|
|
98
114
|
/** 머리 오른쪽 묶음에서 기간 컨트롤과 '상세보기' **사이** 에 놓을 것 — 앱이 카드 머리에 얹는 작은 조작(예: 시간/정답률 SegmentedControl size 32).
|
|
99
115
|
* size='small' 에서도 이것만은 그린다(기간 컨트롤은 그대로 숨김). */
|
|
100
116
|
headExtra?: ReactNode;
|