@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.
@@ -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
- /** 다계열(stacked) 정의 — data의 parts[index]와 1:1. 지정 시 막대를 계열별로 쌓고 범례도 계열별. */
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: 다계열 stacked용 계열별 값(있으면 a는 합계로 사용). */
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.156.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과 동일한 숫자 size (32 / 40 / 48) */
5589
- size?: 32 | 40 | 48;
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 => (d.parts ?? []).reduce((s, v) => s + Math.max(0, 수(v)), 0)))
26
- : Math.max(0, ...data.filter(d => d.a !== -1).flatMap(d => single ? [수(d.a)] : [수(d.a), 수(d.b)])), [data, single, stacked]);
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: "manager-bar-chart-col", style: { cursor: isFuture ? 'default' : 'pointer' }, onMouseEnter: isFuture ? undefined : (e) => {
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: 32, type: "dot", icon: "list", ariaLabel: t('trainingStatus.detail', '상세보기'), onClick: onDetail }) }))] }), subRow: _jsxs(_Fragment, { children: [showTotal ? ((rate || avgMode || (!!totalLabelKey && !!totalFormat)) ? (
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과 동일한 숫자 size (32 / 40 / 48) */
21
- size?: 32 | 40 | 48;
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: 다계열 stacked용 계열별 값(있으면 a는 합계로 사용). */
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
- const partsLen = pts.reduce((n, p) => Math.max(n, p.parts?.length ?? 0), 0);
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
- /** 다계열(stacked) 정의 — data의 parts[index]와 1:1. 지정 시 막대를 계열별로 쌓고 범례도 계열별. */
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;
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export declare const CORE_VERSION = "0.156.0";
49
+ export declare const CORE_VERSION = "0.158.0";
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export const CORE_VERSION = '0.156.0';
49
+ export const CORE_VERSION = '0.158.0';