@ihabdevteam/core 0.188.0 → 0.189.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.
@@ -1088,7 +1088,7 @@ export declare interface ChartPoint {
1088
1088
  * 공유하도록 두 컴포넌트가 내부적으로 사용한다.
1089
1089
  * @public
1090
1090
  */
1091
- export declare function ChartSection({ title, titleClassName, icon, disc, headRight, headSegment, headMenuItems, collapseBelow, onClick, ariaLabel, tooltip, totalNum, totalUnit, totalLabel, subRow, subRowAlign, className, inGrid, children }: ChartSectionProps): JSX.Element;
1091
+ export declare function ChartSection({ title, titleClassName, icon, disc, headRight, headFloat, headSegment, headMenuItems, collapseBelow, onClick, ariaLabel, tooltip, totalNum, totalUnit, totalLabel, subRow, subRowAlign, className, inGrid, children }: ChartSectionProps): JSX.Element;
1092
1092
 
1093
1093
  /** @public */
1094
1094
  export declare interface ChartSectionProps {
@@ -1105,6 +1105,17 @@ export declare interface ChartSectionProps {
1105
1105
  headRight?: ReactNode | ((state: {
1106
1106
  collapsed: boolean;
1107
1107
  }) => ReactNode);
1108
+ /**
1109
+ * 머리 오른쪽에 **띄우는** 단추 줄 — `headRight` 와 달리 흐름에서 빠진다.
1110
+ *
1111
+ * 40px 짜리 단추를 제목 줄에 함께 세우면 제목 줄이 그만큼 커져 카드마다 머리 높이가 달라진다.
1112
+ * 띄우면 제목은 제 높이로 서고, 제목·곁줄은 이 줄의 **잰 폭만큼** 자리를 비운다. 좁은 화면
1113
+ * (600px 이하)에서는 흐름으로 되돌아가 제목 아래에 선다. 자세한 것은 `SectionHeadFloat`.
1114
+ *
1115
+ * 고르개·상세보기처럼 **제목과 한 줄로 서야 하는 것**은 `headRight` 가 맞다. 이쪽은 '숙제 보내기'
1116
+ * 처럼 카드 전체에 대고 누르는 단추 자리다.
1117
+ */
1118
+ headFloat?: ReactNode;
1108
1119
  /** 머리의 보기 고르개. 카드가 좁으면 '⋯' 한 단추로 접히고, 그 메뉴에 칸들이 들어간다. */
1109
1120
  headSegment?: ChartHeadSegment;
1110
1121
  /** 접힌 '⋯' 메뉴에서 칸들 **뒤** 에 구분선과 함께 붙는 항목(상세보기 등) */
@@ -1606,7 +1617,7 @@ export declare function copyText(text: string): Promise<boolean>;
1606
1617
  *
1607
1618
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
1608
1619
  */
1609
- export declare const CORE_VERSION = "0.188.0";
1620
+ export declare const CORE_VERSION = "0.189.0";
1610
1621
 
1611
1622
  /** @public */
1612
1623
  declare const coreDefault: Record<string, unknown>;
@@ -3165,6 +3176,86 @@ export declare const LEVEL_MAX = 9;
3165
3176
  /** @public */
3166
3177
  export declare const levelToPct: (level: number) => number;
3167
3178
 
3179
+ /** 점 위에 잠깐 머물면 뜨는 뜸 — 지나가는 마우스에 툴팁이 번쩍이지 않을 만큼만 짧게 @public */
3180
+ export declare const LINE_CHART_HOVER_DELAY_MS = 120;
3181
+
3182
+ /**
3183
+ * **작은 선 그래프** — 시간·조건에 따라 값이 어떻게 흐르는지를 그린다.
3184
+ * 세로는 0~100%로 고정이고, 가로 범위는 부르는 쪽이 준다.
3185
+ *
3186
+ * **제 상자를 재어 배율 1 로 그린다.** `viewBox` 를 상자보다 좁게 두면 SVG 가 통째로 늘어나
3187
+ * **글자까지 커진다**(말귀 실측 1.22배 — 사용자 지적 2026-09-17 "이 그래프가 폰트가 크게 보이는게
3188
+ * 스케일 되서 그런건가?"). 잰 픽셀을 그대로 쓰면 CSS 에 적은 12px 이 화면에서도 12px 이다.
3189
+ * 못 재는 자리(첫 렌더·ResizeObserver 없는 틀)에서는 `fallbackWidth` 로 그린다 — 늘어날 뿐 안 깨진다.
3190
+ *
3191
+ * **면 칠하기·짚는 자리처럼 그 화면에만 있는 말은 `overlay` 로 덧그린다** — 자(`x`·`y`)를 받으므로
3192
+ * 좌표 셈을 다시 하지 않는다.
3193
+ * @public
3194
+ */
3195
+ export declare function LineChart({ points, range, ticks, axisTitle, color, height, fallbackWidth, insets, overlay, ariaLabel, className, }: LineChartProps): JSX.Element;
3196
+
3197
+ /** @public */
3198
+ export declare interface LineChartPoint {
3199
+ /** 가로 자리 — `range` 안의 값 */
3200
+ x: number;
3201
+ /** 세로 자리 — 0~100(%) */
3202
+ pct: number;
3203
+ /** 속을 비워 그린다(믿기 이른 값 등). 비운 점은 **선에서도 뺀다** */
3204
+ hollow?: boolean;
3205
+ /** 마우스를 얹으면 뜰 글. 없으면 그 점에는 안 뜬다 */
3206
+ description?: string;
3207
+ }
3208
+
3209
+ /** @public */
3210
+ export declare interface LineChartProps {
3211
+ /** 그릴 점들 — 비면 축과 눈금만 선다 */
3212
+ points?: LineChartPoint[];
3213
+ /** 가로 범위 `[처음, 끝]` — 어떤 축인지(조건·주차·문항 수…)는 부르는 쪽이 정한다 */
3214
+ range: [number, number];
3215
+ /** 가로 눈금 — 안 주면 가로 글자가 없다 */
3216
+ ticks?: {
3217
+ x: number;
3218
+ label: string;
3219
+ }[];
3220
+ /** 세로축 이름. **재는 것이 정답률이 아니면 반드시 준다** — 이름이 없으면 보는 사람이 정답률로 읽는다 */
3221
+ axisTitle?: ReactNode;
3222
+ /** 선·점 색. 안 주면 주 색 */
3223
+ color?: string;
3224
+ /** 그림 높이(px) — 배율이 1 이라 이 수가 곧 화면 높이다 */
3225
+ height?: number;
3226
+ /** 제 상자를 **못 잴 때만** 쓰는 대비 폭(첫 렌더·시험 틀) */
3227
+ fallbackWidth?: number;
3228
+ /** 축 안쪽 여백 — 왼쪽은 "100%" 가 들어갈 만큼이 기본이다 */
3229
+ insets?: Partial<{
3230
+ left: number;
3231
+ right: number;
3232
+ top: number;
3233
+ bottom: number;
3234
+ }>;
3235
+ /** 그림 위에 덧그릴 것 — 자를 받아 SVG 조각을 돌려준다(면 칠하기·짚는 자리 같은 **그 화면의 말**) */
3236
+ overlay?: (scales: LineChartScales) => ReactNode;
3237
+ /** 낭독기가 읽을 글 — 안 주면 점들의 `description` 을 한 줄로 잇는다 */
3238
+ ariaLabel?: string;
3239
+ className?: string;
3240
+ }
3241
+
3242
+ /** 그림 칸 안에서 값을 픽셀 자리로 옮기는 자 — `overlay` 가 이것으로 제 것을 덧그린다 @public */
3243
+ export declare interface LineChartScales {
3244
+ /** 가로 값 → x 픽셀 */
3245
+ x: (v: number) => number;
3246
+ /** 0~100 → y 픽셀 */
3247
+ y: (pct: number) => number;
3248
+ /** 그림이 그려지는 네모(축 안쪽) */
3249
+ plot: {
3250
+ left: number;
3251
+ right: number;
3252
+ top: number;
3253
+ bottom: number;
3254
+ width: number;
3255
+ height: number;
3256
+ };
3257
+ }
3258
+
3168
3259
  /** @public */
3169
3260
  export declare function ListenStep({ _stepIndex, _totalSteps, ...rest }: StepWrapperProps): JSX.Element;
3170
3261
 
@@ -5861,6 +5952,34 @@ export declare interface SectionHeaderProps {
5861
5952
  /** @public */
5862
5953
  export declare type SectionHeaderTone = 'dark' | 'light';
5863
5954
 
5955
+ /**
5956
+ * **카드 머리 오른쪽에 띄우는 단추 줄.**
5957
+ *
5958
+ * 흐름에서 빼는 까닭은 자리가 아니라 **높이**다 — 40px 짜리 단추를 제목 줄에 함께 세우면
5959
+ * 제목 줄이 그만큼 커져 카드마다 머리 높이가 달라진다. 띄우면 제목은 제 높이로 선다.
5960
+ *
5961
+ * 세 가지를 이 조각이 맡는다. 앱마다 손으로 하다 보니 같은 규칙이 여러 벌이 됐다(말귀 두 벌 —
5962
+ * `top/right: 19px` 라는 **카드 안쪽 여백**을 베껴 적고 있었다. 그 수를 아는 쪽은 카드다).
5963
+ *
5964
+ * 1. **자리** — 카드 안쪽 여백에 맞춰 띄운다(`--card-pad`, 기본 19px). 카드가 여백을 고치면 함께 움직인다.
5965
+ * 2. **좁은 화면(600px 이하)에서 흐름으로 되돌린다** — 띄운 채로 두면 제목과 그 아랫줄을 덮는다.
5966
+ * 문턱이 600 인 것은 이 줄이 대개 250px 안팎이라, 그 아래에서는 제목과 나란히 설 자리가 없어서다.
5967
+ * 3. **제 폭을 재어 알린다** — 띄운 줄은 흐름에서 빠져 제목·곁줄을 덮으므로 그만큼 비워 둬야 하는데,
5968
+ * 부르는 쪽이 눈대중으로 `padding-right: 260px` 같은 수를 적고 있었다. 잰 폭을 부모에
5969
+ * `--section-head-float-w` 로 적으므로, 비울 자리는 `padding-right: calc(var(--section-head-float-w, -12px) + 12px)`
5970
+ * 한 줄이면 된다(안 띄운 카드에서는 0 이 된다). 코어 카드(`manager-section`)는 이미 그렇게 해 둔다.
5971
+ *
5972
+ * 기준점은 **부모가 준다** — 카드에 `position: relative` 가 있어야 한다(코어 카드에는 있다).
5973
+ * @public
5974
+ */
5975
+ export declare function SectionHeadFloat({ children, className }: SectionHeadFloatProps): JSX.Element;
5976
+
5977
+ /** @public */
5978
+ export declare interface SectionHeadFloatProps {
5979
+ children?: ReactNode;
5980
+ className?: string;
5981
+ }
5982
+
5864
5983
  /** @public */
5865
5984
  export declare interface SectionProps {
5866
5985
  type?: SectionType;
@@ -9969,8 +10088,23 @@ export declare interface WordTableProps {
9969
10088
  };
9970
10089
  /** 페이지 번호 위치 — 'none'이면 미표시 (기본 'right') */
9971
10090
  pageNumberPosition?: PageNumberPosition;
9972
- /** 컨테이너 높이에도 맞춰 축소한다(세로 용지가 넘쳐 잘리는 것 방지). 부모가 높이 제한 박스여야 한다. */
9973
- fitHeight?: boolean;
10091
+ /**
10092
+ * 정해진 세로 자리에 맞춰 줄인다(세로 용지가 넘쳐 잘리는 것 방지).
10093
+ *
10094
+ * - **수(px)** — 그 높이의 **담는 틀을 표가 스스로** 두른다. 표는 그 안에서 가로·세로 가운데에
10095
+ * 선다. 부르는 쪽은 "이만큼 자리에 맞춰 줄여" 한 마디면 된다.
10096
+ * - **`{ wide, narrow }`** — 좁은 화면(767px 이하)에서는 `narrow` 를 쓴다. 좁으면 **폭이 먼저
10097
+ * 걸려** 표가 가로에 맞춰 줄고 상자 아래가 비므로, 상자를 그만큼 낮춰 빈 자리를 없앤다
10098
+ * (말귀 실측 375px: 320px 상자에 183px 표 — 137px 이 비었다).
10099
+ * - **`true`** — 틀을 두르지 않는다. **부모가 높이 제한 박스여야 한다**(예전 동작).
10100
+ *
10101
+ * 앱마다 이 틀을 손수 만들다 보니 같은 규칙이 여러 벌이 됐다(말귀 두 벌, 주석 문장까지 같았다)
10102
+ * — 틀을 여기로 들인 까닭이다(2026-09-24).
10103
+ */
10104
+ fitHeight?: boolean | number | {
10105
+ wide: number;
10106
+ narrow: number;
10107
+ };
9974
10108
  }
9975
10109
 
9976
10110
  /**
@@ -12,7 +12,12 @@
12
12
  /* 테두리 1px 만큼 padding 을 1px 줄인다(20 → 19). 전역 box-sizing:border-box 는
13
13
  **정한 폭**에만 테두리를 넣어 줄 뿐 높이가 auto 인 카드는 위아래 2px 커지고,
14
14
  안쪽 내용도 1px 씩 안으로 밀린다. 이렇게 두면 바깥 크기와 내용 자리가 전과 같다. */
15
- padding: 19px;
15
+ /* 안쪽 여백을 변수로도 내보낸다 — 머리 오른쪽에 띄우는 줄(`SectionHeadFloat`)이 이 값에
16
+ 맞춰 서야 제목 줄과 윗선이 맞는다. 부르는 쪽이 그 수를 베껴 적지 않게 한다(2026-09-24). */
17
+ --card-pad: 19px;
18
+ padding: var(--card-pad);
19
+ /* 머리 오른쪽에 띄우는 줄(`SectionHeadFloat`)의 기준점 — 없으면 창 구석으로 날아간다 */
20
+ position: relative;
16
21
  margin-bottom: 14px;
17
22
  /* 차트 카드 — inline 나열 가능하도록 최소 폭만 지정(최대 폭 제한 없음) */
18
23
  min-width: 320px;
@@ -37,6 +42,13 @@
37
42
  /* 머리 바로 아래가 합계 줄이라 12px 은 떠 보인다 — 한 눈금 줄인다 */
38
43
  margin-bottom: var(--spacing-xs);
39
44
  }
45
+ /* **띄운 줄이 있으면 그만큼 비워 둔다.** 띄운 줄은 흐름에서 빠져 제목·곁줄을 덮는다.
46
+ `SectionHeadFloat` 이 잰 제 폭을 카드에 적어 두므로 눈대중으로 수를 적을 일이 없다 —
47
+ 안 띄운 카드에서는 -12 + 12 = 0 이라 아무 일도 안 일어난다(2026-09-24). */
48
+ .manager-section__title-row,
49
+ .manager-section__disc {
50
+ padding-right: calc(var(--section-head-float-w, -12px) + 12px);
51
+ }
40
52
  /* 제목은 줄어들 수 있어야 한다 — min-width 기본값(auto)이면 글자 길이만큼 버텨 넘친다 */
41
53
  .manager-section__title {
42
54
  min-width: 0;
@@ -106,7 +118,9 @@
106
118
  flex-wrap: nowrap;
107
119
  }
108
120
  .manager-section {
109
- padding: 17px; /* 18 − 테두리 1px */
121
+ /* 띄운 줄도 이 값에 맞춰 선다 — 여백은 `--card-pad` 한 군데서 정한다 */
122
+ --card-pad: 17px; /* 18 − 테두리 1px */
123
+ padding: var(--card-pad);
110
124
  /* 기본 규칙의 min-width:320px는 "차트 카드를 가로로 나란히 놓을 수 있게" 잡아둔
111
125
  하한인데, 이 폭부터는 카드가 한 장씩 세로로 쌓여 그 이유가 사라진다. 그대로
112
126
  두면 320px 기기에서 카드가 부모(280px)를 40px 넘쳐 오른쪽이 잘렸다.
@@ -207,7 +221,8 @@
207
221
  패딩·헤더 높이·간격을 StatChart(.manager-stat-card)와 동일하게 맞춰, 같은 그리드 행에 나란히
208
222
  놓였을 때 숫자 세로 위치가 어긋나지 않게 한다 */
209
223
  .manager-section--compact {
210
- padding: 19px; /* 20 − 테두리 1px */
224
+ --card-pad: 19px; /* 20 − 테두리 1px */
225
+ padding: var(--card-pad);
211
226
  min-width: 160px;
212
227
  }
213
228
  .manager-section--compact .manager-section__head {
@@ -50,6 +50,17 @@ export interface ChartSectionProps {
50
50
  headRight?: ReactNode | ((state: {
51
51
  collapsed: boolean;
52
52
  }) => ReactNode);
53
+ /**
54
+ * 머리 오른쪽에 **띄우는** 단추 줄 — `headRight` 와 달리 흐름에서 빠진다.
55
+ *
56
+ * 40px 짜리 단추를 제목 줄에 함께 세우면 제목 줄이 그만큼 커져 카드마다 머리 높이가 달라진다.
57
+ * 띄우면 제목은 제 높이로 서고, 제목·곁줄은 이 줄의 **잰 폭만큼** 자리를 비운다. 좁은 화면
58
+ * (600px 이하)에서는 흐름으로 되돌아가 제목 아래에 선다. 자세한 것은 `SectionHeadFloat`.
59
+ *
60
+ * 고르개·상세보기처럼 **제목과 한 줄로 서야 하는 것**은 `headRight` 가 맞다. 이쪽은 '숙제 보내기'
61
+ * 처럼 카드 전체에 대고 누르는 단추 자리다.
62
+ */
63
+ headFloat?: ReactNode;
53
64
  /** 머리의 보기 고르개. 카드가 좁으면 '⋯' 한 단추로 접히고, 그 메뉴에 칸들이 들어간다. */
54
65
  headSegment?: ChartHeadSegment;
55
66
  /** 접힌 '⋯' 메뉴에서 칸들 **뒤** 에 구분선과 함께 붙는 항목(상세보기 등) */
@@ -105,4 +116,4 @@ export interface ChartSectionProps {
105
116
  * 공유하도록 두 컴포넌트가 내부적으로 사용한다.
106
117
  * @public
107
118
  */
108
- export default function ChartSection({ title, titleClassName, icon, disc, headRight, headSegment, headMenuItems, collapseBelow, onClick, ariaLabel, tooltip, totalNum, totalUnit, totalLabel, subRow, subRowAlign, className, inGrid, children }: ChartSectionProps): import("react").JSX.Element;
119
+ export default function ChartSection({ title, titleClassName, icon, disc, headRight, headFloat, headSegment, headMenuItems, collapseBelow, onClick, ariaLabel, tooltip, totalNum, totalUnit, totalLabel, subRow, subRowAlign, className, inGrid, children }: ChartSectionProps): import("react").JSX.Element;
@@ -4,6 +4,7 @@ import './ChartSection.css';
4
4
  import { useCallback, useEffect, useRef, useState } from 'react';
5
5
  import { useTranslation } from 'react-i18next';
6
6
  import Icon from './Icon';
7
+ import SectionHeadFloat from './SectionHeadFloat';
7
8
  import Button from './Button';
8
9
  import SegmentedControl from './SegmentedControl';
9
10
  import Tooltip from './Tooltip/Tooltip';
@@ -28,7 +29,7 @@ export function ChartTotal({ label, children }) {
28
29
  * 공유하도록 두 컴포넌트가 내부적으로 사용한다.
29
30
  * @public
30
31
  */
31
- export default function ChartSection({ title, titleClassName = 'h3', icon, disc, headRight, headSegment, headMenuItems, collapseBelow = 420, onClick, ariaLabel, tooltip, totalNum, totalUnit, totalLabel, subRow, subRowAlign = 'start', className, inGrid = false, children }) {
32
+ export default function ChartSection({ title, titleClassName = 'h3', icon, disc, headRight, headFloat, headSegment, headMenuItems, collapseBelow = 420, onClick, ariaLabel, tooltip, totalNum, totalUnit, totalLabel, subRow, subRowAlign = 'start', className, inGrid = false, children }) {
32
33
  const { t } = useTranslation();
33
34
  const secRef = useRef(null);
34
35
  /* 설명 툴팁 — StatChart 와 같은 결(카드 사각형 기준, 포인터가 나가거나 초점이 빠지면 닫힘).
@@ -113,5 +114,5 @@ export default function ChartSection({ title, titleClassName = 'h3', icon, disc,
113
114
  bottom: 설명자리.bottom,
114
115
  width: 설명자리.width,
115
116
  height: 설명자리.height,
116
- }, text: tooltip })), _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" }), _jsx("div", { className: ['manager-section__title', titleClassName].filter(Boolean).join(' '), children: title })] }), 머리오른쪽, disc && _jsx("div", { className: "manager-section__disc body-3", children: disc })] }), (수줄 || subRow) && (_jsxs("div", { className: ['barchart-sub-row', subRowAlign !== 'start' && `barchart-sub-row--${subRowAlign}`].filter(Boolean).join(' '), children: [수줄, subRow] })), children, 메뉴자리 && (_jsx(Tooltip, { anchorRect: 메뉴자리, onClose: 메뉴닫기, ariaLabel: headSegment?.ariaLabel ?? t('tooltip.menu', '메뉴'), children: _jsx(MenuTooltip, { columns: [{ items: 메뉴항목 }], onClose: 메뉴닫기, size: "small" }) }))] }));
117
+ }, text: tooltip })), headFloat && _jsx(SectionHeadFloat, { children: headFloat }), _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" }), _jsx("div", { className: ['manager-section__title', titleClassName].filter(Boolean).join(' '), children: title })] }), 머리오른쪽, disc && _jsx("div", { className: "manager-section__disc body-3", children: disc })] }), (수줄 || subRow) && (_jsxs("div", { className: ['barchart-sub-row', subRowAlign !== 'start' && `barchart-sub-row--${subRowAlign}`].filter(Boolean).join(' '), children: [수줄, subRow] })), children, 메뉴자리 && (_jsx(Tooltip, { anchorRect: 메뉴자리, onClose: 메뉴닫기, ariaLabel: headSegment?.ariaLabel ?? t('tooltip.menu', '메뉴'), children: _jsx(MenuTooltip, { columns: [{ items: 메뉴항목 }], onClose: 메뉴닫기, size: "small" }) }))] }));
117
118
  }
@@ -0,0 +1,52 @@
1
+ /* 작은 선 그래프 — 까닭과 쓰는 법은 `LineChart.tsx` 의 글에.
2
+ **글자 크기를 여기 px 로 적는 것이 뜻이 있으려면** 그림이 배율 1 이어야 한다(그래서 제 상자를 잰다). */
3
+ .line-chart {
4
+ width: 100%;
5
+ height: auto;
6
+ display: block;
7
+ overflow: visible;
8
+ }
9
+
10
+ .line-chart__axis-title {
11
+ font-size: 12px;
12
+ fill: var(--color-gray);
13
+ }
14
+
15
+ .line-chart__tick {
16
+ font-size: 12px;
17
+ fill: var(--color-gray);
18
+ }
19
+
20
+ .line-chart__grid {
21
+ stroke: var(--color-whiteLine);
22
+ stroke-width: 1;
23
+ }
24
+
25
+ .line-chart__line {
26
+ stroke-width: 2;
27
+ stroke-linejoin: round;
28
+ stroke-linecap: round;
29
+ }
30
+
31
+ /* 겨누는 자리 — 보이지 않지만 손이 닿는다 */
32
+ .line-chart__dot-hit {
33
+ fill: transparent;
34
+ }
35
+
36
+ .line-chart__dot-main {
37
+ stroke-width: 1.5;
38
+ }
39
+ /* 속 빈 점 — 믿기 이른 값. 채움은 여기서 준다(선 색은 부르는 쪽이 정하므로 인라인이다) */
40
+ .line-chart__dot-main.is-hollow {
41
+ fill: var(--color-white);
42
+ }
43
+
44
+ /* 점에 얹으면 뜨는 말풍선 — 좁은 카드 안에서 줄이 잘리지 않게 */
45
+ .line-chart__tip {
46
+ max-width: 220px;
47
+ white-space: normal;
48
+ }
49
+
50
+ .line-chart__dot {
51
+ cursor: default;
52
+ }
@@ -0,0 +1,77 @@
1
+ import './LineChart.css';
2
+ import type { ReactNode } from 'react';
3
+ /** 점 위에 잠깐 머물면 뜨는 뜸 — 지나가는 마우스에 툴팁이 번쩍이지 않을 만큼만 짧게 @public */
4
+ export declare const LINE_CHART_HOVER_DELAY_MS = 120;
5
+ /** @public */
6
+ export interface LineChartPoint {
7
+ /** 가로 자리 — `range` 안의 값 */
8
+ x: number;
9
+ /** 세로 자리 — 0~100(%) */
10
+ pct: number;
11
+ /** 속을 비워 그린다(믿기 이른 값 등). 비운 점은 **선에서도 뺀다** */
12
+ hollow?: boolean;
13
+ /** 마우스를 얹으면 뜰 글. 없으면 그 점에는 안 뜬다 */
14
+ description?: string;
15
+ }
16
+ /** 그림 칸 안에서 값을 픽셀 자리로 옮기는 자 — `overlay` 가 이것으로 제 것을 덧그린다 @public */
17
+ export interface LineChartScales {
18
+ /** 가로 값 → x 픽셀 */
19
+ x: (v: number) => number;
20
+ /** 0~100 → y 픽셀 */
21
+ y: (pct: number) => number;
22
+ /** 그림이 그려지는 네모(축 안쪽) */
23
+ plot: {
24
+ left: number;
25
+ right: number;
26
+ top: number;
27
+ bottom: number;
28
+ width: number;
29
+ height: number;
30
+ };
31
+ }
32
+ /** @public */
33
+ export interface LineChartProps {
34
+ /** 그릴 점들 — 비면 축과 눈금만 선다 */
35
+ points?: LineChartPoint[];
36
+ /** 가로 범위 `[처음, 끝]` — 어떤 축인지(조건·주차·문항 수…)는 부르는 쪽이 정한다 */
37
+ range: [number, number];
38
+ /** 가로 눈금 — 안 주면 가로 글자가 없다 */
39
+ ticks?: {
40
+ x: number;
41
+ label: string;
42
+ }[];
43
+ /** 세로축 이름. **재는 것이 정답률이 아니면 반드시 준다** — 이름이 없으면 보는 사람이 정답률로 읽는다 */
44
+ axisTitle?: ReactNode;
45
+ /** 선·점 색. 안 주면 주 색 */
46
+ color?: string;
47
+ /** 그림 높이(px) — 배율이 1 이라 이 수가 곧 화면 높이다 */
48
+ height?: number;
49
+ /** 제 상자를 **못 잴 때만** 쓰는 대비 폭(첫 렌더·시험 틀) */
50
+ fallbackWidth?: number;
51
+ /** 축 안쪽 여백 — 왼쪽은 "100%" 가 들어갈 만큼이 기본이다 */
52
+ insets?: Partial<{
53
+ left: number;
54
+ right: number;
55
+ top: number;
56
+ bottom: number;
57
+ }>;
58
+ /** 그림 위에 덧그릴 것 — 자를 받아 SVG 조각을 돌려준다(면 칠하기·짚는 자리 같은 **그 화면의 말**) */
59
+ overlay?: (scales: LineChartScales) => ReactNode;
60
+ /** 낭독기가 읽을 글 — 안 주면 점들의 `description` 을 한 줄로 잇는다 */
61
+ ariaLabel?: string;
62
+ className?: string;
63
+ }
64
+ /**
65
+ * **작은 선 그래프** — 시간·조건에 따라 값이 어떻게 흐르는지를 그린다.
66
+ * 세로는 0~100%로 고정이고, 가로 범위는 부르는 쪽이 준다.
67
+ *
68
+ * **제 상자를 재어 배율 1 로 그린다.** `viewBox` 를 상자보다 좁게 두면 SVG 가 통째로 늘어나
69
+ * **글자까지 커진다**(말귀 실측 1.22배 — 사용자 지적 2026-09-17 "이 그래프가 폰트가 크게 보이는게
70
+ * 스케일 되서 그런건가?"). 잰 픽셀을 그대로 쓰면 CSS 에 적은 12px 이 화면에서도 12px 이다.
71
+ * 못 재는 자리(첫 렌더·ResizeObserver 없는 틀)에서는 `fallbackWidth` 로 그린다 — 늘어날 뿐 안 깨진다.
72
+ *
73
+ * **면 칠하기·짚는 자리처럼 그 화면에만 있는 말은 `overlay` 로 덧그린다** — 자(`x`·`y`)를 받으므로
74
+ * 좌표 셈을 다시 하지 않는다.
75
+ * @public
76
+ */
77
+ export default function LineChart({ points, range, ticks, axisTitle, color, height, fallbackWidth, insets, overlay, ariaLabel, className, }: LineChartProps): import("react").JSX.Element;
@@ -0,0 +1,59 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { useCallback, useEffect, useState } from 'react';
4
+ import { cx } from '../utils/cx';
5
+ import './LineChart.css';
6
+ import Tooltip from './Tooltip/Tooltip';
7
+ import { useDelayedHoverAnchor } from '../hooks/useHoverAnchor';
8
+ /** 점 위에 잠깐 머물면 뜨는 뜸 — 지나가는 마우스에 툴팁이 번쩍이지 않을 만큼만 짧게 @public */
9
+ export const LINE_CHART_HOVER_DELAY_MS = 120;
10
+ const 기본여백 = { left: 38, right: 16, top: 26, bottom: 24 };
11
+ /**
12
+ * **작은 선 그래프** — 시간·조건에 따라 값이 어떻게 흐르는지를 그린다.
13
+ * 세로는 0~100%로 고정이고, 가로 범위는 부르는 쪽이 준다.
14
+ *
15
+ * **제 상자를 재어 배율 1 로 그린다.** `viewBox` 를 상자보다 좁게 두면 SVG 가 통째로 늘어나
16
+ * **글자까지 커진다**(말귀 실측 1.22배 — 사용자 지적 2026-09-17 "이 그래프가 폰트가 크게 보이는게
17
+ * 스케일 되서 그런건가?"). 잰 픽셀을 그대로 쓰면 CSS 에 적은 12px 이 화면에서도 12px 이다.
18
+ * 못 재는 자리(첫 렌더·ResizeObserver 없는 틀)에서는 `fallbackWidth` 로 그린다 — 늘어날 뿐 안 깨진다.
19
+ *
20
+ * **면 칠하기·짚는 자리처럼 그 화면에만 있는 말은 `overlay` 로 덧그린다** — 자(`x`·`y`)를 받으므로
21
+ * 좌표 셈을 다시 하지 않는다.
22
+ * @public
23
+ */
24
+ export default function LineChart({ points = [], range, ticks = [], axisTitle, color, height = 156, fallbackWidth = 320, insets, overlay, ariaLabel, className, }) {
25
+ /* **제 상자를 재어 그 픽셀 수를 그림 칸으로 쓴다.** `width: 100%` 는 그대로라 viewBox 를 바꿔도
26
+ 가로가 다시 바뀌지 않는다(되먹임이 없다). */
27
+ const [잰폭, set잰폭] = useState(null);
28
+ const [칸, set칸] = useState(null);
29
+ useEffect(() => {
30
+ if (!칸)
31
+ return;
32
+ const 재기 = (w) => set잰폭(Math.round(w) || null);
33
+ 재기(칸.getBoundingClientRect().width);
34
+ if (typeof ResizeObserver === 'undefined')
35
+ return;
36
+ const 지켜보기 = new ResizeObserver(([e]) => 재기(e.contentRect.width));
37
+ 지켜보기.observe(칸);
38
+ return () => { 지켜보기.disconnect(); };
39
+ }, [칸]);
40
+ const { payload: 짚은글, anchorRect: 짚은자리, openAfterDelay, close: 닫기 } = useDelayedHoverAnchor(LINE_CHART_HOVER_DELAY_MS);
41
+ const 얹힘 = useCallback((글, el) => {
42
+ if (글)
43
+ openAfterDelay(글, el.getBoundingClientRect());
44
+ }, [openAfterDelay]);
45
+ const W = 잰폭 ?? fallbackWidth, H = height;
46
+ const { left: 왼, right: 오른, top: 위, bottom: 아래 } = { ...기본여백, ...insets };
47
+ const [처음, 끝] = range;
48
+ /* 범위는 부르는 쪽이 적어도 한 칸 폭으로 준다(끝 > 처음) — 같으면 0 으로 나누므로 왼쪽 끝에 몰아 둔다 */
49
+ const x = (v) => (끝 === 처음 ? 왼 : 왼 + ((W - 왼 - 오른) * (v - 처음)) / (끝 - 처음));
50
+ const y = (pct) => 위 + ((H - 위 - 아래) * (100 - pct)) / 100;
51
+ const 자 = {
52
+ x, y,
53
+ plot: { left: 왼, right: W - 오른, top: 위, bottom: H - 아래, width: Math.max(0, W - 왼 - 오른), height: Math.max(0, H - 위 - 아래) },
54
+ };
55
+ /* 선은 **믿을 점만** 잇는다 — 속 빈 점까지 이으면 "이만큼 봤다" 는 말이 흐려진다 */
56
+ const 이을점 = points.filter(p => !p.hollow);
57
+ const 색 = color ?? 'var(--color-main)';
58
+ return (_jsxs(_Fragment, { children: [_jsxs("svg", { ref: set칸, className: cx('line-chart', className), viewBox: `0 0 ${W} ${H}`, role: "img", "aria-label": ariaLabel ?? points.map(p => p.description).filter(Boolean).join(', '), children: [axisTitle && _jsx("text", { className: "line-chart__axis-title", x: 0, y: 12, children: axisTitle }), [0, 100].map(v => (_jsxs("g", { children: [_jsx("line", { className: "line-chart__grid", x1: 왼, x2: W - 오른, y1: y(v), y2: y(v) }), _jsxs("text", { className: "line-chart__tick", x: 왼 - 6, y: y(v) + 4, textAnchor: "end", children: [v, "%"] })] }, v))), overlay?.(자), ticks.map(tk => (_jsx("text", { className: "line-chart__tick", x: x(tk.x), y: H - 5, textAnchor: "middle", children: tk.label }, tk.x))), 이을점.length > 1 && (_jsx("polyline", { className: "line-chart__line", fill: "none", stroke: 색, points: 이을점.map(p => `${x(p.x)},${y(p.pct)}`).join(' ') })), points.map(p => (_jsxs("g", { className: "line-chart__dot", onPointerEnter: e => 얹힘(p.description, e.currentTarget), onPointerLeave: 닫기, children: [_jsx("circle", { className: "line-chart__dot-hit", cx: x(p.x), cy: y(p.pct), r: 12 }), _jsx("circle", { className: cx('line-chart__dot-main', p.hollow && 'is-hollow'), cx: x(p.x), cy: y(p.pct), r: 4, stroke: 색, fill: p.hollow ? undefined : 색 })] }, p.x)))] }), 짚은글 != null && (_jsx(Tooltip, { anchorRect: 짚은자리, offset: 10, closeOnOutsideClick: false, className: "line-chart__tip", role: "tooltip", ariaLabel: 짚은글, children: 짚은글 }))] }));
59
+ }
@@ -0,0 +1,25 @@
1
+ /* 카드 머리 오른쪽에 띄우는 단추 줄 — 까닭과 쓰는 법은 `SectionHeadFloat.tsx` 의 글에. */
2
+ .section-head-float {
3
+ position: absolute;
4
+ /* 카드 안쪽 여백과 같은 값이라야 제목 줄과 윗선이 맞는다 — 그 수는 카드가 알고 있다 */
5
+ top: var(--card-pad, 19px);
6
+ right: var(--card-pad, 19px);
7
+ display: flex;
8
+ align-items: center;
9
+ justify-content: flex-end;
10
+ gap: var(--spacing-m);
11
+ /* 제목이 설 자리는 남긴다 */
12
+ max-width: 50%;
13
+ z-index: 1;
14
+ }
15
+
16
+ /* **좁으면 흐름으로 되돌린다** — 띄운 채로 두면 제목과 그 아랫줄을 덮고, 단추 이름도 두 줄로 갈린다.
17
+ 이 줄은 대개 250px 안팎이라 600px 아래에서는 제목과 나란히 설 자리가 없다. */
18
+ @media (max-width: 600px) {
19
+ .section-head-float {
20
+ position: static;
21
+ max-width: none;
22
+ margin-top: var(--spacing-m);
23
+ flex-wrap: wrap;
24
+ }
25
+ }
@@ -0,0 +1,28 @@
1
+ import './SectionHeadFloat.css';
2
+ import type { ReactNode } from 'react';
3
+ /** @public */
4
+ export interface SectionHeadFloatProps {
5
+ children?: ReactNode;
6
+ className?: string;
7
+ }
8
+ /**
9
+ * **카드 머리 오른쪽에 띄우는 단추 줄.**
10
+ *
11
+ * 흐름에서 빼는 까닭은 자리가 아니라 **높이**다 — 40px 짜리 단추를 제목 줄에 함께 세우면
12
+ * 제목 줄이 그만큼 커져 카드마다 머리 높이가 달라진다. 띄우면 제목은 제 높이로 선다.
13
+ *
14
+ * 세 가지를 이 조각이 맡는다. 앱마다 손으로 하다 보니 같은 규칙이 여러 벌이 됐다(말귀 두 벌 —
15
+ * `top/right: 19px` 라는 **카드 안쪽 여백**을 베껴 적고 있었다. 그 수를 아는 쪽은 카드다).
16
+ *
17
+ * 1. **자리** — 카드 안쪽 여백에 맞춰 띄운다(`--card-pad`, 기본 19px). 카드가 여백을 고치면 함께 움직인다.
18
+ * 2. **좁은 화면(600px 이하)에서 흐름으로 되돌린다** — 띄운 채로 두면 제목과 그 아랫줄을 덮는다.
19
+ * 문턱이 600 인 것은 이 줄이 대개 250px 안팎이라, 그 아래에서는 제목과 나란히 설 자리가 없어서다.
20
+ * 3. **제 폭을 재어 알린다** — 띄운 줄은 흐름에서 빠져 제목·곁줄을 덮으므로 그만큼 비워 둬야 하는데,
21
+ * 부르는 쪽이 눈대중으로 `padding-right: 260px` 같은 수를 적고 있었다. 잰 폭을 부모에
22
+ * `--section-head-float-w` 로 적으므로, 비울 자리는 `padding-right: calc(var(--section-head-float-w, -12px) + 12px)`
23
+ * 한 줄이면 된다(안 띄운 카드에서는 0 이 된다). 코어 카드(`manager-section`)는 이미 그렇게 해 둔다.
24
+ *
25
+ * 기준점은 **부모가 준다** — 카드에 `position: relative` 가 있어야 한다(코어 카드에는 있다).
26
+ * @public
27
+ */
28
+ export default function SectionHeadFloat({ children, className }: SectionHeadFloatProps): import("react").JSX.Element;
@@ -0,0 +1,54 @@
1
+ 'use client';
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { useEffect, useRef, useState } from 'react';
4
+ import { cx } from '../utils/cx';
5
+ import './SectionHeadFloat.css';
6
+ /**
7
+ * **카드 머리 오른쪽에 띄우는 단추 줄.**
8
+ *
9
+ * 흐름에서 빼는 까닭은 자리가 아니라 **높이**다 — 40px 짜리 단추를 제목 줄에 함께 세우면
10
+ * 제목 줄이 그만큼 커져 카드마다 머리 높이가 달라진다. 띄우면 제목은 제 높이로 선다.
11
+ *
12
+ * 세 가지를 이 조각이 맡는다. 앱마다 손으로 하다 보니 같은 규칙이 여러 벌이 됐다(말귀 두 벌 —
13
+ * `top/right: 19px` 라는 **카드 안쪽 여백**을 베껴 적고 있었다. 그 수를 아는 쪽은 카드다).
14
+ *
15
+ * 1. **자리** — 카드 안쪽 여백에 맞춰 띄운다(`--card-pad`, 기본 19px). 카드가 여백을 고치면 함께 움직인다.
16
+ * 2. **좁은 화면(600px 이하)에서 흐름으로 되돌린다** — 띄운 채로 두면 제목과 그 아랫줄을 덮는다.
17
+ * 문턱이 600 인 것은 이 줄이 대개 250px 안팎이라, 그 아래에서는 제목과 나란히 설 자리가 없어서다.
18
+ * 3. **제 폭을 재어 알린다** — 띄운 줄은 흐름에서 빠져 제목·곁줄을 덮으므로 그만큼 비워 둬야 하는데,
19
+ * 부르는 쪽이 눈대중으로 `padding-right: 260px` 같은 수를 적고 있었다. 잰 폭을 부모에
20
+ * `--section-head-float-w` 로 적으므로, 비울 자리는 `padding-right: calc(var(--section-head-float-w, -12px) + 12px)`
21
+ * 한 줄이면 된다(안 띄운 카드에서는 0 이 된다). 코어 카드(`manager-section`)는 이미 그렇게 해 둔다.
22
+ *
23
+ * 기준점은 **부모가 준다** — 카드에 `position: relative` 가 있어야 한다(코어 카드에는 있다).
24
+ * @public
25
+ */
26
+ export default function SectionHeadFloat({ children, className }) {
27
+ const ref = useRef(null);
28
+ const [부모, set부모] = useState(null);
29
+ useEffect(() => { set부모(ref.current?.parentElement ?? null); }, []);
30
+ useEffect(() => {
31
+ const el = ref.current;
32
+ if (!el || !부모)
33
+ return;
34
+ /* 흐름으로 돌아간 좁은 화면에서는 비울 자리가 없다 — 그때 폭을 적어 두면 제목이 공연히 좁아진다.
35
+ `position` 을 보고 가른다(문턱을 두 군데 적지 않으려고 — CSS 가 정본이다). */
36
+ const 적기 = () => {
37
+ const 띄웠나 = typeof window !== 'undefined' && window.getComputedStyle(el).position === 'absolute';
38
+ const w = 띄웠나 ? Math.ceil(el.getBoundingClientRect().width) : 0;
39
+ if (w > 0)
40
+ 부모.style.setProperty('--section-head-float-w', `${w}px`);
41
+ else
42
+ 부모.style.removeProperty('--section-head-float-w');
43
+ };
44
+ 적기();
45
+ if (typeof ResizeObserver === 'undefined')
46
+ return () => { 부모.style.removeProperty('--section-head-float-w'); };
47
+ /* 줄 자신과 카드를 함께 본다 — 단추 글자가 바뀌면 줄이, 창이 좁아지면 카드가 먼저 움직인다 */
48
+ const 지켜보기 = new ResizeObserver(() => 적기());
49
+ 지켜보기.observe(el);
50
+ 지켜보기.observe(부모);
51
+ return () => { 지켜보기.disconnect(); 부모.style.removeProperty('--section-head-float-w'); };
52
+ }, [부모, children]);
53
+ return _jsx("div", { ref: ref, className: cx('section-head-float', className), children: children });
54
+ }
@@ -498,3 +498,13 @@
498
498
  border-color: var(--color-warn);
499
499
  --button-icon-color: #ffffff;
500
500
  }
501
+
502
+ /* **미리보기를 담는 틀** — `fitHeight` 에 높이를 주면 표가 스스로 두른다.
503
+ 부르는 쪽마다 이 상자를 손수 만들다 보니 같은 규칙이 여러 벌이 됐다(말귀 두 벌, 주석 문장까지
504
+ 같았다 — 2026-09-24). `useFitScale` 이 스케일러의 **부모**를 재므로 이 틀이 곧 그 자다. */
505
+ .word-table__frame {
506
+ display: flex;
507
+ justify-content: center;
508
+ /* 남는 세로를 위아래로 나눈다 — 표가 상자보다 납작할 때 아래로만 쏠려 보이지 않게 */
509
+ align-items: center;
510
+ }
@@ -69,8 +69,23 @@ export interface WordTableProps {
69
69
  };
70
70
  /** 페이지 번호 위치 — 'none'이면 미표시 (기본 'right') */
71
71
  pageNumberPosition?: PageNumberPosition;
72
- /** 컨테이너 높이에도 맞춰 축소한다(세로 용지가 넘쳐 잘리는 것 방지). 부모가 높이 제한 박스여야 한다. */
73
- fitHeight?: boolean;
72
+ /**
73
+ * 정해진 세로 자리에 맞춰 줄인다(세로 용지가 넘쳐 잘리는 것 방지).
74
+ *
75
+ * - **수(px)** — 그 높이의 **담는 틀을 표가 스스로** 두른다. 표는 그 안에서 가로·세로 가운데에
76
+ * 선다. 부르는 쪽은 "이만큼 자리에 맞춰 줄여" 한 마디면 된다.
77
+ * - **`{ wide, narrow }`** — 좁은 화면(767px 이하)에서는 `narrow` 를 쓴다. 좁으면 **폭이 먼저
78
+ * 걸려** 표가 가로에 맞춰 줄고 상자 아래가 비므로, 상자를 그만큼 낮춰 빈 자리를 없앤다
79
+ * (말귀 실측 375px: 320px 상자에 183px 표 — 137px 이 비었다).
80
+ * - **`true`** — 틀을 두르지 않는다. **부모가 높이 제한 박스여야 한다**(예전 동작).
81
+ *
82
+ * 앱마다 이 틀을 손수 만들다 보니 같은 규칙이 여러 벌이 됐다(말귀 두 벌, 주석 문장까지 같았다)
83
+ * — 틀을 여기로 들인 까닭이다(2026-09-24).
84
+ */
85
+ fitHeight?: boolean | number | {
86
+ wide: number;
87
+ narrow: number;
88
+ };
74
89
  }
75
90
  /**
76
91
  * 단어표(표 디자인) — A4 종이를 컨테이너에 맞춰 축소(scale)하고,