csat-chart.js 1.2.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,260 @@
1
1
  # 변경 기록 — csat-chart.js
2
2
 
3
+ ## 1.5.0 — 2026-09-09
4
+
5
+ **경제 좌표평면을 실물 세 장만큼 더 그릴 수 있게 했다.** 1.4.0 은 열세 장을
6
+ 재어 만들었는데, 같은 회차의 세 장이 더 있었다 — 외부 효과(3번)·명목·실질
7
+ GDP(16번)·수요량에 대한 공급량의 비(17번). 열여섯 장이 됐다. 기준 이미지 36장은
8
+ 바이트 단위로 그대로고, 새 그림 몫으로 3장이 늘어 39장이다.
9
+
10
+ ### 1.4.0 의 기록을 하나 고친다 — **범례 상자가 있다**
11
+
12
+ 1.4.0 은 「실물 열세 장에 범례가 한 번도 나오지 않는다」고 적었고, 그것을 이
13
+ 종류의 성격으로 삼았다. **열여섯 장에서는 틀린 말이다.** 2027학년도 6월 16번은
14
+ 명목 GDP·실질 GDP 두 계열을 그리고 오른쪽 아래에 테두리 두른 상자를 놓아, 줄마다
15
+ 「선 + 기호」와 이름을 적는다.
16
+
17
+ 고쳐 적으면 이렇다. **열여섯 장 중 열다섯 장은 선 끝에 이름을 달고, 계열을 쓰는
18
+ 한 장만 범례 상자를 쓴다.** 그래도 `options.showLegend` 는 여전히 안 읽는다 —
19
+ 그 상자는 켜고 끄는 장식이 아니라 계열 이름을 적을 **유일한** 자리라, 끄면 어느
20
+ 선이 명목이고 실질인지 알 길이 없어진다. 그래서 자료가 부른다(`data.legend`).
21
+
22
+ ### 새로 그릴 수 있는 것
23
+
24
+ | 칸 | 값 | 어느 그림에서 왔나 |
25
+ |---|---|---|
26
+ | `lines[].dashed` | boolean | 3번 — 사적 편익 `D_1` 은 실선, 사회적 편익 `D_2` 는 파선. 흑백 시험지에서 둘을 가르는 것이 이것뿐이라 문항의 뜻이 여기 걸려 있다 |
27
+ | `xAxis.tickLabels`·`yAxis.tickLabels` | `string[]` | 17번의 세로축 `P_1`·`P_2`(숫자가 하나도 없다)와 16번의 가로축 `t년`·`t+1년`·`t+2년` |
28
+ | `series[]` | `{ label, points, dashed, marker, hollow }` | 16번 — 점 여럿을 잇고 기호를 얹은 꺾은선 |
29
+ | `legend` | 모서리 이름 | 16번의 범례 상자 |
30
+ | `seriesGuides` | boolean | 16번 — 꼭짓점에서 가로축으로 내리는 파선 |
31
+
32
+ `ticks` 와 `tickLabels` 를 **한 칸으로 합치지 않은** 까닭이 있다. 이 종류에서
33
+ 눈금은 「값이 곧 자리」다(1.4.0 부터 그렇다). 이름표를 값 자리에 섞으면 그 규칙이
34
+ 무너진다. 자리는 `ticks` 가 정하고, `tickLabels` 는 거기 **무엇이라 적을지만**
35
+ 바꾼다. 그래서 「이름뿐인 세로축」(17번)과 「해[年]가 눈금인 가로축」(16번)이 서로
36
+ 다른 기능이 아니라 **같은 한 칸**으로 풀린다.
37
+
38
+ ### 아래 첨자 — 밑줄로 적는다
39
+
40
+ `D_1`·`P_2`·`E_Y` 처럼 **밑줄 뒤에 이어지는 영문자·숫자**가 작은 글자로
41
+ 내려앉는다. 1.4.0 은 이것을 「못 하는 것」에 적어 두었는데, 한 장이 아니라
42
+ 열여섯 장 중 **세 장**에 나오는 관습이라 이번에 만들었다.
43
+
44
+ 밑줄을 고른 까닭은 **한글 이름과 부딪히지 않기 때문**이다. 시험지 그림의 이름은
45
+ 한글·괄호·쉼표로 되어 있어 밑줄이 들어갈 일이 없고(열여섯 장 어디에도 없다),
46
+ `^`·`~`·`<>` 는 「〈X재 시장〉」·「(가)」처럼 실제로 쓰이는 글자와 부딪힌다.
47
+ 게다가 규칙을 **밑줄 + 영숫자**로 좁혀 두어, 그 밖의 글자 앞에 선 밑줄은 밑줄
48
+ 그대로 남는다 — 「강원_춘천」은 첨자로 끌려가지 않는다.
49
+
50
+ 캔버스에는 첨자라는 것이 없으므로 **작은 글꼴로 한 번 더 그려** 만든다(원래
51
+ 크기의 0.65배, 0.16em 아래). 그래서 **재는 쪽도 합친 결과를 봐야 한다** — 여백을
52
+ 잡는 쪽·선 이름 자리를 고르는 쪽·범례 폭을 재는 쪽이 모두 `richWidth()` 를
53
+ 지난다. 원문 `'P_1'` 을 그대로 재면 밑줄 한 칸이 더 얹혀 왼쪽 여백이 넓어지고,
54
+ 첨자를 지운 `'P1'` 을 재면 좁아져 글자가 캔버스 밖으로 나간다.
55
+
56
+ 첨자가 **없는** 글은 `fit.ts` 의 옛 함수로 그대로 흘러간다. 기준 이미지 36장이
57
+ 바이트 단위로 같은 것이 그 덕이다.
58
+
59
+ ### 못 한다고 적어 둔 셋을 다시 따졌다
60
+
61
+ 표본이 열세 장에서 열여섯 장으로 늘었으니 판단이 바뀌는지 하나씩 다시 봤다.
62
+
63
+ - **축 이름 두 줄 — 이제 된다.** 16번의 세로축이 「GDP」와 「(억 달러)」 두 줄이라
64
+ 두 장이 됐고(수능 7번이 첫 장), 무엇보다 **이 판에서 그려야 하는 그림**이라
65
+ 미룰 수가 없었다. 세로축 이름에 리터럴 `\n`(또는 진짜 줄바꿈)을 넣으면 줄이
66
+ 나뉘고, 여러 줄이면 묶음 가운데 맞춤으로 쌓인다 — 실물이 「GDP」를 「(억 달러)」
67
+ 위에 가운데로 앉힌다. 산점도 `yLabel` 과 같은 규약이다. 가로축 이름은 그대로
68
+ 한 줄이다(실물에서 두 줄로 앉는 것은 언제나 세로축이다).
69
+ - **두 그림 나란히 — 그대로 못 한다.** 3번이 〈X재 시장〉·〈Y재 시장〉을 나란히
70
+ 놓아 두 장이 됐지만(수능 5번이 첫 장), **값이 그대로다.** 한 캔버스에 두 판을
71
+ 담으면 자료 모양이 한 겹 깊어지고(`panels: EconPlaneData[]`), 검증기의 얕은
72
+ 검사와 `options.title` 한 개라는 전제가 함께 무너진다. 반대쪽에는 손해가 거의
73
+ 없다 — 캔버스 둘에 각각 그리고 `options.title` 에 〈X재 시장〉·〈Y재 시장〉을
74
+ 적으면 **실물과 같은 그림**이 나온다(실물도 판마다 제목이 따로 붙어 있다).
75
+ 값은 열여섯 장 중 둘, 치를 값은 그대로. 남겨 둔다.
76
+ - **첨자 — 했다.** 위 참고.
77
+
78
+ ### 그 밖에
79
+
80
+ - 계열 둘이 한 점에서 만나면 **먼저 적은 계열이 위**에 온다. 실물이 그 자리에
81
+ 명목 GDP 의 찬 동그라미를 보이고 실질 GDP 의 빈 네모를 감춘다 — 범례에서 먼저
82
+ 읽는 계열이 그림에서도 앞에 선다.
83
+ - 굵은 선의 파선 무늬는 유도선(`[7, 5]`)보다 성기다(`[12, 8]`). 굵기가 두 배라
84
+ 같은 무늬로 그으면 파선이 아니라 이 빠진 실선으로 보인다.
85
+ - 플롯 안 범례 아이콘이 **속 빈 기호**를 그릴 수 있게 됐다(`LegendItem.hollow`).
86
+ 흑백 시험지가 계열 둘을 가르는 방법이 「실선·빈 네모」와 「파선·찬 동그라미」다.
87
+ - 새 칸은 전부 **선택**이고 기본 데이터에 넣지 않았다. `validate.ts` 는 «기본
88
+ 데이터의 키»를 필수로 보므로, 넣었다면 1.4.0 에 맞춰 적어 둔 자료가 전부
89
+ 「항목이 없습니다」로 막혔을 것이다.
90
+ - 데모의 경제 좌표평면 카드가 16번 그림을 그린다 — 계열·범례 상자·이름표 눈금·두
91
+ 줄 세로축 이름이 한 장에 다 있다. 「그래프 안의 이름」에 첨자 적는 법을 한 줄
92
+ 적어 두었고, 눈금 이름표와 계열 이름도 그 칸에서 고칠 수 있다.
93
+
94
+ ## 1.4.0 — 2026-09-09
95
+
96
+ **첫 비지리 종류를 더했다 — 수능 «경제» 좌표평면(`econ-plane`).** 열여섯이
97
+ 열일곱이 됐다. 기준 이미지 31장은 바이트 단위로 그대로고, 새 종류 몫으로
98
+ 5장이 늘어 36장이다.
99
+
100
+ ```js
101
+ const data = CsatChart.createSupplyDemandData();
102
+ data.xAxis.label = '수량(개)';
103
+ new CsatChart('c', { type: 'econ-plane', data: data });
104
+ ```
105
+
106
+ ### 실물 열세 장에서 재어 만들었다
107
+
108
+ 2026학년도 수능·9월, 2027학년도 6월 경제의 그래프 문항 열세 장을 놓고 그렸다.
109
+ 주제는 다섯 갈래인데(수요·공급, 총수요·총공급, 생산가능곡선, 고용 지표,
110
+ 물가-성장률) **그림은 한 장이었다** — 화살표 달린 좌표평면 위에 네 가지만
111
+ 놓인다. 직선(끝에 제 이름을 단다)·점(글자 하나가 붙는다)·유도선(점에서 축으로
112
+ 내리는 점선)·화살표(점에서 점으로, 또는 곡선 옆에 나란히). 그래서 종류를
113
+ 다섯으로 쪼개지 않고 하나로 두고, 문항마다 갈리는 것만 옵션으로 뺐다.
114
+
115
+ ### 지리 열여섯 종과 갈리는 곳
116
+
117
+ - **범례가 없다.** 열세 장에 범례 상자가 한 번도 나오지 않는다 — 선은 제 끝에
118
+ 이름을 단다. 그래서 이 종류만 `showLegend`·`legendPosition`·
119
+ `showDataLabels` 를 아예 읽지 않는다.
120
+ > **바로잡음 — 1.5.0.** 열여섯 장으로 늘려 보니 계열(꺾은선)을 쓰는 한
121
+ > 장(2027학년도 6월 16번)에 범례 상자가 있다. 「열세 장에 없다」는 사실은
122
+ > 그대로지만, 그것을 이 종류의 성격으로 읽은 것이 틀렸다. 지금 맞는 말은
123
+ > 「열여섯 장 중 열다섯 장은 선 끝에 이름을 달고, 계열을 쓰는 한 장만 범례
124
+ > 상자를 쓴다」이다. `showLegend` 를 안 읽는 것은 여전하다 — 그 상자는
125
+ > 자료가 부른다(`data.legend`).
126
+ - **눈금 표시선이 없다.** 축에 붙는 작은 선분을 그리지 않는다. 격자나 유도선이
127
+ 축까지 닿아 자리를 알려 준다.
128
+ - **눈금은 «값» 배열이다.** 간격이 아니다. `[0, 10, 20, 50]` 이면 50 이 20 의
129
+ 세 배 거리에 선다 — 자는 언제나 고르고 눈금만 띄엄띄엄 찍힌다.
130
+
131
+ ### 문항마다 갈리는 것을 옵션으로
132
+
133
+ - `quadrants: 'all'` — 네 사분면. 축 양끝에 화살촉이 붙고, 세로축 눈금 숫자가
134
+ 축 **오른쪽**(플롯 안쪽)으로 간다. 안쪽 숫자는 뒤로 지나가는 점선을 희게
135
+ 끊고 그린다 — 실물도 그 자리에서 점선이 끊긴다.
136
+ - `xAxis.broken` · `yAxis.broken` — 눈금이 0 에서 시작하지 않을 때 원점과 첫
137
+ 눈금 사이에 넣는 생략 기호 `≈`.
138
+ - `grid` — 눈금 자리마다 깔리는 점선 격자.
139
+ - `dash: 'dotted'` — 촘촘한 점선. 열세 장 중 열둘이 파선이고 한 장만 점선이다.
140
+ - `points[].guide: 'cross'` — 점을 지나 플롯 전체를 가로지르는 유도선.
141
+ - `points[].dot: false` — 점 없이 유도선만. 문항이 쓰는 값마다 파선을 내리되
142
+ 교점에 점은 찍지 않는 그림이 실제로 있다.
143
+ - `arrows[].offset` — 잇는 선에서 진행 방향 오른쪽으로 비켜 놓는 픽셀. 곡선을
144
+ 따라 움직이는 화살표가 이 꼴이다.
145
+
146
+ ### 시작점 셋
147
+
148
+ 좌표를 손으로 치는 일을 줄이려고, 되풀이되는 그림에 이름을 붙여 두었다.
149
+ 개수는 열세 장을 세어 정했다.
150
+
151
+ | 시작점 | 열세 장 중 | 그리는 것 |
152
+ |---|---|---|
153
+ | `createSupplyDemandData()` | 여섯 | 수요·공급 교차 + 균형점 `E` |
154
+ | `createPointShiftData()` | 여섯 | 이름 붙인 점 + 유도선 + 점 사이 화살표 |
155
+ | `createAdAsData()` | 하나 | 총수요·총공급 (물가 × 실질 GDP) |
156
+
157
+ 셋을 합치면 열셋이 딱 맞는다. `createAdAsData()` 만 한 장인데도 이름을 준
158
+ 까닭은, 같은 축 이름(물가 × 실질 GDP)을 쓰는 문항이 한 장 더 있기 때문이다 —
159
+ 그쪽은 곡선 없이 점만 찍으므로 「점 이동」으로 세었다.
160
+
161
+ `createDefaultEconPlaneData()` 는 가장 흔한 `createSupplyDemandData()` 를 그대로
162
+ 돌려준다. 값이 0 뿐인 기본값을 하나 더 만들지 않으려는 뜻이다 — 이 종류는
163
+ 「기본값이 빈 그림인 여섯」에 들어가지 않는다.
164
+
165
+ ### 못 하는 것
166
+
167
+ > **바로잡음 — 1.5.0.** 아래 셋 중 둘은 이제 된다. 축 이름 두 줄과 아래 첨자다.
168
+ > 두 그림 나란히는 그대로 못 한다.
169
+
170
+ - **축 이름이 한 줄뿐이다.** 실물은 「가격」과 「(만 원)」을 두 줄로 앉히기도
171
+ 하는데, 이 렌더러는 `'가격(만 원)'` 한 줄로 그린다.
172
+ - **위 첨자가 없다.** `Eₓ`·`E_Y` 는 캔버스에 첨자라는 것이 없어 `'Ex'` 로
173
+ 적거나 유니코드 첨자 문자를 직접 넣어야 한다.
174
+ - **캔버스 한 장에 그림 한 장이다.** 두 그림을 나란히 놓는 문항(2026학년도
175
+ 수능 5번의 〈X재 시장〉·〈Y재 시장〉)은 캔버스 둘에 각각 그리고 배치는 부르는
176
+ 쪽이 한다. 한 캔버스에 두 판을 담으면 자료 모양이 한 겹 깊어지고
177
+ (`panels: EconPlaneData[]`), 검증기의 얕은 검사와 `options.title` 한 개라는
178
+ 전제가 함께 무너진다 — 그 값을 치를 만큼 잦은 배치가 아니다(열세 장 중 한 장).
179
+
180
+ ### 그 밖에
181
+
182
+ - `sourceLeft` 와 `sourceInline` 을 이 종류도 읽는다. 이제 `sourceLeft` 는
183
+ `stacked`·`econ-plane`, `sourceInline` 은 `scatter`·`econ-plane` 이 읽는다.
184
+ - README 의 번들 크기 표를 다시 쟀다(esbuild 0.27.7). 1.2.0 때 잰 숫자가
185
+ 그대로 남아 있어 실제보다 작게 적혀 있었다 — 전부 112.6 KB, `CsatChart` 만
186
+ 110.0 KB, `renderClimateGraph` 만 12.0 KB 다.
187
+ - 저수준 렌더러 이름 규칙(`render○○Graph`)을 벗어나는 것이 넷에서 다섯이
188
+ 됐다 — `renderEconPlane` 이 늘었다.
189
+
190
+ ## 1.3.0 — 2026-09-09
191
+
192
+ **내 글꼴로 그릴 수 있게 했다.** 시험지 그림은 자리마다 서체가 갈린다 — 축
193
+ 이름·눈금·자료값은 명조, 제목·출처·각주·범례는 고딕이다. 그 짝 자체는 원본이
194
+ 그러니 그대로 두고, **각 자리에 무슨 글꼴을 쓸지**만 갈아 끼우는 `fontStack`
195
+ 옵션을 더했다.
196
+
197
+ ```js
198
+ options: {
199
+ fontStack: {
200
+ serif: "'함초롬바탕', serif", // 축 이름·눈금·자료값
201
+ sans: "'함초롬돋움', sans-serif", // 제목·출처·각주·범례
202
+ },
203
+ }
204
+ ```
205
+
206
+ 두 키 모두 선택이다. 적지 않은 자리는 예전 글꼴 그대로다. **기본 그림은 한
207
+ 픽셀도 안 바뀐다** — 기준 이미지 31장이 바이트 단위로 같다.
208
+
209
+ ### 왜 필요했나
210
+
211
+ 1.2.0 까지 자기 글꼴을 넣는 길은 `customFont` 하나뿐이었는데, 그 값은
212
+ `getFont()` 만 지나갔다. 제목·출처·각주(`labels.ts` 의 `LABEL_FONT`)와
213
+ 범례(`legend.ts` 의 `LEGEND_FONT`)는 글꼴을 **상수로 박아 두고 `options` 를
214
+ 아예 보지 않았다.** 그래서 자기 글꼴을 준 사람은 축만 그 글꼴이고 제목은
215
+ Noto Sans 인, 한 그림 안에 두 서체가 섞인 그림을 받았다. 고딕 자리는 어떤
216
+ 방법으로도 건드릴 수 없었다.
217
+
218
+ 노리는 자리는 **한컴오피스가 깔린 교무실 PC** 다. 함초롬바탕·함초롬돋움이
219
+ 이미 그 컴퓨터에 있으므로 내려받을 것도, 어딘가에 올려 둘 것도, 라이선스를
220
+ 살필 것도 없다. 누가 열어 보든 같아야 하는 경우에만 웹폰트를 쓰고,
221
+ `ensureFonts({ href, families })` 로 **먼저 받아 둔 뒤에** 그린다 — 그 두
222
+ 옵션이 정확히 이 짝이다.
223
+
224
+ ### 어떻게 이었나
225
+
226
+ 글꼴 문자열을 인자로 하나 더 받게 하지 않았다. 그러면 넘겨주기를 빠뜨린
227
+ 호출부가 조용히 예전 글꼴로 그려지는데, **그것이 바로 지금 고치는 결함**이기
228
+ 때문이다. 대신 `drawTitle`·`drawSourceAndFootnote`·`drawLegend` 는 `fonts`
229
+ 라는 **필수** 항목을, 자리 인자가 있는 `measureLegendWidth`·
230
+ `measureBottomLegend`·`layoutBottomLegend` 는 선택 인자 **앞**의 필수 인자를
231
+ 받게 했다(뒤에 붙이면 `'circle'` 같은 기존 인자가 그 자리에 밀려 들어가도
232
+ 컴파일이 통과한다). `getFont()` 도 `(자리, 글꼴이름)` 두 인자 대신 옵션 객체
233
+ 하나를 받는다. 그래서 렌더러 16종의 호출부 130여 곳 중 **하나라도 빠뜨리면
234
+ 컴파일이 막힌다** — `registry.ts` 가 종류별 제네릭으로 오배선을 막는 것과 같은
235
+ 생각이다.
236
+
237
+ `chart.update()` 는 `fontStack` 을 `fontSize` 와 같은 방식으로 깊게 덮는다.
238
+ 얕게 덮으면 `{ fontStack: { sans: … } }` 한 번에 앞서 정해 둔 `serif` 가
239
+ 사라져 축만 기본 글꼴로 돌아간 그림이 조용히 나온다.
240
+
241
+ ### 알아 둘 동작
242
+
243
+ - 없는 글꼴 이름을 줘도 **아무도 알려 주지 않는다.** 브라우저가 조용히 대체
244
+ 글꼴로 그린다. 그래서 이름 뒤에 총칭 글꼴(`, serif` · `, sans-serif`)을 꼭
245
+ 붙인다 — 그래야 틀렸을 때도 명조/고딕 계열로 떨어진다. 데모는 캔버스 폭을
246
+ 재서 「이 컴퓨터에 있다/없다」를 칸마다 적어 준다.
247
+ - `customFont` 의 동작은 **예전 그대로다.** 여전히 축 쪽만 바꾼다.
248
+ - `fontFamily` 와 층이 다르다. `fontFamily` 는 축이 **어느 자리**를 쓸지 고르고,
249
+ `fontStack` 은 그 자리가 **무슨 글꼴**인지 정한다.
250
+
251
+ ### 데모
252
+
253
+ 카드마다 「각주·범례·이름 바꾸기」 안에 「내 글꼴로 바꾸기」 두 칸이 생겼다.
254
+ 글꼴 이름을 적으면 그림이 그 자리에서 다시 그려지고, 칸 아래에 이 컴퓨터에
255
+ 그 글꼴이 있는지 한 줄로 적힌다. 「코드 복사」에도 함께 담기고, 「되돌리기」로
256
+ 함께 되돌아간다.
257
+
3
258
  ## 1.2.0 — 2026-09-08
4
259
 
5
260
  **범례가 아닌 글자가 캔버스 밖에서 잘리던 것을 고쳤다.** 1.1.1 이 「아직 남은
package/README.md CHANGED
@@ -2,8 +2,10 @@
2
2
 
3
3
  수능·모의고사 시험지 양식의 그래프를 Canvas 2D로 그리는 라이브러리입니다.
4
4
  축·범례·각주·출처의 배치, 명조 글꼴, 흑백 인쇄를 전제한 해칭 패턴까지
5
- 시험지 관습을 그대로 따릅니다. **런타임 의존성이 없습니다.**
5
+ 시험지 관습을 그대로 따릅니다. 지리 열여섯 종으로 시작했고, 1.4.0 에서 경제
6
+ 좌표평면이 더해져 열일곱 종입니다. **런타임 의존성이 없습니다.**
6
7
 
8
+ - npm: https://www.npmjs.com/package/csat-chart.js
7
9
  - 데모: https://yhk1m.github.io/csat-chart.js/
8
10
  - AI 참고 문서: [docs/ai-reference.md](docs/ai-reference.md) — 이 라이브러리를 모르는 AI 어시스턴트에게 붙여넣는 용도
9
11
  - 라이선스: MIT
@@ -11,7 +13,7 @@
11
13
  ## 시작하기
12
14
 
13
15
  ```html
14
- <script src="https://yhk1m.github.io/csat-chart.js/lib/csat-chart.umd.min.js"></script>
16
+ <script src="https://cdn.jsdelivr.net/npm/csat-chart.js@1/dist/csat-chart.umd.min.js"></script>
15
17
  <canvas id="c" width="800" height="600"></canvas>
16
18
  <script>
17
19
  CsatChart.ensureFonts().then(function () {
@@ -24,8 +26,8 @@
24
26
  </script>
25
27
  ```
26
28
 
27
- 패키지가 npm에 올라온 뒤에는 `https://cdn.jsdelivr.net/npm/csat-chart.js`
28
- 짧게 있습니다.
29
+ `@1` 1.x 안에서 가장 판을 가리킵니다 — 고친 것이 자동으로 따라오고, 판이
30
+ 2 올라가도 갑자기 바뀌지 않습니다. 한 판에 못 박으려면 `@1.2.0` 처럼 적으세요.
29
31
 
30
32
  `ensureFonts()` 를 부르지 않으면 대체 글꼴로 그려져 시험지 양식이 재현되지 않습니다.
31
33
  던지지 않습니다 — 글꼴을 못 받아도, 제한 시간(기본 5초)을 넘겨도 조용히 `false` 로
@@ -67,7 +69,7 @@
67
69
  `tempRange`·`precipRange` 는 `auto: true` 라 값에 맞춰 알아서 잡힙니다. 눈금을 고정하고
68
70
  싶으면 `auto: false` 로 두고 `min`·`max` 를 적으세요.
69
71
 
70
- ## 그래프 16
72
+ ## 그래프 17
71
73
 
72
74
  | `type` | 그래프 | 기본 데이터 | 저수준 렌더러 | 데이터 타입 |
73
75
  |---|---|---|---|---|
@@ -78,6 +80,7 @@
78
80
  | `data-table` | 항목×지역 표 | `createDefaultDataTableData()` | `renderDataTable` | `DataTableData` |
79
81
  | `deviation-a` | 월별 편차 | `createDefaultDeviationAData()` | `renderDeviationAGraph` | `DeviationAData` |
80
82
  | `deviation-b` | 지역별 편차 | `createDefaultDeviationBData()` | `renderDeviationBGraph` | `DeviationBData` |
83
+ | `econ-plane` | 경제 좌표평면 | `createDefaultEconPlaneData()` | `renderEconPlane` | `EconPlaneData` |
81
84
  | `hythergraph` | 하이서그래프 | `createDefaultHythergraphData()` | `renderHythergraph` | `HythergraphData` |
82
85
  | `line` | 꺾은선 | `createDefaultLineData()` | `renderLineGraph` | `LineGraphData` |
83
86
  | `matrix-table` | 계단식 행렬표 | `createDefaultMatrixTableData()` | `renderMatrixTable` | `MatrixTableData` |
@@ -91,22 +94,123 @@
91
94
  기본 데이터는 표의 `createDefault○○Data()` 로 얻어 고쳐 씁니다 — 어느 종류든 이
92
95
  이름 규칙을 따릅니다. 저수준 렌더러 이름은 그렇지 않습니다 — `renderDataTable`·
93
96
  `renderHythergraph`·`renderMatrixTable`·`renderRadarChart` 넷은 `render○○Graph`
94
- 를 따르지 않으니 표에서 확인하세요. 데이터 모양이 어긋나면 한국어 메시지로
97
+ 를 따르지 않으니 표에서 확인하세요. 저수준 렌더러 이름에는 `renderEconPlane`
98
+ 처럼 `Graph` 가 안 붙는 것도 있습니다. 데이터 모양이 어긋나면 한국어 메시지로
95
99
  알려줍니다 — [오류 가려내기](#오류-가려내기) 참고.
96
100
 
101
+ ## 경제 좌표평면
102
+
103
+ `econ-plane` 은 수능 경제 문항의 그림입니다. 주제가 달라도(수요·공급, 총수요·
104
+ 총공급, 생산가능곡선, 고용 지표, 물가-성장률, 국민 소득) 그림은 한 장입니다 —
105
+ 화살표 달린 좌표평면 위에 **직선·점·유도선·화살표·계열** 다섯 가지만 놓입니다.
106
+
107
+ **이름은 거의 언제나 선 끝에 답니다**(`lines[].label`). 실물 열여섯 장 중 열다섯
108
+ 장이 그렇습니다. 범례 상자는 계열(꺾은선)을 쓰는 한 장에만 있고, 그 상자도
109
+ `options.showLegend` 가 아니라 **자료**가 부릅니다(`legend`). 계열 이름을 적을
110
+ 자리가 그것뿐이라 끌 수 있게 두면 어느 선이 무엇인지 알 길이 없기 때문입니다.
111
+
112
+ ```js
113
+ const data = CsatChart.createSupplyDemandData(); // 수요·공급 교차 + 균형점 E
114
+ data.xAxis.label = '수량(개)';
115
+ data.yAxis.label = '가격(만 원)';
116
+ data.points.push({
117
+ x: 6, y: 2, label: '', labelPos: 'top', guide: 'both', dot: false, // 점 없이 유도선만
118
+ });
119
+ new CsatChart('c', { type: 'econ-plane', data: data });
120
+ ```
121
+
122
+ 기본값 말고 **시작점이 셋** 있습니다. 그리려는 그림에 가까운 것에서 시작하세요.
123
+
124
+ | 시작점 | 그리는 것 |
125
+ |---|---|
126
+ | `createSupplyDemandData()` | 수요·공급 교차 + 균형점 `E` (실물에서 가장 흔한 그림입니다. `createDefaultEconPlaneData()` 가 이것을 돌려줍니다) |
127
+ | `createAdAsData()` | 총수요·총공급 — 물가 × 실질 GDP, 눈금 없음 |
128
+ | `createPointShiftData()` | 이름 붙인 점 + 축으로 내리는 유도선 + 점 사이 화살표 (이것도 그만큼 자주 나옵니다) |
129
+
130
+ 칸마다 하는 일은 이렇습니다.
131
+
132
+ | 칸 | 값 | 하는 일 |
133
+ |---|---|---|
134
+ | `quadrants` | `'first'`·`'all'` | `'all'` 이면 네 사분면을 그리고 축 양끝에 화살촉을 답니다 |
135
+ | `xAxis`·`yAxis` | `{ label, min, max, ticks, broken }` | `ticks` 는 눈금 «값» 배열입니다. **자는 언제나 고르고 눈금만 띄엄띄엄 찍힙니다** — `[0, 10, 20, 50]` 이면 50 이 20 의 세 배 거리에 섭니다. `broken: true` 면 원점과 첫 눈금 사이에 생략 기호 `≈` 를 넣습니다 |
136
+ | `grid` | boolean | 눈금 자리마다 점선 격자를 깝니다 |
137
+ | `dash` | `'dashed'`·`'dotted'` | 격자와 유도선의 점선 모양 |
138
+ | `lines[]` | `{ label, from, to, labelAt }` | 직선 하나. 이름은 `labelAt` 이 가리키는 끝에 붙습니다(`'to'` 가 보통) |
139
+ | `points[]` | `{ x, y, label, labelPos, guide, dot }` | `labelPos` 는 나침반 여덟 방향, `guide` 는 `'none'`·`'to-x'`·`'to-y'`·`'both'`·`'cross'`, `dot: false` 면 점 없이 유도선만 남습니다 |
140
+ | `arrows[]` | `{ from, to, offset, shorten, label, labelPos }` | `offset` 은 잇는 선에서 **진행 방향 오른쪽**으로 비켜 놓는 픽셀(곡선을 따라가는 화살표가 이 꼴), `shorten` 은 양 끝을 줄여 점에 안 닿게 하는 픽셀 |
141
+
142
+ 필요할 때만 적는 **선택** 칸이 다섯 더 있습니다. 적지 않으면 1.4.0 과 똑같이
143
+ 그려집니다.
144
+
145
+ | 칸 | 값 | 하는 일 |
146
+ |---|---|---|
147
+ | `lines[].dashed` | boolean | 그 직선을 파선으로 긋습니다. 흑백 시험지에서 선 종류가 뜻을 나릅니다 — 사적 편익만 반영한 `D_1` 은 실선, 사회적 편익까지 반영한 `D_2` 는 파선입니다 |
148
+ | `xAxis.tickLabels`·`yAxis.tickLabels` | `string[]` | 눈금 자리에 숫자 대신 적을 **글자**입니다. `ticks` 와 자리끼리 짝을 이룹니다 — 자리는 언제나 `ticks` 가 정하고, 이 배열은 거기 무엇이라 적을지만 바꿉니다. 숫자가 하나도 없는 세로축(`P_1`·`P_2`)도, 해[年]가 눈금인 가로축(`t년`·`t+1년`)도 이 한 칸으로 그립니다 |
149
+ | `series[]` | `{ label, points, dashed, marker, hollow }` | 점 여럿을 잇고 기호를 얹은 꺾은선. `marker` 는 `'circle'`·`'square'`, `hollow: true` 면 속을 비웁니다. 계열 둘이 한 점에서 만나면 **먼저 적은 것이 위**에 옵니다 |
150
+ | `legend` | `'bottom-right'` 따위 네 모서리 | 계열 범례 상자를 놓을 자리. 적지 않으면 그리지 않습니다. 그 모서리가 자료에 막히면 나머지 셋을 차례로 봅니다 |
151
+ | `seriesGuides` | boolean | 계열 꼭짓점에서 가로축으로 파선을 내립니다. 한 자리에 점이 여럿이면 가장 높은 점까지 한 번만 긋습니다 |
152
+
153
+ ```js
154
+ // 명목 GDP·실질 GDP — 계열 둘과 범례 상자 (2027학년도 6월 경제 16번 모양)
155
+ new CsatChart('c', { type: 'econ-plane', data: {
156
+ quadrants: 'first',
157
+ xAxis: { label: '연도', min: 0, max: 4.5, ticks: [1, 2, 3],
158
+ tickLabels: ['t년', 't+1년', 't+2년'], broken: true },
159
+ yAxis: { label: 'GDP\\n(억 달러)', min: 0, max: 10, ticks: [], broken: true },
160
+ grid: false, dash: 'dashed', lines: [], points: [], arrows: [],
161
+ series: [
162
+ { label: '명목 GDP', dashed: true, marker: 'circle', hollow: false,
163
+ points: [{ x: 1, y: 7.2 }, { x: 2, y: 6.2 }, { x: 3, y: 3.2 }] },
164
+ { label: '실질 GDP', dashed: false, marker: 'square', hollow: true,
165
+ points: [{ x: 1, y: 3.5 }, { x: 2, y: 6.2 }, { x: 3, y: 8.8 }] },
166
+ ],
167
+ legend: 'bottom-right', seriesGuides: true,
168
+ } });
169
+ ```
170
+
171
+ ### 아래 첨자 — 밑줄로 적습니다
172
+
173
+ 이 종류의 이름은 어디든 `D_1` 처럼 적으면 **밑줄 뒤에 이어지는 영문자·숫자**가
174
+ 아래 첨자로 내려앉습니다(`D₁`). 선 이름·점 이름·화살표 이름·축 이름·눈금
175
+ 이름표·계열 이름이 모두 그렇습니다.
176
+
177
+ 한글 이름과 부딪히지 않게 규칙을 **밑줄 + 영숫자**로 좁혀 두었습니다. 그 밖의
178
+ 글자 앞에 선 밑줄은 밑줄 그대로 남습니다 — `'강원_춘천'` 은 첨자가 아닙니다.
179
+ 이어지는 영숫자는 통째로 첨자가 됩니다(`'수량_2024년'` → 「2024」만 첨자).
180
+
181
+ 제목·출처·각주(`options`)에는 통하지 않습니다. 그쪽은 열일곱 종이 함께 쓰는
182
+ 자리입니다.
183
+
184
+ ### 세로축 이름을 두 줄로
185
+
186
+ 세로축 이름에 리터럴 `\n`(역슬래시 + n)이나 진짜 줄바꿈을 넣으면 줄이 나뉘어
187
+ 쌓입니다 — 실물이 「GDP」와 「(억 달러)」를 두 줄로 앉히는 꼴입니다. 여러 줄이면
188
+ 묶음 가운데 맞춤이 됩니다. 산점도 `yLabel` 과 같은 규약입니다. 가로축 이름은
189
+ 한 줄입니다.
190
+
191
+ 눈금 표시선(축에 붙는 작은 선분)은 그리지 않습니다 — 시험지가 그렇습니다.
192
+ 격자나 유도선이 축까지 닿아 자리를 알려 줍니다.
193
+
194
+ 두 그림을 나란히 놓는 문항(2026학년도 수능 경제 5번의 〈X재 시장〉·〈Y재 시장〉,
195
+ 2027학년도 6월 3번도 같습니다)은 캔버스 둘에 각각 그리고 배치는 쓰시는 쪽에서
196
+ 합니다. 이 종류는 캔버스 한 장에 그림 한 장을 그립니다 — 판마다 제목이 따로
197
+ 붙어 있으므로 `options.title` 에 〈X재 시장〉·〈Y재 시장〉을 적으면 실물과 같은
198
+ 그림이 나옵니다.
199
+
97
200
  ## 옵션
98
201
 
99
- `options` 는 다음 13개 필드를 받습니다. 필요한 것만 적으면 나머지는 기본값을 씁니다.
202
+ `options` 는 다음 14개 필드를 받습니다. 필요한 것만 적으면 나머지는 기본값을 씁니다.
100
203
 
101
204
  | 필드 | 기본값 | 하는 일 |
102
205
  |---|---|---|
103
206
  | `title` | `''` | 제목 |
104
207
  | `source` | `''` | 출처. 각주 위(또는 `sourceInline` 이면 각주와 같은 줄)에 오른쪽 정렬로 적힙니다 |
105
- | `sourceLeft` | 없음 | 출처 줄 왼쪽에 함께 적을 글(예: 자료 연도 `(2024)`). **지금은 `stacked` 에서만 동작합니다** |
106
- | `sourceInline` | 없음(꺼짐) | 출처를 마지막 각주와 같은 줄 오른쪽 끝에 붙입니다(시험지 관습). **지금은 `scatter` 에서만 동작합니다** |
208
+ | `sourceLeft` | 없음 | 출처 줄 왼쪽에 함께 적을 글(예: 자료 연도 `(2024)`). **지금은 `stacked`·`econ-plane` 에서만 동작합니다** |
209
+ | `sourceInline` | 없음(꺼짐) | 출처를 마지막 각주와 같은 줄 오른쪽 끝에 붙입니다(시험지 관습). **지금은 `scatter`·`econ-plane` 에서만 동작합니다** |
107
210
  | `footnotes` | `['']` | 각주 목록. 앞에 `* ` 를 자동으로 붙이므로 직접 적지 않습니다. 빈 문자열은 무시됩니다 |
108
211
  | `fontFamily` | `'serif'` | `'serif'`(명조)·`'sans'`(고딕)·`'custom'` 중 하나 |
109
- | `customFont` | `''` | `fontFamily` 가 `'custom'` 일 때 쓸 글꼴 이름 |
212
+ | `customFont` | `''` | `fontFamily` 가 `'custom'` 일 때 **축 쪽에만** 쓸 글꼴 이름 |
213
+ | `fontStack` | `{}` | 글꼴 자리를 통째로 갈아 끼웁니다 — `{ serif, sans }`. 아래 [내 글꼴로 그리기](#내-글꼴로-그리기) 참고 |
110
214
  | `fontSize` | `{ title: 36, axisLabel: 28, tick: 26, dataLabel: 22 }` | 제목·축 이름·눈금·데이터 값 글자 크기(px). 각주·출처는 `dataLabel` 을 따릅니다 |
111
215
  | `showDataLabels` | `false` | 막대·점에 값을 함께 표시할지 |
112
216
  | `showLegend` | `true` | 범례를 보여줄지 |
@@ -114,20 +218,81 @@
114
218
  | `legendLabel1` | `''` | 두 계열을 쓰는 종류(기후·편차·인구 피라미드)의 첫 계열 범례 이름. 비워 두면 데이터가 준 이름을 씁니다 |
115
219
  | `legendLabel2` | `''` | 같은 종류의 두 번째 계열 범례 이름 |
116
220
 
221
+ `econ-plane` 은 `showLegend`·`legendPosition`·`showDataLabels` 를 읽지 않습니다.
222
+ 시험지 경제 그림은 선 끝에 이름을 달고, 계열을 쓰는 그림의 범례 상자는 옵션이
223
+ 아니라 자료가 부르기 때문입니다(`data.legend` — 위 [경제 좌표평면](#경제-좌표평면)
224
+ 참고). 나머지 옵션(제목·출처·각주·글꼴)은 다른 종류와 똑같이 동작합니다.
225
+
117
226
  `fontSize` 는 하나만 부분 지정해도 됩니다 — TypeScript·JavaScript 모두 마찬가지입니다.
118
227
 
119
228
  ```js
120
229
  chart.update({ options: { fontSize: { title: 44 } } });
121
230
  ```
122
231
 
123
- 나머지 세 값은 그대로 유지됩니다(준 항목만 갈아 끼웁니다). TypeScript 에서 모양의
124
- 옵션 타입 이름은 `PartialGraphOptions`, 종류의 부분 갱신 전체는 `UpdateFor<T>` 입니다.
232
+ 나머지 세 값은 그대로 유지됩니다(준 항목만 갈아 끼웁니다). `fontStack` 같습니다
233
+ 자리만 다시 줘도 나머지 자리는 그대로 남습니다. TypeScript 에서 이 모양의 옵션
234
+ 타입 이름은 `PartialGraphOptions`, 한 종류의 부분 갱신 전체는 `UpdateFor<T>` 입니다.
235
+
236
+ ## 내 글꼴로 그리기
237
+
238
+ 시험지 그림은 자리마다 서체가 다릅니다. **축 이름·눈금·자료값은 명조**, **제목·출처·
239
+ 각주·범례는 고딕**입니다. 실제 시험지가 그렇기 때문에 기본값도 그렇게 두었습니다.
240
+
241
+ `fontStack` 은 그 짝을 그대로 둔 채 **각 자리에 무슨 글꼴을 쓸지**만 바꿉니다.
242
+
243
+ ```js
244
+ new CsatChart('c', {
245
+ type: 'climate',
246
+ data: myData,
247
+ options: {
248
+ fontStack: {
249
+ serif: "'함초롬바탕', serif", // 축 이름·눈금·자료값
250
+ sans: "'함초롬돋움', sans-serif", // 제목·출처·각주·범례
251
+ },
252
+ },
253
+ });
254
+ ```
255
+
256
+ **이 방법이 가장 손이 적게 갑니다.** 한컴오피스가 깔린 컴퓨터에는 함초롬바탕·
257
+ 함초롬돋움이 이미 있습니다. 내려받을 것도, 어딘가에 올려 둘 것도, 라이선스를 살펴볼
258
+ 것도 없습니다 — 보는 사람의 컴퓨터에 있는 글꼴을 그대로 쓰기 때문입니다. 윈도우
259
+ 기본 글꼴(`'맑은 고딕'`·`'바탕'`)도 같은 방식으로 쓸 수 있습니다.
260
+
261
+ 대신 **그 컴퓨터에 그 글꼴이 있어야** 합니다. 없으면 브라우저가 아무 말 없이 뒤의
262
+ 총칭 글꼴(`serif`·`sans-serif`)로 떨어뜨립니다. 그래서 이름 뒤에 `, serif` 를 꼭
263
+ 붙여 두세요 — 그래야 이름이 틀렸을 때도 명조 계열로 떨어집니다.
264
+
265
+ 누가 열어 보든 같게 보여야 한다면 웹폰트를 쓰고, 글꼴을 **먼저 받아 둔 뒤에**
266
+ 그리세요. `ensureFonts()` 의 `href`·`families` 가 그 자리입니다.
267
+
268
+ ```js
269
+ await CsatChart.ensureFonts({
270
+ href: 'https://cdn.example.com/my-font.css',
271
+ families: ['MyFont Serif', 'MyFont Sans'],
272
+ });
273
+ new CsatChart('c', {
274
+ type: 'climate',
275
+ data: myData,
276
+ options: { fontStack: { serif: "'MyFont Serif', serif", sans: "'MyFont Sans', sans-serif" } },
277
+ });
278
+ ```
279
+
280
+ 두 자리 중 하나만 줘도 됩니다. 적지 않은 자리는 기본 글꼴(Noto Serif KR·Noto Sans KR)
281
+ 그대로입니다.
282
+
283
+ `fontFamily` 와는 층이 다릅니다. `fontFamily` 는 **축이 어느 자리를 쓸지**(명조냐
284
+ 고딕이냐) 고르고, `fontStack` 은 **그 자리가 무슨 글꼴인지**를 정합니다. 둘을 함께
285
+ 써도 됩니다 — `fontFamily: 'sans'` + `fontStack.sans` 면 축까지 그 고딕으로 그립니다.
286
+ `customFont` 은 예전 그대로 동작하지만 축 쪽만 바꾸므로, 그림 전체를 바꾸려면
287
+ `fontStack` 을 쓰세요.
125
288
 
126
289
  ## 그림이 이상할 때
127
290
 
128
291
  | 증상 | 원인 | 할 일 |
129
292
  |---|---|---|
130
293
  | 글꼴이 시험지 같지 않습니다 | `ensureFonts()` 를 안 불렀거나, 차트를 만든 **뒤에** 불렀습니다 | `await CsatChart.ensureFonts()` 를 먼저 부르고 그 안에서 차트를 만드세요 |
294
+ | `fontStack` 을 줬는데 그림이 그대로입니다 | 그 이름의 글꼴이 이 컴퓨터에 없어 브라우저가 조용히 대체 글꼴로 떨어뜨렸습니다 | 글꼴 이름을 다시 보세요(한글 이름은 한글 그대로 적습니다). 누구에게나 같게 보여야 하면 웹폰트를 쓰고 `ensureFonts({ href, families })` 로 먼저 받으세요 |
295
+ | `update()` 뒤에 축 글꼴만 기본으로 돌아갑니다 | — | 그런 일은 없습니다. `fontStack` 은 `fontSize` 처럼 준 자리만 갈아 끼우고 나머지는 유지합니다 |
131
296
  | 제목·눈금·각주가 한 덩어리로 겹칩니다 | 캔버스가 너무 작습니다 | 800×600 안팎으로. 500×400 아래로는 내려가지 않습니다 |
132
297
  | 축은 그려지는데 자료가 없습니다 | 기본 데이터를 그대로 썼습니다 (값이 전부 0인 종류가 있습니다) | [내 자료 넣기](#내-자료-넣기) |
133
298
  | 레티나에서 흐릿합니다 | 화면 캔버스는 1배입니다 | `toDataURL({ scale: 2 })` 로 뽑아 `<img>` 로 거세요 |
@@ -250,6 +415,11 @@ TypeScript 에서는 `type` 을 적는 순간 `data` 타입이 그 종류로 좁
250
415
  (기본 5000). 교내망·오프라인이라 `href` 를 바꾼다면 **`families` 도 함께 바꾸세요**
251
416
  — `href` 를 그대로 둔 채 `families` 만 바꾸면 그 글꼴이 없어도 `true` 가 나옵니다.
252
417
 
418
+ 이 둘은 `options.fontStack` 과 짝입니다. 내려받아야 하는 웹폰트를 쓸 때 여기서 먼저
419
+ 받아 두고, `fontStack` 으로 그 글꼴을 자리에 앉힙니다. 이미 컴퓨터에 깔린 글꼴만
420
+ 쓴다면 `ensureFonts()` 는 부를 필요가 없습니다 — [내 글꼴로 그리기](#내-글꼴로-그리기)
421
+ 참고.
422
+
253
423
  ### 오류 가려내기
254
424
 
255
425
  데이터가 어긋나면 `CsatChartError` 를 던집니다. 가려낼 때는 `instanceof` 말고
@@ -315,11 +485,11 @@ render○○(ctx, width, height, data, options): void
315
485
 
316
486
  | 무엇을 가져오나 | 크기 |
317
487
  |---|---|
318
- | 라이브러리 전부 | 95.9 KB |
319
- | `CsatChart` 만 | 94.6 KB |
320
- | `renderClimateGraph` 만 | **10.0 KB** |
488
+ | 라이브러리 전부 | 112.6 KB |
489
+ | `CsatChart` 만 | 110.0 KB |
490
+ | `renderClimateGraph` 만 | **12.0 KB** |
321
491
 
322
- `CsatChart` 는 `type` 을 문자열로 받아 그때그때 렌더러를 고르므로 16종을 전부
492
+ `CsatChart` 는 `type` 을 문자열로 받아 그때그때 렌더러를 고르므로 17종을 전부
323
493
  붙들고 있어야 합니다. 기후 그래프 하나만 필요한 앱이라면 저수준 렌더러를 직접
324
494
  부르는 편이 아홉 배 가볍습니다. CDN 으로 쓰면 어차피 한 벌을 통째로 받으므로 이
325
495
  이야기는 해당하지 않습니다.
@@ -339,6 +509,9 @@ render○○(ctx, width, height, data, options): void
339
509
 
340
510
  지리 교사가 수업·평가 자료를 만들려고 쓰던 렌더러를 떼어내 공개한 것입니다.
341
511
  [GeoTester](https://geotester-v2.vercel.app) 와 GeoGrapher 에서 쓰이던 코드입니다.
512
+ 1.4.0 의 `econ-plane` 은 그 바깥에서 온 첫 종류입니다 — 수능 경제 문항의 그림
513
+ 열세 장(2026학년도 수능·9월, 2027학년도 6월)을 재어 만들었고, 1.5.0 에서 같은
514
+ 회차의 세 장을 더 재어 열여섯 장이 됐습니다.
342
515
 
343
516
  ## 기여
344
517