@ihabdevteam/core 0.163.0 → 0.165.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, seriesMode, loading, onDetail, headExtra, detailSize, barGap, baseline, 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, headSegment, icon, disc, detailSize, barGap, baseline, className, inGrid, }: BarChartProps): JSX.Element;
313
313
 
314
314
  /** @public */
315
315
  export declare interface BarChartControl {
@@ -410,6 +410,13 @@ export declare interface BarChartProps {
410
410
  * 막대 수가 늘면 굵기는 `barWidth` 를 위 한계로 두고 줄어든다(계열 수만큼 센다).
411
411
  */
412
412
  seriesMode?: 'stack' | 'group';
413
+ /** 머리의 보기 고르개 — 넓으면 세그먼트, 카드가 좁으면 '⋯' 한 단추로 접히고 그 메뉴에 칸들과 '상세보기' 가 함께 든다.
414
+ * `headExtra` 로 고르개를 손수 얹고 좁을 때를 따로 다루던 것을 대신한다(ChartSection 이 카드 제 폭으로 잰다). */
415
+ headSegment?: ChartHeadSegment;
416
+ /** 제목 왼쪽 아이콘 — Section·ChartSection 과 같은 자리·같은 색 */
417
+ icon?: string;
418
+ /** 제목 아래 설명 한 줄 */
419
+ disc?: ReactNode;
413
420
  /** 0 선을 캔버스 가운데 두고 **음수는 아래로** 그린다(예: 변동률 %p). 주면 축은 위아래 대칭(±max|값|)이 되고,
414
421
  * 눈금·격자선·이름표가 음수 구간까지 따라가며, 툴팁은 양수에 `+` 를 붙여 방향을 드러낸다.
415
422
  * `series` + `seriesMode='group'` 과 함께 쓰면 갈래마다 0 선 위·아래로 자란다. 지금은 `0` 만 쓴다. */
@@ -458,6 +465,9 @@ export declare interface BarData {
458
465
  isToday?: boolean;
459
466
  parts?: number[];
460
467
  partsB?: number[];
468
+ /** 아직 오지 않은 칸 — 막대를 그리지 않고 글씨를 흐리게, 평균·합계에서 뺀다.
469
+ * `a === -1` 이 같은 뜻의 옛 표시인데, **음수가 값인 차트**(baseline)에서는 −1 이 진짜 값일 수 있어 이 칸을 쓴다. */
470
+ future?: boolean;
461
471
  }
462
472
 
463
473
  /**
@@ -989,6 +999,22 @@ export declare interface ChangeResult {
989
999
  __ts: number;
990
1000
  }
991
1001
 
1002
+ /** @public 카드 머리의 보기 고르개 — 넓으면 세그먼트, 좁으면 '⋯' 메뉴로 접힌다 */
1003
+ declare interface ChartHeadSegment {
1004
+ /** 지금 고른 칸의 열쇠 */
1005
+ value: string;
1006
+ onChange: (key: string) => void;
1007
+ /** 칸들. `icon` 은 접힌 메뉴에서만 쓰인다(고르개는 글자만). */
1008
+ options: {
1009
+ key: string;
1010
+ label: string;
1011
+ icon?: string;
1012
+ disabled?: boolean;
1013
+ }[];
1014
+ /** 고르개·메뉴의 이름(낭독기) */
1015
+ ariaLabel?: string;
1016
+ }
1017
+
992
1018
  /** 차트 범례 — 색상 점 + 라벨 목록. BarChart/RingChart 섹션이 공유. @public */
993
1019
  export declare function ChartLegend({ items, className }: {
994
1020
  items: ChartLegendItem[];
@@ -1013,6 +1039,8 @@ export declare interface ChartPoint {
1013
1039
  b: number;
1014
1040
  parts?: number[];
1015
1041
  partsB?: number[];
1042
+ /** 아직 오지 않은 날 — `a === -1` 과 같은 뜻이고, 음수를 값으로 쓰는 차트(baseline)에서는 이쪽을 쓴다. */
1043
+ future?: boolean;
1016
1044
  }
1017
1045
 
1018
1046
  /**
@@ -1021,16 +1049,29 @@ export declare interface ChartPoint {
1021
1049
  * 공유하도록 두 컴포넌트가 내부적으로 사용한다.
1022
1050
  * @public
1023
1051
  */
1024
- export declare function ChartSection({ title, titleClassName, headRight, subRow, className, inGrid, children }: ChartSectionProps): JSX.Element;
1052
+ export declare function ChartSection({ title, titleClassName, icon, disc, headRight, headSegment, headMenuItems, collapseBelow, subRow, className, inGrid, children }: ChartSectionProps): JSX.Element;
1025
1053
 
1026
1054
  /** @public */
1027
1055
  export declare interface ChartSectionProps {
1028
1056
  /** 섹션 제목 */
1029
1057
  title: ReactNode;
1058
+ /** 제목 왼쪽 아이콘 — `Section` 의 것과 같은 자리·같은 색 */
1059
+ icon?: string;
1060
+ /** 제목 아래 설명 한 줄 — `Section` 의 `disc` 와 같은 결 */
1061
+ disc?: ReactNode;
1030
1062
  /** 제목 타이포그래피 클래스 (기본: 'h3') — 작은 카드(size='small')는 'h5' 등으로 축소 */
1031
1063
  titleClassName?: string;
1032
- /** 헤더 우측 영역 — 기간 네비게이션, 상세보기 버튼 등 */
1033
- headRight?: ReactNode;
1064
+ /** 헤더 우측 영역 — 기간 네비게이션, 상세보기 버튼 등.
1065
+ * 함수로 주면 `collapsed`(머리가 좁아 고르개가 '⋯' 로 접혔는가)를 받는다 — 접혔을 때 단추를 메뉴로 옮기는 쪽이 쓴다. */
1066
+ headRight?: ReactNode | ((state: {
1067
+ collapsed: boolean;
1068
+ }) => ReactNode);
1069
+ /** 머리의 보기 고르개. 카드가 좁으면 '⋯' 한 단추로 접히고, 그 메뉴에 칸들이 들어간다. */
1070
+ headSegment?: ChartHeadSegment;
1071
+ /** 접힌 '⋯' 메뉴에서 칸들 **뒤** 에 구분선과 함께 붙는 항목(상세보기 등) */
1072
+ headMenuItems?: TooltipMenuColumnItem[];
1073
+ /** 고르개를 '⋯' 로 접는 카드 폭(px, 기본 420) */
1074
+ collapseBelow?: number;
1034
1075
  /** 총계(ChartTotal) + 범례(ChartLegend)를 담는 서브로우 */
1035
1076
  subRow?: ReactNode;
1036
1077
  /** 섹션 루트에 추가할 클래스 */
@@ -1500,7 +1541,7 @@ export declare function copyText(text: string): Promise<boolean>;
1500
1541
  *
1501
1542
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
1502
1543
  */
1503
- export declare const CORE_VERSION = "0.163.0";
1544
+ export declare const CORE_VERSION = "0.165.0";
1504
1545
 
1505
1546
  /** @public */
1506
1547
  declare const coreDefault: Record<string, unknown>;
@@ -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, seriesMode, loading, onDetail, headExtra, detailSize, barGap, baseline, 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, headSegment, icon, disc, detailSize, barGap, baseline, className, inGrid, }: BarChartProps): import("react").JSX.Element;
@@ -16,24 +16,31 @@ import { useBarChartControl } from './useBarChartControl';
16
16
  // ─── 막대 렌더링 원시 컴포넌트 ───────────────────────────────────
17
17
  /** 막대 값 — 수가 아니면(undefined·NaN·Infinity) 0 */
18
18
  const 수 = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
19
+ /** 아직 오지 않은 칸인가 — 옛 표시(a === -1)와 새 표시(future)를 함께 본다.
20
+ * 음수가 값인 차트(baseline)에서는 −1 이 진짜 값일 수 있어 `future` 를 쓴다. */
21
+ function 미래칸(d, 옛표시 = true) {
22
+ /* `baseline` 을 쓰는 차트는 −1 이 진짜 값이라 옛 표시를 보지 않는다 — 부르는 쪽이 옛표시=false 로 끈다 */
23
+ return d.future === true || (옛표시 && d.a === -1);
24
+ }
19
25
  function BarChartCanvas({ data, colorA, colorB, labelA, labelB, barWidth = 10, barGap = 1, height = 140, maxOverride, tightMax, seriesColors, seriesLabels, rateLabels, grouped = false, baseline }) {
20
26
  // colorB 미지정 시 단일 계열(A) 모드
21
27
  const single = !colorB;
22
28
  const stacked = !!(seriesColors && seriesColors.length > 0);
23
29
  /* 값이 비었거나(undefined) 수가 아니면 0 으로 친다. 두 계열인데 한 항목의 `b` 가 비면 Math.max 가 NaN 이 되고
24
30
  `NaN || 10` 으로 y축이 데이터와 상관없이 10 에 박혀 막대가 판 위로 넘쳤다(ihab-core-96 이 짚음). */
31
+ /* 기준선 모드에서는 옛 미래 표시(a === -1)를 보지 않는다 — −1 이 진짜 값이다 */
32
+ const 기준 = baseline;
25
33
  const maxVal = useMemo(() => stacked
26
- ? Math.max(0, ...data.filter(d => d.a !== -1).map(d => grouped
34
+ ? Math.max(0, ...data.filter(d => !미래칸(d, 기준 == null)).map(d => grouped
27
35
  /* 나란히 서면 가장 높은 **낱 막대**가 위를 정한다 — 합으로 잡으면 막대가 판의 몇 분의 일로 눌린다 */
28
36
  ? Math.max(0, ...(d.parts ?? []).map(수))
29
37
  : (d.parts ?? []).reduce((s, v) => s + Math.max(0, 수(v)), 0)))
30
- : Math.max(0, ...data.filter(d => d.a !== -1).flatMap(d => single ? [수(d.a)] : [수(d.a), 수(d.b)])), [data, single, stacked, grouped]);
38
+ : Math.max(0, ...data.filter(d => !미래칸(d, 기준 == null)).flatMap(d => single ? [수(d.a)] : [수(d.a), 수(d.b)])), [data, single, stacked, grouped]);
31
39
  /* 기준선 모드: 0 선에서 얼마나 떨어졌는지(|값 − 기준|)가 축을 정한다 — 위아래가 대칭이라야 같은 크기의 오름·내림이 같은 길이로 읽힌다 */
32
- const 기준 = baseline;
33
40
  const 대칭최대 = useMemo(() => {
34
41
  if (기준 == null)
35
42
  return 0;
36
- const 값들 = data.filter(d => d.a !== -1).flatMap(d => stacked ? (d.parts ?? []).map(수) : (single ? [수(d.a)] : [수(d.a), 수(d.b)]));
43
+ const 값들 = data.filter(d => !미래칸(d, 기준 == null)).flatMap(d => stacked ? (d.parts ?? []).map(수) : (single ? [수(d.a)] : [수(d.a), 수(d.b)]));
37
44
  return Math.max(0, ...값들.map(v => Math.abs(v - 기준)));
38
45
  }, [data, 기준, stacked, single]);
39
46
  const padded = useMemo(() => {
@@ -91,7 +98,7 @@ function BarChartCanvas({ data, colorA, colorB, labelA, labelB, barWidth = 10, b
91
98
  return (_jsxs("div", { className: "manager-bar-chart-wrap", style: 묶음간격, 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) => (
92
99
  /* 눈금선은 **적힌 값의 높이**에 둔다 — 가운데 값을 반올림(최대 3 → '2')하면서 자리는 절반(1.5)에 두어 선과 숫자가 어긋났다 */
93
100
  _jsx("div", { className: `manager-bar-chart-gridline${기준 != null && t === 기준 ? ' manager-bar-chart-gridline--baseline' : ''}`, style: { top: 눈금자리(t) * h }, children: _jsx("span", { className: "manager-bar-chart-gridline__label caption-1", children: t }) }, i))), data.map((d, i) => {
94
- const isFuture = d.a === -1;
101
+ const isFuture = 미래칸(d, 기준 == null);
95
102
  /* 판 높이를 넘지 않는다 — maxOverride(진행률 100)보다 큰 값이 와도 막대가 눈금 위로 삐져나가지 않게 */
96
103
  /* 기준선 모드는 반쪽(h/2)을 쓴다 — 0 선에서 위나 아래로 자란다 */
97
104
  const 반 = h / 2;
@@ -159,7 +166,7 @@ function BarChartCanvas({ data, colorA, colorB, labelA, labelB, barWidth = 10, b
159
166
  });
160
167
  })() })) : (기준 != null ? (_jsx(_Fragment, { children: [{ v: 수(d.a), c: colorA }, ...(single ? [] : [{ v: 수(d.b), c: colorB }])].map(({ v, c }, k) => (_jsxs("div", { className: "manager-bar-chart-slot", style: 막대굵기, children: [_jsx("div", { className: "manager-bar-chart-slot__half manager-bar-chart-slot__half--up", children: v >= 기준 && _jsx("div", { className: "manager-bar-chart-bar manager-bar-chart-bar--full", style: { height: 높이(v), background: c } }) }), _jsx("div", { className: "manager-bar-chart-slot__half manager-bar-chart-slot__half--down", children: v < 기준 && _jsx("div", { className: "manager-bar-chart-bar manager-bar-chart-bar--full manager-bar-chart-bar--down", style: { height: 높이(v), background: c } }) })] }, k))) })) : (_jsxs(_Fragment, { children: [_jsx("div", { className: "manager-bar-chart-bar", style: { ...막대굵기, height: ha, background: colorA } }), !single && _jsx("div", { className: "manager-bar-chart-bar", style: { ...막대굵기, height: hb, background: colorB } })] })))) }, i));
161
168
  })] }), _jsx("div", { className: "manager-bar-chart-x-axis", children: data.map((d, i) => {
162
- const isFuture = d.a === -1; // 아직 도래하지 않은 날 — 글씨 흐리게
169
+ const isFuture = 미래칸(d, 기준 == null); // 아직 도래하지 않은 날 — 글씨 흐리게
163
170
  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));
164
171
  }) })] }) }) }), tooltip && (_jsx(DescriptionTooltip, { anchorRect: tooltip.rect, title: tooltip.title, items: tooltip.items, showIndex: false, closeOnOutsideClick: false }))] }));
165
172
  }
@@ -180,18 +187,18 @@ export function BarChartControls({ control }) {
180
187
  * 주 보기의 막대·합계와 월 보기 완료율 주 막대가 **이 한 벌**을 쓴다 — 같은 주가 두 보기에서
181
188
  * 다른 수로 서지 않게(2026-09-12 말귀 검수: 주 보기 25% · 월 보기 38%).
182
189
  */
183
- function 주의날들(data, monday, now) {
190
+ function 주의날들(data, monday, now, 옛표시 = true) {
184
191
  const 오늘 = new Date(now.getFullYear(), now.getMonth(), now.getDate());
185
192
  return Array.from({ length: 7 }, (_, i) => {
186
193
  const dt = new Date(monday);
187
194
  dt.setDate(monday.getDate() + i);
188
195
  const entry = (data[`${dt.getFullYear()}년 ${dt.getMonth() + 1}월`]?.daily ?? [])[dt.getDate() - 1];
189
- const isFuture = entry?.a === -1 || dt > 오늘; // 미래 날 — 평균/막대에서 제외
196
+ const isFuture = (entry != null && 미래칸(entry, 옛표시)) || dt > 오늘; // 미래 날 — 평균/막대에서 제외
190
197
  return { dt, entry, isFuture };
191
198
  });
192
199
  }
193
200
  /** control(기간)에 따라 data를 막대 배열로 변환 */
194
- function barsFromControl(data, control, t, lang) {
201
+ function barsFromControl(data, control, t, lang, 옛표시 = true) {
195
202
  const { period, monthRange, weekRange, weekRanges, DOW } = control;
196
203
  const isKo = lang?.startsWith('ko');
197
204
  const now = getTodayKstDate(); // 칸은 KST 달력 — 오늘·이번 주 표시도 KST 로
@@ -220,19 +227,20 @@ function barsFromControl(data, control, t, lang) {
220
227
  // 시작일이 오늘 이후(또는 미래 달)면 아직 도래하지 않은 주 → 흐리게/막대 미표시
221
228
  isFutureWeek = year > nowY || (year === nowY && (month > nowM || (month === nowM && startDay > nowD)));
222
229
  }
223
- return { ...d, label, sublabel: isKo ? `${KO_ORD[n - 1] ?? n}주` : `Week ${n}`, isToday, a: isFutureWeek ? -1 : d.a, b: isFutureWeek ? 0 : d.b };
230
+ return { ...d, label, sublabel: isKo ? `${KO_ORD[n - 1] ?? n}주` : `Week ${n}`, isToday, future: (d.future === true || isFutureWeek) || undefined, a: isFutureWeek ? -1 : d.a, b: isFutureWeek ? 0 : d.b };
224
231
  });
225
232
  }
226
233
  const opt = weekRanges.find(o => o.value === weekRange);
227
234
  if (!opt)
228
235
  return [];
229
236
  const monday = new Date(opt.year, opt.month - 1, firstMondayOfMonth(opt.year, opt.month - 1) + opt.week * 7);
230
- return 주의날들(data, monday, now).map(({ dt, entry, isFuture }) => {
237
+ return 주의날들(data, monday, now, 옛표시).map(({ dt, entry, isFuture }) => {
231
238
  const dM = dt.getMonth() + 1, dD = dt.getDate(), dY = dt.getFullYear();
232
239
  const sameMonth = dM === opt.month && dY === opt.year;
233
240
  return {
234
241
  label: sameMonth ? `${dD}` : `${dM}/${dD}`,
235
242
  sublabel: DOW[dt.getDay()] ?? '',
243
+ future: isFuture || undefined,
236
244
  a: isFuture ? -1 : (entry && entry.a > 0 ? entry.a : 0),
237
245
  b: isFuture ? 0 : (entry && entry.b > 0 ? entry.b : 0),
238
246
  isToday: dY === nowY && dM === nowM && dD === nowD,
@@ -253,13 +261,13 @@ const BAR_SIZE_PRESET = {
253
261
  * - 주(week): 'YYYY년 M월 N째주' 드롭다운 + 그 주의 요일(월~일) 막대
254
262
  * 원시 렌더링은 내부 BarChartCanvas가 담당한다.
255
263
  */
256
- 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, barGap, baseline, className, inGrid = false, }) {
264
+ 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, headSegment, icon, disc, detailSize = 32, barGap, baseline, className, inGrid = false, }) {
257
265
  const { t, i18n } = useTranslation();
258
266
  const sizePreset = BAR_SIZE_PRESET[size];
259
267
  // 외부 control 미지정 시 내부 컨트롤 사용 (단독 차트 하위 호환)
260
268
  const internalControl = useBarChartControl(data);
261
269
  const control = controlProp ?? internalControl;
262
- const rawBars = useMemo(() => barsFromControl(data, control, t, i18n.language), [data, control, t, i18n.language]);
270
+ const rawBars = useMemo(() => barsFromControl(data, control, t, i18n.language, baseline == null), [data, control, t, i18n.language, baseline]);
263
271
  // 완료율 모드: a = 진행률(a/b×100). labelBKey 지정 시 b = 정답률(parts[0]/parts[1]×100) 2계열.
264
272
  // 월 보기의 주 막대는 **그 주 경과한 날의 일별 진행률 평균(미기록일 0%)** — 아래 total 과
265
273
  // 주 보기가 쓰는 같은 셈. 주 합 a/b 로 두면 기록 없는 날(b=0)이 빠져 같은 주가 두 보기에서
@@ -299,7 +307,7 @@ export default function BarChart({ size = 'medium', barWidth, titleKey, data, co
299
307
  };
300
308
  return rawBars.map((d, i) => {
301
309
  let a = d.a === -1 ? -1 : (d.b > 0 ? Math.round((d.a / d.b) * 100) : 0);
302
- if (월 && d.a !== -1) {
310
+ if (월 && !미래칸(d)) {
303
311
  const y = Number(월[1]), m0 = Number(월[2]) - 1;
304
312
  const 지난날 = 주의날들(data, new Date(y, m0, firstMondayOfMonth(y, m0) + i * 7), now).filter(x => !x.isFuture);
305
313
  if (rateAgg === 'weighted') {
@@ -319,7 +327,7 @@ export default function BarChart({ size = 'medium', barWidth, titleKey, data, co
319
327
  }
320
328
  }
321
329
  let 계열 = null;
322
- if (나란히 && d.a !== -1) {
330
+ if (나란히 && !미래칸(d)) {
323
331
  let 날들;
324
332
  if (월) {
325
333
  const y = Number(월[1]), m0 = Number(월[2]) - 1;
@@ -373,7 +381,7 @@ export default function BarChart({ size = 'medium', barWidth, titleKey, data, co
373
381
  /* 기준선 모드는 부호를 살려 더한다 — 오르내림의 **순변화** 가 합계다(양수만 더하면 내린 주가 사라진다).
374
382
  미래 표시(a === -1)는 여기서도 뺀다 — 기준선 모드에서도 -1 은 '아직 오지 않은 칸' 이다(실제 -1 이 나올 수 있으면 그 칸을 자료에서 뺀다). */
375
383
  if (baseline != null) {
376
- return 합칠것.reduce((s, d) => s + (d.a === -1 ? 0 : 수(d.a)) + (labelBKey ? 수(d.b) : 0), 0);
384
+ return 합칠것.reduce((s, d) => s + (미래칸(d, false) ? 0 : 수(d.a) + (labelBKey ? 수(d.b) : 0)), 0);
377
385
  }
378
386
  return 합칠것.reduce((s, d) => s + (d.a > 0 ? d.a : 0) + (labelBKey && d.b > 0 ? d.b : 0), 0);
379
387
  }, [bars, rawBars, rate, rateAgg, avgMode, baseline, labelBKey, control.period, control.monthRange, data]);
@@ -389,7 +397,9 @@ export default function BarChart({ size = 'medium', barWidth, titleKey, data, co
389
397
  const isLarge = size === 'large';
390
398
  const sectionClassName = [isSmall ? 'manager-section--compact' : isLarge ? 'manager-section--full' : undefined, className]
391
399
  .filter(Boolean).join(' ') || undefined;
392
- 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)) ? (
400
+ return (_jsx(ChartSection, { className: sectionClassName, inGrid: inGrid, icon: icon, disc: disc, headSegment: headSegment,
401
+ /* 좁아 고르개가 '⋯' 로 접히면 상세보기도 그 메뉴로 들어간다 — 머리에 단추 둘이 남지 않게 */
402
+ headMenuItems: onDetail ? [{ label: t('trainingStatus.detail', '상세보기'), icon: 'list', onClick: onDetail, closeOnSelect: true }] : undefined, title: t(titleKey), 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: t('trainingStatus.detail', '상세보기'), showTooltipInfo: t('trainingStatus.detail', '상세보기'), onClick: onDetail }) }))] }), subRow: _jsxs(_Fragment, { children: [showTotal ? ((rate || avgMode || (!!totalLabelKey && !!totalFormat)) ? (
393
403
  // 진행률/평균 모드 또는 totalLabelKey+totalFormat 조합: 라벨(윗줄) + 값(아랫줄). size='small'은 라벨 생략
394
404
  _jsx(ChartTotal, { label: isSmall ? undefined : `${t(control.period === 'week' ? 'manager.chart.weeklyPrefix' : 'manager.chart.monthlyPrefix')} ${t(totalLabelKey ?? labelAKey)}`, children: totalFormat ? (
395
405
  // 숫자는 h0, 단위(시간/분/초 등)는 h5로 분리
@@ -31,6 +31,34 @@
31
31
  .manager-section__title {
32
32
  min-width: 0;
33
33
  }
34
+ /* 아이콘 + (제목·설명) — Section 의 머리와 같은 결 */
35
+ .manager-section__title-row {
36
+ display: flex;
37
+ align-items: flex-start;
38
+ gap: var(--spacing-s);
39
+ min-width: 0;
40
+ flex: 1 1 auto;
41
+ }
42
+ .manager-section__icon {
43
+ width: 20px;
44
+ height: 20px;
45
+ flex-shrink: 0;
46
+ margin-top: 3px;
47
+ display: flex;
48
+ align-items: center;
49
+ justify-content: center;
50
+ color: var(--t-item-fg, var(--color-main));
51
+ }
52
+ .manager-section__title-group {
53
+ display: flex;
54
+ flex-direction: column;
55
+ gap: 2px;
56
+ min-width: 0;
57
+ }
58
+ .manager-section__disc {
59
+ color: var(--color-gray);
60
+ margin: 0;
61
+ }
34
62
  .manager-section__head-right {
35
63
  display: flex;
36
64
  align-items: center;
@@ -1,5 +1,6 @@
1
1
  import './ChartSection.css';
2
- import type { ReactNode } from 'react';
2
+ import { type ReactNode } from 'react';
3
+ import { type TooltipMenuColumnItem } from './Tooltip/MenuTooltip';
3
4
  /** @public */
4
5
  export interface ChartLegendItem {
5
6
  color: string;
@@ -19,14 +20,42 @@ export interface ChartTotalProps {
19
20
  }
20
21
  /** 차트 총계 표시 — label이 있으면 라벨(윗줄)+값(아랫줄) stacked, 없으면 값만. @public */
21
22
  export declare function ChartTotal({ label, children }: ChartTotalProps): import("react").JSX.Element;
23
+ /** @public 카드 머리의 보기 고르개 — 넓으면 세그먼트, 좁으면 '⋯' 메뉴로 접힌다 */
24
+ export interface ChartHeadSegment {
25
+ /** 지금 고른 칸의 열쇠 */
26
+ value: string;
27
+ onChange: (key: string) => void;
28
+ /** 칸들. `icon` 은 접힌 메뉴에서만 쓰인다(고르개는 글자만). */
29
+ options: {
30
+ key: string;
31
+ label: string;
32
+ icon?: string;
33
+ disabled?: boolean;
34
+ }[];
35
+ /** 고르개·메뉴의 이름(낭독기) */
36
+ ariaLabel?: string;
37
+ }
22
38
  /** @public */
23
39
  export interface ChartSectionProps {
24
40
  /** 섹션 제목 */
25
41
  title: ReactNode;
42
+ /** 제목 왼쪽 아이콘 — `Section` 의 것과 같은 자리·같은 색 */
43
+ icon?: string;
44
+ /** 제목 아래 설명 한 줄 — `Section` 의 `disc` 와 같은 결 */
45
+ disc?: ReactNode;
26
46
  /** 제목 타이포그래피 클래스 (기본: 'h3') — 작은 카드(size='small')는 'h5' 등으로 축소 */
27
47
  titleClassName?: string;
28
- /** 헤더 우측 영역 — 기간 네비게이션, 상세보기 버튼 등 */
29
- headRight?: ReactNode;
48
+ /** 헤더 우측 영역 — 기간 네비게이션, 상세보기 버튼 등.
49
+ * 함수로 주면 `collapsed`(머리가 좁아 고르개가 '⋯' 로 접혔는가)를 받는다 — 접혔을 때 단추를 메뉴로 옮기는 쪽이 쓴다. */
50
+ headRight?: ReactNode | ((state: {
51
+ collapsed: boolean;
52
+ }) => ReactNode);
53
+ /** 머리의 보기 고르개. 카드가 좁으면 '⋯' 한 단추로 접히고, 그 메뉴에 칸들이 들어간다. */
54
+ headSegment?: ChartHeadSegment;
55
+ /** 접힌 '⋯' 메뉴에서 칸들 **뒤** 에 구분선과 함께 붙는 항목(상세보기 등) */
56
+ headMenuItems?: TooltipMenuColumnItem[];
57
+ /** 고르개를 '⋯' 로 접는 카드 폭(px, 기본 420) */
58
+ collapseBelow?: number;
30
59
  /** 총계(ChartTotal) + 범례(ChartLegend)를 담는 서브로우 */
31
60
  subRow?: ReactNode;
32
61
  /** 섹션 루트에 추가할 클래스 */
@@ -46,4 +75,4 @@ export interface ChartSectionProps {
46
75
  * 공유하도록 두 컴포넌트가 내부적으로 사용한다.
47
76
  * @public
48
77
  */
49
- export default function ChartSection({ title, titleClassName, headRight, subRow, className, inGrid, children }: ChartSectionProps): import("react").JSX.Element;
78
+ export default function ChartSection({ title, titleClassName, icon, disc, headRight, headSegment, headMenuItems, collapseBelow, subRow, className, inGrid, children }: ChartSectionProps): import("react").JSX.Element;
@@ -1,5 +1,13 @@
1
+ 'use client';
1
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
3
  import './ChartSection.css';
4
+ import { useCallback, useEffect, useRef, useState } from 'react';
5
+ import { useTranslation } from 'react-i18next';
6
+ import Icon from './Icon';
7
+ import Button from './Button';
8
+ import SegmentedControl from './SegmentedControl';
9
+ import Tooltip from './Tooltip/Tooltip';
10
+ import MenuTooltip from './Tooltip/MenuTooltip';
3
11
  /** 차트 범례 — 색상 점 + 라벨 목록. BarChart/RingChart 섹션이 공유. @public */
4
12
  export function ChartLegend({ items, className }) {
5
13
  return (_jsx("div", { className: className ? `manager-bar-chart-legend ${className}` : 'manager-bar-chart-legend', children: items.map((it, i) => (_jsxs("span", { className: "manager-bar-chart-legend-item", children: [_jsx("span", { className: "manager-bar-chart-legend-dot", style: { background: it.color } }), it.label] }, i))) }));
@@ -17,6 +25,48 @@ export function ChartTotal({ label, children }) {
17
25
  * 공유하도록 두 컴포넌트가 내부적으로 사용한다.
18
26
  * @public
19
27
  */
20
- export default function ChartSection({ title, titleClassName = 'h3', headRight, subRow, className, inGrid = false, children }) {
21
- return (_jsxs("section", { className: ['manager-section', inGrid && 'manager-section--grid', className].filter(Boolean).join(' '), children: [_jsxs("div", { className: "manager-section__head", children: [_jsx("div", { className: ['manager-section__title', titleClassName].filter(Boolean).join(' '), children: title }), headRight && _jsx("div", { className: "manager-section__head-right", children: headRight })] }), subRow && _jsx("div", { className: "barchart-sub-row", children: subRow }), children] }));
28
+ export default function ChartSection({ title, titleClassName = 'h3', icon, disc, headRight, headSegment, headMenuItems, collapseBelow = 420, subRow, className, inGrid = false, children }) {
29
+ const { t } = useTranslation();
30
+ const secRef = useRef(null);
31
+ /* 좁은지는 **카드 제 폭** 으로 잰다 — 부르는 쪽이 격자 칸 폭으로 대신 재면 두 칸을 차지하는 카드에서 틀린다.
32
+ ResizeObserver 가 없는 환경(구형·SSR)에서는 접지 않는다(넓은 쪽이 기본). */
33
+ const [좁음, set좁음] = useState(false);
34
+ useEffect(() => {
35
+ const el = secRef.current;
36
+ if (!el || !headSegment)
37
+ return;
38
+ const 재기 = (w) => set좁음(w > 0 && w < collapseBelow);
39
+ 재기(el.clientWidth);
40
+ if (typeof ResizeObserver === 'undefined')
41
+ return;
42
+ const ro = new ResizeObserver(entries => 재기(entries[0].contentRect.width));
43
+ ro.observe(el);
44
+ return () => ro.disconnect();
45
+ }, [headSegment, collapseBelow]);
46
+ const [메뉴자리, set메뉴자리] = useState(null);
47
+ const 메뉴닫기 = useCallback(() => set메뉴자리(null), []);
48
+ const 메뉴열기 = useCallback((e) => {
49
+ const r = e.currentTarget.getBoundingClientRect();
50
+ set메뉴자리(prev => (prev ? null : { left: r.left, right: r.right, top: r.top, bottom: r.bottom, width: r.width, height: r.height, x: r.x, y: r.y }));
51
+ }, []);
52
+ const 접힘 = !!headSegment && 좁음;
53
+ useEffect(() => { if (!접힘)
54
+ set메뉴자리(null); }, [접힘]);
55
+ const 메뉴항목 = headSegment
56
+ ? [
57
+ ...headSegment.options.map(o => ({
58
+ label: o.label,
59
+ icon: o.icon,
60
+ selected: o.key === headSegment.value,
61
+ disabled: o.disabled,
62
+ onClick: () => headSegment.onChange(o.key),
63
+ closeOnSelect: true,
64
+ })),
65
+ /* 성격이 다른 묶음 — 위는 무엇을 볼지, 아래는 창을 연다 */
66
+ ...(headMenuItems ?? []).map((it, i) => (i === 0 ? { ...it, separatorBefore: true } : it)),
67
+ ]
68
+ : [];
69
+ const 오른쪽 = typeof headRight === 'function' ? headRight({ collapsed: 접힘 }) : headRight;
70
+ const 머리오른쪽 = (오른쪽 || headSegment) ? (_jsxs("div", { className: "manager-section__head-right", children: [오른쪽, headSegment && (접힘 ? (_jsx(Button, { htmlType: "button", size: 32, type: "dot", icon: "ellipsis", ariaLabel: headSegment.ariaLabel ?? t('common.more', '더보기'), showTooltipInfo: headSegment.ariaLabel ?? t('common.more', '더보기'), "aria-haspopup": "menu", "aria-expanded": !!메뉴자리, onClick: 메뉴열기 })) : (_jsx(SegmentedControl, { size: 32, ariaLabel: headSegment.ariaLabel, options: headSegment.options.map(o => ({ key: o.key, label: o.label, disabled: o.disabled })), value: headSegment.value, onChange: headSegment.onChange })))] })) : null;
71
+ return (_jsxs("section", { ref: secRef, className: ['manager-section', inGrid && 'manager-section--grid', className].filter(Boolean).join(' '), children: [_jsxs("div", { className: "manager-section__head", children: [_jsxs("div", { className: "manager-section__title-row", children: [icon && _jsx(Icon, { icon: icon, label: typeof title === 'string' ? title : undefined, className: "manager-section__icon" }), _jsxs("div", { className: "manager-section__title-group", children: [_jsx("div", { className: ['manager-section__title', titleClassName].filter(Boolean).join(' '), children: title }), disc && _jsx("div", { className: "manager-section__disc body-3", children: disc })] })] }), 머리오른쪽] }), subRow && _jsx("div", { className: "barchart-sub-row", children: subRow }), children, 메뉴자리 && (_jsx(Tooltip, { anchorRect: 메뉴자리, onClose: 메뉴닫기, ariaLabel: headSegment?.ariaLabel ?? t('tooltip.menu', '메뉴'), children: _jsx(MenuTooltip, { columns: [{ items: 메뉴항목 }], onClose: 메뉴닫기, size: "small" }) }))] }));
22
72
  }
@@ -11,6 +11,9 @@ export interface BarData {
11
11
  isToday?: boolean;
12
12
  parts?: number[];
13
13
  partsB?: number[];
14
+ /** 아직 오지 않은 칸 — 막대를 그리지 않고 글씨를 흐리게, 평균·합계에서 뺀다.
15
+ * `a === -1` 이 같은 뜻의 옛 표시인데, **음수가 값인 차트**(baseline)에서는 −1 이 진짜 값일 수 있어 이 칸을 쓴다. */
16
+ future?: boolean;
14
17
  }
15
18
  /** 한 구간(월 등)의 주별/일별 막대 데이터 묶음 */
16
19
  export type MonthBarData = {
@@ -26,6 +29,8 @@ export interface ChartPoint {
26
29
  b: number;
27
30
  parts?: number[];
28
31
  partsB?: number[];
32
+ /** 아직 오지 않은 날 — `a === -1` 과 같은 뜻이고, 음수를 값으로 쓰는 차트(baseline)에서는 이쪽을 쓴다. */
33
+ future?: boolean;
29
34
  }
30
35
  /** 해당 월(0-based)의 첫 월요일 날짜(1~7). 예: 2026-05(금요일 시작) → 4 */
31
36
  export declare function firstMondayOfMonth(year: number, month0: number): number;
@@ -113,6 +113,8 @@ export function buildMonthChartData(points) {
113
113
  const isFuture = isCurMonth && d > today;
114
114
  daily.push({
115
115
  label: String(d),
116
+ /* 미래는 두 표시를 함께 단다 — 음수를 값으로 쓰는 차트(baseline)는 -1 을 진짜 값으로 읽으므로 future 를 본다 */
117
+ ...(isFuture && dayA[d] === 0 && dayB[d] === 0 ? { future: true } : {}),
116
118
  a: isFuture && dayA[d] === 0 && dayB[d] === 0 ? -1 : dayA[d],
117
119
  b: dayB[d],
118
120
  ...(partsLen ? { parts: dayParts[d] } : {}),
@@ -1,4 +1,5 @@
1
1
  import type { ReactNode } from 'react';
2
+ import type { ChartHeadSegment } from './ChartSection';
2
3
  import type { MonthBarData } from './barChartData';
3
4
  /** @public */
4
5
  export interface BarChartControl {
@@ -95,6 +96,13 @@ export interface BarChartProps {
95
96
  * 막대 수가 늘면 굵기는 `barWidth` 를 위 한계로 두고 줄어든다(계열 수만큼 센다).
96
97
  */
97
98
  seriesMode?: 'stack' | 'group';
99
+ /** 머리의 보기 고르개 — 넓으면 세그먼트, 카드가 좁으면 '⋯' 한 단추로 접히고 그 메뉴에 칸들과 '상세보기' 가 함께 든다.
100
+ * `headExtra` 로 고르개를 손수 얹고 좁을 때를 따로 다루던 것을 대신한다(ChartSection 이 카드 제 폭으로 잰다). */
101
+ headSegment?: ChartHeadSegment;
102
+ /** 제목 왼쪽 아이콘 — Section·ChartSection 과 같은 자리·같은 색 */
103
+ icon?: string;
104
+ /** 제목 아래 설명 한 줄 */
105
+ disc?: ReactNode;
98
106
  /** 0 선을 캔버스 가운데 두고 **음수는 아래로** 그린다(예: 변동률 %p). 주면 축은 위아래 대칭(±max|값|)이 되고,
99
107
  * 눈금·격자선·이름표가 음수 구간까지 따라가며, 툴팁은 양수에 `+` 를 붙여 방향을 드러낸다.
100
108
  * `series` + `seriesMode='group'` 과 함께 쓰면 갈래마다 0 선 위·아래로 자란다. 지금은 `0` 만 쓴다. */
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export declare const CORE_VERSION = "0.163.0";
49
+ export declare const CORE_VERSION = "0.165.0";
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export const CORE_VERSION = '0.163.0';
49
+ export const CORE_VERSION = '0.165.0';