@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/svg.js ADDED
@@ -0,0 +1,1022 @@
1
+ import { DEFAULT_PALETTE } from "./core.js";
2
+ import { formatNumber } from "./scales.js";
3
+ export function escapeXml(s) {
4
+ return s
5
+ .replace(/&/g, "&")
6
+ .replace(/</g, "&lt;")
7
+ .replace(/>/g, "&gt;")
8
+ .replace(/"/g, "&quot;");
9
+ }
10
+ const num = (n) => Number(n.toFixed(2));
11
+ /** 사람 아이콘 path — 24×24 박스 기준, pictogram 아이콘에 scale 적용 */
12
+ const PERSON_ICON_D = "M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4z" +
13
+ "m0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z";
14
+ /** 시리즈/슬라이스 색상 해석 */
15
+ export function seriesColor(color, colorIndex, mode) {
16
+ if (color)
17
+ return color;
18
+ if (mode.export)
19
+ return DEFAULT_PALETTE[colorIndex % DEFAULT_PALETTE.length];
20
+ return `var(--chart-palette-${(colorIndex % DEFAULT_PALETTE.length) + 1})`;
21
+ }
22
+ /** fill/stroke 속성 문자열 — dom은 style로(var 지원), export는 프레젠테이션 속성 */
23
+ function paint(prop, color, colorIndex, mode) {
24
+ const c = seriesColor(color, colorIndex, mode);
25
+ return mode.export
26
+ ? `${prop}="${escapeXml(c)}"`
27
+ : `style="${prop}:${escapeXml(c)}"`;
28
+ }
29
+ // ── 접근성 패턴 (patterns 옵션) ─────────────────────────────
30
+ /** 패턴 무늬 — colorIndex % 8로 선택. 모두 6px 타일 기준 */
31
+ const PATTERN_BODIES = [
32
+ "M-1 1 L1 -1 M0 6 L6 0 M5 7 L7 5", // / 대각선
33
+ "M-1 5 L1 7 M0 0 L6 6 M5 -1 L7 1", // \ 역대각선
34
+ "M-1 1 L1 -1 M0 6 L6 0 M5 7 L7 5 M-1 5 L1 7 M0 0 L6 6 M5 -1 L7 1", // 교차
35
+ "", // 점 (아래에서 circle로)
36
+ "M0 3 L6 3", // 수평
37
+ "M3 0 L3 6", // 수직
38
+ "M0 3 L6 3 M3 0 L3 6", // 격자
39
+ "M0 7 L7 0", // 넓은 대각선
40
+ ];
41
+ /**
42
+ * patterns 옵션의 색→패턴 맵. 같은 (색상,colorIndex) 조합은 같은
43
+ * 패턴을 공유한다 — 명시 color를 쓴 시리즈도 자기 색 기반 무늬를 받는다.
44
+ */
45
+ class PatternRegistry {
46
+ constructor(clipId) {
47
+ Object.defineProperty(this, "clipId", {
48
+ enumerable: true,
49
+ configurable: true,
50
+ writable: true,
51
+ value: clipId
52
+ });
53
+ Object.defineProperty(this, "ids", {
54
+ enumerable: true,
55
+ configurable: true,
56
+ writable: true,
57
+ value: new Map()
58
+ });
59
+ }
60
+ idOf(color, colorIndex) {
61
+ const key = `${color ?? ""}|${colorIndex % 8}`;
62
+ let id = this.ids.get(key);
63
+ if (!id) {
64
+ id = `${this.clipId}-pat-${this.ids.size}`;
65
+ this.ids.set(key, id);
66
+ }
67
+ return id;
68
+ }
69
+ /** 사용된 패턴의 <defs> 마크업 */
70
+ defs(mode) {
71
+ const fg = mode.export ? "#ffffff" : "var(--chart-bg)";
72
+ const out = [];
73
+ for (const [key, id] of this.ids) {
74
+ const [color, idx] = key.split("|");
75
+ const ci = Number(idx);
76
+ const base = color || seriesColor(null, ci, mode);
77
+ const body = ci === 3
78
+ ? `<circle cx="3" cy="3" r="1.2" fill="${escapeXml(fg)}" fill-opacity="0.9"/>`
79
+ : `<path d="${PATTERN_BODIES[ci] ?? PATTERN_BODIES[0]}" stroke="${escapeXml(fg)}" stroke-width="1.2" stroke-opacity="0.85" fill="none"/>`;
80
+ out.push(`<pattern id="${escapeXml(id)}" class="mc-pattern" ` +
81
+ `patternUnits="userSpaceOnUse" width="6" height="6">` +
82
+ `<rect width="6" height="6" fill="${escapeXml(base)}"/>${body}</pattern>`);
83
+ }
84
+ return out.join("");
85
+ }
86
+ }
87
+ /** 값→색 보간 — dom은 CSS 변수 color-mix, export는 선형 보간 hex */
88
+ function scaleFill(t, from, to, mode, cssFrom, cssTo, hexFrom, hexTo) {
89
+ const pct = Math.round(t * 100);
90
+ if (!mode.export) {
91
+ const f = from ?? cssFrom;
92
+ const tt = to ?? cssTo;
93
+ return `style="fill:color-mix(in srgb, ${escapeXml(tt)} ${pct}%, ${escapeXml(f)})"`;
94
+ }
95
+ const lerp = (a, b) => {
96
+ const pa = parseInt(a.slice(1), 16);
97
+ const pb = parseInt(b.slice(1), 16);
98
+ const ch = (sh) => Math.round(((pa >> sh) & 255) + (((pb >> sh) & 255) - ((pa >> sh) & 255)) * t);
99
+ return `#${((1 << 24) | (ch(16) << 16) | (ch(8) << 8) | ch(0)).toString(16).slice(1)}`;
100
+ };
101
+ return `fill="${lerp(from ?? hexFrom, to ?? hexTo)}"`;
102
+ }
103
+ /** 히트맵 셀 색 — dom은 CSS 변수 color-mix, export는 선형 보간 hex */
104
+ function heatFill(cell, g, mode) {
105
+ return scaleFill(cell.t, g.from, g.to, mode, "var(--chart-heat-from)", "var(--chart-heat-to)", "#dbeafe", "#1d4ed8");
106
+ }
107
+ /** 지도 지역 채움 — 값 스케일 t 위치 색 (from/to 커스텀 가능) */
108
+ function mapFill(t, g, mode) {
109
+ return scaleFill(t, g.from, g.to, mode, "var(--chart-map-from)", "var(--chart-map-to)", "#dbeafe", "#1d4ed8");
110
+ }
111
+ function axisMarkup(a, snap, mode) {
112
+ const { plot } = snap;
113
+ const stroke = mode.export ? 'stroke="#cbd5e1"' : "";
114
+ const fill = mode.export ? 'fill="#64748b"' : "";
115
+ const parts = [];
116
+ if (a.kind === "x") {
117
+ const y = a.position === "top" ? plot.y : plot.y + plot.h;
118
+ parts.push(`<line class="mc-axis-line" x1="${num(plot.x)}" y1="${num(y)}" x2="${num(plot.x + plot.w)}" y2="${num(y)}" ${stroke}/>`);
119
+ for (const t of a.ticks) {
120
+ const dir = a.position === "top" ? -1 : 1;
121
+ parts.push(`<g class="mc-tick"><line class="mc-tick-line" x1="${num(t.pos)}" y1="${num(y)}" x2="${num(t.pos)}" y2="${num(y + dir * 5)}" ${stroke}/>` +
122
+ `<text class="mc-tick-label" x="${num(t.pos)}" y="${num(y + dir * 16)}" text-anchor="middle" ${fill}>${escapeXml(t.label)}</text></g>`);
123
+ }
124
+ // 그룹 카테고리 — 두 번째 틱 행 (범위 구분선 + 그룹 라벨)
125
+ if (a.groups?.length) {
126
+ const dir = a.position === "top" ? -1 : 1;
127
+ const gy = y + dir * 30;
128
+ for (const gr of a.groups) {
129
+ parts.push(`<g class="mc-tick-group">` +
130
+ `<line class="mc-tick-group-line" x1="${num(gr.x0)}" y1="${num(y + dir * 20)}" x2="${num(gr.x0)}" y2="${num(gy + dir * 6)}" ${stroke}/>` +
131
+ `<line class="mc-tick-group-line" x1="${num(gr.x1)}" y1="${num(y + dir * 20)}" x2="${num(gr.x1)}" y2="${num(gy + dir * 6)}" ${stroke}/>` +
132
+ `<text class="mc-tick-group-label" x="${num(gr.pos)}" y="${num(gy)}" text-anchor="middle" ${fill}>${escapeXml(gr.label)}</text>` +
133
+ `</g>`);
134
+ }
135
+ }
136
+ if (a.label)
137
+ parts.push(`<text class="mc-axis-label" x="${num(plot.x + plot.w / 2)}" y="${num(a.position === "top" ? plot.y - 22 : plot.y + plot.h + 26)}" text-anchor="middle" ${fill}>${escapeXml(a.label)}</text>`);
138
+ }
139
+ else {
140
+ const x = a.position === "right" ? plot.x + plot.w : plot.x;
141
+ parts.push(`<line class="mc-axis-line" x1="${num(x)}" y1="${num(plot.y)}" x2="${num(x)}" y2="${num(plot.y + plot.h)}" ${stroke}/>`);
142
+ for (const t of a.ticks) {
143
+ const dir = a.position === "right" ? 1 : -1;
144
+ const anchor = a.position === "right" ? "start" : "end";
145
+ parts.push(`<g class="mc-tick"><line class="mc-tick-line" x1="${num(x)}" y1="${num(t.pos)}" x2="${num(x + dir * 5)}" y2="${num(t.pos)}" ${stroke}/>` +
146
+ `<text class="mc-tick-label" x="${num(x + dir * 8)}" y="${num(t.pos + 3)}" text-anchor="${anchor}" ${fill}>${escapeXml(t.label)}</text></g>`);
147
+ }
148
+ if (a.label) {
149
+ const lx = a.position === "right" ? plot.x + plot.w + 34 : plot.x - 38;
150
+ parts.push(`<text class="mc-axis-label" x="${num(lx)}" y="${num(plot.y + plot.h / 2)}" text-anchor="middle" transform="rotate(-90 ${num(lx)} ${num(plot.y + plot.h / 2)})" ${fill}>${escapeXml(a.label)}</text>`);
151
+ }
152
+ }
153
+ const pos = a.position;
154
+ return `<g class="mc-axis mc-axis-${a.kind} mc-axis-${pos}" data-axis-id="${escapeXml(a.id)}">${parts.join("")}</g>`;
155
+ }
156
+ function gridMarkup(snap, mode) {
157
+ const stroke = mode.export ? 'stroke="#e2e8f0"' : "";
158
+ const parts = [];
159
+ for (const a of snap.axes) {
160
+ if (!a.grid)
161
+ continue;
162
+ for (const t of a.ticks) {
163
+ if (a.kind === "x")
164
+ parts.push(`<line class="mc-grid-line" x1="${num(t.pos)}" y1="${num(snap.plot.y)}" x2="${num(t.pos)}" y2="${num(snap.plot.y + snap.plot.h)}" ${stroke}/>`);
165
+ else
166
+ parts.push(`<line class="mc-grid-line" x1="${num(snap.plot.x)}" y1="${num(t.pos)}" x2="${num(snap.plot.x + snap.plot.w)}" y2="${num(t.pos)}" ${stroke}/>`);
167
+ }
168
+ }
169
+ return `<g class="mc-grid">${parts.join("")}</g>`;
170
+ }
171
+ function pointAttrs(p, extra, extraClass = "") {
172
+ const cls = [
173
+ "mc-point",
174
+ extraClass,
175
+ p.focused ? "mc-focused" : "",
176
+ p.dimmed ? "mc-dimmed" : "",
177
+ p.selected ? "mc-selected" : "",
178
+ ]
179
+ .filter(Boolean)
180
+ .join(" ");
181
+ return `class="${cls}" data-series-id="${escapeXml(p.seriesId)}" data-index="${p.index}" aria-label="${escapeXml(`${p.label}: ${p.value ?? ""}`)}" ${extra}`;
182
+ }
183
+ export function svgInner(snap, mode) {
184
+ const { plot } = snap;
185
+ // 시리즈를 먼저 빌드해 사용된 패턴을 수집한 뒤 defs를 만든다
186
+ const patterns = snap.patterns ? new PatternRegistry(snap.clipId) : null;
187
+ const fillOf = (color, colorIndex) => patterns
188
+ ? `fill="url(#${escapeXml(patterns.idOf(color, colorIndex))})"`
189
+ : paint("fill", color, colorIndex, mode);
190
+ const seriesParts = [];
191
+ const sliceMarkup = (g, cls) => {
192
+ const slices = g.slices
193
+ .map((sl) => {
194
+ if (!sl.path)
195
+ return "";
196
+ const cls2 = [
197
+ "mc-slice",
198
+ sl.focused ? "mc-focused" : "",
199
+ sl.dimmed ? "mc-dimmed" : "",
200
+ sl.selected ? "mc-selected" : "",
201
+ ]
202
+ .filter(Boolean)
203
+ .join(" ");
204
+ return `<path class="${cls2}" data-index="${sl.index}" d="${sl.path}" aria-label="${escapeXml(`${sl.label}: ${sl.value}`)}" ${fillOf(sl.color, sl.colorIndex)} stroke-width="1" ${mode.export ? 'stroke="#ffffff"' : ""}/>`;
205
+ })
206
+ .join("");
207
+ seriesParts.push(`<g class="mc-series ${cls}" data-series-id="${escapeXml(g.seriesId)}">${slices}</g>`);
208
+ };
209
+ for (const g of snap.geometry) {
210
+ const s = snap.series.find((x) => x.id === g.seriesId);
211
+ const col = (prop) => paint(prop, s?.color ?? null, s?.colorIndex ?? 0, mode);
212
+ // 포인트 고정색(itemStyle/points.color) > 시리즈 색
213
+ const colP = (prop, pc) => paint(prop, pc ?? s?.color ?? null, s?.colorIndex ?? 0, mode);
214
+ if (g.kind === "line" || g.kind === "scatter") {
215
+ const line = g.kind === "line" && g.path
216
+ ? `<path class="mc-line${g.shape && g.shape !== "linear" ? ` mc-${g.shape}` : ""}" d="${g.path}" fill="none" ${col("stroke")}/>`
217
+ : "";
218
+ const pts = g.points
219
+ .map((p) => `<circle ${pointAttrs(p, `cx="${num(p.x)}" cy="${num(p.y)}" r="3.5"`)} ${colP("fill", p.color)}/>`)
220
+ .join("");
221
+ seriesParts.push(`<g class="mc-series mc-type-${g.kind}" data-series-id="${escapeXml(g.seriesId)}">${line}${pts}</g>`);
222
+ }
223
+ else if (g.kind === "bubble") {
224
+ const pts = g.points
225
+ .map((p) => `<circle ${pointAttrs(p, `cx="${num(p.x)}" cy="${num(p.y)}" r="${num(p.r ?? 5)}"`, "mc-bubble")} ${fillOf(p.color ?? s?.color ?? null, s?.colorIndex ?? 0)}/>`)
226
+ .join("");
227
+ seriesParts.push(`<g class="mc-series mc-type-bubble" data-series-id="${escapeXml(g.seriesId)}">${pts}</g>`);
228
+ }
229
+ else if (g.kind === "area") {
230
+ const pts = g.points
231
+ .map((p) => `<circle ${pointAttrs(p, `cx="${num(p.x)}" cy="${num(p.y)}" r="3.5"`)} ${colP("fill", p.color)}/>`)
232
+ .join("");
233
+ seriesParts.push(`<g class="mc-series mc-type-area" data-series-id="${escapeXml(g.seriesId)}">` +
234
+ (g.path
235
+ ? `<path class="mc-area" d="${g.path}" ${fillOf(s?.color ?? null, s?.colorIndex ?? 0)}/>`
236
+ : "") +
237
+ (g.linePath
238
+ ? `<path class="mc-line" d="${g.linePath}" fill="none" ${col("stroke")}/>`
239
+ : "") +
240
+ pts +
241
+ `</g>`);
242
+ }
243
+ else if (g.kind === "bar") {
244
+ const bars = g.bars
245
+ .map((b) => {
246
+ const cls = [
247
+ "mc-bar",
248
+ b.tone ? `mc-bar-${b.tone}` : "",
249
+ b.focused ? "mc-focused" : "",
250
+ b.dimmed ? "mc-dimmed" : "",
251
+ b.selected ? "mc-selected" : "",
252
+ ]
253
+ .filter(Boolean)
254
+ .join(" ");
255
+ // waterfall 톤 — 시리즈/포인트 고정색이 없으면 증감 색 변수를 쓴다
256
+ const fill = b.tone && !s?.color && !b.color && !patterns
257
+ ? mode.export
258
+ ? `fill="${b.tone === "up" ? "#16a34a" : "#dc2626"}"`
259
+ : `style="fill:var(--chart-${b.tone}-color)"`
260
+ : fillOf(b.color ?? s?.color ?? null, s?.colorIndex ?? 0);
261
+ return `<rect class="${cls}" data-series-id="${escapeXml(b.seriesId)}" data-index="${b.index}" x="${num(b.x)}" y="${num(b.y)}" width="${num(b.w)}" height="${num(b.h)}" rx="2" aria-label="${escapeXml(`${b.label}: ${b.value}`)}" ${fill}/>`;
262
+ })
263
+ .join("");
264
+ const conn = g.connectorPath
265
+ ? `<path class="mc-waterfall-line" d="${g.connectorPath}" fill="none"/>`
266
+ : "";
267
+ seriesParts.push(`<g class="mc-series mc-type-bar${g.horizontal ? " mc-horizontal" : ""}" data-series-id="${escapeXml(g.seriesId)}">${conn}${bars}</g>`);
268
+ }
269
+ else if (g.kind === "pie") {
270
+ sliceMarkup(g, "mc-type-pie");
271
+ }
272
+ else if (g.kind === "funnel") {
273
+ sliceMarkup(g, "mc-type-funnel");
274
+ }
275
+ else if (g.kind === "cone-funnel") {
276
+ // cone-funnel — 값 비례 높이 + 직선 외곽 퍼널 (.mc-type-cone-funnel)
277
+ sliceMarkup(g, "mc-type-cone-funnel");
278
+ }
279
+ else if (g.kind === "pyramid") {
280
+ sliceMarkup(g, "mc-type-pyramid");
281
+ }
282
+ else if (g.kind === "heatmap") {
283
+ const cells = g.cells
284
+ .map((c) => {
285
+ const cls = [
286
+ "mc-heat-cell",
287
+ c.focused ? "mc-focused" : "",
288
+ c.dimmed ? "mc-dimmed" : "",
289
+ c.selected ? "mc-selected" : "",
290
+ ]
291
+ .filter(Boolean)
292
+ .join(" ");
293
+ return (`<rect class="${cls}" data-series-id="${escapeXml(c.seriesId)}" ` +
294
+ `data-index="${c.index}" x="${num(c.x)}" y="${num(c.y)}" ` +
295
+ `width="${num(c.w)}" height="${num(c.h)}" ` +
296
+ `aria-label="${escapeXml(`${c.label}: ${c.value}`)}" ${heatFill(c, g, mode)}/>`);
297
+ })
298
+ .join("");
299
+ seriesParts.push(`<g class="mc-series mc-type-heatmap" data-series-id="${escapeXml(g.cells[0]?.seriesId ?? "heat")}">${cells}</g>`);
300
+ }
301
+ else if (g.kind === "wordcloud") {
302
+ const words = g.words
303
+ .map((w) => {
304
+ const cls = [
305
+ "mc-word",
306
+ w.focused ? "mc-focused" : "",
307
+ w.dimmed ? "mc-dimmed" : "",
308
+ w.selected ? "mc-selected" : "",
309
+ ]
310
+ .filter(Boolean)
311
+ .join(" ");
312
+ return (`<text class="${cls}" data-index="${w.index}" x="${num(w.x)}" ` +
313
+ `y="${num(w.y)}" font-size="${num(w.size)}" ` +
314
+ `aria-label="${escapeXml(`${w.text}: ${w.value}`)}" ` +
315
+ `${fillOf(w.color, w.colorIndex)}>${escapeXml(w.text)}</text>`);
316
+ })
317
+ .join("");
318
+ seriesParts.push(`<g class="mc-series mc-type-wordcloud" data-series-id="${escapeXml(g.seriesId)}">${words}</g>`);
319
+ }
320
+ else if (g.kind === "pictogram") {
321
+ // 픽토그램 — 행 라벨 + 사람 아이콘 그리드. 빈 아이콘은 윤곽만 (CSS)
322
+ const rows = g.rows
323
+ .map((r) => {
324
+ const icons = r.icons
325
+ .map((ic) => {
326
+ const cls = [
327
+ "mc-picto",
328
+ ic.filled ? "mc-picto-fill" : "mc-picto-empty",
329
+ ic.focused ? "mc-focused" : "",
330
+ ic.dimmed ? "mc-dimmed" : "",
331
+ ic.selected ? "mc-selected" : "",
332
+ ]
333
+ .filter(Boolean)
334
+ .join(" ");
335
+ return (`<path class="${cls}" d="${PERSON_ICON_D}" ` +
336
+ `transform="translate(${num(ic.x)} ${num(ic.y)}) scale(${num(ic.size / 24)})" ` +
337
+ `data-index="${ic.index}"` +
338
+ (ic.seriesId
339
+ ? ` data-series-id="${escapeXml(ic.seriesId)}"` +
340
+ ` aria-label="${escapeXml(`${ic.label}: ${ic.value ?? ""}`)}" ` +
341
+ fillOf(null, ic.colorIndex)
342
+ : "") +
343
+ `/>`);
344
+ })
345
+ .join("");
346
+ return (`<g class="mc-picto-row" data-index="${r.index}">` +
347
+ `<text class="mc-picto-label" x="${num(r.labelX)}" y="${num(r.labelY)}" ` +
348
+ `text-anchor="end" dominant-baseline="central">${escapeXml(r.label)}</text>` +
349
+ icons +
350
+ `</g>`);
351
+ })
352
+ .join("");
353
+ seriesParts.push(`<g class="mc-series mc-type-pictogram" data-series-id="${escapeXml(g.seriesId)}">${rows}</g>`);
354
+ }
355
+ else if (g.kind === "radar") {
356
+ // 레이더 — 폐합 폴리곤. radar-area는 패턴/색 채움 + 반투명 (CSS fill-opacity)
357
+ const pts = g.points
358
+ .map((p) => `<circle ${pointAttrs(p, `cx="${num(p.x)}" cy="${num(p.y)}" r="3"`)} ${col("fill")}/>`)
359
+ .join("");
360
+ const path = g.path
361
+ ? `<path class="mc-radar${g.area ? " mc-radar-area" : ""}" d="${g.path}" ` +
362
+ `${g.area ? fillOf(s?.color ?? null, s?.colorIndex ?? 0) : 'fill="none"'} ${col("stroke")}/>`
363
+ : "";
364
+ seriesParts.push(`<g class="mc-series mc-type-${g.area ? "radar-area" : "radar"}" data-series-id="${escapeXml(g.seriesId)}">${path}${pts}</g>`);
365
+ }
366
+ else if (g.kind === "range-bar") {
367
+ const bars = g.bars
368
+ .map((b) => {
369
+ const cls = [
370
+ "mc-range-bar",
371
+ b.focused ? "mc-focused" : "",
372
+ b.dimmed ? "mc-dimmed" : "",
373
+ b.selected ? "mc-selected" : "",
374
+ ]
375
+ .filter(Boolean)
376
+ .join(" ");
377
+ return `<rect class="${cls}" data-series-id="${escapeXml(b.seriesId)}" data-index="${b.index}" x="${num(b.x)}" y="${num(b.y)}" width="${num(b.w)}" height="${num(b.h)}" rx="2" aria-label="${escapeXml(`${b.label}: ${b.low}–${b.high}`)}" ${fillOf(b.color ?? s?.color ?? null, s?.colorIndex ?? 0)}/>`;
378
+ })
379
+ .join("");
380
+ seriesParts.push(`<g class="mc-series mc-type-range-bar${g.horizontal ? " mc-horizontal" : ""}" data-series-id="${escapeXml(g.seriesId)}">${bars}</g>`);
381
+ }
382
+ else if (g.kind === "range-area") {
383
+ const pts = g.points
384
+ .map((p) => `<circle ${pointAttrs(p, `cx="${num(p.x)}" cy="${num(p.y)}" r="3"`)} ${col("fill")}/>`)
385
+ .join("");
386
+ seriesParts.push(`<g class="mc-series mc-type-range-area" data-series-id="${escapeXml(g.seriesId)}">` +
387
+ (g.bandPath
388
+ ? `<path class="mc-range-area" d="${g.bandPath}" ${fillOf(s?.color ?? null, s?.colorIndex ?? 0)}/>`
389
+ : "") +
390
+ pts +
391
+ `</g>`);
392
+ }
393
+ else if (g.kind === "candlestick") {
394
+ const candles = g.candles
395
+ .map((c) => {
396
+ const cls = [
397
+ "mc-candle",
398
+ `mc-candle-${c.tone}`,
399
+ c.focused ? "mc-focused" : "",
400
+ c.dimmed ? "mc-dimmed" : "",
401
+ c.selected ? "mc-selected" : "",
402
+ ]
403
+ .filter(Boolean)
404
+ .join(" ");
405
+ // 증감 톤 — 시리즈 고정색이 없으면 up/down CSS 변수(waterfall과 공유)
406
+ const paintC = !s?.color && !patterns
407
+ ? mode.export
408
+ ? `fill="${c.tone === "up" ? "#16a34a" : "#dc2626"}" stroke="${c.tone === "up" ? "#16a34a" : "#dc2626"}"`
409
+ : `style="fill:var(--chart-${c.tone}-color);stroke:var(--chart-${c.tone}-color)"`
410
+ : `${fillOf(s?.color ?? null, s?.colorIndex ?? 0)} ${col("stroke")}`;
411
+ return (`<g class="${cls}" data-series-id="${escapeXml(c.seriesId)}" data-index="${c.index}" ` +
412
+ `aria-label="${escapeXml(`${c.label}: O${c.open} H${c.high} L${c.low} C${c.close}`)}">` +
413
+ // 심지 — 시리즈 고정색이면 인라인, 아니면 CSS가 톤 변수로 칠한다
414
+ `<line class="mc-candle-wick" x1="${num(c.cx)}" y1="${num(c.highY)}" x2="${num(c.cx)}" y2="${num(c.lowY)}" ` +
415
+ (s?.color
416
+ ? `style="stroke:${escapeXml(s.color)}"`
417
+ : mode.export
418
+ ? `stroke="${c.tone === "up" ? "#16a34a" : "#dc2626"}"`
419
+ : "") +
420
+ `/>` +
421
+ `<rect class="mc-candle-body" x="${num(c.x)}" y="${num(c.y)}" width="${num(c.w)}" height="${num(c.h)}" ${paintC}/>` +
422
+ `</g>`);
423
+ })
424
+ .join("");
425
+ seriesParts.push(`<g class="mc-series mc-type-candlestick" data-series-id="${escapeXml(g.seriesId)}">${candles}</g>`);
426
+ }
427
+ else if (g.kind === "ohlc") {
428
+ // ohlc — 캔들 바디 대신 시가(좌)/종가(우) 틱 + 고저 심지 선분.
429
+ // open/close의 y는 톤으로 복원한다: up=close가 위, down=open이 위
430
+ const bars2 = g.candles
431
+ .map((c) => {
432
+ const cls = [
433
+ "mc-ohlc",
434
+ `mc-ohlc-${c.tone}`,
435
+ c.focused ? "mc-focused" : "",
436
+ c.dimmed ? "mc-dimmed" : "",
437
+ c.selected ? "mc-selected" : "",
438
+ ]
439
+ .filter(Boolean)
440
+ .join(" ");
441
+ const openY = c.tone === "up" ? c.y + c.h : c.y;
442
+ const closeY = c.tone === "up" ? c.y : c.y + c.h;
443
+ const strokeC = s?.color
444
+ ? `style="stroke:${escapeXml(s.color)}"`
445
+ : mode.export
446
+ ? `stroke="${c.tone === "up" ? "#16a34a" : "#dc2626"}"`
447
+ : "";
448
+ return (`<g class="${cls}" data-series-id="${escapeXml(c.seriesId)}" data-index="${c.index}" ` +
449
+ `aria-label="${escapeXml(`${c.label}: O${c.open} H${c.high} L${c.low} C${c.close}`)}">` +
450
+ `<line class="mc-ohlc-stem" x1="${num(c.cx)}" y1="${num(c.highY)}" x2="${num(c.cx)}" y2="${num(c.lowY)}" ${strokeC}/>` +
451
+ `<line class="mc-ohlc-open" x1="${num(c.x)}" y1="${num(openY)}" x2="${num(c.cx)}" y2="${num(openY)}" ${strokeC}/>` +
452
+ `<line class="mc-ohlc-close" x1="${num(c.cx)}" y1="${num(closeY)}" x2="${num(c.x + c.w)}" y2="${num(closeY)}" ${strokeC}/>` +
453
+ `</g>`);
454
+ })
455
+ .join("");
456
+ seriesParts.push(`<g class="mc-series mc-type-ohlc" data-series-id="${escapeXml(g.seriesId)}">${bars2}</g>`);
457
+ }
458
+ else if (g.kind === "boxplot") {
459
+ // boxplot — 수염(min↔max) + 상자(Q1~Q3) + 중앙값 선 + 수염 캡
460
+ const boxes = g.boxes
461
+ .map((b) => {
462
+ const cls = [
463
+ "mc-box",
464
+ b.focused ? "mc-focused" : "",
465
+ b.dimmed ? "mc-dimmed" : "",
466
+ b.selected ? "mc-selected" : "",
467
+ ]
468
+ .filter(Boolean)
469
+ .join(" ");
470
+ const fillB = fillOf(b.color ?? s?.color ?? null, s?.colorIndex ?? 0);
471
+ const strokeB = colP("stroke", b.color);
472
+ const capW = b.w / 2;
473
+ return (`<g class="${cls}" data-series-id="${escapeXml(b.seriesId)}" data-index="${b.index}" ` +
474
+ `aria-label="${escapeXml(`${b.label}: ${b.min}–${b.max}, med ${b.median}`)}">` +
475
+ `<line class="mc-box-whisker" x1="${num(b.cx)}" y1="${num(b.highY)}" x2="${num(b.cx)}" y2="${num(b.lowY)}" ${strokeB}/>` +
476
+ `<line class="mc-box-cap" x1="${num(b.cx - capW / 2)}" y1="${num(b.highY)}" x2="${num(b.cx + capW / 2)}" y2="${num(b.highY)}" ${strokeB}/>` +
477
+ `<line class="mc-box-cap" x1="${num(b.cx - capW / 2)}" y1="${num(b.lowY)}" x2="${num(b.cx + capW / 2)}" y2="${num(b.lowY)}" ${strokeB}/>` +
478
+ `<rect class="mc-box-body" x="${num(b.x)}" y="${num(b.y)}" width="${num(b.w)}" height="${num(b.h)}" ${fillB} ${strokeB}/>` +
479
+ `<line class="mc-box-median" x1="${num(b.x)}" y1="${num(b.medY)}" x2="${num(b.x + b.w)}" y2="${num(b.medY)}" ${strokeB}/>` +
480
+ `</g>`);
481
+ })
482
+ .join("");
483
+ seriesParts.push(`<g class="mc-series mc-type-boxplot" data-series-id="${escapeXml(g.seriesId)}">${boxes}</g>`);
484
+ }
485
+ else if (g.kind === "map") {
486
+ // 지도 — 지역 폴리곤. 조인된 값이 있으면 스케일 색, 없으면 no-data
487
+ const regions = g.regions
488
+ .map((r) => {
489
+ const cls = [
490
+ "mc-map-region",
491
+ r.value == null ? "mc-map-no-data" : "",
492
+ r.focused ? "mc-focused" : "",
493
+ r.dimmed ? "mc-dimmed" : "",
494
+ r.selected ? "mc-selected" : "",
495
+ ]
496
+ .filter(Boolean)
497
+ .join(" ");
498
+ const fill = r.value == null
499
+ ? mode.export
500
+ ? 'fill="#e2e8f0"'
501
+ : ""
502
+ : mapFill(r.t, g, mode);
503
+ return (`<path class="${cls}" data-map-code="${escapeXml(r.code)}" ` +
504
+ `data-index="${r.index}" d="${r.path}" ` +
505
+ `aria-label="${escapeXml(`${r.name}: ${r.value ?? ""}`)}" ${fill}/>`);
506
+ })
507
+ .join("");
508
+ // 마커 레이어 — mapPoints의 투영된 원. image가 있으면 원형 클립
509
+ // 아바타(프로필 핀), pulse면 내 위치 펄스 링을 함께 그린다
510
+ const markers = g.markers
511
+ .map((m) => {
512
+ const cls = [
513
+ "mc-map-marker",
514
+ m.image ? "mc-map-avatar" : "",
515
+ m.focused ? "mc-focused" : "",
516
+ m.dimmed ? "mc-dimmed" : "",
517
+ m.selected ? "mc-selected" : "",
518
+ ]
519
+ .filter(Boolean)
520
+ .join(" ");
521
+ const aria = `aria-label="${escapeXml(`${m.label}: ${m.value ?? ""}`)}"`;
522
+ const pulseRing = m.pulse
523
+ ? `<circle class="mc-map-pulse" cx="${num(m.x)}" cy="${num(m.y)}" r="${num(m.r)}" fill="none"/>`
524
+ : "";
525
+ if (m.image) {
526
+ const cid = `${snap.clipId}-av${m.index}`;
527
+ return (`<g class="${cls}" data-index="${m.index}" ${aria}>` +
528
+ pulseRing +
529
+ `<circle class="mc-map-avatar-ring" cx="${num(m.x)}" cy="${num(m.y)}" r="${num(m.r)}" ` +
530
+ `${fillOf(m.color ?? s?.color ?? null, s?.colorIndex ?? 0)}/>` +
531
+ `<clipPath id="${escapeXml(cid)}"><circle cx="${num(m.x)}" cy="${num(m.y)}" r="${num(m.r - 1.5)}"/></clipPath>` +
532
+ `<image class="mc-map-avatar-img" href="${escapeXml(m.image)}" ` +
533
+ `x="${num(m.x - m.r)}" y="${num(m.y - m.r)}" width="${num(m.r * 2)}" height="${num(m.r * 2)}" ` +
534
+ `clip-path="url(#${escapeXml(cid)})" preserveAspectRatio="xMidYMid slice"/>` +
535
+ `</g>`);
536
+ }
537
+ return (pulseRing +
538
+ `<circle class="${cls}" data-index="${m.index}" ` +
539
+ `cx="${num(m.x)}" cy="${num(m.y)}" r="${num(m.r)}" ` +
540
+ `${aria} ` +
541
+ `${fillOf(m.color ?? s?.color ?? null, s?.colorIndex ?? 0)}/>`);
542
+ })
543
+ .join("");
544
+ // 클러스터 레이어 — map.cluster로 묶인 마커의 카운트 버블
545
+ const clusters = g.clusters
546
+ .map((c) => `<g class="mc-map-cluster" data-members="${c.members.join(",")}" ` +
547
+ `aria-label="${c.count} points">` +
548
+ `<circle class="mc-map-cluster-bg" cx="${num(c.x)}" cy="${num(c.y)}" r="${num(c.r)}"/>` +
549
+ `<text class="mc-map-cluster-count" x="${num(c.x)}" y="${num(c.y)}" ` +
550
+ `text-anchor="middle" dominant-baseline="central">${c.count}</text>` +
551
+ `</g>`)
552
+ .join("");
553
+ // 라인 레이어 — mapLines의 투영된 선분
554
+ const mlines = g.lines
555
+ .map((l) => `<path class="mc-map-line" data-index="${l.index}" d="${l.path}" fill="none" ` +
556
+ `${mode.export ? 'stroke="#64748b" stroke-width="1.5"' : ""}/>`)
557
+ .join("");
558
+ // panZoom — 지역/라인만 그룹 transform (마커는 코어가 좌표를 변환)
559
+ const tfAttr = g.transform ? ` transform="${g.transform}"` : "";
560
+ const geo = regions || mlines
561
+ ? `<g class="mc-map-geo"${tfAttr}>${regions}` +
562
+ (mlines ? `<g class="mc-map-lines">${mlines}</g>` : "") +
563
+ `</g>`
564
+ : "";
565
+ seriesParts.push(`<g class="mc-series mc-type-map" data-series-id="${escapeXml(g.seriesId)}">${geo}` +
566
+ (clusters ? `<g class="mc-map-clusters">${clusters}</g>` : "") +
567
+ (markers ? `<g class="mc-map-markers">${markers}</g>` : "") +
568
+ `</g>`);
569
+ }
570
+ else if (g.kind === "organization") {
571
+ // organization — 엘보 링크를 노드 박스 아래에 깐다
572
+ const links = g.links
573
+ .map((l) => `<path class="mc-org-link" d="${l.path}" fill="none" ` +
574
+ `${mode.export ? 'stroke="#94a3b8"' : ""}/>`)
575
+ .join("");
576
+ const nodes = g.nodes
577
+ .map((n) => {
578
+ const cls = [
579
+ "mc-org-node",
580
+ n.focused ? "mc-focused" : "",
581
+ n.dimmed ? "mc-dimmed" : "",
582
+ n.selected ? "mc-selected" : "",
583
+ ]
584
+ .filter(Boolean)
585
+ .join(" ");
586
+ return (`<rect class="${cls}" data-index="${n.index}" x="${num(n.x)}" ` +
587
+ `y="${num(n.y)}" width="${num(n.w)}" height="${num(n.h)}" rx="4" ` +
588
+ `${fillOf(n.color, n.colorIndex)} ` +
589
+ `aria-label="${escapeXml(`${n.name}: ${n.value}`)}"/>`);
590
+ })
591
+ .join("");
592
+ seriesParts.push(`<g class="mc-series mc-type-organization" data-series-id="${escapeXml(g.seriesId)}">` +
593
+ `<g class="mc-org-links">${links}</g>` +
594
+ `<g class="mc-org-nodes">${nodes}</g></g>`);
595
+ }
596
+ else if (g.kind === "sunburst") {
597
+ // sunburst — 깊이 링의 환형 섹터 path
598
+ const nodes = g.nodes
599
+ .map((n) => {
600
+ const cls = [
601
+ "mc-sunburst-node",
602
+ n.focused ? "mc-focused" : "",
603
+ n.dimmed ? "mc-dimmed" : "",
604
+ n.selected ? "mc-selected" : "",
605
+ ]
606
+ .filter(Boolean)
607
+ .join(" ");
608
+ return (`<path class="${cls}" data-index="${n.index}" d="${n.path}" ` +
609
+ `${fillOf(n.color, n.colorIndex)} ` +
610
+ `aria-label="${escapeXml(`${n.name}: ${n.value}`)}"/>`);
611
+ })
612
+ .join("");
613
+ seriesParts.push(`<g class="mc-series mc-type-sunburst" data-series-id="${escapeXml(g.seriesId)}">${nodes}</g>`);
614
+ }
615
+ else if (g.kind === "treemap") {
616
+ // treemap — 리프 rect. 이름 라벨은 dataLabels 채널로 나간다
617
+ const nodes = g.nodes
618
+ .map((n) => {
619
+ const cls = [
620
+ "mc-treemap-node",
621
+ n.focused ? "mc-focused" : "",
622
+ n.dimmed ? "mc-dimmed" : "",
623
+ n.selected ? "mc-selected" : "",
624
+ ]
625
+ .filter(Boolean)
626
+ .join(" ");
627
+ return (`<rect class="${cls}" data-index="${n.index}" x="${num(n.x)}" ` +
628
+ `y="${num(n.y)}" width="${num(n.w)}" height="${num(n.h)}" ` +
629
+ `${fillOf(n.color, n.colorIndex)} ` +
630
+ `aria-label="${escapeXml(`${n.name}: ${n.value}`)}"/>`);
631
+ })
632
+ .join("");
633
+ seriesParts.push(`<g class="mc-series mc-type-treemap" data-series-id="${escapeXml(g.seriesId)}">${nodes}</g>`);
634
+ }
635
+ else if (g.kind === "sankey") {
636
+ // sankey — 리본을 노드 아래에 깔고 노드 rect를 위에
637
+ const links = g.links
638
+ .map((l) => `<path class="mc-sankey-link" data-source="${l.sourceIndex}" ` +
639
+ `data-target="${l.targetIndex}" d="${l.path}" ` +
640
+ `${fillOf(null, l.colorIndex)}/>`)
641
+ .join("");
642
+ const nodes = g.nodes
643
+ .map((n) => {
644
+ const cls = [
645
+ "mc-sankey-node",
646
+ n.focused ? "mc-focused" : "",
647
+ n.dimmed ? "mc-dimmed" : "",
648
+ n.selected ? "mc-selected" : "",
649
+ ]
650
+ .filter(Boolean)
651
+ .join(" ");
652
+ return (`<rect class="${cls}" data-index="${n.index}" x="${num(n.x)}" ` +
653
+ `y="${num(n.y)}" width="${num(n.w)}" height="${num(n.h)}" ` +
654
+ `${fillOf(n.color, n.colorIndex)} ` +
655
+ `aria-label="${escapeXml(`${n.name}: ${n.value}`)}"/>`);
656
+ })
657
+ .join("");
658
+ seriesParts.push(`<g class="mc-series mc-type-sankey" data-series-id="${escapeXml(g.seriesId)}">` +
659
+ `<g class="mc-sankey-links">${links}</g>` +
660
+ `<g class="mc-sankey-nodes">${nodes}</g></g>`);
661
+ }
662
+ else if (g.kind === "chord") {
663
+ // chord — 리본을 그룹 아크 아래에 깐다
664
+ const ribbons = g.ribbons
665
+ .map((rb) => `<path class="mc-chord-ribbon" data-source="${rb.sourceIndex}" ` +
666
+ `data-target="${rb.targetIndex}" d="${rb.path}" ` +
667
+ `${fillOf(null, rb.colorIndex)}/>`)
668
+ .join("");
669
+ const groups = g.groups
670
+ .map((gr) => {
671
+ const cls = [
672
+ "mc-chord-group",
673
+ gr.focused ? "mc-focused" : "",
674
+ gr.dimmed ? "mc-dimmed" : "",
675
+ gr.selected ? "mc-selected" : "",
676
+ ]
677
+ .filter(Boolean)
678
+ .join(" ");
679
+ return (`<path class="${cls}" data-index="${gr.index}" d="${gr.path}" ` +
680
+ `${fillOf(gr.color, gr.colorIndex)} ` +
681
+ `aria-label="${escapeXml(`${gr.name}: ${gr.value}`)}"/>`);
682
+ })
683
+ .join("");
684
+ seriesParts.push(`<g class="mc-series mc-type-chord" data-series-id="${escapeXml(g.seriesId)}">` +
685
+ `<g class="mc-chord-ribbons">${ribbons}</g>` +
686
+ `<g class="mc-chord-groups">${groups}</g></g>`);
687
+ }
688
+ else if (g.kind === "nightingale" ||
689
+ g.kind === "radial-bar" ||
690
+ g.kind === "radial-column") {
691
+ // 극좌표 섹터 — 환형/부채꼴 path. 클래스는 타입별로 나눈다
692
+ const cls0 = g.kind === "nightingale"
693
+ ? "mc-nightingale-node"
694
+ : g.kind === "radial-column"
695
+ ? "mc-radial-column"
696
+ : "mc-radial-bar";
697
+ const nodes = g.sectors
698
+ .map((s) => {
699
+ const cls = [
700
+ cls0,
701
+ s.focused ? "mc-focused" : "",
702
+ s.dimmed ? "mc-dimmed" : "",
703
+ s.selected ? "mc-selected" : "",
704
+ ]
705
+ .filter(Boolean)
706
+ .join(" ");
707
+ return (`<path class="${cls}" data-index="${s.index}" ` +
708
+ `data-series-id="${escapeXml(s.seriesId)}" d="${s.path}" ` +
709
+ `${fillOf(s.color, s.colorIndex)} ` +
710
+ `aria-label="${escapeXml(`${s.name}: ${s.value}`)}"/>`);
711
+ })
712
+ .join("");
713
+ seriesParts.push(`<g class="mc-series mc-type-${g.kind}" data-series-id="${escapeXml(g.seriesId)}">${nodes}</g>`);
714
+ }
715
+ else if (g.kind === "gauge") {
716
+ // 게이지 — 트랙 아크 + 값 아크 + 바늘 + 중앙 라벨 + min/max 눈금
717
+ const gv = g.gauge;
718
+ const vcls = [
719
+ "mc-gauge-value",
720
+ gv.focused ? "mc-focused" : "",
721
+ gv.dimmed ? "mc-dimmed" : "",
722
+ gv.selected ? "mc-selected" : "",
723
+ ]
724
+ .filter(Boolean)
725
+ .join(" ");
726
+ const target = gv.target
727
+ ? `<line class="mc-gauge-target" x1="${num(gv.target.x1)}" y1="${num(gv.target.y1)}" ` +
728
+ `x2="${num(gv.target.x2)}" y2="${num(gv.target.y2)}"/>`
729
+ : "";
730
+ const ticks = gv.ticks
731
+ .map((t) => `<text class="mc-gauge-tick" x="${num(t.x)}" y="${num(t.y)}" ` +
732
+ `text-anchor="middle">${escapeXml(t.text)}</text>`)
733
+ .join("");
734
+ seriesParts.push(`<g class="mc-series mc-type-gauge" data-series-id="${escapeXml(g.seriesId)}">` +
735
+ `<path class="mc-gauge-track" d="${gv.trackPath}"/>` +
736
+ (gv.valuePath
737
+ ? `<path class="${vcls}" data-index="0" d="${gv.valuePath}" ` +
738
+ `${fillOf(gv.color, gv.colorIndex)} aria-label="${escapeXml(`value: ${gv.value}`)}"/>`
739
+ : "") +
740
+ target +
741
+ `<line class="mc-gauge-needle" x1="${num(gv.needle.x1)}" y1="${num(gv.needle.y1)}" ` +
742
+ `x2="${num(gv.needle.x2)}" y2="${num(gv.needle.y2)}"/>` +
743
+ `<circle class="mc-gauge-hub" cx="${num(gv.needle.x1)}" cy="${num(gv.needle.y1)}" r="4"/>` +
744
+ `<text class="mc-gauge-label" x="${num(gv.label.x)}" y="${num(gv.label.y)}" ` +
745
+ `text-anchor="middle">${escapeXml(gv.label.text)}</text>` +
746
+ ticks +
747
+ `</g>`);
748
+ }
749
+ else if (g.kind === "linear-gauge") {
750
+ // linear-gauge — 수평 트랙 + 값 채움 바 + 목표 눈금 + 라벨
751
+ const gv = g.gauge;
752
+ const vcls = [
753
+ "mc-lg-value",
754
+ gv.focused ? "mc-focused" : "",
755
+ gv.dimmed ? "mc-dimmed" : "",
756
+ gv.selected ? "mc-selected" : "",
757
+ ]
758
+ .filter(Boolean)
759
+ .join(" ");
760
+ const target = gv.target
761
+ ? `<line class="mc-lg-target" x1="${num(gv.target.x)}" y1="${num(gv.target.y0)}" ` +
762
+ `x2="${num(gv.target.x)}" y2="${num(gv.target.y1)}"/>`
763
+ : "";
764
+ const ticks = gv.ticks
765
+ .map((t, ti) => `<text class="mc-lg-tick" x="${num(t.x)}" y="${num(t.y)}" ` +
766
+ `text-anchor="${ti === 0 ? "start" : "end"}">${escapeXml(t.text)}</text>`)
767
+ .join("");
768
+ seriesParts.push(`<g class="mc-series mc-type-linear-gauge" data-series-id="${escapeXml(g.seriesId)}">` +
769
+ `<rect class="mc-lg-track" x="${num(gv.track.x)}" y="${num(gv.track.y)}" ` +
770
+ `width="${num(gv.track.w)}" height="${num(gv.track.h)}" rx="3"/>` +
771
+ `<rect class="${vcls}" data-index="0" x="${num(gv.bar.x)}" y="${num(gv.bar.y)}" ` +
772
+ `width="${num(gv.bar.w)}" height="${num(gv.bar.h)}" rx="3" ` +
773
+ `${fillOf(gv.color, gv.colorIndex)} aria-label="${escapeXml(`value: ${gv.value}`)}"/>` +
774
+ target +
775
+ `<text class="mc-lg-label" x="${num(gv.label.x)}" y="${num(gv.label.y)}" ` +
776
+ `text-anchor="middle">${escapeXml(gv.label.text)}</text>` +
777
+ ticks +
778
+ `</g>`);
779
+ }
780
+ }
781
+ const out = [];
782
+ const defs = [
783
+ `<clipPath id="${escapeXml(snap.clipId)}"><rect x="${num(plot.x)}" y="${num(plot.y)}" width="${num(plot.w)}" height="${num(plot.h)}"/></clipPath>`,
784
+ patterns ? patterns.defs(mode) : "",
785
+ ];
786
+ // 지도 값 범례 그라데이션 — .mc-map-scale 바가 참조
787
+ if (snap.mapScale) {
788
+ const ms = snap.mapScale;
789
+ const f = ms.from ?? (mode.export ? "#dbeafe" : "var(--chart-map-from)");
790
+ const t2 = ms.to ?? (mode.export ? "#1d4ed8" : "var(--chart-map-to)");
791
+ defs.push(`<linearGradient id="${escapeXml(snap.clipId)}-mapscale" x1="0" x2="1" y1="0" y2="0">` +
792
+ `<stop offset="0" stop-color="${escapeXml(f)}"/>` +
793
+ `<stop offset="1" stop-color="${escapeXml(t2)}"/></linearGradient>`);
794
+ }
795
+ out.push(`<defs>${defs.join("")}</defs>`);
796
+ out.push(gridMarkup(snap, mode));
797
+ // 레이더 그리드 — 동심원 링 + 방사 스포크 (직교 축 대체)
798
+ if (snap.radar) {
799
+ const rg = snap.radar;
800
+ const stroke = mode.export ? 'stroke="#e2e8f0"' : "";
801
+ const fill = mode.export ? 'fill="#64748b" font-size="10"' : "";
802
+ const rings = rg.rings
803
+ .map((r) => `<circle class="mc-radar-ring" cx="${num(rg.cx)}" cy="${num(rg.cy)}" r="${num(r.r)}" fill="none" ${stroke}/>` +
804
+ `<text class="mc-radar-ring-label" x="${num(rg.cx + 3)}" y="${num(rg.cy - r.r + 3)}" ${fill}>${escapeXml(r.label)}</text>`)
805
+ .join("");
806
+ const spokes = rg.spokes
807
+ .map((sp) => `<line class="mc-radar-spoke" x1="${num(rg.cx)}" y1="${num(rg.cy)}" x2="${num(sp.x2)}" y2="${num(sp.y2)}" ${stroke}/>` +
808
+ `<text class="mc-spoke-label" x="${num(sp.lx)}" y="${num(sp.ly)}" text-anchor="middle" ${fill}>${escapeXml(sp.label)}</text>`)
809
+ .join("");
810
+ out.push(`<g class="mc-radar-grid">${rings}${spokes}</g>`);
811
+ }
812
+ // 값 축 기준 영역 — 그리드 위, 시리즈 아래
813
+ if (snap.markBands.length) {
814
+ const bands = snap.markBands
815
+ .map((b) => {
816
+ const fill = b.color
817
+ ? mode.export
818
+ ? `fill="${escapeXml(b.color)}" fill-opacity="0.12"`
819
+ : `style="fill:${escapeXml(b.color)};fill-opacity:0.12"`
820
+ : mode.export
821
+ ? 'fill="#f43f5e" fill-opacity="0.08"'
822
+ : "";
823
+ return (`<g class="mc-mark"><rect class="mc-mark-band" x="${num(b.x)}" y="${num(b.y)}" width="${num(b.w)}" height="${num(b.h)}" ${fill}/>` +
824
+ (b.label
825
+ ? `<text class="mc-mark-label" x="${num(b.lx)}" y="${num(b.ly)}" ${mode.export ? `fill="${escapeXml(b.color ?? "#f43f5e")}"` : ""}>${escapeXml(b.label)}</text>`
826
+ : "") +
827
+ `</g>`);
828
+ })
829
+ .join("");
830
+ out.push(`<g class="mc-mark-bands">${bands}</g>`);
831
+ }
832
+ for (const a of snap.axes)
833
+ out.push(axisMarkup(a, snap, mode));
834
+ const clip = snap.geometry.some((g) => g.kind !== "pie" &&
835
+ g.kind !== "funnel" &&
836
+ g.kind !== "cone-funnel" &&
837
+ g.kind !== "pyramid")
838
+ ? ` clip-path="url(#${escapeXml(snap.clipId)})"`
839
+ : "";
840
+ out.push(`<g class="mc-plot"${clip}>${seriesParts.join("")}</g>`);
841
+ // 에러바 — 시리즈 위 오버레이 (포인트별 ±오차, 수직 막대 + 캡)
842
+ if (snap.errors.length) {
843
+ const bars = snap.errors
844
+ .map((e) => `<path class="mc-error" data-series-id="${escapeXml(e.seriesId)}" data-index="${e.index}" ` +
845
+ `d="M${num(e.x - 4)} ${num(e.yLo)} H${num(e.x + 4)} M${num(e.x)} ${num(e.yLo)} V${num(e.yHi)} M${num(e.x - 4)} ${num(e.yHi)} H${num(e.x + 4)}" ` +
846
+ `fill="none" ${mode.export ? 'stroke="#64748b"' : ""}/>`)
847
+ .join("");
848
+ out.push(`<g class="mc-errors"${clip}>${bars}</g>`);
849
+ }
850
+ // 추세선 — 시리즈 위 오버레이 (series.trend)
851
+ if (snap.trendLines.length) {
852
+ const tls = snap.trendLines
853
+ .map((t) => `<path class="mc-trendline" data-series-id="${escapeXml(t.seriesId)}" d="${t.path}" fill="none" ` +
854
+ (mode.export
855
+ ? `stroke="${escapeXml(seriesColor(t.color, t.colorIndex, mode))}" stroke-dasharray="6 3"`
856
+ : `style="stroke:${escapeXml(seriesColor(t.color, t.colorIndex, mode))}"`) +
857
+ `/>`)
858
+ .join("");
859
+ out.push(`<g class="mc-trendlines"${clip}>${tls}</g>`);
860
+ }
861
+ // 값 라벨 — clip 바깥에 두어 경계에서 잘리지 않게 한다
862
+ if (snap.dataLabels.length) {
863
+ const labels = snap.dataLabels
864
+ .map((d) => `<text class="mc-data-label" x="${num(d.x)}" y="${num(d.y)}" text-anchor="${d.anchor}" ${mode.export ? 'fill="#475569" font-size="10"' : ""}>${escapeXml(d.text)}</text>`)
865
+ .join("");
866
+ out.push(`<g class="mc-data-labels">${labels}</g>`);
867
+ }
868
+ // 값 축 기준선 — 플롯 위, 시리즈 아래가 아니라 위에 둔다 (가시성)
869
+ for (const m of snap.markLines) {
870
+ const stroke = m.color
871
+ ? `stroke="${escapeXml(m.color)}"`
872
+ : mode.export
873
+ ? 'stroke="#f43f5e"'
874
+ : "";
875
+ out.push(`<g class="mc-mark"><line class="mc-mark-line" x1="${num(m.x1)}" y1="${num(m.y1)}" x2="${num(m.x2)}" y2="${num(m.y2)}" ${stroke}/>` +
876
+ (m.label
877
+ ? `<text class="mc-mark-label" x="${num(m.lx)}" y="${num(m.ly)}" text-anchor="end" ${mode.export ? `fill="${escapeXml(m.color ?? "#f43f5e")}"` : ""}>${escapeXml(m.label)}</text>`
878
+ : "") +
879
+ `</g>`);
880
+ }
881
+ // 인라인 주석 — 데이터 좌표에 텍스트/이미지 (clip 바깥, 시리즈 위)
882
+ if (snap.annotations.length) {
883
+ const anns = snap.annotations
884
+ .map((a) => `<g class="mc-annotation" data-key="${escapeXml(a.key)}">` +
885
+ `<circle class="mc-annotation-dot" cx="${num(a.x)}" cy="${num(a.y)}" r="2.5" ${mode.export ? 'fill="#64748b"' : ""}/>` +
886
+ (a.image
887
+ ? `<image class="mc-annotation-image" href="${escapeXml(a.image)}" x="${num(a.x - a.w / 2)}" y="${num(a.y - a.h - 4)}" width="${num(a.w)}" height="${num(a.h)}"/>`
888
+ : "") +
889
+ (a.label
890
+ ? `<text class="mc-annotation-label" x="${num(a.x)}" y="${num(a.y - (a.image ? a.h + 8 : 8))}" text-anchor="middle" ${mode.export ? 'fill="#475569" font-size="10"' : ""}>${escapeXml(a.label)}</text>`
891
+ : "") +
892
+ `</g>`)
893
+ .join("");
894
+ out.push(`<g class="mc-annotations">${anns}</g>`);
895
+ }
896
+ // 그린 주석 — setDrawMode 드래그로 만든 선/영역 (clip 바깥, 시리즈 위)
897
+ if (snap.drawings.length) {
898
+ const ds = snap.drawings
899
+ .map((d) => {
900
+ const col = escapeXml(seriesColor(d.color, d.colorIndex, mode));
901
+ const cls = `mc-draw mc-draw-${d.tool}${d.draft ? " mc-draw-draft" : ""}`;
902
+ const strokeAttr = mode.export
903
+ ? `stroke="${col}" stroke-width="1.5"${d.draft ? ' stroke-dasharray="4 3"' : ""}`
904
+ : `style="stroke:${col}${d.draft ? ";stroke-dasharray:4 3" : ""}"`;
905
+ const fillAttr = mode.export
906
+ ? `fill="${col}" fill-opacity="0.1" stroke="${col}" stroke-width="1.5"${d.draft ? ' stroke-dasharray="4 3"' : ""}`
907
+ : `style="stroke:${col};fill:${col};fill-opacity:0.1${d.draft ? ";stroke-dasharray:4 3" : ""}"`;
908
+ const label = d.label
909
+ ? `<text class="mc-draw-label" x="${num((d.x1 + d.x2) / 2)}" y="${num(Math.min(d.y1, d.y2) - 4)}" text-anchor="middle" ${mode.export ? `fill="${col}" font-size="10"` : `style="fill:${col}"`}>${escapeXml(d.label)}</text>`
910
+ : "";
911
+ if (d.tool === "rect")
912
+ return (`<g class="${cls}" data-key="${escapeXml(d.key)}">` +
913
+ `<rect class="mc-draw-box" x="${num(Math.min(d.x1, d.x2))}" y="${num(Math.min(d.y1, d.y2))}" width="${num(Math.abs(d.x2 - d.x1))}" height="${num(Math.abs(d.y2 - d.y1))}" ${fillAttr}/>` +
914
+ label +
915
+ `</g>`);
916
+ if (d.tool === "channel") {
917
+ // 추세선 + 평행선 + 사이 음영 밴드 — 오프셋은 선분의 수직 방향
918
+ const w = d.width ?? 16;
919
+ const dx = d.x2 - d.x1;
920
+ const dy = d.y2 - d.y1;
921
+ const len = Math.hypot(dx, dy) || 1;
922
+ const nx = (-dy / len) * w;
923
+ const ny = (dx / len) * w;
924
+ const bandFill = mode.export
925
+ ? `fill="${col}" fill-opacity="0.08"`
926
+ : `style="fill:${col};fill-opacity:0.08"`;
927
+ return (`<g class="${cls}" data-key="${escapeXml(d.key)}">` +
928
+ `<polygon class="mc-draw-band" points="${num(d.x1)},${num(d.y1)} ${num(d.x2)},${num(d.y2)} ${num(d.x2 + nx)},${num(d.y2 + ny)} ${num(d.x1 + nx)},${num(d.y1 + ny)}" ${bandFill}/>` +
929
+ `<line class="mc-draw-seg" x1="${num(d.x1)}" y1="${num(d.y1)}" x2="${num(d.x2)}" y2="${num(d.y2)}" ${strokeAttr}/>` +
930
+ `<line class="mc-draw-seg mc-draw-parallel" x1="${num(d.x1 + nx)}" y1="${num(d.y1 + ny)}" x2="${num(d.x2 + nx)}" y2="${num(d.y2 + ny)}" ${strokeAttr}/>` +
931
+ label +
932
+ `</g>`);
933
+ }
934
+ if (d.tool === "fibonacci") {
935
+ // 되돌림 레벨 — y1..y2 구간을 고정 비율로 나눈 수평선들
936
+ const FIB = [0, 0.236, 0.382, 0.5, 0.618, 0.786, 1];
937
+ const labelAttr = mode.export
938
+ ? `fill="${col}" font-size="9"`
939
+ : `style="fill:${col}"`;
940
+ const levels = FIB.map((r) => {
941
+ const y = d.y1 + (d.y2 - d.y1) * r;
942
+ return (`<line class="mc-draw-fib-level" x1="${num(d.x1)}" y1="${num(y)}" x2="${num(d.x2)}" y2="${num(y)}" ${strokeAttr}/>` +
943
+ `<text class="mc-draw-fib-label" x="${num(d.x2 + 3)}" y="${num(y + 3)}" font-size="9" ${labelAttr}>${r * 100}%</text>`);
944
+ }).join("");
945
+ return (`<g class="${cls}" data-key="${escapeXml(d.key)}">` +
946
+ levels +
947
+ label +
948
+ `</g>`);
949
+ }
950
+ return (`<g class="${cls}" data-key="${escapeXml(d.key)}">` +
951
+ `<line class="mc-draw-seg" x1="${num(d.x1)}" y1="${num(d.y1)}" x2="${num(d.x2)}" y2="${num(d.y2)}" ${strokeAttr}/>` +
952
+ label +
953
+ `</g>`);
954
+ })
955
+ .join("");
956
+ out.push(`<g class="mc-drawings">${ds}</g>`);
957
+ }
958
+ if (snap.crosshair != null)
959
+ out.push(`<line class="mc-crosshair" x1="${num(snap.crosshair)}" y1="${num(plot.y)}" x2="${num(snap.crosshair)}" y2="${num(plot.y + plot.h)}" ${mode.export ? 'stroke="#94a3b8" stroke-dasharray="4 3"' : ""}/>`);
960
+ if (snap.selection)
961
+ out.push(`<rect class="mc-selection" x="${num(snap.selection.x)}" y="${num(snap.selection.y ?? plot.y)}" width="${num(snap.selection.w)}" height="${num(snap.selection.h ?? plot.h)}" ${mode.export ? 'fill="#2563eb" fill-opacity="0.12"' : ""}/>`);
962
+ if (snap.empty)
963
+ out.push(`<text class="mc-empty" x="${num(snap.width / 2)}" y="${num(snap.height / 2)}" text-anchor="middle" ${mode.export ? 'fill="#94a3b8"' : ""}>${escapeXml(snap.emptyText)}</text>`);
964
+ // 네비게이터(미니맵) — 플롯 아래 전체 도메인 개요 + 줌 창/핸들
965
+ if (snap.navigator) {
966
+ const n = snap.navigator;
967
+ const tracks = n.tracks
968
+ .map((t) => `<path class="mc-nav-track" d="${t.path}" fill="none" ` +
969
+ (mode.export
970
+ ? `stroke="${escapeXml(seriesColor(t.color, t.colorIndex, mode))}"`
971
+ : `style="stroke:${escapeXml(seriesColor(t.color, t.colorIndex, mode))}"`) +
972
+ `/>`)
973
+ .join("");
974
+ const winFill = mode.export
975
+ ? 'fill="#2563eb" fill-opacity="0.08" stroke="#2563eb" stroke-opacity="0.35"'
976
+ : "";
977
+ const handleFill = mode.export ? 'fill="#2563eb" fill-opacity="0.3"' : "";
978
+ out.push(`<g class="mc-navigator">` +
979
+ `<rect class="mc-nav-bg" x="${num(n.left)}" y="${num(n.y)}" width="${num(n.right - n.left)}" height="${num(n.h)}" ${mode.export ? 'fill="#f1f5f9"' : ""}/>` +
980
+ tracks +
981
+ `<rect class="mc-nav-window" x="${num(n.window.x)}" y="${num(n.y)}" width="${num(n.window.w)}" height="${num(n.h)}" ${winFill}/>` +
982
+ `<rect class="mc-nav-handle mc-nav-handle-l" x="${num(n.window.x - 3)}" y="${num(n.y)}" width="6" height="${num(n.h)}" rx="2" ${handleFill}/>` +
983
+ `<rect class="mc-nav-handle mc-nav-handle-r" x="${num(n.window.x + n.window.w - 3)}" y="${num(n.y)}" width="6" height="${num(n.h)}" rx="2" ${handleFill}/>` +
984
+ `</g>`);
985
+ }
986
+ // 스크롤바 — 미니 시리즈 없는 얇은 줌 스트립 (.mc-scrollbar)
987
+ if (snap.scrollbar) {
988
+ const n = snap.scrollbar;
989
+ const winFill = mode.export
990
+ ? 'fill="#2563eb" fill-opacity="0.08" stroke="#2563eb" stroke-opacity="0.35"'
991
+ : "";
992
+ const handleFill = mode.export ? 'fill="#2563eb" fill-opacity="0.3"' : "";
993
+ out.push(`<g class="mc-scrollbar">` +
994
+ `<rect class="mc-sb-bg" x="${num(n.left)}" y="${num(n.y)}" width="${num(n.right - n.left)}" height="${num(n.h)}" rx="${num(n.h / 2)}" ${mode.export ? 'fill="#f1f5f9"' : ""}/>` +
995
+ `<rect class="mc-sb-window" x="${num(n.window.x)}" y="${num(n.y + 2)}" width="${num(n.window.w)}" height="${num(n.h - 4)}" rx="${num((n.h - 4) / 2)}" ${winFill}/>` +
996
+ `<rect class="mc-sb-handle mc-sb-handle-l" x="${num(n.window.x - 3)}" y="${num(n.y + 2)}" width="6" height="${num(n.h - 4)}" rx="3" ${handleFill}/>` +
997
+ `<rect class="mc-sb-handle mc-sb-handle-r" x="${num(n.window.x + n.window.w - 3)}" y="${num(n.y + 2)}" width="6" height="${num(n.h - 4)}" rx="3" ${handleFill}/>` +
998
+ `</g>`);
999
+ }
1000
+ // 지도 값 범례 — 플롯 우하단 그라데이션 바 + min/max 라벨
1001
+ if (snap.mapScale) {
1002
+ const ms = snap.mapScale;
1003
+ const bx = plot.x + plot.w - 96;
1004
+ const by = plot.y + plot.h - 14;
1005
+ const lbl = mode.export ? 'fill="#64748b" font-size="10"' : "";
1006
+ out.push(`<g class="mc-map-scale">` +
1007
+ `<rect class="mc-map-scale-bar" x="${num(bx)}" y="${num(by)}" width="80" height="8" fill="url(#${escapeXml(snap.clipId)}-mapscale)"/>` +
1008
+ `<text class="mc-map-scale-label" x="${num(bx)}" y="${num(by - 2)}" text-anchor="start" ${lbl}>${escapeXml(formatNumber(ms.min))}</text>` +
1009
+ `<text class="mc-map-scale-label" x="${num(bx + 80)}" y="${num(by - 2)}" text-anchor="end" ${lbl}>${escapeXml(formatNumber(ms.max))}</text>` +
1010
+ `</g>`);
1011
+ }
1012
+ return out.join("");
1013
+ }
1014
+ /** 독립 SVG 문서 — toSVGString / PNG 변환의 소스 */
1015
+ export function svgString(snap) {
1016
+ return (`<svg xmlns="http://www.w3.org/2000/svg" width="${snap.width}" height="${snap.height}" ` +
1017
+ `viewBox="0 0 ${snap.width} ${snap.height}" font-family="system-ui, sans-serif" font-size="11">` +
1018
+ `<rect width="100%" height="100%" fill="${snap.theme === "dark" ? "#0f172a" : "#ffffff"}"/>` +
1019
+ svgInner(snap, { export: true }) +
1020
+ `</svg>`);
1021
+ }
1022
+ //# sourceMappingURL=svg.js.map