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