@moda-chart/core 0.1.2
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/LICENSE +21 -0
- package/README.md +25 -0
- package/dist/conformance.d.ts +40 -0
- package/dist/conformance.d.ts.map +1 -0
- package/dist/conformance.js +1608 -0
- package/dist/conformance.js.map +1 -0
- package/dist/core.d.ts +448 -0
- package/dist/core.d.ts.map +1 -0
- package/dist/core.js +6026 -0
- package/dist/core.js.map +1 -0
- package/dist/geo.d.ts +85 -0
- package/dist/geo.d.ts.map +1 -0
- package/dist/geo.js +264 -0
- package/dist/geo.js.map +1 -0
- package/dist/geometry.d.ts +29 -0
- package/dist/geometry.d.ts.map +1 -0
- package/dist/geometry.js +92 -0
- package/dist/geometry.js.map +1 -0
- package/dist/hierarchy.d.ts +142 -0
- package/dist/hierarchy.d.ts.map +1 -0
- package/dist/hierarchy.js +422 -0
- package/dist/hierarchy.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/maps/world.d.ts +9 -0
- package/dist/maps/world.d.ts.map +1 -0
- package/dist/maps/world.js +9 -0
- package/dist/maps/world.js.map +1 -0
- package/dist/mount.d.ts +75 -0
- package/dist/mount.d.ts.map +1 -0
- package/dist/mount.js +795 -0
- package/dist/mount.js.map +1 -0
- package/dist/scales.d.ts +22 -0
- package/dist/scales.d.ts.map +1 -0
- package/dist/scales.js +118 -0
- package/dist/scales.js.map +1 -0
- package/dist/sparkline.d.ts +4 -0
- package/dist/sparkline.d.ts.map +1 -0
- package/dist/sparkline.js +62 -0
- package/dist/sparkline.js.map +1 -0
- package/dist/styles.css +989 -0
- package/dist/svg.d.ts +21 -0
- package/dist/svg.d.ts.map +1 -0
- package/dist/svg.js +1022 -0
- package/dist/svg.js.map +1 -0
- package/dist/text.d.ts +23 -0
- package/dist/text.d.ts.map +1 -0
- package/dist/text.js +67 -0
- package/dist/text.js.map +1 -0
- package/dist/types.d.ts +1881 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -0
- package/dist/xml.d.ts +13 -0
- package/dist/xml.d.ts.map +1 -0
- package/dist/xml.js +109 -0
- package/dist/xml.js.map +1 -0
- package/package.json +44 -0
|
@@ -0,0 +1,1608 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
/** 스펙 기본 픽스처 — 4카테고리 × 2시리즈 라인 */
|
|
3
|
+
export function specOptions(extra = {}) {
|
|
4
|
+
return {
|
|
5
|
+
type: "line",
|
|
6
|
+
categories: ["1월", "2월", "3월", "4월"],
|
|
7
|
+
series: [
|
|
8
|
+
{ name: "매출", values: [10, 25, 15, 30] },
|
|
9
|
+
{ name: "비용", values: [6, 12, 9, 18] },
|
|
10
|
+
],
|
|
11
|
+
width: 400,
|
|
12
|
+
height: 240,
|
|
13
|
+
legend: true,
|
|
14
|
+
tooltip: "shared",
|
|
15
|
+
zoom: true,
|
|
16
|
+
brush: true,
|
|
17
|
+
animation: false,
|
|
18
|
+
...extra,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/** 마운트 헬퍼 — 매 테스트마다 새 인스턴스 */
|
|
22
|
+
async function mountSpecChart(mount, extra = {}) {
|
|
23
|
+
const m = await mount(specOptions(extra));
|
|
24
|
+
const flush = async () => {
|
|
25
|
+
await m.flush?.();
|
|
26
|
+
};
|
|
27
|
+
return { ...m, flush };
|
|
28
|
+
}
|
|
29
|
+
export function runChartConformance(label, mount) {
|
|
30
|
+
describe(`${label} 렌더 계약`, () => {
|
|
31
|
+
it("뼈대 요소를 렌더한다", async () => {
|
|
32
|
+
const { el, destroy } = await mountSpecChart(mount);
|
|
33
|
+
const root = el.querySelector(".mc-root");
|
|
34
|
+
expect(root.getAttribute("role")).toBe("figure");
|
|
35
|
+
expect(root.getAttribute("tabindex")).toBe("0");
|
|
36
|
+
expect(root.getAttribute("aria-label")).toBeTruthy();
|
|
37
|
+
expect(el.querySelector(".mc-svg")).toBeTruthy();
|
|
38
|
+
expect(el.querySelector(".mc-toolbar .mc-export-svg")).toBeTruthy();
|
|
39
|
+
expect(el.querySelector(".mc-toolbar .mc-export-png")).toBeTruthy();
|
|
40
|
+
expect(el.querySelector(".mc-toolbar .mc-zoom-reset")).toBeTruthy();
|
|
41
|
+
expect(el.querySelector(".mc-overlay")).toBeTruthy();
|
|
42
|
+
expect(el.querySelector(".mc-tooltip")).toBeTruthy();
|
|
43
|
+
expect(el.querySelector(".mc-a11y")?.getAttribute("aria-live")).toBe("polite");
|
|
44
|
+
destroy();
|
|
45
|
+
});
|
|
46
|
+
it("축·그리드·시리즈·범례를 렌더한다", async () => {
|
|
47
|
+
const { el, destroy } = await mountSpecChart(mount);
|
|
48
|
+
const xTicks = el.querySelectorAll(".mc-axis-x .mc-tick-label");
|
|
49
|
+
expect(xTicks.length).toBe(4);
|
|
50
|
+
expect(xTicks[0]?.textContent).toBe("1월");
|
|
51
|
+
expect(el.querySelectorAll(".mc-axis-y .mc-tick").length).toBeGreaterThan(2);
|
|
52
|
+
expect(el.querySelectorAll(".mc-grid-line").length).toBeGreaterThan(0);
|
|
53
|
+
expect(el.querySelectorAll(".mc-series").length).toBe(2);
|
|
54
|
+
expect(el.querySelectorAll(".mc-point").length).toBe(8);
|
|
55
|
+
expect(el.querySelectorAll(".mc-line").length).toBe(2);
|
|
56
|
+
const items = el.querySelectorAll(".mc-legend-item");
|
|
57
|
+
expect(items.length).toBe(2);
|
|
58
|
+
expect(items[0]?.textContent).toContain("매출");
|
|
59
|
+
destroy();
|
|
60
|
+
});
|
|
61
|
+
it("범례 클릭으로 시리즈를 숨긴다", async () => {
|
|
62
|
+
const { el, flush, destroy } = await mountSpecChart(mount);
|
|
63
|
+
const item = el.querySelector('.mc-legend-item[data-id="매출"]');
|
|
64
|
+
item.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
65
|
+
await flush();
|
|
66
|
+
expect(el.querySelectorAll(".mc-series").length).toBe(1);
|
|
67
|
+
expect(el.querySelectorAll(".mc-point").length).toBe(4);
|
|
68
|
+
// 범례는 재렌더되므로 다시 조회한다
|
|
69
|
+
const after = el.querySelector('.mc-legend-item[data-id="매출"]');
|
|
70
|
+
expect(after.classList.contains("mc-hidden")).toBe(true);
|
|
71
|
+
expect(after.getAttribute("aria-pressed")).toBe("false");
|
|
72
|
+
destroy();
|
|
73
|
+
});
|
|
74
|
+
it("bar 타입은 막대를 렌더한다", async () => {
|
|
75
|
+
const { el, destroy } = await mountSpecChart(mount, { type: "bar" });
|
|
76
|
+
expect(el.querySelectorAll(".mc-bar").length).toBe(8);
|
|
77
|
+
expect(el.querySelector(".mc-horizontal")).toBeNull();
|
|
78
|
+
destroy();
|
|
79
|
+
});
|
|
80
|
+
it("direction=horizontal은 수평 막대를 렌더한다", async () => {
|
|
81
|
+
const { el, destroy } = await mountSpecChart(mount, {
|
|
82
|
+
type: "bar",
|
|
83
|
+
direction: "horizontal",
|
|
84
|
+
});
|
|
85
|
+
expect(el.querySelector(".mc-horizontal")).toBeTruthy();
|
|
86
|
+
expect(el.querySelectorAll(".mc-bar").length).toBe(8);
|
|
87
|
+
destroy();
|
|
88
|
+
});
|
|
89
|
+
it("pie는 조각과 카테고리 범례를 렌더한다", async () => {
|
|
90
|
+
const { el, destroy } = await mountSpecChart(mount, {
|
|
91
|
+
type: "pie",
|
|
92
|
+
series: [{ name: "비중", values: [10, 25, 15, 30] }],
|
|
93
|
+
});
|
|
94
|
+
expect(el.querySelectorAll(".mc-slice").length).toBe(4);
|
|
95
|
+
const items = el.querySelectorAll(".mc-legend-item");
|
|
96
|
+
expect(items.length).toBe(4);
|
|
97
|
+
expect(items[1]?.textContent).toContain("2월");
|
|
98
|
+
destroy();
|
|
99
|
+
});
|
|
100
|
+
it("호버하면 공유 툴팁과 크로스헤어가 뜬다", async () => {
|
|
101
|
+
const { el, chart, flush, destroy } = await mountSpecChart(mount);
|
|
102
|
+
const plot = chart.getSnapshot().plot;
|
|
103
|
+
chart.hoverAt(plot.w / 2, plot.h / 2);
|
|
104
|
+
await flush();
|
|
105
|
+
const tip = el.querySelector(".mc-tooltip");
|
|
106
|
+
expect(tip.hidden).toBe(false);
|
|
107
|
+
expect(tip.querySelector(".mc-tooltip-title")?.textContent).toBe("3월");
|
|
108
|
+
expect(tip.querySelectorAll(".mc-tooltip-row").length).toBe(2);
|
|
109
|
+
expect(el.querySelector(".mc-crosshair")).toBeTruthy();
|
|
110
|
+
chart.clearHover();
|
|
111
|
+
await flush();
|
|
112
|
+
expect(tip.hidden).toBe(true);
|
|
113
|
+
destroy();
|
|
114
|
+
});
|
|
115
|
+
it("줌하면 리셋 버튼이 활성화되고 zoomChange가 발행된다", async () => {
|
|
116
|
+
const { el, chart, flush, destroy } = await mountSpecChart(mount);
|
|
117
|
+
let evt = null;
|
|
118
|
+
chart.on("zoomChange", (e) => {
|
|
119
|
+
evt = e;
|
|
120
|
+
});
|
|
121
|
+
chart.zoomAt(chart.getSnapshot().plot.w / 2, 2);
|
|
122
|
+
await flush();
|
|
123
|
+
expect(evt).toBeTruthy();
|
|
124
|
+
const reset = el.querySelector(".mc-zoom-reset");
|
|
125
|
+
expect(reset.disabled).toBe(false);
|
|
126
|
+
// 줌 후 카테고리 틱이 줄어든다
|
|
127
|
+
expect(el.querySelectorAll(".mc-axis-x .mc-tick-label").length).toBeLessThan(4);
|
|
128
|
+
chart.resetZoom();
|
|
129
|
+
await flush();
|
|
130
|
+
expect(reset.disabled).toBe(true);
|
|
131
|
+
destroy();
|
|
132
|
+
});
|
|
133
|
+
it("브러시 선택 영역을 렌더하고 selectionChange를 발행한다", async () => {
|
|
134
|
+
const { el, chart, flush, destroy } = await mountSpecChart(mount);
|
|
135
|
+
let indices = [];
|
|
136
|
+
chart.on("selectionChange", (e) => {
|
|
137
|
+
indices = e.indices;
|
|
138
|
+
});
|
|
139
|
+
chart.setSelection({ min: 1, max: 3 });
|
|
140
|
+
await flush();
|
|
141
|
+
expect(el.querySelector(".mc-selection")).toBeTruthy();
|
|
142
|
+
expect(indices).toEqual([1, 2]);
|
|
143
|
+
chart.clearSelection();
|
|
144
|
+
await flush();
|
|
145
|
+
expect(el.querySelector(".mc-selection")).toBeNull();
|
|
146
|
+
destroy();
|
|
147
|
+
});
|
|
148
|
+
it("화살표 키로 포인트 포커스를 이동한다", async () => {
|
|
149
|
+
const { el, flush, destroy } = await mountSpecChart(mount);
|
|
150
|
+
const root = el.querySelector(".mc-root");
|
|
151
|
+
root.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }));
|
|
152
|
+
await flush();
|
|
153
|
+
root.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }));
|
|
154
|
+
await flush();
|
|
155
|
+
const focused = el.querySelector(".mc-point.mc-focused");
|
|
156
|
+
expect(focused).toBeTruthy();
|
|
157
|
+
expect(focused?.getAttribute("data-index")).toBe("1");
|
|
158
|
+
expect(el.querySelector(".mc-a11y")?.textContent).toContain("매출");
|
|
159
|
+
destroy();
|
|
160
|
+
});
|
|
161
|
+
it("animation:false면 mc-animated를 붙이지 않는다", async () => {
|
|
162
|
+
const { el, destroy } = await mountSpecChart(mount, {
|
|
163
|
+
animation: false,
|
|
164
|
+
});
|
|
165
|
+
expect(el.querySelector(".mc-root").className).not.toContain("mc-animated");
|
|
166
|
+
destroy();
|
|
167
|
+
});
|
|
168
|
+
it("animation 켜짐이면 mc-animated 클래스를 붙인다", async () => {
|
|
169
|
+
const { el, destroy } = await mountSpecChart(mount, {
|
|
170
|
+
animation: { duration: 500 },
|
|
171
|
+
});
|
|
172
|
+
const root = el.querySelector(".mc-root");
|
|
173
|
+
expect(root.className).toContain("mc-animated");
|
|
174
|
+
expect(root.style.getPropertyValue("--chart-animation-duration")).toBe("500ms");
|
|
175
|
+
destroy();
|
|
176
|
+
});
|
|
177
|
+
it("theme=dark는 data-theme을 설정한다", async () => {
|
|
178
|
+
const { el, destroy } = await mountSpecChart(mount, { theme: "dark" });
|
|
179
|
+
expect(el.querySelector(".mc-root")?.getAttribute("data-theme")).toBe("dark");
|
|
180
|
+
destroy();
|
|
181
|
+
});
|
|
182
|
+
it("데이터가 없으면 .mc-empty를 렌더한다", async () => {
|
|
183
|
+
const { el, destroy } = await mountSpecChart(mount, { series: [] });
|
|
184
|
+
expect(el.querySelector(".mc-empty")).toBeTruthy();
|
|
185
|
+
destroy();
|
|
186
|
+
});
|
|
187
|
+
it("title/subtitle 옵션이 .mc-title/.mc-subtitle에 렌더된다", async () => {
|
|
188
|
+
const { el, destroy } = await mountSpecChart(mount, {
|
|
189
|
+
title: "월별 추이",
|
|
190
|
+
subtitle: "2024 상반기",
|
|
191
|
+
});
|
|
192
|
+
expect(el.querySelector(".mc-title")?.textContent).toBe("월별 추이");
|
|
193
|
+
expect(el.querySelector(".mc-subtitle")?.textContent).toBe("2024 상반기");
|
|
194
|
+
expect(el.querySelector(".mc-title").hidden).toBe(false);
|
|
195
|
+
destroy();
|
|
196
|
+
});
|
|
197
|
+
it("dataLabels 옵션으로 값 라벨을 렌더한다", async () => {
|
|
198
|
+
const { el, destroy } = await mountSpecChart(mount, {
|
|
199
|
+
type: "bar",
|
|
200
|
+
dataLabels: true,
|
|
201
|
+
});
|
|
202
|
+
const labels = el.querySelectorAll(".mc-data-label");
|
|
203
|
+
expect(labels.length).toBe(8);
|
|
204
|
+
expect(labels[0]?.textContent).toBe("10");
|
|
205
|
+
destroy();
|
|
206
|
+
});
|
|
207
|
+
it("markLines로 값 축 기준선을 렌더한다", async () => {
|
|
208
|
+
const { el, destroy } = await mountSpecChart(mount, {
|
|
209
|
+
markLines: [{ value: 15, label: "목표" }],
|
|
210
|
+
});
|
|
211
|
+
expect(el.querySelector(".mc-mark-line")).toBeTruthy();
|
|
212
|
+
expect(el.querySelector(".mc-mark-label")?.textContent).toBe("목표");
|
|
213
|
+
// 도메인 밖 값은 생략된다
|
|
214
|
+
const { el: el2, destroy: d2 } = await mountSpecChart(mount, {
|
|
215
|
+
markLines: [{ value: 9999, label: "x" }],
|
|
216
|
+
});
|
|
217
|
+
expect(el2.querySelector(".mc-mark-line")).toBeNull();
|
|
218
|
+
destroy();
|
|
219
|
+
d2();
|
|
220
|
+
});
|
|
221
|
+
it("loading 옵션은 .mc-loading 오버레이와 aria-busy를 설정한다", async () => {
|
|
222
|
+
const { el, chart, flush, destroy } = await mountSpecChart(mount, {
|
|
223
|
+
loading: true,
|
|
224
|
+
});
|
|
225
|
+
const overlay = el.querySelector(".mc-loading");
|
|
226
|
+
expect(overlay.hidden).toBe(false);
|
|
227
|
+
expect(el.querySelector(".mc-root")?.getAttribute("aria-busy")).toBe("true");
|
|
228
|
+
chart.setLoading(false);
|
|
229
|
+
await flush();
|
|
230
|
+
expect(overlay.hidden).toBe(true);
|
|
231
|
+
destroy();
|
|
232
|
+
});
|
|
233
|
+
it("emptyText 옵션으로 빈 상태 문구를 바꾼다", async () => {
|
|
234
|
+
const { el, destroy } = await mountSpecChart(mount, {
|
|
235
|
+
series: [],
|
|
236
|
+
emptyText: "데이터가 없습니다",
|
|
237
|
+
});
|
|
238
|
+
expect(el.querySelector(".mc-empty")?.textContent).toBe("데이터가 없습니다");
|
|
239
|
+
destroy();
|
|
240
|
+
});
|
|
241
|
+
it("범례 hover는 다른 시리즈를 dim하고 legendHover를 발행한다", async () => {
|
|
242
|
+
const { el, chart, flush, destroy } = await mountSpecChart(mount);
|
|
243
|
+
let evt = null;
|
|
244
|
+
chart.on("legendHover", (e) => {
|
|
245
|
+
evt = e;
|
|
246
|
+
});
|
|
247
|
+
const item = el.querySelector('.mc-legend-item[data-id="매출"]');
|
|
248
|
+
item.dispatchEvent(new MouseEvent("pointerover", { bubbles: true }));
|
|
249
|
+
await flush();
|
|
250
|
+
expect(evt).toEqual({ id: "매출" });
|
|
251
|
+
// 비용 시리즈 포인트만 dim
|
|
252
|
+
const dimmed = el.querySelectorAll(".mc-point.mc-dimmed");
|
|
253
|
+
expect(dimmed.length).toBe(4);
|
|
254
|
+
expect(dimmed[0]?.getAttribute("data-series-id")).toBe("비용");
|
|
255
|
+
// 범례는 매 렌더 재생성되므로 다시 조회한다
|
|
256
|
+
el.querySelector('.mc-legend-item[data-id="매출"]').dispatchEvent(new MouseEvent("pointerout", { bubbles: true }));
|
|
257
|
+
await flush();
|
|
258
|
+
expect(el.querySelectorAll(".mc-point.mc-dimmed").length).toBe(0);
|
|
259
|
+
destroy();
|
|
260
|
+
});
|
|
261
|
+
it("포커스/클릭 이벤트 — focusChange·pointDblClick·chartClick", async () => {
|
|
262
|
+
const { chart, destroy } = await mountSpecChart(mount);
|
|
263
|
+
const events = [];
|
|
264
|
+
chart.on("focusChange", (e) => events.push(`focus:${e.point?.index}`));
|
|
265
|
+
chart.on("pointDblClick", (e) => events.push(`dbl:${e.index}`));
|
|
266
|
+
chart.on("chartClick", (e) => events.push(`click:${e.point?.index}`));
|
|
267
|
+
chart.moveFocus("right");
|
|
268
|
+
const plot = chart.getSnapshot().plot;
|
|
269
|
+
chart.dblClickAt(plot.w / 2, plot.h / 2);
|
|
270
|
+
chart.clickAt(plot.w / 2, plot.h / 2);
|
|
271
|
+
expect(events).toContain("focus:0");
|
|
272
|
+
expect(events.some((e) => e.startsWith("dbl:"))).toBe(true);
|
|
273
|
+
expect(events.some((e) => e.startsWith("click:"))).toBe(true);
|
|
274
|
+
destroy();
|
|
275
|
+
});
|
|
276
|
+
// ── IBChart 대비 신규 계약 ──────────────────────────────
|
|
277
|
+
it("step 타입은 계단형 라인을 렌더한다", async () => {
|
|
278
|
+
const { el, destroy } = await mountSpecChart(mount, { type: "step" });
|
|
279
|
+
expect(el.querySelector(".mc-root")?.getAttribute("data-chart-type")).toBe("step");
|
|
280
|
+
const path = el.querySelector(".mc-line.mc-step");
|
|
281
|
+
expect(path).toBeTruthy();
|
|
282
|
+
// 계단 패스는 수직 세그먼트(같은 x)를 포함한다
|
|
283
|
+
const d = path.getAttribute("d");
|
|
284
|
+
const xs = d
|
|
285
|
+
.match(/L([\d.]+) ([\d.]+)/g)
|
|
286
|
+
.map((s) => s.slice(1).trim().split(" ").map(Number));
|
|
287
|
+
expect(xs.some((p, i) => i > 0 && p[0] === xs[i - 1][0])).toBe(true);
|
|
288
|
+
destroy();
|
|
289
|
+
});
|
|
290
|
+
it("spline 타입은 곡선 라인을 렌더한다", async () => {
|
|
291
|
+
const { el, destroy } = await mountSpecChart(mount, {
|
|
292
|
+
type: "spline",
|
|
293
|
+
});
|
|
294
|
+
const path = el.querySelector(".mc-line.mc-spline");
|
|
295
|
+
expect(path).toBeTruthy();
|
|
296
|
+
expect(path.getAttribute("d")).toContain("C");
|
|
297
|
+
destroy();
|
|
298
|
+
});
|
|
299
|
+
it("percent-stacked-bar는 점유율 스택을 렌더한다", async () => {
|
|
300
|
+
const { el, destroy } = await mountSpecChart(mount, {
|
|
301
|
+
type: "percent-stacked-bar",
|
|
302
|
+
dataLabels: true,
|
|
303
|
+
});
|
|
304
|
+
expect(el.querySelector(".mc-root")?.getAttribute("data-chart-type")).toBe("percent-stacked-bar");
|
|
305
|
+
expect(el.querySelectorAll(".mc-bar").length).toBe(8);
|
|
306
|
+
// 라벨은 % 표기
|
|
307
|
+
expect(el.querySelector(".mc-data-label")?.textContent).toContain("%");
|
|
308
|
+
destroy();
|
|
309
|
+
});
|
|
310
|
+
it("waterfall은 증감 톤 막대와 연결선을 렌더한다", async () => {
|
|
311
|
+
const { el, destroy } = await mountSpecChart(mount, {
|
|
312
|
+
type: "waterfall",
|
|
313
|
+
series: [{ name: "손익", values: [10, -4, 6, -2] }],
|
|
314
|
+
});
|
|
315
|
+
expect(el.querySelectorAll(".mc-bar.mc-bar-up").length).toBe(2);
|
|
316
|
+
expect(el.querySelectorAll(".mc-bar.mc-bar-down").length).toBe(2);
|
|
317
|
+
expect(el.querySelector(".mc-waterfall-line")).toBeTruthy();
|
|
318
|
+
destroy();
|
|
319
|
+
});
|
|
320
|
+
it("bubble은 크기가 다른 버블 원을 렌더한다", async () => {
|
|
321
|
+
const { el, destroy } = await mountSpecChart(mount, {
|
|
322
|
+
type: "bubble",
|
|
323
|
+
categories: [],
|
|
324
|
+
series: [
|
|
325
|
+
{
|
|
326
|
+
name: "s",
|
|
327
|
+
points: [
|
|
328
|
+
{ x: 1, y: 2, z: 4 },
|
|
329
|
+
{ x: 2, y: 8, z: 100 },
|
|
330
|
+
{ x: 3, y: 5, z: 20 },
|
|
331
|
+
],
|
|
332
|
+
},
|
|
333
|
+
],
|
|
334
|
+
});
|
|
335
|
+
const bubbles = el.querySelectorAll(".mc-bubble");
|
|
336
|
+
expect(bubbles.length).toBe(3);
|
|
337
|
+
const radii = [...bubbles].map((b) => Number(b.getAttribute("r")));
|
|
338
|
+
expect(Math.max(...radii)).toBeGreaterThan(Math.min(...radii));
|
|
339
|
+
destroy();
|
|
340
|
+
});
|
|
341
|
+
it("heatmap은 시리즈×카테고리 셀 그리드를 렌더한다", async () => {
|
|
342
|
+
const { el, destroy } = await mountSpecChart(mount, {
|
|
343
|
+
type: "heatmap",
|
|
344
|
+
});
|
|
345
|
+
expect(el.querySelectorAll(".mc-heat-cell").length).toBe(8);
|
|
346
|
+
// 행 라벨(y축)에 시리즈명이 나온다
|
|
347
|
+
const yTicks = el.querySelectorAll(".mc-axis-y .mc-tick-label");
|
|
348
|
+
expect(yTicks[0]?.textContent).toBe("매출");
|
|
349
|
+
expect(yTicks[1]?.textContent).toBe("비용");
|
|
350
|
+
// 셀 색상은 color-mix 그래디언트
|
|
351
|
+
const cell = el.querySelector(".mc-heat-cell");
|
|
352
|
+
expect(cell.getAttribute("style")).toContain("color-mix");
|
|
353
|
+
destroy();
|
|
354
|
+
});
|
|
355
|
+
it("funnel/pyramid는 슬라이스와 카테고리 범례를 렌더한다", async () => {
|
|
356
|
+
for (const type of ["funnel", "pyramid"]) {
|
|
357
|
+
const { el, destroy } = await mountSpecChart(mount, { type });
|
|
358
|
+
expect(el.querySelector(".mc-root")?.getAttribute("data-chart-type")).toBe(type);
|
|
359
|
+
expect(el.querySelectorAll(".mc-slice").length).toBe(4);
|
|
360
|
+
const items = el.querySelectorAll(".mc-legend-item");
|
|
361
|
+
expect(items.length).toBe(4);
|
|
362
|
+
expect(items[1]?.textContent).toContain("2월");
|
|
363
|
+
destroy();
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
it("markBands는 값 축 기준 영역을 렌더한다", async () => {
|
|
367
|
+
const { el, destroy } = await mountSpecChart(mount, {
|
|
368
|
+
markBands: [{ from: 10, to: 20, label: "주의 구간" }],
|
|
369
|
+
});
|
|
370
|
+
expect(el.querySelector(".mc-mark-band")).toBeTruthy();
|
|
371
|
+
expect(el.querySelector(".mc-mark-label")?.textContent).toBe("주의 구간");
|
|
372
|
+
// 완전히 도메인 밖이면 생략
|
|
373
|
+
const { el: el2, destroy: d2 } = await mountSpecChart(mount, {
|
|
374
|
+
markBands: [{ from: 9000, to: 9999 }],
|
|
375
|
+
});
|
|
376
|
+
expect(el2.querySelector(".mc-mark-band")).toBeNull();
|
|
377
|
+
destroy();
|
|
378
|
+
d2();
|
|
379
|
+
});
|
|
380
|
+
it("drilldown — 드릴하면 .mc-drillup이 보이고 drillUp으로 복귀한다", async () => {
|
|
381
|
+
const { el, chart, flush, destroy } = await mountSpecChart(mount, {
|
|
382
|
+
drilldown: {
|
|
383
|
+
"1월": {
|
|
384
|
+
series: [{ name: "1월 주별", values: [3, 4, 3] }],
|
|
385
|
+
categories: ["1주", "2주", "3주"],
|
|
386
|
+
},
|
|
387
|
+
},
|
|
388
|
+
});
|
|
389
|
+
const btn = el.querySelector(".mc-drillup");
|
|
390
|
+
expect(btn.hidden).toBe(true);
|
|
391
|
+
expect(chart.drillDown("1월")).toBe(true);
|
|
392
|
+
await flush();
|
|
393
|
+
expect(btn.hidden).toBe(false);
|
|
394
|
+
// 범례가 하위 시리즈로 바뀐다
|
|
395
|
+
expect(el.querySelector(".mc-legend-item")?.textContent).toContain("1월 주별");
|
|
396
|
+
chart.drillUp();
|
|
397
|
+
await flush();
|
|
398
|
+
expect(btn.hidden).toBe(true);
|
|
399
|
+
expect(el.querySelector(".mc-legend-item")?.textContent).toContain("매출");
|
|
400
|
+
destroy();
|
|
401
|
+
});
|
|
402
|
+
it("patterns는 defs에 무늬를 만들고 채움에 url을 쓴다", async () => {
|
|
403
|
+
const { el, destroy } = await mountSpecChart(mount, {
|
|
404
|
+
type: "bar",
|
|
405
|
+
patterns: true,
|
|
406
|
+
});
|
|
407
|
+
expect(el.querySelectorAll("pattern.mc-pattern").length).toBeGreaterThan(0);
|
|
408
|
+
const bar = el.querySelector(".mc-bar");
|
|
409
|
+
expect(bar.getAttribute("fill")).toMatch(/^url\(#/);
|
|
410
|
+
destroy();
|
|
411
|
+
});
|
|
412
|
+
it("실시간 API — addPoint/addSeries/removePoint가 DOM에 반영된다", async () => {
|
|
413
|
+
const { el, chart, flush, destroy } = await mountSpecChart(mount);
|
|
414
|
+
chart.addPoint("매출", 33);
|
|
415
|
+
await flush();
|
|
416
|
+
expect(el.querySelectorAll(".mc-point").length).toBe(9);
|
|
417
|
+
chart.addSeries({ name: "이익", values: [1, 2, 3, 4, 5] });
|
|
418
|
+
await flush();
|
|
419
|
+
expect(el.querySelectorAll(".mc-legend-item").length).toBe(3);
|
|
420
|
+
expect(el.querySelectorAll(".mc-point").length).toBe(14);
|
|
421
|
+
chart.removePoint("매출", 0);
|
|
422
|
+
await flush();
|
|
423
|
+
expect(el.querySelectorAll(".mc-point").length).toBe(13);
|
|
424
|
+
chart.removeSeries("이익");
|
|
425
|
+
await flush();
|
|
426
|
+
expect(el.querySelectorAll(".mc-legend-item").length).toBe(2);
|
|
427
|
+
destroy();
|
|
428
|
+
});
|
|
429
|
+
it("wordcloud는 단어 텍스트와 카테고리 범례를 렌더한다", async () => {
|
|
430
|
+
const { el, destroy } = await mountSpecChart(mount, {
|
|
431
|
+
type: "wordcloud",
|
|
432
|
+
});
|
|
433
|
+
expect(el.querySelector(".mc-root")?.getAttribute("data-chart-type")).toBe("wordcloud");
|
|
434
|
+
const words = el.querySelectorAll(".mc-word");
|
|
435
|
+
expect(words.length).toBe(4);
|
|
436
|
+
// 값이 큰 카테고리가 더 큰 font-size를 갖는다
|
|
437
|
+
const sizes = [...words].map((w) => Number(w.getAttribute("font-size")));
|
|
438
|
+
expect(Math.max(...sizes)).toBeGreaterThan(Math.min(...sizes));
|
|
439
|
+
// 축 없이 카테고리 범례
|
|
440
|
+
expect(el.querySelectorAll(".mc-axis").length).toBe(0);
|
|
441
|
+
expect(el.querySelectorAll(".mc-legend-item").length).toBe(4);
|
|
442
|
+
destroy();
|
|
443
|
+
});
|
|
444
|
+
it("annotations는 데이터 좌표에 주석을 렌더한다", async () => {
|
|
445
|
+
const { el, destroy } = await mountSpecChart(mount, {
|
|
446
|
+
annotations: [{ x: "2월", y: 25, label: "최고" }],
|
|
447
|
+
});
|
|
448
|
+
expect(el.querySelector(".mc-annotation")).toBeTruthy();
|
|
449
|
+
expect(el.querySelector(".mc-annotation-label")?.textContent).toBe("최고");
|
|
450
|
+
destroy();
|
|
451
|
+
});
|
|
452
|
+
it("allowPointSelect — 클릭으로 .mc-selected를 토글하고 이벤트를 발행한다", async () => {
|
|
453
|
+
const { el, chart, flush, destroy } = await mountSpecChart(mount, {
|
|
454
|
+
type: "bar",
|
|
455
|
+
allowPointSelect: true,
|
|
456
|
+
});
|
|
457
|
+
let sel = null;
|
|
458
|
+
let unsel = 0;
|
|
459
|
+
chart.on("pointSelect", (e) => {
|
|
460
|
+
sel = e;
|
|
461
|
+
});
|
|
462
|
+
chart.on("pointUnselect", () => unsel++);
|
|
463
|
+
const bar = el.querySelector(".mc-bar");
|
|
464
|
+
const x = Number(bar.getAttribute("x")) + Number(bar.getAttribute("width")) / 2;
|
|
465
|
+
const y = Number(bar.getAttribute("y"));
|
|
466
|
+
const plot = chart.getSnapshot().plot;
|
|
467
|
+
chart.clickAt(x - plot.x, y - plot.y);
|
|
468
|
+
await flush();
|
|
469
|
+
expect(sel).toEqual({ seriesId: "매출", index: 0, value: 10 });
|
|
470
|
+
expect(el.querySelector(".mc-bar.mc-selected")).toBeTruthy();
|
|
471
|
+
// 같은 막대 재클릭 → 해제
|
|
472
|
+
chart.clickAt(x - plot.x, y - plot.y);
|
|
473
|
+
await flush();
|
|
474
|
+
expect(unsel).toBe(1);
|
|
475
|
+
expect(el.querySelector(".mc-selected")).toBeNull();
|
|
476
|
+
destroy();
|
|
477
|
+
});
|
|
478
|
+
it("locale ko는 툴바 버튼과 빈 상태를 한국어로 렌더한다", async () => {
|
|
479
|
+
const { el, destroy } = await mountSpecChart(mount, {
|
|
480
|
+
locale: "ko",
|
|
481
|
+
});
|
|
482
|
+
expect(el.querySelector(".mc-zoom-reset")?.textContent).toBe("리셋");
|
|
483
|
+
destroy();
|
|
484
|
+
const { el: el2, destroy: d2 } = await mountSpecChart(mount, {
|
|
485
|
+
locale: "ko",
|
|
486
|
+
series: [],
|
|
487
|
+
});
|
|
488
|
+
expect(el2.querySelector(".mc-empty")?.textContent).toBe("데이터 없음");
|
|
489
|
+
d2();
|
|
490
|
+
});
|
|
491
|
+
it("window beforeprint/afterprint를 beforePrint/afterPrint 이벤트로 전달한다", async () => {
|
|
492
|
+
const { chart, destroy } = await mountSpecChart(mount);
|
|
493
|
+
const phases = [];
|
|
494
|
+
chart.on("beforePrint", () => phases.push("before"));
|
|
495
|
+
chart.on("afterPrint", () => phases.push("after"));
|
|
496
|
+
window.dispatchEvent(new Event("beforeprint"));
|
|
497
|
+
window.dispatchEvent(new Event("afterprint"));
|
|
498
|
+
expect(phases).toEqual(["before", "after"]);
|
|
499
|
+
destroy();
|
|
500
|
+
});
|
|
501
|
+
it("axes:xy 줌 — 값 축 틱이 좁혀지고 리셋 버튼이 활성화된다", async () => {
|
|
502
|
+
const { el, chart, flush, destroy } = await mountSpecChart(mount, {
|
|
503
|
+
zoom: { axes: "xy" },
|
|
504
|
+
});
|
|
505
|
+
const yTicks = () => [...el.querySelectorAll(".mc-axis-y .mc-tick-label")].map((t) => t.textContent);
|
|
506
|
+
const before = yTicks();
|
|
507
|
+
chart.setZoomWindow({ y: { min: 0, max: 15 } });
|
|
508
|
+
await flush();
|
|
509
|
+
expect(yTicks()).not.toEqual(before);
|
|
510
|
+
expect(el.querySelector(".mc-zoom-reset").disabled).toBe(false);
|
|
511
|
+
chart.resetZoom();
|
|
512
|
+
await flush();
|
|
513
|
+
expect(yTicks()).toEqual(before);
|
|
514
|
+
destroy();
|
|
515
|
+
});
|
|
516
|
+
it("zoom.select 드래그는 y 범위를 가진 사각형 미리보기를 만든다", async () => {
|
|
517
|
+
const { el, chart, flush, destroy } = await mountSpecChart(mount, {
|
|
518
|
+
zoom: { axes: "xy", select: true, pan: false },
|
|
519
|
+
brush: false,
|
|
520
|
+
});
|
|
521
|
+
chart.startDrag(50, false, 20);
|
|
522
|
+
chart.dragTo(200, 120);
|
|
523
|
+
await flush();
|
|
524
|
+
const rect = el.querySelector(".mc-selection");
|
|
525
|
+
expect(rect).toBeTruthy();
|
|
526
|
+
const plot = chart.getSnapshot().plot;
|
|
527
|
+
// y 범위가 있으면 플롯 전체 높이가 아닌 사각형이 된다
|
|
528
|
+
expect(Number(rect.getAttribute("height"))).toBeLessThan(plot.h);
|
|
529
|
+
chart.endDrag();
|
|
530
|
+
await flush();
|
|
531
|
+
expect(el.querySelector(".mc-selection")).toBeNull();
|
|
532
|
+
expect(el.querySelector(".mc-zoom-reset").disabled).toBe(false);
|
|
533
|
+
destroy();
|
|
534
|
+
});
|
|
535
|
+
// ── AG Charts 대비 신규 계약 ─────────────────────────────
|
|
536
|
+
it("radar는 극좌표 그리드 + 폐합 폴리곤을 렌더한다", async () => {
|
|
537
|
+
const { el, destroy } = await mountSpecChart(mount, {
|
|
538
|
+
type: "radar",
|
|
539
|
+
});
|
|
540
|
+
expect(el.querySelector(".mc-root")?.getAttribute("data-chart-type")).toBe("radar");
|
|
541
|
+
// 직교 축 대신 동심원 링 + 스포크
|
|
542
|
+
expect(el.querySelectorAll(".mc-axis").length).toBe(0);
|
|
543
|
+
expect(el.querySelectorAll(".mc-radar-ring").length).toBeGreaterThan(0);
|
|
544
|
+
expect(el.querySelectorAll(".mc-radar-spoke").length).toBe(4);
|
|
545
|
+
expect(el.querySelectorAll(".mc-spoke-label").length).toBe(4);
|
|
546
|
+
const path = el.querySelector(".mc-radar");
|
|
547
|
+
expect(path.getAttribute("d")).toMatch(/ Z$/);
|
|
548
|
+
// 범례는 시리즈 단위
|
|
549
|
+
expect(el.querySelectorAll(".mc-legend-item").length).toBe(2);
|
|
550
|
+
destroy();
|
|
551
|
+
});
|
|
552
|
+
it("radar-area는 채움 클래스가 붙은 폴리곤을 렌더한다", async () => {
|
|
553
|
+
const { el, destroy } = await mountSpecChart(mount, {
|
|
554
|
+
type: "radar-area",
|
|
555
|
+
});
|
|
556
|
+
const path = el.querySelector(".mc-radar.mc-radar-area");
|
|
557
|
+
expect(path).toBeTruthy();
|
|
558
|
+
expect(path.getAttribute("d")).toMatch(/ Z$/);
|
|
559
|
+
destroy();
|
|
560
|
+
});
|
|
561
|
+
it("range-bar는 [low,high] 구간 막대를 렌더한다", async () => {
|
|
562
|
+
const { el, destroy } = await mountSpecChart(mount, {
|
|
563
|
+
type: "range-bar",
|
|
564
|
+
dataLabels: true,
|
|
565
|
+
series: [
|
|
566
|
+
{
|
|
567
|
+
name: "기온",
|
|
568
|
+
ranges: [
|
|
569
|
+
[5, 15],
|
|
570
|
+
[3, 18],
|
|
571
|
+
[7, 21],
|
|
572
|
+
[2, 12],
|
|
573
|
+
],
|
|
574
|
+
},
|
|
575
|
+
],
|
|
576
|
+
});
|
|
577
|
+
expect(el.querySelectorAll(".mc-range-bar").length).toBe(4);
|
|
578
|
+
expect(el.querySelector(".mc-data-label")?.textContent).toContain("–");
|
|
579
|
+
destroy();
|
|
580
|
+
});
|
|
581
|
+
it("range-area는 닫힌 밴드 경로를 렌더한다", async () => {
|
|
582
|
+
const { el, destroy } = await mountSpecChart(mount, {
|
|
583
|
+
type: "range-area",
|
|
584
|
+
series: [
|
|
585
|
+
{
|
|
586
|
+
name: "기온",
|
|
587
|
+
ranges: [
|
|
588
|
+
[5, 15],
|
|
589
|
+
[3, 18],
|
|
590
|
+
[7, 21],
|
|
591
|
+
[2, 12],
|
|
592
|
+
],
|
|
593
|
+
},
|
|
594
|
+
],
|
|
595
|
+
});
|
|
596
|
+
const band = el.querySelector(".mc-range-area");
|
|
597
|
+
expect(band).toBeTruthy();
|
|
598
|
+
expect(band.getAttribute("d")).toMatch(/ Z$/);
|
|
599
|
+
destroy();
|
|
600
|
+
});
|
|
601
|
+
it("candlestick은 up/down 캔들 + 심지를 렌더한다", async () => {
|
|
602
|
+
const { el, destroy } = await mountSpecChart(mount, {
|
|
603
|
+
type: "candlestick",
|
|
604
|
+
series: [
|
|
605
|
+
{
|
|
606
|
+
name: "주가",
|
|
607
|
+
ohlc: [
|
|
608
|
+
[10, 14, 8, 13],
|
|
609
|
+
[13, 14, 9, 10],
|
|
610
|
+
[10, 16, 9, 15],
|
|
611
|
+
[15, 17, 11, 12],
|
|
612
|
+
],
|
|
613
|
+
},
|
|
614
|
+
],
|
|
615
|
+
});
|
|
616
|
+
expect(el.querySelectorAll(".mc-candle").length).toBe(4);
|
|
617
|
+
expect(el.querySelectorAll(".mc-candle-up").length).toBe(2);
|
|
618
|
+
expect(el.querySelectorAll(".mc-candle-down").length).toBe(2);
|
|
619
|
+
expect(el.querySelectorAll(".mc-candle-wick").length).toBe(4);
|
|
620
|
+
destroy();
|
|
621
|
+
});
|
|
622
|
+
it("series.errors는 에러바 오버레이를 렌더한다", async () => {
|
|
623
|
+
const { el, destroy } = await mountSpecChart(mount, {
|
|
624
|
+
series: [
|
|
625
|
+
{
|
|
626
|
+
name: "측정",
|
|
627
|
+
values: [10, 25, 15, 30],
|
|
628
|
+
errors: [
|
|
629
|
+
[2, 3],
|
|
630
|
+
[1, 1],
|
|
631
|
+
[4, 2],
|
|
632
|
+
[2, 2],
|
|
633
|
+
],
|
|
634
|
+
},
|
|
635
|
+
],
|
|
636
|
+
});
|
|
637
|
+
const bars = el.querySelectorAll(".mc-error");
|
|
638
|
+
expect(bars.length).toBe(4);
|
|
639
|
+
expect(bars[0]?.getAttribute("d")).toContain("M");
|
|
640
|
+
destroy();
|
|
641
|
+
});
|
|
642
|
+
it("series.trend는 추세선 path를 렌더한다", async () => {
|
|
643
|
+
const { el, destroy } = await mountSpecChart(mount, {
|
|
644
|
+
series: [
|
|
645
|
+
{
|
|
646
|
+
name: "추세",
|
|
647
|
+
values: [5, 12, 15, 28],
|
|
648
|
+
trend: { type: "linear" },
|
|
649
|
+
},
|
|
650
|
+
],
|
|
651
|
+
});
|
|
652
|
+
const tl = el.querySelector(".mc-trendline");
|
|
653
|
+
expect(tl).toBeTruthy();
|
|
654
|
+
expect(tl.getAttribute("d")).toMatch(/^M/);
|
|
655
|
+
destroy();
|
|
656
|
+
});
|
|
657
|
+
it("navigator는 미니맵 + 창 + 핸들을 렌더하고 드래그로 줌 창을 바꾼다", async () => {
|
|
658
|
+
const { el, chart, flush, destroy } = await mountSpecChart(mount, {
|
|
659
|
+
navigator: true,
|
|
660
|
+
});
|
|
661
|
+
expect(el.querySelector(".mc-navigator")).toBeTruthy();
|
|
662
|
+
expect(el.querySelectorAll(".mc-nav-track").length).toBe(2);
|
|
663
|
+
const win = el.querySelector(".mc-nav-window");
|
|
664
|
+
expect(win).toBeTruthy();
|
|
665
|
+
expect(el.querySelector(".mc-nav-handle-l")).toBeTruthy();
|
|
666
|
+
expect(el.querySelector(".mc-nav-handle-r")).toBeTruthy();
|
|
667
|
+
// 창을 좁히면 .mc-nav-window의 x/width가 갱신된다
|
|
668
|
+
const n = chart.getSnapshot().navigator;
|
|
669
|
+
chart.navDragStart(n.window.x + n.window.w, "r");
|
|
670
|
+
chart.navDragTo(n.window.x + n.window.w * 0.5);
|
|
671
|
+
chart.navDragEnd();
|
|
672
|
+
await flush();
|
|
673
|
+
const after = el.querySelector(".mc-nav-window");
|
|
674
|
+
expect(Number(after.getAttribute("width"))).toBeLessThan(Number(win.getAttribute("width")));
|
|
675
|
+
expect(el.querySelector(".mc-zoom-reset").disabled).toBe(false);
|
|
676
|
+
destroy();
|
|
677
|
+
});
|
|
678
|
+
it("IBChart식 메서드 — updatePoint/updateSeries/setCategories가 DOM에 반영된다", async () => {
|
|
679
|
+
const { el, chart, flush, destroy } = await mountSpecChart(mount);
|
|
680
|
+
let pu = 0;
|
|
681
|
+
let rd = 0;
|
|
682
|
+
chart.on("pointUpdate", () => pu++);
|
|
683
|
+
chart.on("redraw", () => rd++);
|
|
684
|
+
chart.updatePoint("매출", 0, 99);
|
|
685
|
+
await flush();
|
|
686
|
+
expect(pu).toBe(1);
|
|
687
|
+
chart.updateSeries("비용", { name: "비용2" });
|
|
688
|
+
await flush();
|
|
689
|
+
expect(el.querySelector(".mc-legend-item")?.textContent).not.toContain("비용2"); // 첫 항목은 매출
|
|
690
|
+
expect([...el.querySelectorAll(".mc-legend-item")].some((x) => x.textContent?.includes("비용2"))).toBe(true);
|
|
691
|
+
chart.setCategories(["A", "B", "C", "D"]);
|
|
692
|
+
await flush();
|
|
693
|
+
expect(el.querySelector(".mc-axis-x .mc-tick-label")?.textContent).toBe("A");
|
|
694
|
+
chart.redraw();
|
|
695
|
+
expect(rd).toBe(1);
|
|
696
|
+
destroy();
|
|
697
|
+
});
|
|
698
|
+
it("map 타입은 지역 폴리곤 + 값 범례를 렌더하고 지역을 선택한다", async () => {
|
|
699
|
+
// 작은 GeoJSON 픽스처 — KR/JP는 데이터 조인, CN은 no-data
|
|
700
|
+
const topo = {
|
|
701
|
+
type: "FeatureCollection",
|
|
702
|
+
features: [
|
|
703
|
+
{
|
|
704
|
+
type: "Feature",
|
|
705
|
+
id: "410",
|
|
706
|
+
properties: { name: "Korea" },
|
|
707
|
+
geometry: {
|
|
708
|
+
type: "Polygon",
|
|
709
|
+
coordinates: [
|
|
710
|
+
[
|
|
711
|
+
[126, 43],
|
|
712
|
+
[130, 43],
|
|
713
|
+
[130, 33],
|
|
714
|
+
[126, 33],
|
|
715
|
+
[126, 43],
|
|
716
|
+
],
|
|
717
|
+
],
|
|
718
|
+
},
|
|
719
|
+
},
|
|
720
|
+
{
|
|
721
|
+
type: "Feature",
|
|
722
|
+
id: "392",
|
|
723
|
+
properties: { name: "Japan" },
|
|
724
|
+
geometry: {
|
|
725
|
+
type: "Polygon",
|
|
726
|
+
coordinates: [
|
|
727
|
+
[
|
|
728
|
+
[132, 43],
|
|
729
|
+
[140, 43],
|
|
730
|
+
[140, 33],
|
|
731
|
+
[132, 33],
|
|
732
|
+
[132, 43],
|
|
733
|
+
],
|
|
734
|
+
],
|
|
735
|
+
},
|
|
736
|
+
},
|
|
737
|
+
{
|
|
738
|
+
type: "Feature",
|
|
739
|
+
id: "156",
|
|
740
|
+
properties: { name: "China" },
|
|
741
|
+
geometry: {
|
|
742
|
+
type: "Polygon",
|
|
743
|
+
coordinates: [
|
|
744
|
+
[
|
|
745
|
+
[80, 45],
|
|
746
|
+
[110, 45],
|
|
747
|
+
[110, 30],
|
|
748
|
+
[80, 30],
|
|
749
|
+
[80, 45],
|
|
750
|
+
],
|
|
751
|
+
],
|
|
752
|
+
},
|
|
753
|
+
},
|
|
754
|
+
],
|
|
755
|
+
};
|
|
756
|
+
const { el, chart, flush, destroy } = await mountSpecChart(mount, {
|
|
757
|
+
type: "map",
|
|
758
|
+
allowPointSelect: true,
|
|
759
|
+
series: [
|
|
760
|
+
{
|
|
761
|
+
name: "지표",
|
|
762
|
+
mapData: [
|
|
763
|
+
{ code: "410", value: 50 },
|
|
764
|
+
{ code: "Japan", value: 80 },
|
|
765
|
+
],
|
|
766
|
+
},
|
|
767
|
+
],
|
|
768
|
+
map: { topology: topo },
|
|
769
|
+
});
|
|
770
|
+
// 지역 폴리곤 — 2개 데이터 + 1개 no-data
|
|
771
|
+
expect(el.querySelectorAll(".mc-map-region").length).toBe(3);
|
|
772
|
+
expect(el.querySelector('.mc-map-region[data-map-code="410"]')).toBeTruthy();
|
|
773
|
+
expect(el.querySelectorAll(".mc-map-no-data").length).toBe(1);
|
|
774
|
+
// 값 범례 — 그라데이션 바 + min/max 라벨
|
|
775
|
+
expect(el.querySelector(".mc-map-scale")).toBeTruthy();
|
|
776
|
+
expect(el.querySelector(".mc-map-scale-bar")).toBeTruthy();
|
|
777
|
+
// 지도는 직교 축을 렌더하지 않는다
|
|
778
|
+
expect(el.querySelector(".mc-axis")).toBeNull();
|
|
779
|
+
// 지역 선택 — selectPoint가 .mc-selected를 만든다
|
|
780
|
+
chart.selectPoint("지표", 0);
|
|
781
|
+
await flush();
|
|
782
|
+
expect(el.querySelector('.mc-map-region.mc-selected[data-map-code="410"]')).toBeTruthy();
|
|
783
|
+
destroy();
|
|
784
|
+
});
|
|
785
|
+
it("sunburst/treemap은 계층 노드를 렌더하고 노드를 선택한다", async () => {
|
|
786
|
+
const tree = [
|
|
787
|
+
{
|
|
788
|
+
name: "A",
|
|
789
|
+
children: [
|
|
790
|
+
{ name: "A1", value: 30 },
|
|
791
|
+
{ name: "A2", value: 10 },
|
|
792
|
+
],
|
|
793
|
+
},
|
|
794
|
+
{ name: "B", value: 20 },
|
|
795
|
+
];
|
|
796
|
+
for (const type of ["sunburst", "treemap"]) {
|
|
797
|
+
const { el, chart, flush, destroy } = await mountSpecChart(mount, {
|
|
798
|
+
type,
|
|
799
|
+
allowPointSelect: true,
|
|
800
|
+
series: [{ name: "계층", tree }],
|
|
801
|
+
});
|
|
802
|
+
const sel = type === "sunburst" ? ".mc-sunburst-node" : ".mc-treemap-node";
|
|
803
|
+
// sunburst는 내부 노드 포함(4), treemap은 리프만(3)
|
|
804
|
+
expect(el.querySelectorAll(sel).length).toBe(type === "sunburst" ? 4 : 3);
|
|
805
|
+
// 범례는 최상위 노드 단위
|
|
806
|
+
expect([...el.querySelectorAll(".mc-legend-item")].map((x) => x.textContent?.trim())).toEqual(["A", "B"]);
|
|
807
|
+
expect(el.querySelector(".mc-axis")).toBeNull();
|
|
808
|
+
// treemap은 리프만 렌더 — index 0(내부 노드 A) 대신 리프 B(3)를 선택
|
|
809
|
+
chart.selectPoint("계층", type === "sunburst" ? 0 : 3);
|
|
810
|
+
await flush();
|
|
811
|
+
expect(el.querySelector(`${sel}.mc-selected`)).toBeTruthy();
|
|
812
|
+
destroy();
|
|
813
|
+
}
|
|
814
|
+
});
|
|
815
|
+
it("sankey는 노드 열과 유량 리본을 렌더한다", async () => {
|
|
816
|
+
const { el, destroy } = await mountSpecChart(mount, {
|
|
817
|
+
type: "sankey",
|
|
818
|
+
series: [
|
|
819
|
+
{
|
|
820
|
+
name: "유입",
|
|
821
|
+
links: [
|
|
822
|
+
{ source: "A", target: "B", value: 10 },
|
|
823
|
+
{ source: "B", target: "C", value: 5 },
|
|
824
|
+
],
|
|
825
|
+
},
|
|
826
|
+
],
|
|
827
|
+
});
|
|
828
|
+
expect(el.querySelectorAll(".mc-sankey-node").length).toBe(3);
|
|
829
|
+
expect(el.querySelectorAll(".mc-sankey-link").length).toBe(2);
|
|
830
|
+
expect([...el.querySelectorAll(".mc-legend-item")].map((x) => x.textContent?.trim())).toEqual(["A", "B", "C"]);
|
|
831
|
+
destroy();
|
|
832
|
+
});
|
|
833
|
+
it("chord는 그룹 아크와 리본을 렌더한다", async () => {
|
|
834
|
+
const { el, chart, flush, destroy } = await mountSpecChart(mount, {
|
|
835
|
+
type: "chord",
|
|
836
|
+
allowPointSelect: true,
|
|
837
|
+
series: [], // 시리즈 없이 options.chord만으로 렌더 — seriesId는 "chord"
|
|
838
|
+
chord: {
|
|
839
|
+
matrix: [
|
|
840
|
+
[0, 10, 0],
|
|
841
|
+
[4, 0, 8],
|
|
842
|
+
[0, 2, 0],
|
|
843
|
+
],
|
|
844
|
+
labels: ["X", "Y", "Z"],
|
|
845
|
+
},
|
|
846
|
+
});
|
|
847
|
+
expect(el.querySelectorAll(".mc-chord-group").length).toBe(3);
|
|
848
|
+
expect(el.querySelectorAll(".mc-chord-ribbon").length).toBe(2);
|
|
849
|
+
expect([...el.querySelectorAll(".mc-legend-item")].map((x) => x.textContent?.trim())).toEqual(["X", "Y", "Z"]);
|
|
850
|
+
expect(el.querySelector(".mc-axis")).toBeNull();
|
|
851
|
+
chart.selectPoint("chord", 0);
|
|
852
|
+
await flush();
|
|
853
|
+
expect(el.querySelector(".mc-chord-group.mc-selected")).toBeTruthy();
|
|
854
|
+
destroy();
|
|
855
|
+
});
|
|
856
|
+
it("nightingale/radial-bar/radial-column은 극좌표 섹터를 렌더하고 선택한다", async () => {
|
|
857
|
+
for (const type of [
|
|
858
|
+
"nightingale",
|
|
859
|
+
"radial-bar",
|
|
860
|
+
"radial-column",
|
|
861
|
+
]) {
|
|
862
|
+
const { el, chart, flush, destroy } = await mountSpecChart(mount, {
|
|
863
|
+
type,
|
|
864
|
+
allowPointSelect: true,
|
|
865
|
+
});
|
|
866
|
+
const sel = type === "nightingale"
|
|
867
|
+
? ".mc-nightingale-node"
|
|
868
|
+
: type === "radial-column"
|
|
869
|
+
? ".mc-radial-column"
|
|
870
|
+
: ".mc-radial-bar";
|
|
871
|
+
// 4카테고리 × 2시리즈 = 8섹터, 직교 축 없음
|
|
872
|
+
expect(el.querySelectorAll(sel).length).toBe(8);
|
|
873
|
+
expect(el.querySelector(".mc-axis")).toBeNull();
|
|
874
|
+
chart.selectPoint("매출", 0);
|
|
875
|
+
await flush();
|
|
876
|
+
expect(el.querySelector(`${sel}.mc-selected`)).toBeTruthy();
|
|
877
|
+
destroy();
|
|
878
|
+
}
|
|
879
|
+
});
|
|
880
|
+
it("nightingale은 레이더 그리드(링+스포크 라벨)와 카테고리 범례를 그린다", async () => {
|
|
881
|
+
const { el, destroy } = await mountSpecChart(mount, {
|
|
882
|
+
type: "nightingale",
|
|
883
|
+
});
|
|
884
|
+
expect(el.querySelector(".mc-radar-grid")).toBeTruthy();
|
|
885
|
+
expect(el.querySelectorAll(".mc-radar-spoke").length).toBe(4);
|
|
886
|
+
// 범례는 카테고리(1월~4월) 단위
|
|
887
|
+
expect([...el.querySelectorAll(".mc-legend-item")].map((x) => x.textContent?.trim())).toEqual(["1월", "2월", "3월", "4월"]);
|
|
888
|
+
destroy();
|
|
889
|
+
});
|
|
890
|
+
it("radial-column은 눈금 그리드와 시리즈 범례를 그린다", async () => {
|
|
891
|
+
const { el, destroy } = await mountSpecChart(mount, {
|
|
892
|
+
type: "radial-column",
|
|
893
|
+
});
|
|
894
|
+
expect(el.querySelector(".mc-radar-grid")).toBeTruthy();
|
|
895
|
+
expect(el.querySelectorAll(".mc-radar-spoke").length).toBe(4);
|
|
896
|
+
// 범례는 시리즈 단위
|
|
897
|
+
expect([...el.querySelectorAll(".mc-legend-item")].map((x) => x.textContent?.trim())).toEqual(["매출", "비용"]);
|
|
898
|
+
destroy();
|
|
899
|
+
});
|
|
900
|
+
it("gauge는 트랙+값 아크+바늘+중앙 라벨을 렌더한다", async () => {
|
|
901
|
+
const { el, destroy } = await mountSpecChart(mount, {
|
|
902
|
+
type: "gauge",
|
|
903
|
+
series: [{ name: "CPU", values: [72] }],
|
|
904
|
+
gauge: { min: 0, max: 100, target: 80 },
|
|
905
|
+
});
|
|
906
|
+
expect(el.querySelector(".mc-gauge-track")).toBeTruthy();
|
|
907
|
+
expect(el.querySelector(".mc-gauge-value")).toBeTruthy();
|
|
908
|
+
expect(el.querySelector(".mc-gauge-needle")).toBeTruthy();
|
|
909
|
+
expect(el.querySelector(".mc-gauge-target")).toBeTruthy();
|
|
910
|
+
expect(el.querySelector(".mc-gauge-label")?.textContent).toBe("72");
|
|
911
|
+
// min/max 눈금 라벨
|
|
912
|
+
expect([...el.querySelectorAll(".mc-gauge-tick")].map((x) => x.textContent)).toEqual(["0", "100"]);
|
|
913
|
+
// 범례·축 없음
|
|
914
|
+
expect(el.querySelector(".mc-legend-item")).toBeNull();
|
|
915
|
+
expect(el.querySelector(".mc-axis")).toBeNull();
|
|
916
|
+
destroy();
|
|
917
|
+
});
|
|
918
|
+
// ── benchmark.md 갭 개발 신규 계약 ─────────────────────
|
|
919
|
+
it("boxplot은 상자·수염·캡·중앙값을 렌더한다", async () => {
|
|
920
|
+
const { el, destroy } = await mountSpecChart(mount, {
|
|
921
|
+
type: "boxplot",
|
|
922
|
+
series: [
|
|
923
|
+
{
|
|
924
|
+
name: "분포",
|
|
925
|
+
box: [
|
|
926
|
+
[2, 6, 9, 14, 20],
|
|
927
|
+
[4, 8, 11, 15, 22],
|
|
928
|
+
[1, 5, 8, 12, 18],
|
|
929
|
+
[6, 10, 13, 17, 25],
|
|
930
|
+
],
|
|
931
|
+
},
|
|
932
|
+
],
|
|
933
|
+
});
|
|
934
|
+
expect(el.querySelectorAll(".mc-box").length).toBe(4);
|
|
935
|
+
expect(el.querySelectorAll(".mc-box-body").length).toBe(4);
|
|
936
|
+
expect(el.querySelectorAll(".mc-box-whisker").length).toBe(4);
|
|
937
|
+
expect(el.querySelectorAll(".mc-box-median").length).toBe(4);
|
|
938
|
+
expect(el.querySelectorAll(".mc-box-cap").length).toBe(8);
|
|
939
|
+
destroy();
|
|
940
|
+
});
|
|
941
|
+
it("histogram은 원시 값을 구간으로 나눠 막대를 렌더한다", async () => {
|
|
942
|
+
const { el, destroy } = await mountSpecChart(mount, {
|
|
943
|
+
type: "histogram",
|
|
944
|
+
categories: [],
|
|
945
|
+
series: [
|
|
946
|
+
{
|
|
947
|
+
name: "빈도",
|
|
948
|
+
values: [1, 2, 2, 3, 3, 3, 4, 4, 5, 8, 9, 10],
|
|
949
|
+
},
|
|
950
|
+
],
|
|
951
|
+
histogram: { bins: 5 },
|
|
952
|
+
});
|
|
953
|
+
const bars = el.querySelectorAll(".mc-bar");
|
|
954
|
+
expect(bars.length).toBe(5);
|
|
955
|
+
destroy();
|
|
956
|
+
});
|
|
957
|
+
it("ohlc는 시가/종가 틱과 고저 심지를 렌더한다", async () => {
|
|
958
|
+
const { el, destroy } = await mountSpecChart(mount, {
|
|
959
|
+
type: "ohlc",
|
|
960
|
+
series: [
|
|
961
|
+
{
|
|
962
|
+
name: "주가",
|
|
963
|
+
ohlc: [
|
|
964
|
+
[10, 14, 8, 13],
|
|
965
|
+
[13, 14, 9, 10],
|
|
966
|
+
[10, 16, 9, 15],
|
|
967
|
+
[15, 17, 11, 12],
|
|
968
|
+
],
|
|
969
|
+
},
|
|
970
|
+
],
|
|
971
|
+
});
|
|
972
|
+
expect(el.querySelectorAll(".mc-ohlc").length).toBe(4);
|
|
973
|
+
expect(el.querySelectorAll(".mc-ohlc-up").length).toBe(2);
|
|
974
|
+
expect(el.querySelectorAll(".mc-ohlc-down").length).toBe(2);
|
|
975
|
+
expect(el.querySelectorAll(".mc-ohlc-stem").length).toBe(4);
|
|
976
|
+
expect(el.querySelectorAll(".mc-ohlc-open").length).toBe(4);
|
|
977
|
+
expect(el.querySelectorAll(".mc-ohlc-close").length).toBe(4);
|
|
978
|
+
destroy();
|
|
979
|
+
});
|
|
980
|
+
it("cone-funnel은 값 비례 폭의 슬라이스를 렌더한다", async () => {
|
|
981
|
+
const { el, destroy } = await mountSpecChart(mount, {
|
|
982
|
+
type: "cone-funnel",
|
|
983
|
+
});
|
|
984
|
+
expect(el.querySelector(".mc-root")?.getAttribute("data-chart-type")).toBe("cone-funnel");
|
|
985
|
+
expect(el.querySelectorAll(".mc-slice").length).toBe(4);
|
|
986
|
+
expect(el.querySelectorAll(".mc-legend-item").length).toBe(4);
|
|
987
|
+
destroy();
|
|
988
|
+
});
|
|
989
|
+
it("linear-gauge는 수평 트랙·값 채움·눈금을 렌더한다", async () => {
|
|
990
|
+
const { el, destroy } = await mountSpecChart(mount, {
|
|
991
|
+
type: "linear-gauge",
|
|
992
|
+
series: [{ name: "CPU", values: [72] }],
|
|
993
|
+
gauge: { min: 0, max: 100, target: 80 },
|
|
994
|
+
});
|
|
995
|
+
expect(el.querySelector(".mc-lg-track")).toBeTruthy();
|
|
996
|
+
expect(el.querySelector(".mc-lg-value")).toBeTruthy();
|
|
997
|
+
expect(el.querySelector(".mc-lg-target")).toBeTruthy();
|
|
998
|
+
expect([...el.querySelectorAll(".mc-lg-tick")].map((x) => x.textContent)).toEqual(["0", "100"]);
|
|
999
|
+
destroy();
|
|
1000
|
+
});
|
|
1001
|
+
it("organization은 노드 박스와 부모-자식 링크를 렌더한다", async () => {
|
|
1002
|
+
const { el, destroy } = await mountSpecChart(mount, {
|
|
1003
|
+
type: "organization",
|
|
1004
|
+
series: [
|
|
1005
|
+
{
|
|
1006
|
+
name: "조직",
|
|
1007
|
+
tree: [
|
|
1008
|
+
{
|
|
1009
|
+
name: "CEO",
|
|
1010
|
+
children: [
|
|
1011
|
+
{ name: "CTO", children: [{ name: "Dev" }] },
|
|
1012
|
+
{ name: "CFO" },
|
|
1013
|
+
],
|
|
1014
|
+
},
|
|
1015
|
+
],
|
|
1016
|
+
},
|
|
1017
|
+
],
|
|
1018
|
+
});
|
|
1019
|
+
expect(el.querySelectorAll(".mc-org-node").length).toBe(4);
|
|
1020
|
+
expect(el.querySelectorAll(".mc-org-link").length).toBe(3);
|
|
1021
|
+
expect(el.querySelector(".mc-axis")).toBeNull();
|
|
1022
|
+
destroy();
|
|
1023
|
+
});
|
|
1024
|
+
it("pictogram은 카테고리 행마다 값 비례 아이콘과 빈 아이콘을 렌더한다", async () => {
|
|
1025
|
+
const { el, destroy } = await mountSpecChart(mount, {
|
|
1026
|
+
type: "pictogram",
|
|
1027
|
+
categories: ["A", "B", "C"],
|
|
1028
|
+
series: [{ name: "인구", values: [30, 20, 10] }],
|
|
1029
|
+
pictogram: { unit: 10 },
|
|
1030
|
+
});
|
|
1031
|
+
// unit=10 → 채움 3+2+1=6, 빈 아이콘은 최대 행(3) 기준 0+1+2=3
|
|
1032
|
+
expect(el.querySelectorAll(".mc-picto-fill").length).toBe(6);
|
|
1033
|
+
expect(el.querySelectorAll(".mc-picto-empty").length).toBe(3);
|
|
1034
|
+
expect(el.querySelectorAll(".mc-picto-label").length).toBe(3);
|
|
1035
|
+
// 값 축이 없다 — 행 라벨이 카테고리 역할
|
|
1036
|
+
expect(el.querySelector(".mc-axis")).toBeNull();
|
|
1037
|
+
destroy();
|
|
1038
|
+
});
|
|
1039
|
+
it("population-pyramid은 첫 시리즈를 좌측으로 미러링한 대칭 막대를 렌더한다", async () => {
|
|
1040
|
+
const { el, destroy } = await mountSpecChart(mount, {
|
|
1041
|
+
type: "population-pyramid",
|
|
1042
|
+
categories: ["0-9", "10-19", "20-29"],
|
|
1043
|
+
series: [
|
|
1044
|
+
{ name: "남", values: [30, 40, 35] },
|
|
1045
|
+
{ name: "여", values: [32, 42, 38] },
|
|
1046
|
+
],
|
|
1047
|
+
});
|
|
1048
|
+
const bars = [...el.querySelectorAll(".mc-bar")];
|
|
1049
|
+
expect(bars.length).toBe(6);
|
|
1050
|
+
const plotX = el.querySelector(".mc-svg").getAttribute("width");
|
|
1051
|
+
expect(plotX).toBeTruthy();
|
|
1052
|
+
// 중앙값 추정 — 좌측 막대의 오른쪽 끝과 우측 막대의 왼쪽 끝이 같은 x
|
|
1053
|
+
const firstSeries = bars.filter((b) => b.getAttribute("data-series-id") === "남");
|
|
1054
|
+
const secondSeries = bars.filter((b) => b.getAttribute("data-series-id") === "여");
|
|
1055
|
+
expect(firstSeries.length).toBe(3);
|
|
1056
|
+
expect(secondSeries.length).toBe(3);
|
|
1057
|
+
// 좌측 막대의 오른쪽 끝 ≈ 우측 막대의 왼쪽 끝 (중앙축)
|
|
1058
|
+
const leftRight = parseFloat(firstSeries[0].getAttribute("x")) +
|
|
1059
|
+
parseFloat(firstSeries[0].getAttribute("width"));
|
|
1060
|
+
const rightLeft = parseFloat(secondSeries[0].getAttribute("x"));
|
|
1061
|
+
expect(Math.abs(leftRight - rightLeft)).toBeLessThan(2);
|
|
1062
|
+
// 좌측 막대는 중앙에서 왼쪽으로 뻗는다
|
|
1063
|
+
expect(parseFloat(firstSeries[0].getAttribute("x"))).toBeLessThan(leftRight - 10);
|
|
1064
|
+
// 값 축 틱은 절대값 — 음수 라벨이 없다
|
|
1065
|
+
const labels = [...el.querySelectorAll(".mc-axis .mc-tick-label")].map((t) => t.textContent);
|
|
1066
|
+
expect(labels.some((t) => t?.startsWith("-"))).toBe(false);
|
|
1067
|
+
destroy();
|
|
1068
|
+
});
|
|
1069
|
+
it("mapPoints/mapLines는 지도 위에 마커·선 레이어를 렌더한다", async () => {
|
|
1070
|
+
const topo = {
|
|
1071
|
+
type: "FeatureCollection",
|
|
1072
|
+
features: [
|
|
1073
|
+
{
|
|
1074
|
+
type: "Feature",
|
|
1075
|
+
id: "410",
|
|
1076
|
+
properties: { name: "Korea" },
|
|
1077
|
+
geometry: {
|
|
1078
|
+
type: "Polygon",
|
|
1079
|
+
coordinates: [
|
|
1080
|
+
[
|
|
1081
|
+
[126, 43],
|
|
1082
|
+
[130, 43],
|
|
1083
|
+
[130, 33],
|
|
1084
|
+
[126, 33],
|
|
1085
|
+
[126, 43],
|
|
1086
|
+
],
|
|
1087
|
+
],
|
|
1088
|
+
},
|
|
1089
|
+
},
|
|
1090
|
+
],
|
|
1091
|
+
};
|
|
1092
|
+
const { el, destroy } = await mountSpecChart(mount, {
|
|
1093
|
+
type: "map",
|
|
1094
|
+
series: [
|
|
1095
|
+
{
|
|
1096
|
+
name: "지표",
|
|
1097
|
+
mapData: [{ code: "410", value: 50 }],
|
|
1098
|
+
mapPoints: [{ lon: 128, lat: 38, label: "서울" }],
|
|
1099
|
+
mapLines: [
|
|
1100
|
+
{
|
|
1101
|
+
from: [126, 43],
|
|
1102
|
+
to: [130, 33],
|
|
1103
|
+
},
|
|
1104
|
+
],
|
|
1105
|
+
},
|
|
1106
|
+
],
|
|
1107
|
+
map: { topology: topo },
|
|
1108
|
+
});
|
|
1109
|
+
expect(el.querySelectorAll(".mc-map-region").length).toBe(1);
|
|
1110
|
+
expect(el.querySelectorAll(".mc-map-marker").length).toBe(1);
|
|
1111
|
+
expect(el.querySelectorAll(".mc-map-line").length).toBe(1);
|
|
1112
|
+
destroy();
|
|
1113
|
+
});
|
|
1114
|
+
it("map 사용자 지도 — 아바타 핀·펄스·클러스터·panZoom", async () => {
|
|
1115
|
+
const topo = {
|
|
1116
|
+
type: "FeatureCollection",
|
|
1117
|
+
features: [
|
|
1118
|
+
{
|
|
1119
|
+
type: "Feature",
|
|
1120
|
+
id: "410",
|
|
1121
|
+
properties: { name: "Korea" },
|
|
1122
|
+
geometry: {
|
|
1123
|
+
type: "Polygon",
|
|
1124
|
+
coordinates: [
|
|
1125
|
+
[
|
|
1126
|
+
[126, 43],
|
|
1127
|
+
[130, 43],
|
|
1128
|
+
[130, 33],
|
|
1129
|
+
[126, 33],
|
|
1130
|
+
[126, 43],
|
|
1131
|
+
],
|
|
1132
|
+
],
|
|
1133
|
+
},
|
|
1134
|
+
},
|
|
1135
|
+
],
|
|
1136
|
+
};
|
|
1137
|
+
const img = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'/%3E";
|
|
1138
|
+
const { el, chart, flush, destroy } = await mountSpecChart(mount, {
|
|
1139
|
+
type: "map",
|
|
1140
|
+
series: [
|
|
1141
|
+
{
|
|
1142
|
+
name: "지표",
|
|
1143
|
+
mapPoints: [
|
|
1144
|
+
{ lon: 127.0, lat: 37.0, label: "A", image: img },
|
|
1145
|
+
{ lon: 127.01, lat: 37.01, label: "B", image: img },
|
|
1146
|
+
{ lon: 129.5, lat: 42.5, label: "C", pulse: true },
|
|
1147
|
+
{ lon: 126.5, lat: 33.5, label: "D" },
|
|
1148
|
+
],
|
|
1149
|
+
},
|
|
1150
|
+
],
|
|
1151
|
+
map: { topology: topo, cluster: true, panZoom: true },
|
|
1152
|
+
});
|
|
1153
|
+
// 가까운 A,B는 클러스터 버블로, 먼 C,D는 개별 마커로 렌더된다
|
|
1154
|
+
expect(el.querySelectorAll(".mc-map-cluster").length).toBe(1);
|
|
1155
|
+
expect(el.querySelector(".mc-map-cluster-count")?.textContent).toBe("2");
|
|
1156
|
+
expect(el.querySelectorAll(".mc-map-marker").length).toBe(2);
|
|
1157
|
+
// C의 펄스 링
|
|
1158
|
+
expect(el.querySelector(".mc-map-pulse")).toBeTruthy();
|
|
1159
|
+
// 줌 — .mc-map-geo에 transform이 생기고 resetZoom으로 해제된다
|
|
1160
|
+
expect(el.querySelector(".mc-map-geo")?.getAttribute("transform")).toBeNull();
|
|
1161
|
+
chart.mapZoomAt(100, 2, 50);
|
|
1162
|
+
await flush();
|
|
1163
|
+
expect(el.querySelector(".mc-map-geo")?.getAttribute("transform")).toMatch(/^translate/);
|
|
1164
|
+
chart.resetZoom();
|
|
1165
|
+
await flush();
|
|
1166
|
+
expect(el.querySelector(".mc-map-geo")?.getAttribute("transform")).toBeNull();
|
|
1167
|
+
destroy();
|
|
1168
|
+
});
|
|
1169
|
+
it("map 아바타 핀 — image 마커는 원형 클립 이미지로 렌더한다", async () => {
|
|
1170
|
+
const topo = {
|
|
1171
|
+
type: "FeatureCollection",
|
|
1172
|
+
features: [
|
|
1173
|
+
{
|
|
1174
|
+
type: "Feature",
|
|
1175
|
+
id: "410",
|
|
1176
|
+
properties: { name: "Korea" },
|
|
1177
|
+
geometry: {
|
|
1178
|
+
type: "Polygon",
|
|
1179
|
+
coordinates: [
|
|
1180
|
+
[
|
|
1181
|
+
[126, 43],
|
|
1182
|
+
[130, 43],
|
|
1183
|
+
[130, 33],
|
|
1184
|
+
[126, 33],
|
|
1185
|
+
[126, 43],
|
|
1186
|
+
],
|
|
1187
|
+
],
|
|
1188
|
+
},
|
|
1189
|
+
},
|
|
1190
|
+
],
|
|
1191
|
+
};
|
|
1192
|
+
const { el, destroy } = await mountSpecChart(mount, {
|
|
1193
|
+
type: "map",
|
|
1194
|
+
series: [
|
|
1195
|
+
{
|
|
1196
|
+
name: "지표",
|
|
1197
|
+
mapPoints: [
|
|
1198
|
+
{
|
|
1199
|
+
lon: 128,
|
|
1200
|
+
lat: 38,
|
|
1201
|
+
label: "서울",
|
|
1202
|
+
image: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'/%3E",
|
|
1203
|
+
},
|
|
1204
|
+
],
|
|
1205
|
+
},
|
|
1206
|
+
],
|
|
1207
|
+
map: { topology: topo },
|
|
1208
|
+
});
|
|
1209
|
+
const avatar = el.querySelector(".mc-map-avatar");
|
|
1210
|
+
expect(avatar).toBeTruthy();
|
|
1211
|
+
expect(avatar.querySelector("image")).toBeTruthy();
|
|
1212
|
+
expect(avatar.querySelector(".mc-map-avatar-ring")).toBeTruthy();
|
|
1213
|
+
expect(avatar.querySelector("clipPath circle")).toBeTruthy();
|
|
1214
|
+
destroy();
|
|
1215
|
+
});
|
|
1216
|
+
it("points[].color와 itemStyle이 포인트별 색을 오버라이드한다", async () => {
|
|
1217
|
+
const { el, destroy } = await mountSpecChart(mount, {
|
|
1218
|
+
type: "bar",
|
|
1219
|
+
series: [
|
|
1220
|
+
{
|
|
1221
|
+
name: "매출",
|
|
1222
|
+
points: [
|
|
1223
|
+
{ x: 0, y: 10, color: "#ff0000" },
|
|
1224
|
+
{ x: 1, y: 25 },
|
|
1225
|
+
{ x: 2, y: 15 },
|
|
1226
|
+
{ x: 3, y: 30 },
|
|
1227
|
+
],
|
|
1228
|
+
itemStyle: (it) => (it.index === 2 ? { color: "#00ff00" } : null),
|
|
1229
|
+
},
|
|
1230
|
+
],
|
|
1231
|
+
});
|
|
1232
|
+
const bars = [...el.querySelectorAll(".mc-bar")];
|
|
1233
|
+
expect(bars[0]?.getAttribute("style")).toContain("fill:#ff0000");
|
|
1234
|
+
expect(bars[2]?.getAttribute("style")).toContain("fill:#00ff00");
|
|
1235
|
+
destroy();
|
|
1236
|
+
});
|
|
1237
|
+
it("legend.checkboxes는 체크박스를 렌더하고 legendCheck를 발행한다", async () => {
|
|
1238
|
+
const { el, chart, flush, destroy } = await mountSpecChart(mount, {
|
|
1239
|
+
legend: { checkboxes: true },
|
|
1240
|
+
});
|
|
1241
|
+
const checks = el.querySelectorAll(".mc-legend-check");
|
|
1242
|
+
expect(checks.length).toBe(2);
|
|
1243
|
+
expect(checks[0]?.checked).toBe(true);
|
|
1244
|
+
let evt = null;
|
|
1245
|
+
chart.on("legendCheck", (e) => {
|
|
1246
|
+
evt = e;
|
|
1247
|
+
});
|
|
1248
|
+
checks[0].dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
1249
|
+
await flush();
|
|
1250
|
+
expect(evt).toEqual({ id: "매출", checked: false });
|
|
1251
|
+
expect(el.querySelectorAll(".mc-series").length).toBe(1);
|
|
1252
|
+
destroy();
|
|
1253
|
+
});
|
|
1254
|
+
it("contextMenu는 우클릭에 .mc-menu를 열고 menuAction을 발행한다", async () => {
|
|
1255
|
+
const { el, chart, flush, destroy } = await mountSpecChart(mount, {
|
|
1256
|
+
contextMenu: { items: [{ id: "custom-act", label: "내 액션" }] },
|
|
1257
|
+
});
|
|
1258
|
+
let act = null;
|
|
1259
|
+
chart.on("menuAction", (e) => {
|
|
1260
|
+
act = e;
|
|
1261
|
+
});
|
|
1262
|
+
// jsdom은 getBoundingClientRect가 0이라 posOf가 null — 코어 API로 연다
|
|
1263
|
+
chart.openMenu(50, 50);
|
|
1264
|
+
await flush();
|
|
1265
|
+
const menu = el.querySelector(".mc-menu");
|
|
1266
|
+
expect(menu.hidden).toBe(false);
|
|
1267
|
+
const items = el.querySelectorAll(".mc-menu-item");
|
|
1268
|
+
expect(items.length).toBe(4); // reset + svg + png + custom
|
|
1269
|
+
const custom = el.querySelector('.mc-menu-item[data-id="custom-act"]');
|
|
1270
|
+
custom.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
1271
|
+
await flush();
|
|
1272
|
+
expect(act).toBeTruthy();
|
|
1273
|
+
expect(act.id).toBe("custom-act");
|
|
1274
|
+
expect(menu.hidden).toBe(true);
|
|
1275
|
+
destroy();
|
|
1276
|
+
});
|
|
1277
|
+
it("tooltip.render는 HTML 콜백 결과를 툴팁에 렌더한다", async () => {
|
|
1278
|
+
const { el, chart, flush, destroy } = await mountSpecChart(mount, {
|
|
1279
|
+
tooltip: {
|
|
1280
|
+
render: (ctx) => `<b class="my-tip">${ctx.title}</b>`,
|
|
1281
|
+
},
|
|
1282
|
+
});
|
|
1283
|
+
const plot = chart.getSnapshot().plot;
|
|
1284
|
+
chart.hoverAt(plot.w / 2, plot.h / 2);
|
|
1285
|
+
await flush();
|
|
1286
|
+
const tip = el.querySelector(".mc-tooltip");
|
|
1287
|
+
expect(tip.hidden).toBe(false);
|
|
1288
|
+
expect(tip.querySelector(".my-tip")?.textContent).toBeTruthy();
|
|
1289
|
+
destroy();
|
|
1290
|
+
});
|
|
1291
|
+
it("scrollbar는 네비게이터 없이 얇은 줌 스트립을 렌더한다", async () => {
|
|
1292
|
+
const { el, destroy } = await mountSpecChart(mount, {
|
|
1293
|
+
scrollbar: true,
|
|
1294
|
+
navigator: false,
|
|
1295
|
+
});
|
|
1296
|
+
expect(el.querySelector(".mc-scrollbar")).toBeTruthy();
|
|
1297
|
+
expect(el.querySelector(".mc-sb-window")).toBeTruthy();
|
|
1298
|
+
expect(el.querySelector(".mc-sb-handle-l")).toBeTruthy();
|
|
1299
|
+
expect(el.querySelector(".mc-sb-handle-r")).toBeTruthy();
|
|
1300
|
+
destroy();
|
|
1301
|
+
});
|
|
1302
|
+
it("그룹 카테고리는 두 번째 축 틱 행을 렌더한다", async () => {
|
|
1303
|
+
const { el, destroy } = await mountSpecChart(mount, {
|
|
1304
|
+
categories: [
|
|
1305
|
+
["Q1", "1월"],
|
|
1306
|
+
["Q1", "2월"],
|
|
1307
|
+
["Q2", "3월"],
|
|
1308
|
+
["Q2", "4월"],
|
|
1309
|
+
],
|
|
1310
|
+
});
|
|
1311
|
+
const groups = el.querySelectorAll(".mc-tick-group-label");
|
|
1312
|
+
expect(groups.length).toBe(2);
|
|
1313
|
+
expect(groups[0]?.textContent).toBe("Q1");
|
|
1314
|
+
expect(groups[1]?.textContent).toBe("Q2");
|
|
1315
|
+
destroy();
|
|
1316
|
+
});
|
|
1317
|
+
it("drillUpAll은 한 번에 최상위로 복귀한다", async () => {
|
|
1318
|
+
const { el, chart, flush, destroy } = await mountSpecChart(mount, {
|
|
1319
|
+
// drilldown 맵은 라벨 키로 조회 — 하위 레벨 키도 같은 맵에 둔다
|
|
1320
|
+
drilldown: {
|
|
1321
|
+
"1월": {
|
|
1322
|
+
series: [{ name: "1월 주별", values: [3, 4, 3] }],
|
|
1323
|
+
categories: ["1주", "2주", "3주"],
|
|
1324
|
+
},
|
|
1325
|
+
"1주": {
|
|
1326
|
+
series: [{ name: "1주 일별", values: [1, 2] }],
|
|
1327
|
+
categories: ["월", "화"],
|
|
1328
|
+
},
|
|
1329
|
+
},
|
|
1330
|
+
});
|
|
1331
|
+
chart.drillDown("1월");
|
|
1332
|
+
chart.drillDown("1주");
|
|
1333
|
+
await flush();
|
|
1334
|
+
expect(chart.getSnapshot().drillDepth).toBe(2);
|
|
1335
|
+
chart.drillUpAll();
|
|
1336
|
+
await flush();
|
|
1337
|
+
expect(chart.getSnapshot().drillDepth).toBe(0);
|
|
1338
|
+
expect(el.querySelector(".mc-legend-item")?.textContent).toContain("매출");
|
|
1339
|
+
destroy();
|
|
1340
|
+
});
|
|
1341
|
+
it("getState/setState는 줌·숨김 상태를 왕복한다", async () => {
|
|
1342
|
+
const { el, chart, flush, destroy } = await mountSpecChart(mount);
|
|
1343
|
+
chart.toggleLegend("매출");
|
|
1344
|
+
chart.zoomAt(chart.getSnapshot().plot.w / 2, 2);
|
|
1345
|
+
await flush();
|
|
1346
|
+
const state = chart.getState();
|
|
1347
|
+
// 초기화
|
|
1348
|
+
chart.setState({
|
|
1349
|
+
type: chart.getSnapshot().type,
|
|
1350
|
+
theme: "light",
|
|
1351
|
+
hidden: [],
|
|
1352
|
+
window: null,
|
|
1353
|
+
});
|
|
1354
|
+
await flush();
|
|
1355
|
+
expect(el.querySelectorAll(".mc-series").length).toBe(2);
|
|
1356
|
+
// 복원
|
|
1357
|
+
chart.setState(state);
|
|
1358
|
+
await flush();
|
|
1359
|
+
expect(el.querySelectorAll(".mc-series").length).toBe(1);
|
|
1360
|
+
destroy();
|
|
1361
|
+
});
|
|
1362
|
+
it("sync 그룹은 줌을 형제 차트에 전파한다", async () => {
|
|
1363
|
+
const a = await mountSpecChart(mount, { sync: "grp1" });
|
|
1364
|
+
const b = await mountSpecChart(mount, { sync: "grp1" });
|
|
1365
|
+
a.chart.zoomAt(a.chart.getSnapshot().plot.w / 2, 3);
|
|
1366
|
+
await a.flush();
|
|
1367
|
+
await b.flush();
|
|
1368
|
+
// b도 같은 줌 창을 갖는다 — 축 틱 수가 같아진다
|
|
1369
|
+
const ticksOf = (el) => el.querySelectorAll(".mc-axis-x .mc-tick-label").length;
|
|
1370
|
+
expect(ticksOf(b.el)).toBe(ticksOf(a.el));
|
|
1371
|
+
expect(ticksOf(b.el)).toBeLessThan(4);
|
|
1372
|
+
a.destroy();
|
|
1373
|
+
b.destroy();
|
|
1374
|
+
});
|
|
1375
|
+
it("batch는 여러 변경을 한 번의 notify로 묶는다", async () => {
|
|
1376
|
+
const { el, chart, flush, destroy } = await mountSpecChart(mount);
|
|
1377
|
+
let notified = 0;
|
|
1378
|
+
chart.subscribe(() => notified++);
|
|
1379
|
+
chart.batch(() => {
|
|
1380
|
+
chart.addPoint("매출", 33);
|
|
1381
|
+
chart.addPoint("비용", 20);
|
|
1382
|
+
chart.setCategories(["1월", "2월", "3월", "4월", "5월"]);
|
|
1383
|
+
});
|
|
1384
|
+
await flush();
|
|
1385
|
+
expect(notified).toBe(1);
|
|
1386
|
+
expect(el.querySelectorAll(".mc-point").length).toBe(10);
|
|
1387
|
+
destroy();
|
|
1388
|
+
});
|
|
1389
|
+
it("주석 그리기 — 드로잉 드래그로 .mc-draw-line을 만들고 제거한다", async () => {
|
|
1390
|
+
const { el, chart, flush, destroy } = await mountSpecChart(mount, {
|
|
1391
|
+
drawing: true,
|
|
1392
|
+
});
|
|
1393
|
+
let drew = null;
|
|
1394
|
+
chart.on("drawEnd", (e) => (drew = e.drawing));
|
|
1395
|
+
chart.setDrawMode("line");
|
|
1396
|
+
// 제스처 경로 — px를 코어가 데이터 좌표로 변환한다
|
|
1397
|
+
chart.drawStartAt(20, 20);
|
|
1398
|
+
chart.drawMoveTo(160, 90);
|
|
1399
|
+
await flush();
|
|
1400
|
+
expect(el.querySelector(".mc-draw-draft")).not.toBeNull();
|
|
1401
|
+
chart.drawEndAt();
|
|
1402
|
+
await flush();
|
|
1403
|
+
expect(drew).not.toBeNull();
|
|
1404
|
+
expect(el.querySelector(".mc-draw-line")).not.toBeNull();
|
|
1405
|
+
expect(el.querySelector(".mc-drawings")).not.toBeNull();
|
|
1406
|
+
// rect 도구 + API 추가
|
|
1407
|
+
chart.addDrawing({ tool: "rect", x1: 0.5, y1: 10, x2: 2.5, y2: 25 });
|
|
1408
|
+
await flush();
|
|
1409
|
+
expect(el.querySelector(".mc-draw-rect")).not.toBeNull();
|
|
1410
|
+
chart.clearDrawings();
|
|
1411
|
+
await flush();
|
|
1412
|
+
expect(el.querySelector(".mc-drawings")).toBeNull();
|
|
1413
|
+
destroy();
|
|
1414
|
+
});
|
|
1415
|
+
it("getState/setState는 그린 주석을 왕복한다", async () => {
|
|
1416
|
+
const { el, chart, flush, destroy } = await mountSpecChart(mount, {
|
|
1417
|
+
drawing: true,
|
|
1418
|
+
});
|
|
1419
|
+
chart.addDrawing({ tool: "hline", x1: 0, y1: 20 });
|
|
1420
|
+
await flush();
|
|
1421
|
+
const state = chart.getState();
|
|
1422
|
+
chart.clearDrawings();
|
|
1423
|
+
await flush();
|
|
1424
|
+
expect(el.querySelector(".mc-draw-hline")).toBeNull();
|
|
1425
|
+
chart.setState(state);
|
|
1426
|
+
await flush();
|
|
1427
|
+
expect(el.querySelector(".mc-draw-hline")).not.toBeNull();
|
|
1428
|
+
destroy();
|
|
1429
|
+
});
|
|
1430
|
+
it("legend.pagination은 항목을 나누고 네비 버튼으로 넘긴다", async () => {
|
|
1431
|
+
const { el, chart, flush, destroy } = await mountSpecChart(mount, {
|
|
1432
|
+
type: "pie",
|
|
1433
|
+
series: [
|
|
1434
|
+
{
|
|
1435
|
+
name: "점유율",
|
|
1436
|
+
values: [10, 9, 8, 7, 6, 5, 4, 3, 2, 1],
|
|
1437
|
+
},
|
|
1438
|
+
],
|
|
1439
|
+
categories: ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"],
|
|
1440
|
+
legend: { position: "bottom", pagination: 4 },
|
|
1441
|
+
});
|
|
1442
|
+
// 10개 항목 × 페이지 4 — 3페이지, 첫 페이지는 4개만.
|
|
1443
|
+
// nav 버튼은 innerHTML로 교체되므로 클릭 후 재조회한다
|
|
1444
|
+
const nav = () => ({
|
|
1445
|
+
next: el.querySelector(".mc-legend-next"),
|
|
1446
|
+
prev: el.querySelector(".mc-legend-prev"),
|
|
1447
|
+
});
|
|
1448
|
+
expect(el.querySelectorAll(".mc-legend-item").length).toBe(4);
|
|
1449
|
+
expect(nav().next).not.toBeNull();
|
|
1450
|
+
expect(nav().prev.disabled).toBe(true);
|
|
1451
|
+
nav().next.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
1452
|
+
await flush();
|
|
1453
|
+
expect(el.querySelectorAll(".mc-legend-item").length).toBe(4);
|
|
1454
|
+
expect(nav().prev.disabled).toBe(false);
|
|
1455
|
+
nav().next.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
1456
|
+
await flush();
|
|
1457
|
+
// 마지막 페이지 — 나머지 2개
|
|
1458
|
+
expect(el.querySelectorAll(".mc-legend-item").length).toBe(2);
|
|
1459
|
+
expect(nav().next.disabled).toBe(true);
|
|
1460
|
+
nav().prev.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
1461
|
+
await flush();
|
|
1462
|
+
expect(el.querySelectorAll(".mc-legend-item").length).toBe(4);
|
|
1463
|
+
destroy();
|
|
1464
|
+
});
|
|
1465
|
+
it("search.load는 외부 데이터를 가져와 setData에 주입한다", async () => {
|
|
1466
|
+
const { el, chart, flush, destroy } = await mountSpecChart(mount, {
|
|
1467
|
+
search: {
|
|
1468
|
+
url: "/data.json",
|
|
1469
|
+
fetcher: async () => ({
|
|
1470
|
+
series: [{ name: "원격", values: [3, 6, 9] }],
|
|
1471
|
+
categories: ["A", "B", "C"],
|
|
1472
|
+
}),
|
|
1473
|
+
},
|
|
1474
|
+
});
|
|
1475
|
+
let ended = 0;
|
|
1476
|
+
chart.on("searchEnd", (e) => (ended = e.seriesCount));
|
|
1477
|
+
const ok = await chart.search.load();
|
|
1478
|
+
await flush();
|
|
1479
|
+
expect(ok).toBe(true);
|
|
1480
|
+
expect(ended).toBe(1);
|
|
1481
|
+
expect(el.querySelectorAll(".mc-point").length).toBe(3);
|
|
1482
|
+
// 실패 경로 — searchError 발행 후 false 반환
|
|
1483
|
+
let failed = "";
|
|
1484
|
+
chart.on("searchError", (e) => (failed = e.message));
|
|
1485
|
+
const bad = await chart.search.load({
|
|
1486
|
+
url: "/err",
|
|
1487
|
+
transform: () => {
|
|
1488
|
+
throw new Error("bad payload");
|
|
1489
|
+
},
|
|
1490
|
+
});
|
|
1491
|
+
expect(bad).toBe(false);
|
|
1492
|
+
expect(failed).toBe("bad payload");
|
|
1493
|
+
destroy();
|
|
1494
|
+
});
|
|
1495
|
+
it("고급 그리기 도구 — channel/fibonacci/measure를 렌더한다", async () => {
|
|
1496
|
+
const { el, chart, flush, destroy } = await mountSpecChart(mount, {
|
|
1497
|
+
drawing: true,
|
|
1498
|
+
});
|
|
1499
|
+
// 채널 — 추세선 + 평행선 + 음영 밴드
|
|
1500
|
+
chart.addDrawing({ tool: "channel", x1: 0, y1: 5, x2: 4, y2: 20 });
|
|
1501
|
+
// 피보나치 — 되돌림 레벨 7개
|
|
1502
|
+
chart.addDrawing({ tool: "fibonacci", x1: 0, y1: 0, x2: 4, y2: 30 });
|
|
1503
|
+
// 측정 — Δx/Δy 자동 라벨
|
|
1504
|
+
chart.addDrawing({ tool: "measure", x1: 1, y1: 5, x2: 3, y2: 15 });
|
|
1505
|
+
await flush();
|
|
1506
|
+
const channel = el.querySelector(".mc-draw-channel");
|
|
1507
|
+
expect(channel).not.toBeNull();
|
|
1508
|
+
expect(channel.querySelector(".mc-draw-band")).not.toBeNull();
|
|
1509
|
+
expect(channel.querySelectorAll(".mc-draw-seg").length).toBe(2);
|
|
1510
|
+
const fib = el.querySelector(".mc-draw-fibonacci");
|
|
1511
|
+
expect(fib).not.toBeNull();
|
|
1512
|
+
expect(fib.querySelectorAll(".mc-draw-fib-level").length).toBe(7);
|
|
1513
|
+
const measure = el.querySelector(".mc-draw-measure");
|
|
1514
|
+
expect(measure).not.toBeNull();
|
|
1515
|
+
expect(measure.querySelector(".mc-draw-label")?.textContent).toContain("Δx");
|
|
1516
|
+
destroy();
|
|
1517
|
+
});
|
|
1518
|
+
it("updateDelta는 부분 옵션/데이터를 한 번에 병합한다", async () => {
|
|
1519
|
+
const { el, chart, flush, destroy } = await mountSpecChart(mount);
|
|
1520
|
+
let notified = 0;
|
|
1521
|
+
chart.subscribe(() => notified++);
|
|
1522
|
+
chart.updateDelta({
|
|
1523
|
+
series: [
|
|
1524
|
+
{ name: "델타", values: [9, 8, 7, 6] },
|
|
1525
|
+
{ name: "비용", values: [4, 3, 2, 1] },
|
|
1526
|
+
],
|
|
1527
|
+
legend: { position: "top" },
|
|
1528
|
+
});
|
|
1529
|
+
await flush();
|
|
1530
|
+
expect(notified).toBe(1);
|
|
1531
|
+
const labels = [...el.querySelectorAll(".mc-legend-label")].map((x) => x.textContent);
|
|
1532
|
+
expect(labels).toContain("델타");
|
|
1533
|
+
destroy();
|
|
1534
|
+
});
|
|
1535
|
+
it("데이터 갱신 후 첫 스냅샷에 .mc-updating이 붙는다", async () => {
|
|
1536
|
+
// 스펙 기본값 animation:false — 갱신 전환 계약은 켜서 검증한다
|
|
1537
|
+
const { el, chart, flush, destroy } = await mountSpecChart(mount, {
|
|
1538
|
+
animation: true,
|
|
1539
|
+
});
|
|
1540
|
+
expect(el.querySelector(".mc-root.mc-updating")).toBeNull();
|
|
1541
|
+
chart.addPoint("매출", 30);
|
|
1542
|
+
await flush();
|
|
1543
|
+
expect(el.querySelector(".mc-root.mc-updating")).not.toBeNull();
|
|
1544
|
+
// 갱신이 아닌 무효화(호버) 후엔 해제된다
|
|
1545
|
+
chart.hoverAt(30, 30);
|
|
1546
|
+
await flush();
|
|
1547
|
+
expect(el.querySelector(".mc-root.mc-updating")).toBeNull();
|
|
1548
|
+
destroy();
|
|
1549
|
+
});
|
|
1550
|
+
it("etcData가 툴팁 렌더 컨텍스트와 pointSelect에 전달된다", async () => {
|
|
1551
|
+
const { el, chart, flush, destroy } = await mountSpecChart(mount, {
|
|
1552
|
+
series: [
|
|
1553
|
+
{ name: "매출", values: [10, 20, 30, 40], etcData: { unit: "KRW" } },
|
|
1554
|
+
],
|
|
1555
|
+
categories: ["a", "b", "c", "d"],
|
|
1556
|
+
allowPointSelect: true,
|
|
1557
|
+
tooltip: { render: (c) => `<b>${JSON.stringify(c.rows[0].etcData)}</b>` },
|
|
1558
|
+
});
|
|
1559
|
+
expect(chart.getEtcData("매출")).toEqual({ unit: "KRW" });
|
|
1560
|
+
// 호버 툴팁 — render 콜백의 row.etcData
|
|
1561
|
+
chart.hoverAt(30, 30);
|
|
1562
|
+
await flush();
|
|
1563
|
+
expect(el.querySelector(".mc-tooltip")?.textContent).toContain("KRW");
|
|
1564
|
+
// 선택 이벤트 — etcData 필드
|
|
1565
|
+
let got = undefined;
|
|
1566
|
+
chart.on("pointSelect", (e) => (got = e.etcData));
|
|
1567
|
+
chart.selectPoint("매출", 0);
|
|
1568
|
+
expect(got).toEqual({ unit: "KRW" });
|
|
1569
|
+
destroy();
|
|
1570
|
+
});
|
|
1571
|
+
it("loadXml은 XML 데이터를 해석해 차트를 갱신한다", async () => {
|
|
1572
|
+
const { el, chart, flush, destroy } = await mountSpecChart(mount);
|
|
1573
|
+
const ok = chart.loadXml(`
|
|
1574
|
+
<chartData>
|
|
1575
|
+
<categories><category>X</category><category>Y</category></categories>
|
|
1576
|
+
<series name="xml"><value>7</value><value>9</value></series>
|
|
1577
|
+
</chartData>
|
|
1578
|
+
`);
|
|
1579
|
+
await flush();
|
|
1580
|
+
expect(ok).toBe(true);
|
|
1581
|
+
expect(el.querySelectorAll(".mc-point").length).toBe(2);
|
|
1582
|
+
const labels = [...el.querySelectorAll(".mc-legend-label")].map((x) => x.textContent);
|
|
1583
|
+
expect(labels).toContain("xml");
|
|
1584
|
+
expect(chart.loadXml("<nope/>")).toBe(false);
|
|
1585
|
+
destroy();
|
|
1586
|
+
});
|
|
1587
|
+
it("데이터 갱신 시 같은 키의 지오메트리에 .mc-morph가 붙는다", async () => {
|
|
1588
|
+
// morph는 animation.updates 경로 — 스펙 기본값 animation:false를 덮는다
|
|
1589
|
+
const { el, chart, flush, destroy } = await mountSpecChart(mount, {
|
|
1590
|
+
type: "bar",
|
|
1591
|
+
animation: true,
|
|
1592
|
+
});
|
|
1593
|
+
chart.updatePoint("매출", 0, 35);
|
|
1594
|
+
await flush();
|
|
1595
|
+
// 값이 바뀐 막대에 morph 마커가 붙는다
|
|
1596
|
+
const morphed = el.querySelectorAll(".mc-bar.mc-morph");
|
|
1597
|
+
expect(morphed.length).toBeGreaterThan(0);
|
|
1598
|
+
expect(morphed[0].getAttribute("data-index")).toBe("0");
|
|
1599
|
+
destroy();
|
|
1600
|
+
});
|
|
1601
|
+
it("destroy 후 루트를 제거한다", async () => {
|
|
1602
|
+
const { el, destroy } = await mountSpecChart(mount);
|
|
1603
|
+
destroy();
|
|
1604
|
+
expect(el.querySelector(".mc-root")).toBeNull();
|
|
1605
|
+
});
|
|
1606
|
+
});
|
|
1607
|
+
}
|
|
1608
|
+
//# sourceMappingURL=conformance.js.map
|