@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.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +25 -0
  3. package/dist/conformance.d.ts +40 -0
  4. package/dist/conformance.d.ts.map +1 -0
  5. package/dist/conformance.js +1608 -0
  6. package/dist/conformance.js.map +1 -0
  7. package/dist/core.d.ts +448 -0
  8. package/dist/core.d.ts.map +1 -0
  9. package/dist/core.js +6026 -0
  10. package/dist/core.js.map +1 -0
  11. package/dist/geo.d.ts +85 -0
  12. package/dist/geo.d.ts.map +1 -0
  13. package/dist/geo.js +264 -0
  14. package/dist/geo.js.map +1 -0
  15. package/dist/geometry.d.ts +29 -0
  16. package/dist/geometry.d.ts.map +1 -0
  17. package/dist/geometry.js +92 -0
  18. package/dist/geometry.js.map +1 -0
  19. package/dist/hierarchy.d.ts +142 -0
  20. package/dist/hierarchy.d.ts.map +1 -0
  21. package/dist/hierarchy.js +422 -0
  22. package/dist/hierarchy.js.map +1 -0
  23. package/dist/index.d.ts +18 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +12 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/maps/world.d.ts +9 -0
  28. package/dist/maps/world.d.ts.map +1 -0
  29. package/dist/maps/world.js +9 -0
  30. package/dist/maps/world.js.map +1 -0
  31. package/dist/mount.d.ts +75 -0
  32. package/dist/mount.d.ts.map +1 -0
  33. package/dist/mount.js +795 -0
  34. package/dist/mount.js.map +1 -0
  35. package/dist/scales.d.ts +22 -0
  36. package/dist/scales.d.ts.map +1 -0
  37. package/dist/scales.js +118 -0
  38. package/dist/scales.js.map +1 -0
  39. package/dist/sparkline.d.ts +4 -0
  40. package/dist/sparkline.d.ts.map +1 -0
  41. package/dist/sparkline.js +62 -0
  42. package/dist/sparkline.js.map +1 -0
  43. package/dist/styles.css +989 -0
  44. package/dist/svg.d.ts +21 -0
  45. package/dist/svg.d.ts.map +1 -0
  46. package/dist/svg.js +1022 -0
  47. package/dist/svg.js.map +1 -0
  48. package/dist/text.d.ts +23 -0
  49. package/dist/text.d.ts.map +1 -0
  50. package/dist/text.js +67 -0
  51. package/dist/text.js.map +1 -0
  52. package/dist/types.d.ts +1881 -0
  53. package/dist/types.d.ts.map +1 -0
  54. package/dist/types.js +8 -0
  55. package/dist/types.js.map +1 -0
  56. package/dist/xml.d.ts +13 -0
  57. package/dist/xml.d.ts.map +1 -0
  58. package/dist/xml.js +109 -0
  59. package/dist/xml.js.map +1 -0
  60. package/package.json +44 -0
package/dist/mount.js ADDED
@@ -0,0 +1,795 @@
1
+ import { ChartCore } from "./core.js";
2
+ import { escapeXml, seriesColor, svgInner } from "./svg.js";
3
+ import { sparklineMarkup } from "./sparkline.js";
4
+ function legendItemHtml(id, label, color, colorIndex, visible, check) {
5
+ const c = seriesColor(color, colorIndex, { export: false });
6
+ return (`<button type="button" class="mc-legend-item${visible ? "" : " mc-hidden"}" ` +
7
+ `data-id="${escapeXml(id)}" aria-pressed="${visible}">` +
8
+ (check
9
+ ? `<input type="checkbox" class="mc-legend-check"${visible ? " checked" : ""} tabindex="-1" aria-hidden="true">`
10
+ : "") +
11
+ `<span class="mc-legend-swatch" style="background:${escapeXml(c)}"></span>` +
12
+ `<span class="mc-legend-label">${escapeXml(label)}</span></button>`);
13
+ }
14
+ function tooltipHtml(snap) {
15
+ const t = snap.tooltip;
16
+ if (!t)
17
+ return "";
18
+ // tooltip.render의 커스텀 HTML — 신뢰된 마크업이라 escape하지 않는다
19
+ if (t.html != null)
20
+ return t.html;
21
+ const rows = t.rows
22
+ .map((r) => {
23
+ const c = seriesColor(r.color, r.colorIndex, { export: false });
24
+ return (`<div class="mc-tooltip-row">` +
25
+ `<span class="mc-tooltip-swatch" style="background:${escapeXml(c)}"></span>` +
26
+ `<span class="mc-tooltip-name">${escapeXml(r.name)}</span>` +
27
+ `<span class="mc-tooltip-value">${escapeXml(r.value)}</span></div>`);
28
+ })
29
+ .join("");
30
+ return `<div class="mc-tooltip-title">${escapeXml(t.title)}</div>${rows}`;
31
+ }
32
+ /** 렌더러가 공유하는 정적 뼈대 마크업 — 어댑터도 같은 구조를 만든다 */
33
+ export function skeletonHtml() {
34
+ return (`<div class="mc-root">` +
35
+ `<div class="mc-title" hidden></div>` +
36
+ `<div class="mc-subtitle" hidden></div>` +
37
+ `<div class="mc-toolbar">` +
38
+ `<button type="button" class="mc-drillup" hidden>‹ Back</button>` +
39
+ `<button type="button" class="mc-zoom-reset">Reset</button>` +
40
+ `<button type="button" class="mc-export-svg">SVG</button>` +
41
+ `<button type="button" class="mc-export-png">PNG</button>` +
42
+ `</div>` +
43
+ `<div class="mc-viewport"><svg class="mc-svg" role="img"></svg>` +
44
+ `<div class="mc-overlay"></div>` +
45
+ `<div class="mc-tooltip" role="tooltip" hidden></div>` +
46
+ `<div class="mc-menu" role="menu" hidden></div>` +
47
+ `</div>` +
48
+ `<div class="mc-legend"></div>` +
49
+ `<div class="mc-loading" hidden><div class="mc-spinner"></div></div>` +
50
+ `<div class="mc-a11y" aria-live="polite"></div>` +
51
+ `</div>`);
52
+ }
53
+ const MORPH_SELECTOR = ".mc-bar, .mc-point";
54
+ function morphKey(el) {
55
+ return `${el.tagName.toLowerCase()}:${el.getAttribute("data-series-id")}:${el.getAttribute("data-index")}`;
56
+ }
57
+ function morphBox(el) {
58
+ const n = (a) => Number(el.getAttribute(a) ?? 0);
59
+ const tag = el.tagName.toLowerCase();
60
+ if (tag === "rect")
61
+ return { x: n("x"), y: n("y"), w: n("width"), h: n("height") };
62
+ if (tag === "circle") {
63
+ const r = n("r");
64
+ return { x: n("cx") - r, y: n("cy") - r, w: 2 * r, h: 2 * r };
65
+ }
66
+ return null;
67
+ }
68
+ /**
69
+ * 재렌더 전 현재 .mc-bar/.mc-point의 박스를 키로 수집한다.
70
+ * innerHTML 교체 직전에 호출한다.
71
+ */
72
+ export function captureMorph(svg) {
73
+ const map = new Map();
74
+ for (const el of svg.querySelectorAll(MORPH_SELECTOR)) {
75
+ const box = morphBox(el);
76
+ if (box)
77
+ map.set(morphKey(el), box);
78
+ }
79
+ return map;
80
+ }
81
+ /**
82
+ * 새 지오메트리에 이전 박스로 되돌리는 transform을 입힌다 —
83
+ * transition이 없는 환경에서는 transform이 즉시 해제된다.
84
+ * morph된 요소에는 .mc-morph가 붙고 transition 종료 후 떨어진다.
85
+ * 반환값은 morph가 적용된 요소 수.
86
+ */
87
+ export function applyMorph(svg, prev, duration = 300) {
88
+ if (!prev.size)
89
+ return 0;
90
+ let applied = 0;
91
+ for (const el of svg.querySelectorAll(MORPH_SELECTOR)) {
92
+ const old = prev.get(morphKey(el));
93
+ const cur = morphBox(el);
94
+ if (!old || !cur)
95
+ continue;
96
+ const dx = old.x - cur.x;
97
+ const dy = old.y - cur.y;
98
+ const sx = cur.w ? old.w / cur.w : 1;
99
+ const sy = cur.h ? old.h / cur.h : 1;
100
+ if (!dx && !dy && sx === 1 && sy === 1)
101
+ continue;
102
+ const el2 = el;
103
+ el2.style.transformBox = "fill-box";
104
+ el2.style.transformOrigin = "0px 0px";
105
+ el2.style.transform = `translate(${dx}px, ${dy}px) scale(${sx}, ${sy})`;
106
+ el.classList.add("mc-morph");
107
+ applied++;
108
+ // reflow로 초기 상태를 확정한 뒤 해제 — transition이 보간한다
109
+ void el2.getBoundingClientRect?.();
110
+ el2.style.transform = "";
111
+ const off = () => el.classList.remove("mc-morph");
112
+ el.addEventListener("transitionend", off, { once: true });
113
+ // transition이 없는 환경(jsdom/애니메이션 off)용 폴백
114
+ setTimeout(off, duration + 150);
115
+ }
116
+ return applied;
117
+ }
118
+ /** 스냅샷을 루트 DOM에 반영 — mountChart와 어댑터 테스트에서 공유 */
119
+ export function renderInto(root, snap) {
120
+ root.className = `mc-root${snap.animate ? " mc-animated" : ""}${snap.updating && snap.animate ? " mc-updating" : ""}${snap.drawMode ? " mc-drawing" : ""}`;
121
+ root.setAttribute("data-theme", snap.theme);
122
+ root.setAttribute("data-chart-type", snap.type);
123
+ root.setAttribute("data-legend", snap.legendPosition);
124
+ root.setAttribute("role", "figure");
125
+ root.setAttribute("aria-label", snap.ariaLabel);
126
+ root.setAttribute("tabindex", "0");
127
+ root.setAttribute("aria-busy", String(snap.loading));
128
+ root.style.setProperty("--chart-animation-duration", `${snap.animationDuration}ms`);
129
+ const title = root.querySelector(".mc-title");
130
+ title.textContent = snap.title ?? "";
131
+ title.hidden = snap.title === null;
132
+ const subtitle = root.querySelector(".mc-subtitle");
133
+ subtitle.textContent = snap.subtitle ?? "";
134
+ subtitle.hidden = snap.subtitle === null;
135
+ const loading = root.querySelector(".mc-loading");
136
+ loading.hidden = !snap.loading;
137
+ const svg = root.querySelector("svg.mc-svg");
138
+ svg.setAttribute("width", String(snap.width));
139
+ svg.setAttribute("height", String(snap.height));
140
+ svg.setAttribute("viewBox", `0 0 ${snap.width} ${snap.height}`);
141
+ // 데이터 갱신 전환 — 교체 전 지오메트리 박스를 키로 잡아 morph한다
142
+ const morphPrev = snap.updating && snap.animate ? captureMorph(svg) : null;
143
+ svg.innerHTML = svgInner(snap, { export: false });
144
+ if (morphPrev)
145
+ applyMorph(svg, morphPrev, snap.animationDuration);
146
+ const viewport = root.querySelector(".mc-viewport");
147
+ viewport.style.width = `${snap.width}px`;
148
+ viewport.style.height = `${snap.height}px`;
149
+ const overlay = root.querySelector(".mc-overlay");
150
+ overlay.style.left = `${snap.plot.x}px`;
151
+ overlay.style.top = `${snap.plot.y}px`;
152
+ overlay.style.width = `${snap.plot.w}px`;
153
+ overlay.style.height = `${snap.plot.h}px`;
154
+ const legend = root.querySelector(".mc-legend");
155
+ // 페이지네이션 — pageSize로 잘라 네비 버튼(.mc-legend-prev/next)을 붙인다
156
+ const lp = snap.legendPage;
157
+ const legendItems = lp
158
+ ? snap.legend.slice(lp.page * lp.size, (lp.page + 1) * lp.size)
159
+ : snap.legend;
160
+ legend.innerHTML =
161
+ (lp
162
+ ? `<button type="button" class="mc-legend-prev" aria-label="prev" ${lp.page === 0 ? "disabled" : ""}>‹</button>`
163
+ : "") +
164
+ legendItems
165
+ .map((l) => legendItemHtml(l.id, l.label, l.color, l.colorIndex, l.visible, snap.legendChecks))
166
+ .join("") +
167
+ (lp
168
+ ? `<span class="mc-legend-page">${lp.page + 1}/${lp.pages}</span>` +
169
+ `<button type="button" class="mc-legend-next" aria-label="next" ${lp.page >= lp.pages - 1 ? "disabled" : ""}>›</button>`
170
+ : "");
171
+ const tooltip = root.querySelector(".mc-tooltip");
172
+ if (snap.tooltip) {
173
+ tooltip.innerHTML = tooltipHtml(snap);
174
+ tooltip.hidden = false;
175
+ tooltip.style.left = `${snap.tooltip.x}px`;
176
+ tooltip.style.top = `${snap.tooltip.y}px`;
177
+ tooltip.classList.toggle("mc-flip-x", snap.tooltip.x > snap.width * 0.7);
178
+ }
179
+ else {
180
+ tooltip.hidden = true;
181
+ }
182
+ const menu = root.querySelector(".mc-menu");
183
+ if (snap.menu) {
184
+ menu.innerHTML = snap.menu.items
185
+ .map((it) => `<button type="button" role="menuitem" class="mc-menu-item" ` +
186
+ `data-id="${escapeXml(it.id)}">${escapeXml(it.label)}</button>`)
187
+ .join("");
188
+ menu.hidden = false;
189
+ // 뷰포트를 넘지 않게 오른쪽/아래 가장자리에서 뒤집는다
190
+ menu.style.left = `${Math.min(snap.menu.x, snap.width - 140)}px`;
191
+ menu.style.top = `${Math.min(snap.menu.y, snap.height - 40)}px`;
192
+ }
193
+ else {
194
+ menu.hidden = true;
195
+ }
196
+ const reset = root.querySelector(".mc-zoom-reset");
197
+ if (reset) {
198
+ reset.disabled = !snap.zoomed;
199
+ reset.textContent = snap.labels.reset;
200
+ }
201
+ const drillup = root.querySelector(".mc-drillup");
202
+ if (drillup) {
203
+ drillup.hidden = snap.drillDepth === 0;
204
+ drillup.textContent = snap.labels.drillUp;
205
+ }
206
+ const a11y = root.querySelector(".mc-a11y");
207
+ a11y.textContent = snap.a11yText;
208
+ }
209
+ // ── 인터랙션 배선 (5개 렌더러 공용) ─────────────────────────
210
+ /**
211
+ * 포인터·휠·키보드·툴바/범례 이벤트를 코어 액션으로 연결한다.
212
+ * 어댑터는 루트 요소를 넘겨 한 번만 호출한다 — 반환 함수는 해제용.
213
+ */
214
+ export function bindChart(root, chart) {
215
+ const disposers = [];
216
+ const on = (el, type, fn, opts) => {
217
+ el.addEventListener(type, fn, opts);
218
+ disposers.push(() => el.removeEventListener(type, fn, opts));
219
+ };
220
+ const overlay = () => root.querySelector(".mc-overlay");
221
+ const posOf = (e) => {
222
+ const ov = overlay();
223
+ if (!ov)
224
+ return null;
225
+ const rect = ov.getBoundingClientRect();
226
+ const snap = chart.getSnapshot();
227
+ if (!rect.width || !rect.height)
228
+ return null;
229
+ return {
230
+ x: ((e.clientX - rect.left) / rect.width) * snap.plot.w,
231
+ y: ((e.clientY - rect.top) / rect.height) * snap.plot.h,
232
+ };
233
+ };
234
+ let downX = 0;
235
+ let downY = 0;
236
+ // 오버레이 위에서 시작된 pointerdown 여부 — 비직교 타입(지도/파이 등)은
237
+ // startDrag가 no-op이라 드래그 없이도 클릭으로 해석해야 한다
238
+ let downOnOverlay = false;
239
+ // 주석 그리기 드래그 진행 중 — drawMode가 켜지면 줌/선택 대신 주석을 만든다
240
+ let drawingDown = false;
241
+ // 핀치 추적 — 오버레이 위 활성 포인터들. 2개면 핀치 줌이 된다
242
+ const pointers = new Map();
243
+ let pinchDist = 0;
244
+ on(root, "pointermove", (e) => {
245
+ const t = e.target;
246
+ if (!t?.closest(".mc-overlay")) {
247
+ if (!chart.isDragging())
248
+ chart.clearHover();
249
+ return;
250
+ }
251
+ const p = posOf(e);
252
+ if (p && !chart.isDragging() && !drawingDown && pointers.size < 2)
253
+ chart.hoverAt(p.x, p.y);
254
+ });
255
+ on(root, "pointerleave", () => chart.clearHover());
256
+ on(root, "pointerdown", (e) => {
257
+ const t = e.target;
258
+ if (!t?.closest(".mc-overlay"))
259
+ return;
260
+ const p = posOf(e);
261
+ if (!p)
262
+ return;
263
+ pointers.set(e.pointerId, p);
264
+ if (pointers.size === 2) {
265
+ // 핀치 시작 — 진행 중 드래그/드로잉은 취소하고 두 손가락 간격을 잰다
266
+ const pts = [...pointers.values()];
267
+ pinchDist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
268
+ if (chart.isDragging())
269
+ chart.endDrag();
270
+ if (drawingDown) {
271
+ drawingDown = false;
272
+ chart.drawCancel();
273
+ }
274
+ downOnOverlay = false;
275
+ return;
276
+ }
277
+ if (e.button !== 0)
278
+ return;
279
+ downX = p.x;
280
+ downY = p.y;
281
+ downOnOverlay = true;
282
+ if (chart.getDrawMode()) {
283
+ drawingDown = true;
284
+ chart.drawStartAt(p.x, p.y);
285
+ return;
286
+ }
287
+ chart.startDrag(p.x, e.shiftKey, p.y);
288
+ });
289
+ on(window, "pointermove", (e) => {
290
+ // 핀치 — 두 포인터 간격 변화율로 중점 앵커 줌 (터치 제스처)
291
+ if (pointers.size === 2 && pointers.has(e.pointerId)) {
292
+ const p = posOf(e);
293
+ if (!p)
294
+ return;
295
+ pointers.set(e.pointerId, p);
296
+ const pts = [...pointers.values()];
297
+ const dist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
298
+ const mid = { x: (pts[0].x + pts[1].x) / 2, y: (pts[0].y + pts[1].y) / 2 };
299
+ if (pinchDist > 0 && dist > 0)
300
+ chart.zoomAt(mid.x, dist / pinchDist, mid.y);
301
+ pinchDist = dist;
302
+ return;
303
+ }
304
+ if (drawingDown) {
305
+ const p = posOf(e);
306
+ if (p)
307
+ chart.drawMoveTo(p.x, p.y);
308
+ return;
309
+ }
310
+ if (!chart.isDragging())
311
+ return;
312
+ const p = posOf(e);
313
+ if (p)
314
+ chart.dragTo(p.x, p.y);
315
+ });
316
+ on(window, "pointerup", (e) => {
317
+ pointers.delete(e.pointerId);
318
+ if (pointers.size < 2)
319
+ pinchDist = 0;
320
+ if (drawingDown) {
321
+ drawingDown = false;
322
+ const p = posOf(e);
323
+ // 거의 움직이지 않은 클릭은 주석을 만들지 않는다
324
+ if (p && Math.abs(p.x - downX) >= 4 || p && Math.abs(p.y - downY) >= 4)
325
+ chart.drawEndAt();
326
+ else
327
+ chart.drawCancel();
328
+ return;
329
+ }
330
+ const wasDown = downOnOverlay;
331
+ downOnOverlay = false;
332
+ const p = posOf(e);
333
+ if (chart.isDragging())
334
+ chart.endDrag();
335
+ // 드래그로 안 움직였으면 클릭으로 해석 — 비직교 타입처럼 드래그가
336
+ // 시작되지 않은 경우도 포함한다
337
+ if (wasDown && p && Math.abs(p.x - downX) < 4 && Math.abs(p.y - downY) < 4)
338
+ chart.clickAt(p.x, p.y);
339
+ });
340
+ on(window, "pointercancel", (e) => {
341
+ pointers.delete(e.pointerId);
342
+ if (pointers.size < 2)
343
+ pinchDist = 0;
344
+ if (drawingDown) {
345
+ drawingDown = false;
346
+ chart.drawCancel();
347
+ }
348
+ });
349
+ on(root, "dblclick", (e) => {
350
+ const t = e.target;
351
+ if (!t?.closest(".mc-overlay"))
352
+ return;
353
+ const p = posOf(e);
354
+ if (p)
355
+ chart.dblClickAt(p.x, p.y);
356
+ chart.resetZoom();
357
+ });
358
+ on(root, "wheel", (e) => {
359
+ const t = e.target;
360
+ if (!t?.closest(".mc-overlay"))
361
+ return;
362
+ const p = posOf(e);
363
+ if (!p)
364
+ return;
365
+ e.preventDefault();
366
+ // 지도는 연속 스텝(휠량 비례), 직교 차트는 고정 스텝
367
+ const factor = chart.getSnapshot().type === "map"
368
+ ? Math.exp(-e.deltaY * 0.0022)
369
+ : e.deltaY > 0
370
+ ? 0.8
371
+ : 1.25;
372
+ chart.zoomAt(p.x, factor, p.y);
373
+ }, { passive: false });
374
+ // ── 컨텍스트 메뉴 — 플롯 우클릭으로 .mc-menu를 연다
375
+ on(root, "contextmenu", (e) => {
376
+ const t = e.target;
377
+ if (!t?.closest(".mc-overlay"))
378
+ return;
379
+ const p = posOf(e);
380
+ if (!p)
381
+ return;
382
+ e.preventDefault();
383
+ const snap = chart.getSnapshot();
384
+ // posOf는 플롯 상대 — 메뉴는 뷰포트 기준 px로 위치한다
385
+ chart.openMenu(snap.plot.x + p.x, snap.plot.y + p.y);
386
+ });
387
+ // ── 네비게이터/스크롤바 — 미니맵·스트립의 창 이동/리사이즈/트랙 점프
388
+ // 오버레이가 플롯만 덮으므로 SVG px 좌표를 직접 변환해 코어에 전달한다
389
+ let navDragging = false;
390
+ const svgXOf = (e) => {
391
+ const svg = root.querySelector("svg.mc-svg");
392
+ if (!svg)
393
+ return null;
394
+ const rect = svg.getBoundingClientRect();
395
+ if (!rect.width)
396
+ return null;
397
+ return ((e.clientX - rect.left) / rect.width) * chart.getSnapshot().width;
398
+ };
399
+ on(root, "pointerdown", (e) => {
400
+ const t = e.target;
401
+ const inNav = t?.closest(".mc-navigator");
402
+ const inSb = !inNav && t?.closest(".mc-scrollbar");
403
+ if ((!inNav && !inSb) || e.button !== 0)
404
+ return;
405
+ const x = svgXOf(e);
406
+ if (x === null)
407
+ return;
408
+ e.preventDefault();
409
+ const L = inNav ? ".mc-nav-handle-l" : ".mc-sb-handle-l";
410
+ const R = inNav ? ".mc-nav-handle-r" : ".mc-sb-handle-r";
411
+ const W = inNav ? ".mc-nav-window" : ".mc-sb-window";
412
+ if (t.closest(L))
413
+ chart.navDragStart(x, "l");
414
+ else if (t.closest(R))
415
+ chart.navDragStart(x, "r");
416
+ else if (t.closest(W))
417
+ chart.navDragStart(x, "c");
418
+ else {
419
+ // 배경/트랙 클릭 — 창 중심을 그 위치로 점프 후 이동 드래그 진입
420
+ chart.navJump(x);
421
+ chart.navDragStart(x, "c");
422
+ }
423
+ navDragging = true;
424
+ });
425
+ on(window, "pointermove", (e) => {
426
+ if (!navDragging)
427
+ return;
428
+ const x = svgXOf(e);
429
+ if (x !== null)
430
+ chart.navDragTo(x);
431
+ });
432
+ on(window, "pointerup", () => {
433
+ if (!navDragging)
434
+ return;
435
+ navDragging = false;
436
+ chart.navDragEnd();
437
+ });
438
+ // 인쇄 이벤트 전달 — IBChart의 beforePrint/afterPrint 해당
439
+ on(window, "beforeprint", () => chart.emitPrint("before"));
440
+ on(window, "afterprint", () => chart.emitPrint("after"));
441
+ // 범례 hover → 다른 시리즈 강조(dim)
442
+ on(root, "pointerover", (e) => {
443
+ const t = e.target;
444
+ const item = t?.closest(".mc-legend-item");
445
+ chart.hoverLegend(item?.dataset.id ?? null);
446
+ });
447
+ on(root, "pointerout", (e) => {
448
+ const t = e.target;
449
+ if (t?.closest(".mc-legend-item"))
450
+ chart.hoverLegend(null);
451
+ });
452
+ // 툴바 + 범례 + 컨텍스트 메뉴 위임 클릭
453
+ on(root, "click", (e) => {
454
+ const t = e.target;
455
+ // 메뉴 항목 — 내장 export는 렌더러가 직접 처리한다
456
+ const menuItem = t?.closest(".mc-menu-item");
457
+ if (menuItem?.dataset.id) {
458
+ const id = menuItem.dataset.id;
459
+ if (id === "export-svg") {
460
+ downloadSVG(chart);
461
+ chart.closeMenu();
462
+ }
463
+ else if (id === "export-png") {
464
+ void downloadPNG(chart);
465
+ chart.closeMenu();
466
+ }
467
+ else
468
+ chart.activateMenuItem(id);
469
+ return;
470
+ }
471
+ // 메뉴 바깥 클릭 — 열린 메뉴를 닫는다
472
+ if (!t?.closest(".mc-menu"))
473
+ chart.closeMenu();
474
+ // 범례 페이지네이션 네비
475
+ if (t?.closest(".mc-legend-prev")) {
476
+ chart.setLegendPage(chart.getLegendPage() - 1);
477
+ return;
478
+ }
479
+ if (t?.closest(".mc-legend-next")) {
480
+ chart.setLegendPage(chart.getLegendPage() + 1);
481
+ return;
482
+ }
483
+ const legendItem = t?.closest(".mc-legend-item");
484
+ if (legendItem?.dataset.id) {
485
+ // 체크박스 클릭은 legendCheck 경로 — 이벤트가 다르다
486
+ if (t?.closest(".mc-legend-check"))
487
+ chart.legendCheck(legendItem.dataset.id);
488
+ else
489
+ chart.toggleLegend(legendItem.dataset.id);
490
+ return;
491
+ }
492
+ if (t?.closest(".mc-export-svg"))
493
+ downloadSVG(chart);
494
+ else if (t?.closest(".mc-export-png"))
495
+ void downloadPNG(chart);
496
+ else if (t?.closest(".mc-zoom-reset"))
497
+ chart.resetZoom();
498
+ else if (t?.closest(".mc-drillup"))
499
+ chart.drillUp();
500
+ });
501
+ // 키보드 포인트 내비 — IME 조합 중에는 가로채지 않는다
502
+ on(root, "keydown", (e) => {
503
+ if (e.isComposing)
504
+ return;
505
+ const map = {
506
+ ArrowLeft: "left",
507
+ ArrowRight: "right",
508
+ ArrowUp: "up",
509
+ ArrowDown: "down",
510
+ };
511
+ const dir = map[e.key];
512
+ if (dir) {
513
+ e.preventDefault();
514
+ chart.moveFocus(dir);
515
+ }
516
+ else if (e.key === "Home") {
517
+ e.preventDefault();
518
+ chart.clearFocus();
519
+ chart.moveFocus("left");
520
+ }
521
+ else if (e.key === "End") {
522
+ e.preventDefault();
523
+ for (let i = 0; i < 512; i++)
524
+ chart.moveFocus("right");
525
+ }
526
+ else if (e.key === "Escape") {
527
+ chart.escape();
528
+ }
529
+ else if (e.key === "Enter" || e.key === " ") {
530
+ const snap = chart.getSnapshot();
531
+ const f = snap.focused;
532
+ if (f) {
533
+ e.preventDefault();
534
+ // 포커스된 요소의 svg 좌표 → 플롯 상대 좌표로 clickAt
535
+ const pt = snap.geometry
536
+ .flatMap((g) => {
537
+ if (g.kind === "pie" ||
538
+ g.kind === "funnel" ||
539
+ g.kind === "cone-funnel" ||
540
+ g.kind === "pyramid")
541
+ return g.slices.map((s) => ({
542
+ seriesId: g.seriesId,
543
+ index: s.index,
544
+ x: s.mx,
545
+ y: s.my,
546
+ }));
547
+ if (g.kind === "heatmap")
548
+ return g.cells.map((c) => ({
549
+ seriesId: c.seriesId,
550
+ index: c.index,
551
+ x: c.x + c.w / 2,
552
+ y: c.y + c.h / 2,
553
+ }));
554
+ if (g.kind === "bar")
555
+ return g.bars.map((b) => ({
556
+ seriesId: b.seriesId,
557
+ index: b.index,
558
+ x: g.horizontal ? b.x + b.w : b.x + b.w / 2,
559
+ y: g.horizontal ? b.y + b.h / 2 : b.y,
560
+ }));
561
+ if (g.kind === "wordcloud")
562
+ return g.words.map((w) => ({
563
+ seriesId: g.seriesId,
564
+ index: w.index,
565
+ x: w.x,
566
+ y: w.y,
567
+ }));
568
+ if (g.kind === "range-bar")
569
+ return g.bars.map((b) => ({
570
+ seriesId: b.seriesId,
571
+ index: b.index,
572
+ x: g.horizontal ? b.x + b.w : b.x + b.w / 2,
573
+ y: g.horizontal ? b.y + b.h / 2 : b.y,
574
+ }));
575
+ if (g.kind === "candlestick" || g.kind === "ohlc")
576
+ return g.candles.map((c) => ({
577
+ seriesId: c.seriesId,
578
+ index: c.index,
579
+ x: c.cx,
580
+ y: c.highY,
581
+ }));
582
+ if (g.kind === "boxplot")
583
+ return g.boxes.map((b) => ({
584
+ seriesId: b.seriesId,
585
+ index: b.index,
586
+ x: b.cx,
587
+ y: b.medY,
588
+ }));
589
+ if (g.kind === "map")
590
+ return [
591
+ ...g.regions
592
+ .filter((r) => r.index >= 0)
593
+ .map((r) => ({
594
+ seriesId: g.seriesId,
595
+ index: r.index,
596
+ x: r.cx,
597
+ y: r.cy,
598
+ })),
599
+ ...g.markers.map((m) => ({
600
+ seriesId: g.seriesId,
601
+ index: -1 - m.index,
602
+ x: m.x,
603
+ y: m.y,
604
+ })),
605
+ ];
606
+ if (g.kind === "sunburst")
607
+ return g.nodes.map((n) => ({
608
+ seriesId: g.seriesId,
609
+ index: n.index,
610
+ x: n.cx,
611
+ y: n.cy,
612
+ }));
613
+ if (g.kind === "organization")
614
+ return g.nodes.map((n) => ({
615
+ seriesId: g.seriesId,
616
+ index: n.index,
617
+ x: n.cx,
618
+ y: n.cy,
619
+ }));
620
+ if (g.kind === "treemap")
621
+ return g.nodes.map((n) => ({
622
+ seriesId: g.seriesId,
623
+ index: n.index,
624
+ x: n.x + n.w / 2,
625
+ y: n.y + n.h / 2,
626
+ }));
627
+ if (g.kind === "sankey")
628
+ return g.nodes.map((n) => ({
629
+ seriesId: g.seriesId,
630
+ index: n.index,
631
+ x: n.x + n.w / 2,
632
+ y: n.y + n.h / 2,
633
+ }));
634
+ if (g.kind === "chord")
635
+ return g.groups.map((gr) => ({
636
+ seriesId: g.seriesId,
637
+ index: gr.index,
638
+ x: g.cx + ((g.r + g.ri) / 2) * Math.sin((gr.a0 + gr.a1) / 2),
639
+ y: g.cy - ((g.r + g.ri) / 2) * Math.cos((gr.a0 + gr.a1) / 2),
640
+ }));
641
+ if (g.kind === "nightingale" ||
642
+ g.kind === "radial-bar" ||
643
+ g.kind === "radial-column")
644
+ return g.sectors.map((s) => ({
645
+ seriesId: g.seriesId,
646
+ index: s.index,
647
+ x: s.cx,
648
+ y: s.cy,
649
+ }));
650
+ if (g.kind === "gauge")
651
+ return [
652
+ {
653
+ seriesId: g.seriesId,
654
+ index: 0,
655
+ x: g.gauge.cx,
656
+ y: g.gauge.cy,
657
+ },
658
+ ];
659
+ if (g.kind === "linear-gauge")
660
+ return [
661
+ {
662
+ seriesId: g.seriesId,
663
+ index: 0,
664
+ x: g.gauge.bar.x + g.gauge.bar.w,
665
+ y: g.gauge.bar.y,
666
+ },
667
+ ];
668
+ return "points" in g ? g.points : [];
669
+ })
670
+ .find((t) => t.seriesId === f.seriesId && t.index === f.index);
671
+ if (pt)
672
+ chart.clickAt(pt.x - snap.plot.x, pt.y - snap.plot.y);
673
+ }
674
+ }
675
+ });
676
+ return () => {
677
+ for (const d of disposers)
678
+ d();
679
+ };
680
+ }
681
+ /** ResizeObserver 배선 — 없는 환경(SSR/happy-dom)에서는 no-op */
682
+ export function observeSize(el, cb) {
683
+ if (typeof ResizeObserver === "undefined")
684
+ return () => { };
685
+ const ro = new ResizeObserver((entries) => {
686
+ const r = entries[0]?.contentRect;
687
+ if (r)
688
+ cb(r.width, r.height);
689
+ });
690
+ ro.observe(el);
691
+ return () => ro.disconnect();
692
+ }
693
+ // ── 보내기 ──────────────────────────────────────────────────
694
+ function download(href, filename) {
695
+ const a = document.createElement("a");
696
+ a.href = href;
697
+ a.download = filename;
698
+ a.click();
699
+ }
700
+ /** 현재 스냅샷을 SVG 파일로 저장한다 */
701
+ export function downloadSVG(chart, filename = "chart.svg") {
702
+ const markup = chart.toSVGString();
703
+ const blob = new Blob([markup], { type: "image/svg+xml" });
704
+ const url = URL.createObjectURL(blob);
705
+ download(url, filename);
706
+ URL.revokeObjectURL(url);
707
+ }
708
+ /** SVG 문자열 → PNG data URL (2배 해상도) */
709
+ export async function downloadPNG(chart, filename = "chart.png") {
710
+ const snap = chart.getSnapshot();
711
+ const markup = chart.toSVGString();
712
+ const url = URL.createObjectURL(new Blob([markup], { type: "image/svg+xml" }));
713
+ try {
714
+ const img = await new Promise((res, rej) => {
715
+ const i = new Image();
716
+ i.onload = () => res(i);
717
+ i.onerror = rej;
718
+ i.src = url;
719
+ });
720
+ const canvas = document.createElement("canvas");
721
+ canvas.width = snap.width * 2;
722
+ canvas.height = snap.height * 2;
723
+ const ctx = canvas.getContext("2d");
724
+ ctx.scale(2, 2);
725
+ ctx.drawImage(img, 0, 0);
726
+ download(canvas.toDataURL("image/png"), filename);
727
+ }
728
+ finally {
729
+ URL.revokeObjectURL(url);
730
+ }
731
+ }
732
+ /**
733
+ * el 안에 차트 DOM을 만들고 코어를 연결한다 — 5개 어댑터가 공유하는
734
+ * 렌더 경로. 어댑터는 자기 생명주기 훅에서 이 함수를 1회 호출하고
735
+ * 반환된 cleanup을 unmount 시 실행한다.
736
+ *
737
+ * DOM 계약:
738
+ * .mc-root[role=figure][tabindex=0][data-theme][data-chart-type]
739
+ * .mc-toolbar > .mc-zoom-reset / .mc-export-svg / .mc-export-png
740
+ * .mc-viewport > svg.mc-svg(.mc-grid/.mc-axis/.mc-plot/.mc-series/...)
741
+ * + .mc-overlay + .mc-tooltip
742
+ * .mc-legend > .mc-legend-item[data-id]
743
+ * .mc-a11y[aria-live=polite]
744
+ * 새 DOM 요소를 추가할 때는 같은 mc- 접두어와 conformance 계약을 갱신한다.
745
+ */
746
+ export function attachChartView(el, chart) {
747
+ el.innerHTML = skeletonHtml();
748
+ const root = el.firstElementChild;
749
+ const render = () => renderInto(root, chart.getSnapshot());
750
+ const unsubscribe = chart.subscribe(render);
751
+ render();
752
+ const unbind = bindChart(root, chart);
753
+ const unobs = chart.isResponsive()
754
+ ? observeSize(el, (w) => chart.setSize(w))
755
+ : () => { };
756
+ chart.emitReady();
757
+ return () => {
758
+ unbind();
759
+ unobs();
760
+ unsubscribe();
761
+ el.textContent = "";
762
+ };
763
+ }
764
+ /**
765
+ * DOM 전용 렌더러 (vanilla) — 코어 생성 + attachChartView.
766
+ * 어댑터들과 같은 DOM 계약을 만든다.
767
+ */
768
+ export function mountChart(host, options) {
769
+ const chart = new ChartCore(options);
770
+ const root = document.createElement("div");
771
+ root.className = "mc-host";
772
+ host.appendChild(root);
773
+ const detach = attachChartView(root, chart);
774
+ return {
775
+ chart,
776
+ rootEl: root.firstElementChild,
777
+ destroy() {
778
+ detach();
779
+ root.remove();
780
+ },
781
+ };
782
+ }
783
+ /**
784
+ * sparkline — 축/범례 없는 인라인 미니 차트 (AG Charts sparklines 해당).
785
+ * ChartCore 없이 el에 SVG를 바로 마운트한다. 값 갱신은 반환된 update로.
786
+ */
787
+ export function mountSparkline(host, opts) {
788
+ host.innerHTML = sparklineMarkup(opts);
789
+ return {
790
+ update(next) {
791
+ host.innerHTML = sparklineMarkup(next);
792
+ },
793
+ };
794
+ }
795
+ //# sourceMappingURL=mount.js.map