@ihabdevteam/core 0.157.0 → 0.159.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, detailSize, 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
  /** 지정 시 헤더 우측에 '상세보기' 버튼 표시 → 클릭 콜백 */
@@ -426,7 +439,8 @@ export declare interface BarChartProps {
426
439
  export declare type BarChartSize = 'small' | 'medium' | 'large';
427
440
 
428
441
  /** 막대 하나의 데이터 — a/b 두 계열. a === -1 이면 미래(데이터 없음)로 표시하지 않음.
429
- * parts: 다계열 stacked용 계열별 값(있으면 a는 합계로 사용). */
442
+ * parts: 다계열용 계열별 값(있으면 a는 합계로 사용).
443
+ * partsB: `parts` 와 1:1 인 계열별 **분모** — `rate` 에서 계열마다 parts[k]/partsB[k] 를 낸다(a/b 의 계열판). */
430
444
  export declare interface BarData {
431
445
  label: string;
432
446
  sublabel?: string;
@@ -436,6 +450,7 @@ export declare interface BarData {
436
450
  rawB?: number;
437
451
  isToday?: boolean;
438
452
  parts?: number[];
453
+ partsB?: number[];
439
454
  }
440
455
 
441
456
  /**
@@ -990,6 +1005,7 @@ export declare interface ChartPoint {
990
1005
  a: number;
991
1006
  b: number;
992
1007
  parts?: number[];
1008
+ partsB?: number[];
993
1009
  }
994
1010
 
995
1011
  /**
@@ -1477,7 +1493,7 @@ export declare function copyText(text: string): Promise<boolean>;
1477
1493
  *
1478
1494
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
1479
1495
  */
1480
- export declare const CORE_VERSION = "0.157.0";
1496
+ export declare const CORE_VERSION = "0.159.0";
1481
1497
 
1482
1498
  /** @public */
1483
1499
  declare const coreDefault: Record<string, unknown>;
@@ -5588,8 +5604,9 @@ export declare interface SegmentedControlProps {
5588
5604
  */
5589
5605
  onChange?: (v: string) => void;
5590
5606
  className?: string;
5591
- /** 버튼 높이 — Button과 동일한 숫자 size (32 / 40 / 48) */
5592
- size?: 32 | 40 | 48;
5607
+ /** 버튼 높이 — Button 과 같은 숫자 size (24 / 32 / 40 / 48). **겉높이가 곧 size 다**(0.159.0 부터 판 여백 없음) —
5608
+ * 옆에 `Button size={32}` 를 세우면 셈 없이 나란히 선다. */
5609
+ size?: 24 | 32 | 40 | 48;
5593
5610
  shape?: 'rounded' | 'pill';
5594
5611
  useBG?: boolean;
5595
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, detailSize, 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); // 맨 위(최상단) 세그먼트
@@ -121,7 +137,7 @@ export function BarChartControls({ control }) {
121
137
  주 보기에서 낭독기·툴팁이 실제 동작과 다른 말을 한다(말귀 검수 실측). */
122
138
  const prevLabel = period === 'week' ? t('calendar.prevWeek', '이전 주') : t('calendar.prevMonth', '이전 달');
123
139
  const nextLabel = period === 'week' ? t('calendar.nextWeek', '다음 주') : t('calendar.nextMonth', '다음 달');
124
- return (_jsxs("div", { className: "manager-section-controls", children: [_jsx(Button, { htmlType: "button", size: 32, type: "dot", icon: "chevron-left", ariaLabel: prevLabel, showTooltipInfo: prevLabel, disabled: activeIdx === activeOptions.length - 1, onClick: handlePrev }), _jsx(Dropdown, { value: range, onChange: setRange, options: activeOptions, size: 40, className: "month-dropdown", ariaLabel: t('calendar.rangeSelect', '기간 선택') }), _jsx(Button, { htmlType: "button", size: 32, type: "dot", icon: "chevron-right", ariaLabel: nextLabel, showTooltipInfo: nextLabel, disabled: activeIdx <= 0, onClick: handleNext }), _jsx(SegmentedControl, { ariaLabel: t('calendar.periodSelect', '기간 단위'), options: periodOptions, value: period, onChange: (k) => setPeriod(k), size: 32 })] }));
140
+ return (_jsxs("div", { className: "manager-section-controls", children: [_jsx(Button, { htmlType: "button", size: 32, type: "dot", icon: "chevron-left", ariaLabel: prevLabel, showTooltipInfo: prevLabel, disabled: activeIdx === activeOptions.length - 1, onClick: handlePrev }), _jsx(Dropdown, { value: range, onChange: setRange, options: activeOptions, size: 40, className: "month-dropdown", ariaLabel: t('calendar.rangeSelect', '기간 선택') }), _jsx(Button, { htmlType: "button", size: 32, type: "dot", icon: "chevron-right", ariaLabel: nextLabel, showTooltipInfo: nextLabel, disabled: activeIdx <= 0, onClick: handleNext }), _jsx(SegmentedControl, { ariaLabel: t('calendar.periodSelect', '기간 단위'), options: periodOptions, value: period, onChange: (k) => setPeriod(k), size: 40 })] }));
125
141
  }
126
142
  /**
127
143
  * 한 주(월요일부터 7일)의 날마다 — 그 날의 자료와 미래 여부. 달을 넘는 날은 그 달
@@ -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, detailSize = 32, 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(() => {
@@ -296,5 +352,5 @@ export default function BarChart({ size = 'medium', barWidth, titleKey, data, co
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
  }
@@ -77,7 +77,9 @@ export default function FilterPanel({ title, headerAction, collapsible = false,
77
77
  ],
78
78
  /* 아무것도 안 골랐으면 빈 값 — undefined 를 넘기면 SegmentedControl 의 기본값(첫 보기)이 걸려, 상태는
79
79
  비었는데 첫 보기가 골라진 것처럼 보였다(필터는 걸리지 않았는데 걸린 듯). */
80
- value: isAllSelected && section.allOption ? FILTER_ALL_VALUE : selected[section.label] ?? '', onChange: (value) => onSelect?.(value, section.label), size: 32 })) : (!section.multiple && section.display === 'dropdown') ? (_jsx(Dropdown, { className: "filter-panel__dropdown", size: 40, options: [
80
+ value: isAllSelected && section.allOption ? FILTER_ALL_VALUE : selected[section.label] ?? '', onChange: (value) => onSelect?.(value, section.label),
81
+ /* 옆 Dropdown·TextField(40)와 같은 높이 — 0.159.0 에서 고르개 여백이 빠져 겉높이가 곧 size 다(32 면 8px 낮아진다) */
82
+ size: 40 })) : (!section.multiple && section.display === 'dropdown') ? (_jsx(Dropdown, { className: "filter-panel__dropdown", size: 40, options: [
81
83
  ...(section.allOption ? [{ value: FILTER_ALL_VALUE, label: section.allLabel ?? t('common.all', '전체'), disabled: section.allOptionDisabled }] : []),
82
84
  ...items.map(item => {
83
85
  const value = typeof item === 'string' ? item : item.value;
@@ -4,22 +4,27 @@
4
4
  flex-shrink: 0;
5
5
  gap: var(--spacing-xs);
6
6
  background: var(--color-darkWhite);
7
- padding: 4px;
7
+ /* 여백 없음 — **겉높이 = size**(사용자 지시 2026-09-16). 위아래 4px 이 붙어 있어 size 32 고르개가 40px 로 서고,
8
+ 옆에 세우는 Button 크기를 고를 때마다 한 단 어긋난 값을 골라야 했다(같은 카드에서 두 번 어긋났다).
9
+ 골라 놓은 칸의 알약은 이제 고르개 테두리에 닿는다 — 여백을 남기면 "size = 단추 크기" 라는 뜻이 다시 깨진다. */
10
+ padding: 0;
8
11
  /* 줄바꿈 시 버튼이 container의 rounded corner 밖으로 나오지 않도록 */
9
12
  overflow: hidden;
10
13
  }
11
14
  .segmented-control--pill { border-radius: 999px; }
12
- .segmented-control--rounded { border-radius: 12px; } /* button rounded(8px) + padding(4px) */
15
+ /* 여백이 없으니 판 모서리 = 단추 모서리 */
16
+ .segmented-control--rounded { border-radius: 8px; }
17
+ .segmented-control--24.segmented-control--rounded { border-radius: 6px; }
13
18
 
14
19
  /* 라벨이 짧아도 세그먼트가 지나치게 쪼그라들지 않도록 size별 최소 너비 */
15
20
  .segmented-control .button { min-width: 96px; } /* 기본(48) */
21
+ .segmented-control--24 .button { min-width: 48px; }
16
22
  .segmented-control--32 .button { min-width: 64px; }
17
23
  .segmented-control--40 .button { min-width: 80px; }
18
24
  .segmented-control--48 .button { min-width: 96px; }
19
25
 
20
26
  .segmented-control--no-bg {
21
27
  background: transparent;
22
- padding: 0;
23
28
  }
24
29
 
25
30
  /* fullWidth: 외부에서 고정된 폭을 버튼들이 1:1로 나눠 가득 채움 */
@@ -49,16 +54,16 @@
49
54
  display: grid;
50
55
  grid-template-columns: repeat(2, 1fr);
51
56
  width: 100%;
52
- border-radius: 14px;
57
+ border-radius: 8px; /* 판 모서리 = 단추 모서리(여백 없음) */
53
58
  }
54
59
  .segmented-control:has(> .button:nth-child(4):last-child) > .button {
55
60
  width: 100%;
56
61
  justify-content: center;
57
62
  }
58
63
 
59
- /* 5개 이상: flex-wrap으로 2행 이상이 될 때 pill 라운드 조정 */
64
+ /* 5개 이상: flex-wrap 으로 2행 이상이 될 때 pill 라운드 조정 */
60
65
  .segmented-control--pill:has(> .button:nth-child(5)) {
61
- border-radius: 14px;
66
+ border-radius: 8px;
62
67
  }
63
68
  /* 5개 이상: 줄바꿈된 마지막 줄이 왼쪽으로 치우치지 않도록 가운데 정렬 */
64
69
  .segmented-control:has(> .button:nth-child(5)) {
@@ -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). **겉높이가 곧 size 다**(0.159.0 부터 판 여백 없음) —
21
+ * 옆에 `Button size={32}` 를 세우면 셈 없이 나란히 선다. */
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
  /** 지정 시 헤더 우측에 '상세보기' 버튼 표시 → 클릭 콜백 */
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export declare const CORE_VERSION = "0.157.0";
49
+ export declare const CORE_VERSION = "0.159.0";
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export const CORE_VERSION = '0.157.0';
49
+ export const CORE_VERSION = '0.159.0';