@funnest/charts-cli 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js ADDED
@@ -0,0 +1,935 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { mkdir, readFile, writeFile } from "fs/promises";
5
+ import { dirname, extname, join, resolve } from "path";
6
+
7
+ // src/data.ts
8
+ function parseCsv(text) {
9
+ const rows = [];
10
+ let field = "";
11
+ let row = [];
12
+ let quoted = false;
13
+ for (let i = 0; i < text.length; i++) {
14
+ const c = text[i];
15
+ if (quoted) {
16
+ if (c === '"' && text[i + 1] === '"') {
17
+ field += '"';
18
+ i++;
19
+ } else if (c === '"') quoted = false;
20
+ else field += c;
21
+ } else if (c === '"') quoted = true;
22
+ else if (c === ",") {
23
+ row.push(field);
24
+ field = "";
25
+ } else if (c === "\n" || c === "\r") {
26
+ if (c === "\r" && text[i + 1] === "\n") i++;
27
+ row.push(field);
28
+ rows.push(row);
29
+ row = [];
30
+ field = "";
31
+ } else field += c;
32
+ }
33
+ if (field !== "" || row.length) {
34
+ row.push(field);
35
+ rows.push(row);
36
+ }
37
+ const [header, ...body] = rows.filter((r) => r.some((v) => v.trim() !== ""));
38
+ if (!header) return [];
39
+ const keys = header.map((h) => h.trim().replace(/^/, ""));
40
+ return body.map(
41
+ (r) => Object.fromEntries(
42
+ keys.map((k, i) => {
43
+ const raw = (r[i] ?? "").trim();
44
+ const n = raw.replace(/,/g, "");
45
+ return [k, raw !== "" && Number.isFinite(Number(n)) && /^-?[\d,.]+$/.test(raw) ? Number(n) : raw];
46
+ })
47
+ )
48
+ );
49
+ }
50
+ function parseData(text, filename) {
51
+ if (/\.csv$/i.test(filename)) return parseCsv(text);
52
+ const json = JSON.parse(text);
53
+ if (Array.isArray(json)) return json;
54
+ if (json && typeof json === "object" && Array.isArray(json.data)) return json.data;
55
+ throw new Error(`${filename}: expected an array of rows`);
56
+ }
57
+ var DATE = /^\d{4}-\d{2}(-\d{2})?([T ][\d:.]+Z?)?$|^\d{1,2}\/\d{1,2}$|^\d{1,2}월$|^\d{4}\.\d{1,2}(\.\d{1,2})?$/;
58
+ function suggestCharts(rows) {
59
+ if (rows.length === 0) return [];
60
+ const cols = Object.keys(rows[0]);
61
+ const numeric = cols.filter((c) => rows.every((r) => typeof r[c] === "number" || r[c] === null || r[c] === ""));
62
+ const text = cols.filter((c) => !numeric.includes(c));
63
+ const time = text.find((c) => rows.every((r) => DATE.test(String(r[c]))));
64
+ const category = text.find((c) => c !== time);
65
+ const out = [];
66
+ const allPositive = (c) => rows.every((r) => typeof r[c] !== "number" || r[c] >= 0);
67
+ const hasNegative = (c) => rows.some((r) => typeof r[c] === "number" && r[c] < 0);
68
+ if (time && numeric.length) {
69
+ const magnitude = (c) => Math.max(...rows.map((r) => Math.abs(Number(r[c]) || 0)));
70
+ const base = numeric.find(allPositive) ?? numeric[0];
71
+ const similar = numeric.filter((c) => allPositive(c) && magnitude(c) > 0 && magnitude(base) / magnitude(c) < 10 && magnitude(c) / magnitude(base) < 10);
72
+ for (const c of numeric.slice(0, 4)) {
73
+ if (hasNegative(c)) {
74
+ out.push({
75
+ reason: { ko: `${c}\uC5D0 \uC74C\uC218\uAC00 \uC788\uC5B4 0 \uAE30\uC900 \uB9C9\uB300(\uBE68\uAC15/\uD30C\uB791)\uAC00 \uC798 \uB9DE\uC544\uC694`, en: `${c} has negatives: bars around 0` },
76
+ spec: { type: "bar", data: rows, x: time, y: c, colorBySign: true, referenceLines: [{ y: 0 }] }
77
+ });
78
+ } else if (!similar.includes(c) || similar.length === 1) {
79
+ out.push({
80
+ reason: { ko: `${time}\uC5D0 \uB530\uB978 ${c} \uD750\uB984`, en: `${c} over ${time}` },
81
+ spec: { type: "line", data: rows, x: time, y: c, area: "gradient" }
82
+ });
83
+ }
84
+ }
85
+ if (similar.length > 1) {
86
+ out.push({
87
+ reason: { ko: `\uBE44\uC2B7\uD55C \uD06C\uAE30\uC758 ${similar.join(", ")}\uB97C \uD55C \uCC28\uD2B8\uC5D0\uC11C \uBE44\uAD50`, en: `Compare ${similar.join(", ")} (similar scale)` },
88
+ spec: { type: "line", data: rows, x: time, y: similar.slice(0, 5) }
89
+ });
90
+ out.push({
91
+ reason: { ko: "\uC313\uC544\uC11C \uD569\uACC4\uC640 \uAD6C\uC131\uC744 \uD568\uAED8", en: "Stack them to show total and composition" },
92
+ spec: { type: "area", data: rows, x: time, y: similar.slice(0, 5), stacked: true }
93
+ });
94
+ }
95
+ out.push({ reason: { ko: `\uBAA9\uB85D\uC5D0 \uB123\uC744 ${base}\uC758 \uC791\uC740 \uD750\uB984`, en: `Tiny ${base} trend for lists` }, spec: { type: "sparkline", data: rows, x: time, y: base } });
96
+ }
97
+ if (category && numeric.length) {
98
+ const v = numeric[0];
99
+ if (rows.length <= 8 && allPositive(v)) {
100
+ out.push({
101
+ reason: { ko: `${category}\uBCC4 ${v} \uBE44\uC911`, en: `Share of ${v} by ${category}` },
102
+ spec: { type: "percentage-bar", data: rows, label: category, value: v }
103
+ });
104
+ }
105
+ out.push({
106
+ reason: { ko: `${category}\uBCC4 ${v} \uBE44\uAD50`, en: `Compare ${v} by ${category}` },
107
+ spec: { type: "bar", data: rows, x: category, y: numeric.length > 1 ? numeric.slice(0, 4) : v, ...hasNegative(v) ? { colorBySign: true } : {} }
108
+ });
109
+ }
110
+ if (!time && !category && numeric.length) {
111
+ out.push({ reason: { ko: "\uD589 \uC21C\uC11C\uB300\uB85C \uAC12\uC758 \uD750\uB984", en: "Values in row order" }, spec: { type: "line", data: rows, y: numeric[0] } });
112
+ }
113
+ return out;
114
+ }
115
+
116
+ // src/i18n.ts
117
+ var MESSAGES = {
118
+ ko: {
119
+ required: (f) => `${f}: \uD544\uC218 \uD56D\uBAA9\uC774\uC5D0\uC694.`,
120
+ unknownField: (f, hint) => `${f}: \uC54C \uC218 \uC5C6\uB294 \uD56D\uBAA9\uC774\uC5D0\uC694.${hint ? ` \uD639\uC2DC "${hint}"\uC778\uAC00\uC694?` : ""}`,
121
+ wrongType: (f, want) => `${f}: ${want} \uAC12\uC774\uC5B4\uC57C \uD574\uC694.`,
122
+ notOneOf: (f, list) => `${f}: ${list} \uC911 \uD558\uB098\uC5EC\uC57C \uD574\uC694.`,
123
+ unknownType: (t, hint) => `type: "${t}"\uB294 \uC5C6\uB294 \uCC28\uD2B8\uC608\uC694.${hint ? ` \uD639\uC2DC "${hint}"\uC778\uAC00\uC694?` : ""} (funnest-charts list)`,
124
+ noColumn: (f, col, cols, hint) => `${f}: \uB370\uC774\uD130\uC5D0 "${col}" \uC5F4\uC774 \uC5C6\uC5B4\uC694.${hint ? ` \uD639\uC2DC "${hint}"\uC778\uAC00\uC694?` : ""} \uC788\uB294 \uC5F4: ${cols}`,
125
+ notNumeric: (f, col) => `${f}: "${col}" \uC5F4\uC5D0 \uC22B\uC790\uAC00 \uC544\uB2CC \uAC12\uC774 \uC788\uC5B4\uC694.`,
126
+ emptyData: "data: \uB370\uC774\uD130\uAC00 \uBE44\uC5B4 \uC788\uC5B4\uC694.",
127
+ valid: "\uC2A4\uD399\uC5D0 \uBB38\uC81C\uAC00 \uC5C6\uC5B4\uC694.",
128
+ invalid: (n) => `\uC624\uB958 ${n}\uAC1C`,
129
+ wrote: (p) => `\uC800\uC7A5\uD588\uC5B4\uC694: ${p}`,
130
+ usage: `funnest-charts \u2014 Funnest Charts \uBA85\uB839\uC904 \uB3C4\uAD6C
131
+
132
+ \uC0AC\uC6A9\uBC95: funnest-charts <\uBA85\uB839> [\uC635\uC158]
133
+
134
+ \uBA85\uB839:
135
+ list \uCC28\uD2B8 \uBAA9\uB85D
136
+ info <type> \uD56D\uBAA9, \uC608\uC2DC, React \uCF54\uB4DC
137
+ example <type> [--out f] \uC2DC\uC791\uC6A9 \uC2A4\uD399
138
+ schema <type> [--out f] JSON \uC2A4\uD0A4\uB9C8
139
+ validate <spec.json> \uC2A4\uD399 \uAC80\uC0AC (\uC624\uB958\uAC00 \uC788\uC73C\uBA74 1\uB85C \uB05D\uB098\uC694)
140
+ render <spec.json> --out f SVG \xB7 PNG \xB7 HTML\uB85C \uADF8\uB9AC\uAE30 (--theme light|dark, --scale 2)
141
+ suggest <data.csv|json> \uB370\uC774\uD130\uC5D0 \uB9DE\uB294 \uCC28\uD2B8 \uCD94\uCC9C (--out-dir charts)
142
+ init <dir> [--type line] \uC2DC\uC791 \uD3F4\uB354 \uB9CC\uB4E4\uAE30 (spec.json, Chart.tsx)
143
+
144
+ \uBAA8\uB4E0 \uBA85\uB839\uC5D0 --locale ko|en \uC744 \uBD99\uC77C \uC218 \uC788\uC5B4\uC694.`,
145
+ unknownCommand: (c) => `\uC54C \uC218 \uC5C6\uB294 \uBA85\uB839\uC774\uC5D0\uC694: ${c}`,
146
+ needArg: (what) => `${what}\uC774(\uAC00) \uD544\uC694\uD574\uC694.`,
147
+ pngMissing: "PNG\uB85C \uADF8\uB9AC\uB824\uBA74 @resvg/resvg-js\uAC00 \uD544\uC694\uD574\uC694: npm i @resvg/resvg-js",
148
+ suggested: (n) => `\uCD94\uCC9C \uCC28\uD2B8 ${n}\uAC1C`,
149
+ initDone: (dir) => `${dir} \uD3F4\uB354\uB97C \uB9CC\uB4E4\uC5C8\uC5B4\uC694. spec.json\uC744 \uACE0\uCE58\uACE0 funnest-charts render spec.json --out chart.svg \uB85C \uADF8\uB824\uBCF4\uC138\uC694.`
150
+ },
151
+ en: {
152
+ required: (f) => `${f}: required.`,
153
+ unknownField: (f, hint) => `${f}: unknown field.${hint ? ` Did you mean "${hint}"?` : ""}`,
154
+ wrongType: (f, want) => `${f}: must be ${want}.`,
155
+ notOneOf: (f, list) => `${f}: must be one of ${list}.`,
156
+ unknownType: (t, hint) => `type: no chart "${t}".${hint ? ` Did you mean "${hint}"?` : ""} (funnest-charts list)`,
157
+ noColumn: (f, col, cols, hint) => `${f}: no column "${col}" in data.${hint ? ` Did you mean "${hint}"?` : ""} Columns: ${cols}`,
158
+ notNumeric: (f, col) => `${f}: column "${col}" has non-numeric values.`,
159
+ emptyData: "data: is empty.",
160
+ valid: "Spec is valid.",
161
+ invalid: (n) => `${n} error(s)`,
162
+ wrote: (p) => `Wrote ${p}`,
163
+ usage: `funnest-charts \u2014 Funnest Charts command line
164
+
165
+ Usage: funnest-charts <command> [options]
166
+
167
+ Commands:
168
+ list List charts
169
+ info <type> Fields, example, React code
170
+ example <type> [--out f] Starter spec
171
+ schema <type> [--out f] JSON Schema
172
+ validate <spec.json> Check a spec (exits 1 on errors)
173
+ render <spec.json> --out f Render to SVG, PNG or HTML (--theme light|dark, --scale 2)
174
+ suggest <data.csv|json> Suggest charts for your data (--out-dir charts)
175
+ init <dir> [--type line] Create a starter folder (spec.json, Chart.tsx)
176
+
177
+ Every command accepts --locale ko|en.`,
178
+ unknownCommand: (c) => `Unknown command: ${c}`,
179
+ needArg: (what) => `Missing ${what}.`,
180
+ pngMissing: "PNG output needs @resvg/resvg-js: npm i @resvg/resvg-js",
181
+ suggested: (n) => `${n} suggestion(s)`,
182
+ initDone: (dir) => `Created ${dir}. Edit spec.json, then run funnest-charts render spec.json --out chart.svg`
183
+ }
184
+ };
185
+ var messages = (locale = "ko") => MESSAGES[locale];
186
+
187
+ // src/render.tsx
188
+ import { AreaChart, BarChart, LineChart, Point, ReferenceLine } from "@funnest/charts-react";
189
+ import { percentages, seriesColor } from "@funnest/charts-viz";
190
+ import { renderToStaticMarkup } from "react-dom/server";
191
+ import { jsx } from "react/jsx-runtime";
192
+ var UP = "#F04452";
193
+ var DOWN = "#3182F6";
194
+ var FONT = "Pretendard, 'Apple SD Gothic Neo', 'Malgun Gothic', 'Noto Sans KR', sans-serif";
195
+ var SYSTEM_FONT = { win32: "Malgun Gothic", darwin: "Apple SD Gothic Neo" };
196
+ var defaultFont = () => SYSTEM_FONT[typeof process === "undefined" ? "" : process.platform] ?? "Noto Sans CJK KR";
197
+ var THEMES = {
198
+ light: { bg: "#ffffff", text: "#191f28", muted: "#8b95a1", grid: "#eef0f3", line: "#c5cad1" },
199
+ dark: { bg: "#17171c", text: "#f2f2f4", muted: "#7e7e87", grid: "#26262e", line: "#4a4a55" }
200
+ };
201
+ var num = (v) => {
202
+ if (v === null || v === void 0 || v === "") return null;
203
+ const n = typeof v === "number" ? v : Number(v);
204
+ return Number.isFinite(n) ? n : null;
205
+ };
206
+ var esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
207
+ var yColumns = (spec) => Array.isArray(spec.y) ? spec.y : spec.y ? [spec.y] : [];
208
+ var xLabel = (v) => {
209
+ const s = String(v ?? "");
210
+ const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s);
211
+ return m ? `${Number(m[2])}/${Number(m[3])}` : s;
212
+ };
213
+ function valueFormatter(spec) {
214
+ const f = spec.format ?? {};
215
+ const nf = new Intl.NumberFormat(spec.locale === "en" ? "en-US" : "ko-KR", { maximumFractionDigits: f.decimals ?? 2, minimumFractionDigits: f.decimals ?? 0 });
216
+ return (v) => `${f.prefix ?? ""}${nf.format(v)}${f.suffix ?? ""}`;
217
+ }
218
+ var indexOf = (spec, x) => {
219
+ if (typeof x === "number") return x;
220
+ const i = spec.x ? spec.data.findIndex((r) => String(r[spec.x]) === x) : -1;
221
+ return i >= 0 ? i : Number(x);
222
+ };
223
+ function seriesOf(spec) {
224
+ return yColumns(spec).map((col, i) => ({
225
+ id: col,
226
+ label: spec.labels?.[col] ?? col,
227
+ data: spec.data.map((r) => num(r[col])),
228
+ color: spec.colors?.[col] ?? seriesColor(i)
229
+ }));
230
+ }
231
+ function element(spec, width, height) {
232
+ const series = seriesOf(spec);
233
+ const fmt = valueFormatter(spec);
234
+ const xAxis = spec.x ? { data: spec.data.map((r) => xLabel(r[spec.x])) } : void 0;
235
+ const common = {
236
+ series,
237
+ width,
238
+ height,
239
+ animate: false,
240
+ ...xAxis ? { xAxis } : {},
241
+ showXAxis: spec.showXAxis ?? !!spec.x,
242
+ showYAxis: spec.showYAxis === false ? false : { tickLabelFormatter: fmt, width: 64 }
243
+ };
244
+ const extras = [
245
+ ...(spec.referenceLines ?? []).map((r, i) => /* @__PURE__ */ jsx(
246
+ ReferenceLine,
247
+ {
248
+ ...r.y !== void 0 ? { dataY: r.y } : {},
249
+ ...r.x !== void 0 ? { dataX: indexOf(spec, r.x) } : {},
250
+ ...r.label !== void 0 ? { label: r.label } : {},
251
+ ...r.color ? { color: r.color } : {}
252
+ },
253
+ `r${i}`
254
+ )),
255
+ ...(spec.points ?? []).map((p, i) => /* @__PURE__ */ jsx(
256
+ Point,
257
+ {
258
+ dataX: indexOf(spec, p.x),
259
+ ...p.y !== void 0 ? { dataY: p.y } : { seriesId: p.series ?? series[0]?.id ?? "" },
260
+ ...p.label !== void 0 ? { label: p.label } : {},
261
+ ...p.color ? { color: p.color } : {},
262
+ ...p.pulse ? { pulse: true } : {}
263
+ },
264
+ `p${i}`
265
+ ))
266
+ ];
267
+ switch (spec.type) {
268
+ case "area":
269
+ return /* @__PURE__ */ jsx(AreaChart, { ...common, stacked: spec.stacked ?? false, ...spec.curve ? { curve: spec.curve } : {}, children: extras });
270
+ case "bar":
271
+ return /* @__PURE__ */ jsx(BarChart, { ...common, stacked: spec.stacked ?? false, ...spec.colorBySign ? { getBarColor: (v) => v >= 0 ? UP : DOWN } : {}, children: extras });
272
+ case "sparkline": {
273
+ const s = series[0];
274
+ const vals = (s?.data ?? []).filter((v) => v !== null);
275
+ const up = (vals.at(-1) ?? 0) >= (vals[0] ?? 0);
276
+ const color = spec.colorBySign === false ? s?.color ?? UP : up ? UP : DOWN;
277
+ return /* @__PURE__ */ jsx(LineChart, { series: s ? [{ ...s, color }] : [], width, height, inset: 2, strokeWidth: 1.5, animate: false });
278
+ }
279
+ default:
280
+ return /* @__PURE__ */ jsx(LineChart, { ...common, ...spec.curve ? { curve: spec.curve } : {}, ...spec.area ? { area: spec.area } : {}, children: extras });
281
+ }
282
+ }
283
+ function resolveVars(markup, map) {
284
+ let out = "";
285
+ let i = 0;
286
+ while (i < markup.length) {
287
+ const at = markup.indexOf("var(--fnv-", i);
288
+ if (at < 0) {
289
+ out += markup.slice(i);
290
+ break;
291
+ }
292
+ out += markup.slice(i, at);
293
+ let depth = 0;
294
+ let j = at;
295
+ for (; j < markup.length; j++) {
296
+ if (markup[j] === "(") depth++;
297
+ else if (markup[j] === ")" && --depth === 0) break;
298
+ }
299
+ const name = /^var\(--fnv-([a-z-]+)/.exec(markup.slice(at))?.[1] ?? "";
300
+ out += map[name] ?? "currentColor";
301
+ i = j + 1;
302
+ }
303
+ return out;
304
+ }
305
+ var percentBarSvg = (spec, width, top, p) => {
306
+ const rows = spec.data;
307
+ const items = rows.map((r, i) => ({
308
+ id: String(r[spec.label] ?? i),
309
+ value: num(r[spec.value]) ?? 0
310
+ }));
311
+ const segs = percentages(items);
312
+ const barH = 14;
313
+ const gap = 2;
314
+ const visible = segs.filter((s) => s.fraction > 0);
315
+ const usable = width - gap * Math.max(0, visible.length - 1);
316
+ let x = 0;
317
+ const bars = visible.map((s) => {
318
+ const w = Math.max(2, s.fraction * usable);
319
+ const color = spec.colors?.[s.id] ?? seriesColor(items.findIndex((it) => it.id === s.id));
320
+ const rect = `<rect x="${x.toFixed(1)}" y="${top}" width="${w.toFixed(1)}" height="${barH}" rx="3" fill="${color}"/>`;
321
+ x += w + gap;
322
+ return rect;
323
+ }).join("");
324
+ let lx = 0;
325
+ let ly = top + barH + 24;
326
+ const labels = segs.map((s, i) => {
327
+ const color = spec.colors?.[s.id] ?? seriesColor(i);
328
+ const text = `${s.id} ${(Math.round(s.fraction * 1e3) / 10).toString()}%`;
329
+ const w = textWidth(text, 12) + 26;
330
+ if (lx > 0 && lx + w > width) {
331
+ lx = 0;
332
+ ly += 20;
333
+ }
334
+ const g = `<circle cx="${lx + 4}" cy="${ly - 4}" r="4" fill="${color}"/><text x="${lx + 13}" y="${ly}" font-size="12" fill="${p.muted}">${esc(s.id)} <tspan fill="${p.text}" font-weight="700">${(Math.round(s.fraction * 1e3) / 10).toString()}%</tspan></text>`;
335
+ lx += w;
336
+ return g;
337
+ }).join("");
338
+ return bars + labels;
339
+ };
340
+ var textWidth = (t, size) => [...t].reduce((w, ch) => w + (ch.charCodeAt(0) > 11904 ? size : size * 0.6), 0);
341
+ function renderSvg(spec, options = {}) {
342
+ const theme = THEMES[options.theme ?? spec.theme ?? "light"];
343
+ const isSpark = spec.type === "sparkline";
344
+ const width = spec.width ?? (isSpark ? 120 : 640);
345
+ const height = spec.height ?? (isSpark ? 36 : spec.type === "percentage-bar" ? 96 : 320);
346
+ const pad = isSpark ? 0 : 16;
347
+ const innerW = width - pad * 2;
348
+ let y = pad;
349
+ let head = "";
350
+ if (spec.title && !isSpark) {
351
+ head += `<text x="${pad}" y="${y + 16}" font-size="16" font-weight="700" fill="${theme.text}">${esc(spec.title)}</text>`;
352
+ y += 30;
353
+ }
354
+ let body;
355
+ if (spec.type === "percentage-bar") {
356
+ body = percentBarSvg(spec, innerW, 0, theme);
357
+ body = `<g transform="translate(${pad},${y})">${body}</g>`;
358
+ } else {
359
+ const series = seriesOf(spec);
360
+ const legend = !isSpark && (spec.legend ?? series.length > 1);
361
+ if (legend) {
362
+ let lx = pad;
363
+ head += series.map((s) => {
364
+ const label = s.label ?? s.id;
365
+ const g = `<rect x="${lx}" y="${y + 3}" width="12" height="3" rx="1.5" fill="${s.color}"/><text x="${lx + 18}" y="${y + 8}" font-size="12" fill="${theme.muted}">${esc(label)}</text>`;
366
+ lx += textWidth(label, 12) + 34;
367
+ return g;
368
+ }).join("");
369
+ y += 22;
370
+ }
371
+ const chartH = Math.max(20, height - y - pad);
372
+ const markup = renderToStaticMarkup(element(spec, innerW, chartH));
373
+ const svg = markup.slice(markup.indexOf("<svg"), markup.lastIndexOf("</svg>") + 6);
374
+ body = svg.replace("<svg", `<svg x="${pad}" y="${y}"`);
375
+ }
376
+ const doc = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" font-family="${FONT}"><rect width="100%" height="100%" fill="${theme.bg}"/>${head}${body}</svg>`;
377
+ return resolveVars(doc, {
378
+ text: theme.text,
379
+ "text-muted": theme.muted,
380
+ grid: theme.grid,
381
+ line: theme.line,
382
+ bg: theme.bg,
383
+ accent: "#6C4DFF",
384
+ font: FONT
385
+ }).replace(/font-family:inherit/g, `font-family:${FONT}`);
386
+ }
387
+ function renderHtml(spec, options = {}) {
388
+ const theme = THEMES[options.theme ?? spec.theme ?? "light"];
389
+ return `<!doctype html>
390
+ <html lang="${spec.locale ?? "ko"}">
391
+ <head>
392
+ <meta charset="utf-8">
393
+ <meta name="viewport" content="width=device-width, initial-scale=1">
394
+ <title>${esc(spec.title ?? "Funnest Charts")}</title>
395
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css">
396
+ <style>body{margin:0;min-height:100vh;display:grid;place-items:center;background:${theme.bg}}svg{max-width:100%;height:auto}</style>
397
+ </head>
398
+ <body>
399
+ ${renderSvg(spec, options)}
400
+ </body>
401
+ </html>
402
+ `;
403
+ }
404
+ async function renderPng(spec, options = {}) {
405
+ let Resvg;
406
+ try {
407
+ ({ Resvg } = await import("@resvg/resvg-js"));
408
+ } catch {
409
+ throw Object.assign(new Error("PNG_RENDERER_MISSING"), { code: "PNG_RENDERER_MISSING" });
410
+ }
411
+ const svg = renderSvg(spec, options);
412
+ const r = new Resvg(svg, {
413
+ fitTo: { mode: "zoom", value: options.scale ?? 2 },
414
+ font: { loadSystemFonts: true, defaultFontFamily: defaultFont() }
415
+ });
416
+ return r.render().asPng();
417
+ }
418
+ var lit = (v) => JSON.stringify(v);
419
+ function reactCode(spec) {
420
+ const ys = yColumns(spec);
421
+ const dataLine = spec.data.length <= 40 ? `const data = ${JSON.stringify(spec.data, null, 2)};` : `// data: ${spec.data.length}\uD589 \u2014 \uB0B4 \uB370\uC774\uD130\uB85C \uBC14\uAFD4\uC8FC\uC138\uC694
422
+ const data = [/* \u2026 */];`;
423
+ const fmt = spec.format ? `const fmt = (v: number) => \`${spec.format.prefix ?? ""}\${v.toLocaleString()}${spec.format.suffix ?? ""}\`;
424
+ ` : "";
425
+ if (spec.type === "percentage-bar") {
426
+ return `import { PercentageBarChart } from '@funnest/charts-react';
427
+
428
+ ${dataLine}
429
+
430
+ export function Chart() {
431
+ return (
432
+ <PercentageBarChart
433
+ data={data.map((d) => ({ id: String(d[${lit(spec.label)}]), label: String(d[${lit(spec.label)}]), value: d[${lit(spec.value)}]${spec.colors ? `, color: (${lit(spec.colors)} as Record<string, string>)[String(d[${lit(spec.label)}])]` : ""} }))}
434
+ />
435
+ );
436
+ }
437
+ `;
438
+ }
439
+ const component = spec.type === "area" ? "AreaChart" : spec.type === "bar" ? "BarChart" : "LineChart";
440
+ const imports = /* @__PURE__ */ new Set([component]);
441
+ if (spec.referenceLines?.length) imports.add("ReferenceLine");
442
+ if (spec.points?.length) imports.add("Point");
443
+ const series = ys.map((c) => `{ id: ${lit(c)}, label: ${lit(spec.labels?.[c] ?? c)}, data: data.map((d) => d[${lit(c)}])${spec.colors?.[c] ? `, color: ${lit(spec.colors[c])}` : ""} }`).join(",\n ");
444
+ const props = [`series={[
445
+ ${series},
446
+ ]}`];
447
+ if (spec.type === "sparkline") props.push("height={32}", "inset={2}", "strokeWidth={1.5}");
448
+ else {
449
+ props.push(`height={${spec.height ?? 320}}`);
450
+ if (spec.x) props.push(`xAxis={{ data: data.map((d) => d[${lit(spec.x)}]) }}`);
451
+ if (spec.showXAxis ?? !!spec.x) props.push("showXAxis");
452
+ if (spec.showYAxis !== false) props.push(fmt ? "showYAxis={{ tickLabelFormatter: fmt }}" : "showYAxis");
453
+ props.push(fmt ? "scrubber={{ showValues: true, valueFormatter: fmt }}" : "scrubber={{ showValues: true }}");
454
+ }
455
+ if (spec.stacked) props.push("stacked");
456
+ if (spec.area && spec.area !== "none") props.push(`area=${lit(spec.area)}`);
457
+ if (spec.curve) props.push(`curve=${lit(spec.curve)}`);
458
+ if (spec.type === "bar" && spec.colorBySign) props.push("getBarColor={(v) => (v >= 0 ? '#F04452' : '#3182F6')}");
459
+ if (ys.length > 1 || spec.legend) props.push("legend");
460
+ const children = [
461
+ ...(spec.referenceLines ?? []).map(
462
+ (r) => `<ReferenceLine${r.y !== void 0 ? ` dataY={${r.y}}` : ""}${r.x !== void 0 ? ` dataX={${typeof r.x === "number" ? r.x : `data.findIndex((d) => d[${lit(spec.x)}] === ${lit(r.x)})`}}` : ""}${r.label ? ` label=${lit(r.label)}` : ""}${r.color ? ` color=${lit(r.color)}` : ""} />`
463
+ ),
464
+ ...(spec.points ?? []).map(
465
+ (p) => `<Point dataX={${typeof p.x === "number" ? p.x : `data.findIndex((d) => d[${lit(spec.x)}] === ${lit(p.x)})`}}${p.y !== void 0 ? ` dataY={${p.y}}` : ` seriesId=${lit(p.series ?? ys[0])}`}${p.label ? ` label=${lit(p.label)}` : ""}${p.color ? ` color=${lit(p.color)}` : ""}${p.pulse ? " pulse" : ""} />`
466
+ )
467
+ ];
468
+ const open = `<${component}
469
+ ${props.join("\n ")}
470
+ `;
471
+ const jsx2 = children.length ? `${open}>
472
+ ${children.join("\n ")}
473
+ </${component}>` : `${open}/>`;
474
+ return `import { ${[...imports].join(", ")} } from '@funnest/charts-react';
475
+
476
+ ${dataLine}
477
+ ${fmt}
478
+ export function Chart() {
479
+ return (
480
+ ${jsx2}
481
+ );
482
+ }
483
+ `;
484
+ }
485
+
486
+ // src/spec.ts
487
+ var COMMON = {
488
+ type: { kind: "string", required: true, ko: "\uCC28\uD2B8 \uC885\uB958", en: "Chart type" },
489
+ data: { kind: "rows", required: true, ko: "\uB370\uC774\uD130 \uD589 \uBC30\uC5F4 (CLI\uB294 .csv/.json \uD30C\uC77C \uACBD\uB85C\uB3C4 \uB3FC\uC694)", en: "Array of data rows (CLI also accepts a .csv/.json path)" },
490
+ title: { kind: "string", ko: "\uC81C\uBAA9", en: "Title" },
491
+ width: { kind: "number", default: 640, ko: "\uB108\uBE44(px)", en: "Width (px)" },
492
+ height: { kind: "number", default: 320, ko: "\uB192\uC774(px)", en: "Height (px)" },
493
+ theme: { kind: "string", enum: ["light", "dark"], default: "light", ko: "\uD14C\uB9C8", en: "Theme" },
494
+ locale: { kind: "string", enum: ["ko", "en"], default: "ko", ko: "\uC22B\uC790\xB7\uB0A0\uC9DC \uD615\uC2DD \uC5B8\uC5B4", en: "Number/date formatting locale" }
495
+ };
496
+ var FORMAT = {
497
+ kind: "object",
498
+ ko: "\uAC12 \uD45C\uC2DC \uD615\uC2DD { prefix, suffix, decimals }",
499
+ en: "Value format { prefix, suffix, decimals }",
500
+ items: { type: "object", properties: { prefix: { type: "string" }, suffix: { type: "string" }, decimals: { type: "number" } }, additionalProperties: false }
501
+ };
502
+ var SERIES = {
503
+ x: { kind: "string", column: "any", ko: "x\uCD95 \uC5F4 (\uB0A0\uC9DC\xB7\uB77C\uBCA8). \uC5C6\uC73C\uBA74 \uD589 \uC21C\uC11C", en: "x column (dates/labels); row index when omitted" },
504
+ y: { kind: "string|string[]", required: true, column: "numeric", ko: "\uADF8\uB9B4 \uC22B\uC790 \uC5F4 (\uC5EC\uB7EC \uAC1C\uBA74 \uBC30\uC5F4)", en: "Numeric column(s) to draw" },
505
+ labels: { kind: "record", ko: "\uC5F4 \uC774\uB984 \u2192 \uD45C\uC2DC \uC774\uB984", en: "Column \u2192 display name" },
506
+ colors: { kind: "record", ko: "\uC5F4 \uC774\uB984 \u2192 \uC0C9", en: "Column \u2192 color" },
507
+ showXAxis: { kind: "boolean", default: true, ko: "x\uCD95 \uD45C\uC2DC", en: "Show x axis" },
508
+ showYAxis: { kind: "boolean", default: true, ko: "y\uCD95 \uD45C\uC2DC", en: "Show y axis" },
509
+ legend: { kind: "boolean", ko: "\uBC94\uB840 (\uACC4\uC5F4\uC774 \uC5EC\uB7EC \uAC1C\uBA74 \uAE30\uBCF8\uC73C\uB85C \uCF1C\uC838\uC694)", en: "Legend (on by default for several series)" },
510
+ format: FORMAT,
511
+ referenceLines: {
512
+ kind: "array",
513
+ ko: "\uAE30\uC900\uC120 [{ y, label, color }] \uB610\uB294 [{ x, label }]",
514
+ en: "Reference lines [{ y, label, color }] or [{ x, label }]",
515
+ items: { type: "object", properties: { y: { type: "number" }, x: { type: ["number", "string"] }, label: { type: "string" }, color: { type: "string" } }, additionalProperties: false }
516
+ },
517
+ points: {
518
+ kind: "array",
519
+ ko: "\uC810 \uD45C\uC2DC [{ x, series, label, color, pulse }]",
520
+ en: "Markers [{ x, series, label, color, pulse }]",
521
+ items: {
522
+ type: "object",
523
+ required: ["x"],
524
+ properties: { x: { type: ["number", "string"] }, y: { type: "number" }, series: { type: "string" }, label: { type: "string" }, color: { type: "string" }, pulse: { type: "boolean" } },
525
+ additionalProperties: false
526
+ }
527
+ }
528
+ };
529
+ var CURVE = { kind: "string", enum: ["monotone", "linear", "step"], default: "monotone", ko: "\uACE1\uC120 \uBC29\uC2DD", en: "Curve" };
530
+ var days = (n) => Array.from({ length: n }, (_, i) => {
531
+ const d = new Date(Date.UTC(2026, 10, 10) - (n - 1 - i) * 864e5);
532
+ return d.toISOString().slice(0, 10);
533
+ });
534
+ var priceRows = () => {
535
+ let p = 71e3;
536
+ return days(30).map((date, i) => {
537
+ p = Math.round(p * (1 + Math.sin(i * 1.7) * 0.012 + 2e-3));
538
+ return { date, price: p };
539
+ });
540
+ };
541
+ var CHARTS = {
542
+ line: {
543
+ type: "line",
544
+ ko: "\uC120 \uCC28\uD2B8. \uAC00\uACA9 \uD750\uB984, \uC218\uC775\uB960 \uCD94\uC774",
545
+ en: "Line chart: price history, returns over time",
546
+ component: "LineChart",
547
+ fields: { ...COMMON, ...SERIES, curve: CURVE, area: { kind: "string", enum: ["none", "gradient", "solid"], default: "none", ko: "\uC120 \uC544\uB798 \uCC44\uC6B0\uAE30", en: "Fill under the line" } },
548
+ example: { title: "\uC0BC\uC131\uC804\uC790 1\uAC1C\uC6D4", data: priceRows(), x: "date", y: "price", area: "gradient", format: { suffix: "\uC6D0" }, referenceLines: [{ y: 73e3, label: "\uD3C9\uB2E8\uAC00" }] }
549
+ },
550
+ area: {
551
+ type: "area",
552
+ ko: "\uC601\uC5ED \uCC28\uD2B8. \uC790\uC0B0 \uAD6C\uC131 \uBCC0\uD654 (stacked)",
553
+ en: "Area chart: composition over time (stacked)",
554
+ component: "AreaChart",
555
+ fields: { ...COMMON, ...SERIES, curve: CURVE, stacked: { kind: "boolean", default: false, ko: "\uC704\uB85C \uC313\uAE30", en: "Stack series" } },
556
+ example: {
557
+ title: "\uB0B4 \uC790\uC0B0 (\uB9CC\uC6D0)",
558
+ data: ["1\uC6D4", "2\uC6D4", "3\uC6D4", "4\uC6D4", "5\uC6D4", "6\uC6D4"].map((month, i) => ({ month, kr: 400 + i * 12, us: 300 + i * 20, crypto: 150 + i * 37 % 60 })),
559
+ x: "month",
560
+ y: ["kr", "us", "crypto"],
561
+ labels: { kr: "\uAD6D\uB0B4\uC8FC\uC2DD", us: "\uD574\uC678\uC8FC\uC2DD", crypto: "\uCF54\uC778" },
562
+ stacked: true,
563
+ format: { suffix: "\uB9CC" }
564
+ }
565
+ },
566
+ bar: {
567
+ type: "bar",
568
+ ko: "\uB9C9\uB300 \uCC28\uD2B8. \uC77C\uBCC4 \uC190\uC775, \uAC70\uB798\uB7C9",
569
+ en: "Bar chart: daily P&L, volume",
570
+ component: "BarChart",
571
+ fields: {
572
+ ...COMMON,
573
+ ...SERIES,
574
+ stacked: { kind: "boolean", default: false, ko: "\uC704\uB85C \uC313\uAE30", en: "Stack series" },
575
+ colorBySign: { kind: "boolean", default: false, ko: "0 \uC774\uC0C1 \uBE68\uAC15, \uBBF8\uB9CC \uD30C\uB791", en: "Red for \u2265 0, blue for < 0" }
576
+ },
577
+ example: {
578
+ title: "\uC77C\uBCC4 \uC190\uC775",
579
+ data: days(12).map((date, i) => ({ date, pnl: Math.round(Math.sin(i * 2.1) * 300 + 60) * 1e3 })),
580
+ x: "date",
581
+ y: "pnl",
582
+ colorBySign: true,
583
+ format: { suffix: "\uC6D0" },
584
+ referenceLines: [{ y: 0 }]
585
+ }
586
+ },
587
+ "percentage-bar": {
588
+ type: "percentage-bar",
589
+ ko: "\uBE44\uC911 \uB9C9\uB300. \uD3EC\uD2B8\uD3F4\uB9AC\uC624 \uBE44\uC911, \uB9E4\uC218\xB7\uB9E4\uB3C4 \uCCB4\uACB0 \uAC15\uB3C4",
590
+ en: "Percentage bar: portfolio weights, buy/sell pressure",
591
+ component: "PercentageBarChart",
592
+ fields: {
593
+ ...COMMON,
594
+ height: { kind: "number", default: 96, ko: "\uB192\uC774(px)", en: "Height (px)" },
595
+ label: { kind: "string", required: true, column: "any", ko: "\uC774\uB984 \uC5F4", en: "Label column" },
596
+ value: { kind: "string", required: true, column: "numeric", ko: "\uAC12 \uC5F4", en: "Value column" },
597
+ colors: { kind: "record", ko: "\uC774\uB984 \u2192 \uC0C9", en: "Label \u2192 color" }
598
+ },
599
+ example: {
600
+ title: "\uD3EC\uD2B8\uD3F4\uB9AC\uC624 \uBE44\uC911",
601
+ data: [
602
+ { asset: "\uAD6D\uB0B4\uC8FC\uC2DD", amount: 4200 },
603
+ { asset: "\uD574\uC678\uC8FC\uC2DD", amount: 3100 },
604
+ { asset: "\uCF54\uC778", amount: 1700 },
605
+ { asset: "\uD604\uAE08", amount: 1e3 }
606
+ ],
607
+ label: "asset",
608
+ value: "amount",
609
+ colors: { \uD604\uAE08: "#B0B8C1" }
610
+ }
611
+ },
612
+ sparkline: {
613
+ type: "sparkline",
614
+ ko: "\uC2A4\uD30C\uD06C\uB77C\uC778. \uAD00\uC2EC\uC885\uBAA9 \uBAA9\uB85D\uC758 \uC791\uC740 \uC120",
615
+ en: "Sparkline: tiny line for watchlists",
616
+ component: "LineChart",
617
+ fields: {
618
+ ...COMMON,
619
+ width: { kind: "number", default: 120, ko: "\uB108\uBE44(px)", en: "Width (px)" },
620
+ height: { kind: "number", default: 36, ko: "\uB192\uC774(px)", en: "Height (px)" },
621
+ y: SERIES.y,
622
+ x: SERIES.x,
623
+ colors: SERIES.colors,
624
+ colorBySign: { kind: "boolean", default: true, ko: "\uC624\uB974\uBA74 \uBE68\uAC15, \uB0B4\uB9AC\uBA74 \uD30C\uB791", en: "Red when up, blue when down" }
625
+ },
626
+ example: { data: priceRows(), y: "price" }
627
+ }
628
+ };
629
+ var CHART_TYPES = Object.keys(CHARTS);
630
+ function distance(a, b) {
631
+ const dp = Array.from({ length: a.length + 1 }, (_, i) => [i, ...new Array(b.length).fill(0)]);
632
+ for (let j = 1; j <= b.length; j++) dp[0][j] = j;
633
+ for (let i = 1; i <= a.length; i++)
634
+ for (let j = 1; j <= b.length; j++)
635
+ dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + (a[i - 1].toLowerCase() === b[j - 1].toLowerCase() ? 0 : 1));
636
+ return dp[a.length][b.length];
637
+ }
638
+ function closest(word, options) {
639
+ let best = null;
640
+ let bestD = Infinity;
641
+ for (const o of options) {
642
+ const d = distance(word, o);
643
+ if (d < bestD) {
644
+ bestD = d;
645
+ best = o;
646
+ }
647
+ }
648
+ return best !== null && bestD <= Math.max(2, Math.floor(word.length / 3)) ? best : null;
649
+ }
650
+ var isNumeric = (v) => typeof v === "number" && Number.isFinite(v) || v === null || typeof v === "string" && v.trim() !== "" && Number.isFinite(Number(v));
651
+ var typeOk = (kind, v) => {
652
+ switch (kind) {
653
+ case "string":
654
+ return typeof v === "string";
655
+ case "number":
656
+ return typeof v === "number" && Number.isFinite(v);
657
+ case "boolean":
658
+ return typeof v === "boolean";
659
+ case "string|string[]":
660
+ return typeof v === "string" || Array.isArray(v) && v.length > 0 && v.every((x) => typeof x === "string");
661
+ case "record":
662
+ return typeof v === "object" && v !== null && !Array.isArray(v) && Object.values(v).every((x) => typeof x === "string");
663
+ case "array":
664
+ return Array.isArray(v);
665
+ case "object":
666
+ return typeof v === "object" && v !== null && !Array.isArray(v);
667
+ case "rows":
668
+ return Array.isArray(v) && v.every((r) => typeof r === "object" && r !== null && !Array.isArray(r));
669
+ }
670
+ };
671
+ var KIND_NAMES = {
672
+ string: { ko: "\uBB38\uC790\uC5F4", en: "a string" },
673
+ number: { ko: "\uC22B\uC790", en: "a number" },
674
+ boolean: { ko: "true/false", en: "true/false" },
675
+ "string|string[]": { ko: "\uBB38\uC790\uC5F4 \uB610\uB294 \uBB38\uC790\uC5F4 \uBC30\uC5F4", en: "a string or string array" },
676
+ record: { ko: "{ \uC774\uB984: \uBB38\uC790\uC5F4 } \uAC1D\uCCB4", en: "an object of strings" },
677
+ array: { ko: "\uBC30\uC5F4", en: "an array" },
678
+ object: { ko: "\uAC1D\uCCB4", en: "an object" },
679
+ rows: { ko: "\uAC1D\uCCB4 \uBC30\uC5F4", en: "an array of objects" }
680
+ };
681
+ function validateSpec(input, locale = "ko") {
682
+ const t = messages(locale);
683
+ const issues = [];
684
+ const add = (path, message) => issues.push({ path, message });
685
+ if (typeof input !== "object" || input === null || Array.isArray(input)) {
686
+ add("", t.wrongType("spec", KIND_NAMES.object[locale]));
687
+ return issues;
688
+ }
689
+ const spec = input;
690
+ const type = spec.type;
691
+ if (typeof type !== "string") {
692
+ add("type", t.required("type"));
693
+ return issues;
694
+ }
695
+ const def = CHARTS[type];
696
+ if (!def) {
697
+ add("type", t.unknownType(type, closest(type, CHART_TYPES)));
698
+ return issues;
699
+ }
700
+ for (const key of Object.keys(spec)) {
701
+ if (!(key in def.fields)) add(key, t.unknownField(key, closest(key, Object.keys(def.fields))));
702
+ }
703
+ for (const [name, f] of Object.entries(def.fields)) {
704
+ const v = spec[name];
705
+ if (v === void 0) {
706
+ if (f.required) add(name, t.required(name));
707
+ continue;
708
+ }
709
+ if (!typeOk(f.kind, v)) {
710
+ add(name, t.wrongType(name, KIND_NAMES[f.kind][locale]));
711
+ continue;
712
+ }
713
+ if (f.enum && !f.enum.includes(v)) add(name, t.notOneOf(name, f.enum.join(" | ")));
714
+ }
715
+ const rows = Array.isArray(spec.data) ? spec.data : null;
716
+ if (rows && rows.length === 0) add("data", t.emptyData);
717
+ if (rows && rows.length > 0) {
718
+ const columns = [...new Set(rows.flatMap((r) => Object.keys(r)))];
719
+ for (const [name, f] of Object.entries(def.fields)) {
720
+ if (!f.column || spec[name] === void 0) continue;
721
+ const cols = Array.isArray(spec[name]) ? spec[name] : typeof spec[name] === "string" ? [spec[name]] : [];
722
+ cols.forEach((col, i) => {
723
+ const path = Array.isArray(spec[name]) ? `${name}[${i}]` : name;
724
+ if (!columns.includes(col)) add(path, t.noColumn(path, col, columns.join(", "), closest(col, columns)));
725
+ else if (f.column === "numeric" && !rows.every((r) => isNumeric(r[col]))) add(path, t.notNumeric(path, col));
726
+ });
727
+ }
728
+ }
729
+ return issues;
730
+ }
731
+ var jsonType = (f) => {
732
+ switch (f.kind) {
733
+ case "string":
734
+ return f.enum ? { type: "string", enum: f.enum } : { type: "string" };
735
+ case "number":
736
+ return { type: "number" };
737
+ case "boolean":
738
+ return { type: "boolean" };
739
+ case "string|string[]":
740
+ return { oneOf: [{ type: "string" }, { type: "array", items: { type: "string" }, minItems: 1 }] };
741
+ case "record":
742
+ return { type: "object", additionalProperties: { type: "string" } };
743
+ case "array":
744
+ return { type: "array", items: f.items ?? {} };
745
+ case "object":
746
+ return f.items ?? { type: "object" };
747
+ case "rows":
748
+ return { type: "array", items: { type: "object" }, minItems: 1 };
749
+ }
750
+ };
751
+ function chartSchema(type, locale = "ko") {
752
+ const def = CHARTS[type];
753
+ const properties = {};
754
+ for (const [name, f] of Object.entries(def.fields)) {
755
+ properties[name] = {
756
+ ...name === "type" ? { const: type } : jsonType(f),
757
+ description: f[locale],
758
+ ...f.default !== void 0 ? { default: f.default } : {}
759
+ };
760
+ }
761
+ return {
762
+ $schema: "https://json-schema.org/draft/2020-12/schema",
763
+ $id: `https://charts.funnest.xyz/schema/${type}.json`,
764
+ title: `Funnest Charts \xB7 ${type}`,
765
+ description: def[locale],
766
+ type: "object",
767
+ required: Object.entries(def.fields).filter(([, f]) => f.required).map(([n]) => n),
768
+ properties,
769
+ additionalProperties: false
770
+ };
771
+ }
772
+ var exampleSpec = (type) => ({ type, ...structuredClone(CHARTS[type].example) });
773
+
774
+ // src/cli.ts
775
+ function parseArgs(argv) {
776
+ const out = { _: [], flags: {} };
777
+ for (let i = 0; i < argv.length; i++) {
778
+ const a = argv[i];
779
+ if (a.startsWith("--")) {
780
+ const [k, v] = a.slice(2).split("=", 2);
781
+ if (v !== void 0) out.flags[k] = v;
782
+ else if (argv[i + 1] && !argv[i + 1].startsWith("--")) out.flags[k] = argv[++i];
783
+ else out.flags[k] = true;
784
+ } else out._.push(a);
785
+ }
786
+ return out;
787
+ }
788
+ var print = (s) => process.stdout.write(`${s}
789
+ `);
790
+ var fail = (s) => {
791
+ process.stderr.write(`${s}
792
+ `);
793
+ process.exit(1);
794
+ };
795
+ async function write(path, data, locale) {
796
+ await mkdir(dirname(resolve(path)), { recursive: true });
797
+ await writeFile(path, data);
798
+ print(messages(locale).wrote(path));
799
+ }
800
+ async function loadSpec(path) {
801
+ const spec = JSON.parse(await readFile(path, "utf8"));
802
+ if (typeof spec.data === "string") {
803
+ const file = resolve(dirname(path), spec.data);
804
+ spec.data = parseData(await readFile(file, "utf8"), file);
805
+ }
806
+ return spec;
807
+ }
808
+ function chartType(name, locale) {
809
+ const t = messages(locale);
810
+ if (!name) fail(t.needArg("<type>"));
811
+ if (!(name in CHARTS)) fail(t.unknownType(name, closest(name, CHART_TYPES)));
812
+ return name;
813
+ }
814
+ async function main() {
815
+ const { _: [command, ...rest], flags } = parseArgs(process.argv.slice(2));
816
+ const locale = flags.locale === "en" ? "en" : "ko";
817
+ const t = messages(locale);
818
+ const json = (v) => JSON.stringify(v, null, 2);
819
+ switch (command) {
820
+ case void 0:
821
+ case "help":
822
+ case "--help":
823
+ print(t.usage);
824
+ return;
825
+ case "list":
826
+ for (const type of CHART_TYPES) print(`${type.padEnd(16)}${CHARTS[type][locale]}`);
827
+ return;
828
+ case "info": {
829
+ const type = chartType(rest[0], locale);
830
+ const def = CHARTS[type];
831
+ print(`${type} \u2014 ${def[locale]} (<${def.component} />)
832
+ `);
833
+ for (const [name, f] of Object.entries(def.fields)) {
834
+ const meta = [f.required ? locale === "ko" ? "\uD544\uC218" : "required" : "", f.enum ? f.enum.join("|") : "", f.default !== void 0 ? `= ${JSON.stringify(f.default)}` : ""].filter(Boolean).join(", ");
835
+ print(` ${name.padEnd(16)}${f[locale]}${meta ? ` (${meta})` : ""}`);
836
+ }
837
+ print(`
838
+ ${locale === "ko" ? "\uC608\uC2DC" : "Example"}:
839
+ ${json(exampleSpec(type))}`);
840
+ print(`
841
+ React:
842
+ ${reactCode(exampleSpec(type))}`);
843
+ return;
844
+ }
845
+ case "example": {
846
+ const type = chartType(rest[0], locale);
847
+ const out = json(exampleSpec(type));
848
+ if (typeof flags.out === "string") await write(flags.out, `${out}
849
+ `, locale);
850
+ else print(out);
851
+ return;
852
+ }
853
+ case "schema": {
854
+ const type = chartType(rest[0], locale);
855
+ const out = json(chartSchema(type, locale));
856
+ if (typeof flags.out === "string") await write(flags.out, `${out}
857
+ `, locale);
858
+ else print(out);
859
+ return;
860
+ }
861
+ case "validate": {
862
+ if (!rest[0]) fail(t.needArg("<spec.json>"));
863
+ const spec = await loadSpec(rest[0]);
864
+ const issues = validateSpec(spec, locale);
865
+ if (issues.length === 0) return print(`\u2713 ${t.valid}`);
866
+ process.stderr.write(`\u2717 ${t.invalid(issues.length)}
867
+ ${issues.map((i) => ` - ${i.message}`).join("\n")}
868
+ `);
869
+ process.exit(1);
870
+ }
871
+ case "render": {
872
+ if (!rest[0]) fail(t.needArg("<spec.json>"));
873
+ const out = typeof flags.out === "string" ? flags.out : fail(t.needArg("--out"));
874
+ const spec = await loadSpec(rest[0]);
875
+ const issues = validateSpec(spec, locale);
876
+ if (issues.length) fail(`\u2717 ${t.invalid(issues.length)}
877
+ ${issues.map((i) => ` - ${i.message}`).join("\n")}`);
878
+ const options = flags.theme === "dark" || flags.theme === "light" ? { theme: flags.theme } : {};
879
+ const ext = extname(out).toLowerCase();
880
+ if (ext === ".png") {
881
+ try {
882
+ const scale = typeof flags.scale === "string" ? Number(flags.scale) : 2;
883
+ await write(out, await renderPng(spec, { ...options, scale }), locale);
884
+ } catch (e) {
885
+ if (e.code === "PNG_RENDERER_MISSING") fail(t.pngMissing);
886
+ throw e;
887
+ }
888
+ } else if (ext === ".html") await write(out, renderHtml(spec, options), locale);
889
+ else if (ext === ".tsx" || ext === ".jsx") await write(out, reactCode(spec), locale);
890
+ else await write(out, renderSvg(spec, options), locale);
891
+ return;
892
+ }
893
+ case "suggest": {
894
+ if (!rest[0]) fail(t.needArg("<data.csv|json>"));
895
+ const rows = parseData(await readFile(rest[0], "utf8"), rest[0]);
896
+ const list = suggestCharts(rows);
897
+ print(t.suggested(list.length));
898
+ const dir = typeof flags["out-dir"] === "string" ? flags["out-dir"] : null;
899
+ for (const [i, s] of list.entries()) {
900
+ const name = `${String(i + 1).padStart(2, "0")}-${s.spec.type}`;
901
+ print(` ${name.padEnd(20)}${s.reason[locale]}`);
902
+ if (dir) {
903
+ const spec = { ...s.spec, data: relativeTo(dir, rest[0]) };
904
+ await write(join(dir, `${name}.json`), `${json(spec)}
905
+ `, locale);
906
+ await write(join(dir, `${name}.svg`), renderSvg(s.spec), locale);
907
+ }
908
+ }
909
+ return;
910
+ }
911
+ case "init": {
912
+ const dir = rest[0] ?? fail(t.needArg("<dir>"));
913
+ const type = chartType(typeof flags.type === "string" ? flags.type : "line", locale);
914
+ const spec = exampleSpec(type);
915
+ await write(join(dir, "spec.json"), `${json(spec)}
916
+ `, locale);
917
+ await write(join(dir, "Chart.tsx"), reactCode(spec), locale);
918
+ await write(join(dir, "chart.svg"), renderSvg(spec), locale);
919
+ print(t.initDone(dir));
920
+ return;
921
+ }
922
+ default:
923
+ fail(`${t.unknownCommand(command)}
924
+
925
+ ${t.usage}`);
926
+ }
927
+ }
928
+ function relativeTo(dir, file) {
929
+ const from = resolve(dir).split(/[\\/]/);
930
+ const to = resolve(file).split(/[\\/]/);
931
+ let i = 0;
932
+ while (i < from.length && from[i] === to[i]) i++;
933
+ return [...from.slice(i).map(() => ".."), ...to.slice(i)].join("/");
934
+ }
935
+ main().catch((e) => fail(e instanceof Error ? e.message : String(e)));