@funnest/charts-cli 0.3.0 → 0.5.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 CHANGED
@@ -65,6 +65,15 @@ function suggestCharts(rows) {
65
65
  const out = [];
66
66
  const allPositive = (c) => rows.every((r) => typeof r[c] !== "number" || r[c] >= 0);
67
67
  const hasNegative = (c) => rows.some((r) => typeof r[c] === "number" && r[c] < 0);
68
+ const find = (...names) => numeric.find((c) => names.includes(c.toLowerCase()));
69
+ const ohlc = { open: find("open", "o", "\uC2DC\uAC00"), high: find("high", "h", "\uACE0\uAC00"), low: find("low", "l", "\uC800\uAC00"), close: find("close", "c", "\uC885\uAC00") };
70
+ if (ohlc.open && ohlc.high && ohlc.low && ohlc.close) {
71
+ const volume = find("volume", "vol", "v", "\uAC70\uB798\uB7C9");
72
+ out.push({
73
+ reason: { ko: "\uC2DC\uAC00\xB7\uACE0\uAC00\xB7\uC800\uAC00\xB7\uC885\uAC00\uAC00 \uC788\uC5B4 \uCE94\uB4E4 \uCC28\uD2B8\uAC00 \uC798 \uB9DE\uC544\uC694", en: "Open/high/low/close columns: candles" },
74
+ spec: { type: "candlestick", data: rows, ...time ? { x: time } : {}, open: ohlc.open, high: ohlc.high, low: ohlc.low, close: ohlc.close, ...volume ? { volume } : {} }
75
+ });
76
+ }
68
77
  if (time && numeric.length) {
69
78
  const magnitude = (c) => Math.max(...rows.map((r) => Math.abs(Number(r[c]) || 0)));
70
79
  const base = numeric.find(allPositive) ?? numeric[0];
@@ -96,11 +105,23 @@ function suggestCharts(rows) {
96
105
  }
97
106
  if (category && numeric.length) {
98
107
  const v = numeric[0];
108
+ const size = numeric.find(allPositive);
109
+ const change = numeric.find(hasNegative);
110
+ if (size && change && rows.length >= 4) {
111
+ out.push({
112
+ reason: { ko: `${size}\uB85C \uD06C\uAE30, ${change}\uB85C \uC0C9\uC744 \uC815\uD55C \uB9C8\uCF13\uB9F5`, en: `Market map: size by ${size}, color by ${change}` },
113
+ spec: { type: "treemap", data: rows, label: category, value: size, change }
114
+ });
115
+ }
99
116
  if (rows.length <= 8 && allPositive(v)) {
100
117
  out.push({
101
118
  reason: { ko: `${category}\uBCC4 ${v} \uBE44\uC911`, en: `Share of ${v} by ${category}` },
102
119
  spec: { type: "percentage-bar", data: rows, label: category, value: v }
103
120
  });
121
+ out.push({
122
+ reason: { ko: `${category}\uBCC4 ${v} \uBE44\uC911\uC744 \uB3C4\uB11B\uC73C\uB85C`, en: `Share of ${v} by ${category} as a donut` },
123
+ spec: { type: "donut", data: rows, label: category, value: v }
124
+ });
104
125
  }
105
126
  out.push({
106
127
  reason: { ko: `${category}\uBCC4 ${v} \uBE44\uAD50`, en: `Compare ${v} by ${category}` },
@@ -185,7 +206,7 @@ Every command accepts --locale ko|en.`,
185
206
  var messages = (locale = "ko") => MESSAGES[locale];
186
207
 
187
208
  // src/render.tsx
188
- import { AreaChart, BarChart, LineChart, Point, ReferenceLine } from "@funnest/charts-react";
209
+ import { AreaChart, BarChart, CandlestickChart, DonutChart, LineChart, Point, ReferenceLine, TreemapChart } from "@funnest/charts-react";
189
210
  import { percentages, seriesColor } from "@funnest/charts-viz";
190
211
  import { renderToStaticMarkup } from "react-dom/server";
191
212
  import { jsx } from "react/jsx-runtime";
@@ -265,6 +286,59 @@ function element(spec, width, height) {
265
286
  ))
266
287
  ];
267
288
  switch (spec.type) {
289
+ case "candlestick": {
290
+ const candles = spec.data.map((r) => {
291
+ const o = num(r[spec.open]);
292
+ const h = num(r[spec.high]);
293
+ const l = num(r[spec.low]);
294
+ const c = num(r[spec.close]);
295
+ if (o === null || h === null || l === null || c === null) return null;
296
+ const v = spec.volume ? num(r[spec.volume]) : null;
297
+ return { open: o, high: h, low: l, close: c, ...v !== null ? { volume: v } : {} };
298
+ });
299
+ return /* @__PURE__ */ jsx(
300
+ CandlestickChart,
301
+ {
302
+ data: candles,
303
+ width,
304
+ height,
305
+ animate: false,
306
+ tooltip: false,
307
+ valueFormatter: fmt,
308
+ ...xAxis ? { xAxis } : {},
309
+ showXAxis: spec.showXAxis ?? !!spec.x,
310
+ showYAxis: spec.showYAxis === false ? false : { tickLabelFormatter: fmt, width: 64 },
311
+ children: (spec.referenceLines ?? []).map((r, i) => /* @__PURE__ */ jsx(
312
+ ReferenceLine,
313
+ {
314
+ yAxisId: "price",
315
+ ...r.y !== void 0 ? { dataY: r.y } : {},
316
+ ...r.x !== void 0 ? { dataX: indexOf(spec, r.x) } : {},
317
+ ...r.label !== void 0 ? { label: r.label } : {},
318
+ ...r.color ? { color: r.color } : {}
319
+ },
320
+ `r${i}`
321
+ ))
322
+ }
323
+ );
324
+ }
325
+ case "treemap":
326
+ return /* @__PURE__ */ jsx(
327
+ TreemapChart,
328
+ {
329
+ width,
330
+ height,
331
+ enableTooltip: false,
332
+ valueFormatter: fmt,
333
+ ...spec.maxChange !== void 0 ? { maxChange: spec.maxChange } : {},
334
+ data: spec.data.map((r, i) => {
335
+ const label = String(r[spec.label] ?? i);
336
+ const change = spec.change ? num(r[spec.change]) : null;
337
+ const color = spec.colors?.[label];
338
+ return { id: `${i}`, label, value: num(r[spec.value]) ?? 0, ...change !== null ? { change } : {}, ...color ? { color } : {} };
339
+ })
340
+ }
341
+ );
268
342
  case "area":
269
343
  return /* @__PURE__ */ jsx(AreaChart, { ...common, stacked: spec.stacked ?? false, ...spec.curve ? { curve: spec.curve } : {}, children: extras });
270
344
  case "bar":
@@ -338,11 +412,43 @@ var percentBarSvg = (spec, width, top, p) => {
338
412
  return bars + labels;
339
413
  };
340
414
  var textWidth = (t, size) => [...t].reduce((w, ch) => w + (ch.charCodeAt(0) > 11904 ? size : size * 0.6), 0);
415
+ var donutSvg = (spec, width, height, p) => {
416
+ const fmt = valueFormatter(spec);
417
+ const items = spec.data.map((r, i) => {
418
+ const label = String(r[spec.label] ?? i);
419
+ return { id: `${i}`, label, value: num(r[spec.value]) ?? 0, color: spec.colors?.[label] ?? seriesColor(i) };
420
+ });
421
+ const size = Math.max(60, Math.min(height, width * 0.5));
422
+ const markup = renderToStaticMarkup(
423
+ /* @__PURE__ */ jsx(
424
+ DonutChart,
425
+ {
426
+ data: items,
427
+ size,
428
+ showLegend: false,
429
+ valueFormatter: fmt,
430
+ ...spec.totalLabel ? { totalLabel: spec.totalLabel } : {},
431
+ ...spec.innerRadius !== void 0 ? { innerRadius: spec.innerRadius } : {}
432
+ }
433
+ )
434
+ );
435
+ const svg = markup.slice(markup.indexOf("<svg"), markup.indexOf("</svg>") + 6).replace("<svg", `<svg x="0" y="${(height - size) / 2}"`);
436
+ const segs = percentages(items);
437
+ const lx = size + 28;
438
+ const rowH = 24;
439
+ const top = height / 2 - items.length * rowH / 2 + rowH / 2;
440
+ const labels = items.map((it, i) => {
441
+ const y = top + i * rowH;
442
+ const pct = `${(Math.round(segs[i].fraction * 1e3) / 10).toString()}%`;
443
+ return `<circle cx="${lx + 4}" cy="${y}" r="4" fill="${it.color}"/><text x="${lx + 14}" y="${y}" dominant-baseline="central" font-size="13" fill="${p.muted}">${esc(it.label)} <tspan fill="${p.text}" font-weight="700">${pct}</tspan> <tspan fill="${p.muted}">${esc(fmt(it.value))}</tspan></text>`;
444
+ }).join("");
445
+ return svg + labels;
446
+ };
341
447
  function renderSvg(spec, options = {}) {
342
448
  const theme = THEMES[options.theme ?? spec.theme ?? "light"];
343
449
  const isSpark = spec.type === "sparkline";
344
450
  const width = spec.width ?? (isSpark ? 120 : 640);
345
- const height = spec.height ?? (isSpark ? 36 : spec.type === "percentage-bar" ? 96 : 320);
451
+ const height = spec.height ?? (isSpark ? 36 : spec.type === "percentage-bar" ? 96 : spec.type === "donut" ? 220 : 320);
346
452
  const pad = isSpark ? 0 : 16;
347
453
  const innerW = width - pad * 2;
348
454
  let y = pad;
@@ -355,6 +461,8 @@ function renderSvg(spec, options = {}) {
355
461
  if (spec.type === "percentage-bar") {
356
462
  body = percentBarSvg(spec, innerW, 0, theme);
357
463
  body = `<g transform="translate(${pad},${y})">${body}</g>`;
464
+ } else if (spec.type === "donut") {
465
+ body = `<g transform="translate(${pad},${y})">${donutSvg(spec, innerW, Math.max(60, height - y - pad), theme)}</g>`;
358
466
  } else {
359
467
  const series = seriesOf(spec);
360
468
  const legend = !isSpark && (spec.legend ?? series.length > 1);
@@ -434,6 +542,57 @@ export function Chart() {
434
542
  />
435
543
  );
436
544
  }
545
+ `;
546
+ }
547
+ if (spec.type === "candlestick") {
548
+ const vol = spec.volume ? `, volume: d[${lit(spec.volume)}]` : "";
549
+ return `import { CandlestickChart${spec.referenceLines?.length ? ", ReferenceLine" : ""} } from '@funnest/charts-react';
550
+
551
+ ${dataLine}
552
+ ${fmt}
553
+ export function Chart() {
554
+ return (
555
+ <CandlestickChart
556
+ data={data.map((d) => ({ open: d[${lit(spec.open)}], high: d[${lit(spec.high)}], low: d[${lit(spec.low)}], close: d[${lit(spec.close)}]${vol} }))}
557
+ height={${spec.height ?? 320}}${spec.x ? `
558
+ xAxis={{ data: data.map((d) => d[${lit(spec.x)}]) }}` : ""}${fmt ? "\n valueFormatter={fmt}" : ""}
559
+ >${(spec.referenceLines ?? []).map((r) => `
560
+ <ReferenceLine yAxisId="price"${r.y !== void 0 ? ` dataY={${r.y}}` : ""}${r.label ? ` label=${lit(r.label)}` : ""} />`).join("")}
561
+ </CandlestickChart>
562
+ );
563
+ }
564
+ `;
565
+ }
566
+ if (spec.type === "donut") {
567
+ return `import { DonutChart } from '@funnest/charts-react';
568
+
569
+ ${dataLine}
570
+ ${fmt}
571
+ export function Chart() {
572
+ return (
573
+ <DonutChart
574
+ 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)}])]` : ""} }))}${spec.totalLabel ? `
575
+ totalLabel=${lit(spec.totalLabel)}` : ""}${spec.innerRadius !== void 0 ? `
576
+ innerRadius={${spec.innerRadius}}` : ""}${fmt ? "\n valueFormatter={fmt}" : ""}
577
+ />
578
+ );
579
+ }
580
+ `;
581
+ }
582
+ if (spec.type === "treemap") {
583
+ return `import { TreemapChart } from '@funnest/charts-react';
584
+
585
+ ${dataLine}
586
+ ${fmt}
587
+ export function Chart() {
588
+ return (
589
+ <TreemapChart
590
+ height={${spec.height ?? 320}}
591
+ data={data.map((d, i) => ({ id: String(i), label: String(d[${lit(spec.label)}]), value: d[${lit(spec.value)}]${spec.change ? `, change: d[${lit(spec.change)}]` : ""} }))}${spec.maxChange !== void 0 ? `
592
+ maxChange={${spec.maxChange}}` : ""}${fmt ? "\n valueFormatter={fmt}" : ""}
593
+ />
594
+ );
595
+ }
437
596
  `;
438
597
  }
439
598
  const component = spec.type === "area" ? "AreaChart" : spec.type === "bar" ? "BarChart" : "LineChart";
@@ -586,6 +745,108 @@ var CHARTS = {
586
745
  referenceLines: [{ y: 0 }]
587
746
  }
588
747
  },
748
+ candlestick: {
749
+ type: "candlestick",
750
+ ko: "\uCE94\uB4E4 \uCC28\uD2B8. \uC2DC\uAC00\xB7\uACE0\uAC00\xB7\uC800\uAC00\xB7\uC885\uAC00\uC640 \uAC70\uB798\uB7C9",
751
+ en: "Candlestick chart: open/high/low/close with volume",
752
+ component: "CandlestickChart",
753
+ fields: {
754
+ ...COMMON,
755
+ x: SERIES.x,
756
+ open: { kind: "string", required: true, column: "numeric", ko: "\uC2DC\uAC00 \uC5F4", en: "Open column" },
757
+ high: { kind: "string", required: true, column: "numeric", ko: "\uACE0\uAC00 \uC5F4", en: "High column" },
758
+ low: { kind: "string", required: true, column: "numeric", ko: "\uC800\uAC00 \uC5F4", en: "Low column" },
759
+ close: { kind: "string", required: true, column: "numeric", ko: "\uC885\uAC00 \uC5F4", en: "Close column" },
760
+ volume: { kind: "string", column: "numeric", ko: "\uAC70\uB798\uB7C9 \uC5F4 (\uC788\uC73C\uBA74 \uC544\uB798\uC5D0 \uB9C9\uB300)", en: "Volume column (bars along the bottom)" },
761
+ showXAxis: SERIES.showXAxis,
762
+ showYAxis: SERIES.showYAxis,
763
+ format: FORMAT,
764
+ referenceLines: SERIES.referenceLines
765
+ },
766
+ example: {
767
+ title: "\uC0BC\uC131\uC804\uC790 \uC77C\uBD09",
768
+ data: (() => {
769
+ let p = 71e3;
770
+ return days(24).map((date, i) => {
771
+ const open = p;
772
+ const close = Math.round(open * (1 + Math.sin(i * 1.3) * 0.018 + 2e-3));
773
+ p = close;
774
+ return { date, open, high: Math.round(Math.max(open, close) * 1.008), low: Math.round(Math.min(open, close) * 0.992), close, volume: 8e6 + i * 137e4 % 6e6 };
775
+ });
776
+ })(),
777
+ x: "date",
778
+ open: "open",
779
+ high: "high",
780
+ low: "low",
781
+ close: "close",
782
+ volume: "volume",
783
+ format: { suffix: "\uC6D0" }
784
+ }
785
+ },
786
+ donut: {
787
+ type: "donut",
788
+ ko: "\uB3C4\uB11B \uCC28\uD2B8. \uC790\uC0B0 \uBE44\uC911, \uAC00\uC6B4\uB370\uC5D0 \uD569\uACC4",
789
+ en: "Donut chart: portfolio shares with the total in the middle",
790
+ component: "DonutChart",
791
+ fields: {
792
+ ...COMMON,
793
+ height: { kind: "number", default: 220, ko: "\uB192\uC774(px)", en: "Height (px)" },
794
+ label: { kind: "string", required: true, column: "any", ko: "\uC774\uB984 \uC5F4", en: "Label column" },
795
+ value: { kind: "string", required: true, column: "numeric", ko: "\uAC12 \uC5F4", en: "Value column" },
796
+ colors: { kind: "record", ko: "\uC774\uB984 \u2192 \uC0C9", en: "Label \u2192 color" },
797
+ totalLabel: { kind: "string", ko: "\uAC00\uC6B4\uB370 \uD569\uACC4 \uC544\uB798 \uAE00\uC528", en: "Text under the total" },
798
+ innerRadius: { kind: "number", default: 0.68, ko: "\uAD6C\uBA4D \uD06C\uAE30 (0\uC774\uBA74 \uD30C\uC774)", en: "Hole size (0 = pie)" },
799
+ format: FORMAT
800
+ },
801
+ example: {
802
+ title: "\uC790\uC0B0 \uAD6C\uC131",
803
+ data: [
804
+ { asset: "\uAD6D\uB0B4\uC8FC\uC2DD", amount: 4200 },
805
+ { asset: "\uD574\uC678\uC8FC\uC2DD", amount: 3100 },
806
+ { asset: "\uCF54\uC778", amount: 1700 },
807
+ { asset: "\uD604\uAE08", amount: 1e3 }
808
+ ],
809
+ label: "asset",
810
+ value: "amount",
811
+ colors: { \uD604\uAE08: "#B0B8C1" },
812
+ totalLabel: "\uCD1D \uC790\uC0B0",
813
+ format: { suffix: "\uB9CC" }
814
+ }
815
+ },
816
+ treemap: {
817
+ type: "treemap",
818
+ ko: "\uD2B8\uB9AC\uB9F5. \uC2DC\uAC00\uCD1D\uC561\uC73C\uB85C \uD06C\uAE30, \uB4F1\uB77D\uB960\uB85C \uC0C9\uC744 \uC815\uD558\uB294 \uB9C8\uCF13\uB9F5",
819
+ en: "Treemap: market map sized by value, colored by change",
820
+ component: "TreemapChart",
821
+ fields: {
822
+ ...COMMON,
823
+ label: { kind: "string", required: true, column: "any", ko: "\uC774\uB984 \uC5F4", en: "Label column" },
824
+ value: { kind: "string", required: true, column: "numeric", ko: "\uD06C\uAE30 \uC5F4 (\uC2DC\uAC00\uCD1D\uC561 \uB4F1)", en: "Size column (market cap, \u2026)" },
825
+ change: { kind: "string", column: "numeric", ko: "\uB4F1\uB77D\uB960 \uC5F4 (%). \uC624\uB974\uBA74 \uBE68\uAC15, \uB0B4\uB9AC\uBA74 \uD30C\uB791", en: "Change column (%): red up, blue down" },
826
+ maxChange: { kind: "number", default: 5, ko: "\uAC00\uC7A5 \uC9C4\uD55C \uC0C9\uC774 \uB418\uB294 \uB4F1\uB77D\uB960", en: "Change for the full color" },
827
+ colors: { kind: "record", ko: "\uC774\uB984 \u2192 \uC0C9 (change \uB300\uC2E0)", en: "Label \u2192 color (instead of change)" },
828
+ format: FORMAT
829
+ },
830
+ example: {
831
+ title: "\uC624\uB298\uC758 \uC2DC\uC7A5",
832
+ data: [
833
+ { name: "\uC0BC\uC131\uC804\uC790", cap: 420, pct: 1.84 },
834
+ { name: "SK\uD558\uC774\uB2C9\uC2A4", cap: 130, pct: 4.12 },
835
+ { name: "LG\uC5D0\uB108\uC9C0\uC194\uB8E8\uC158", cap: 85, pct: -2.31 },
836
+ { name: "\uC0BC\uC131\uBC14\uC774\uC624", cap: 55, pct: -0.4 },
837
+ { name: "\uD604\uB300\uCC28", cap: 48, pct: 0.62 },
838
+ { name: "\uC140\uD2B8\uB9AC\uC628", cap: 40, pct: 2.95 },
839
+ { name: "\uAE30\uC544", cap: 38, pct: 1.05 },
840
+ { name: "NAVER", cap: 32, pct: -0.88 },
841
+ { name: "KB\uAE08\uC735", cap: 30, pct: 0.21 },
842
+ { name: "\uCE74\uCE74\uC624", cap: 18, pct: -3.4 }
843
+ ],
844
+ label: "name",
845
+ value: "cap",
846
+ change: "pct",
847
+ format: { suffix: "\uC870" }
848
+ }
849
+ },
589
850
  "percentage-bar": {
590
851
  type: "percentage-bar",
591
852
  ko: "\uBE44\uC911 \uB9C9\uB300. \uD3EC\uD2B8\uD3F4\uB9AC\uC624 \uBE44\uC911, \uB9E4\uC218\xB7\uB9E4\uB3C4 \uCCB4\uACB0 \uAC15\uB3C4",
package/dist/index.js CHANGED
@@ -171,6 +171,108 @@ var CHARTS = {
171
171
  referenceLines: [{ y: 0 }]
172
172
  }
173
173
  },
174
+ candlestick: {
175
+ type: "candlestick",
176
+ ko: "\uCE94\uB4E4 \uCC28\uD2B8. \uC2DC\uAC00\xB7\uACE0\uAC00\xB7\uC800\uAC00\xB7\uC885\uAC00\uC640 \uAC70\uB798\uB7C9",
177
+ en: "Candlestick chart: open/high/low/close with volume",
178
+ component: "CandlestickChart",
179
+ fields: {
180
+ ...COMMON,
181
+ x: SERIES.x,
182
+ open: { kind: "string", required: true, column: "numeric", ko: "\uC2DC\uAC00 \uC5F4", en: "Open column" },
183
+ high: { kind: "string", required: true, column: "numeric", ko: "\uACE0\uAC00 \uC5F4", en: "High column" },
184
+ low: { kind: "string", required: true, column: "numeric", ko: "\uC800\uAC00 \uC5F4", en: "Low column" },
185
+ close: { kind: "string", required: true, column: "numeric", ko: "\uC885\uAC00 \uC5F4", en: "Close column" },
186
+ volume: { kind: "string", column: "numeric", ko: "\uAC70\uB798\uB7C9 \uC5F4 (\uC788\uC73C\uBA74 \uC544\uB798\uC5D0 \uB9C9\uB300)", en: "Volume column (bars along the bottom)" },
187
+ showXAxis: SERIES.showXAxis,
188
+ showYAxis: SERIES.showYAxis,
189
+ format: FORMAT,
190
+ referenceLines: SERIES.referenceLines
191
+ },
192
+ example: {
193
+ title: "\uC0BC\uC131\uC804\uC790 \uC77C\uBD09",
194
+ data: (() => {
195
+ let p = 71e3;
196
+ return days(24).map((date, i) => {
197
+ const open = p;
198
+ const close = Math.round(open * (1 + Math.sin(i * 1.3) * 0.018 + 2e-3));
199
+ p = close;
200
+ return { date, open, high: Math.round(Math.max(open, close) * 1.008), low: Math.round(Math.min(open, close) * 0.992), close, volume: 8e6 + i * 137e4 % 6e6 };
201
+ });
202
+ })(),
203
+ x: "date",
204
+ open: "open",
205
+ high: "high",
206
+ low: "low",
207
+ close: "close",
208
+ volume: "volume",
209
+ format: { suffix: "\uC6D0" }
210
+ }
211
+ },
212
+ donut: {
213
+ type: "donut",
214
+ ko: "\uB3C4\uB11B \uCC28\uD2B8. \uC790\uC0B0 \uBE44\uC911, \uAC00\uC6B4\uB370\uC5D0 \uD569\uACC4",
215
+ en: "Donut chart: portfolio shares with the total in the middle",
216
+ component: "DonutChart",
217
+ fields: {
218
+ ...COMMON,
219
+ height: { kind: "number", default: 220, ko: "\uB192\uC774(px)", en: "Height (px)" },
220
+ label: { kind: "string", required: true, column: "any", ko: "\uC774\uB984 \uC5F4", en: "Label column" },
221
+ value: { kind: "string", required: true, column: "numeric", ko: "\uAC12 \uC5F4", en: "Value column" },
222
+ colors: { kind: "record", ko: "\uC774\uB984 \u2192 \uC0C9", en: "Label \u2192 color" },
223
+ totalLabel: { kind: "string", ko: "\uAC00\uC6B4\uB370 \uD569\uACC4 \uC544\uB798 \uAE00\uC528", en: "Text under the total" },
224
+ innerRadius: { kind: "number", default: 0.68, ko: "\uAD6C\uBA4D \uD06C\uAE30 (0\uC774\uBA74 \uD30C\uC774)", en: "Hole size (0 = pie)" },
225
+ format: FORMAT
226
+ },
227
+ example: {
228
+ title: "\uC790\uC0B0 \uAD6C\uC131",
229
+ data: [
230
+ { asset: "\uAD6D\uB0B4\uC8FC\uC2DD", amount: 4200 },
231
+ { asset: "\uD574\uC678\uC8FC\uC2DD", amount: 3100 },
232
+ { asset: "\uCF54\uC778", amount: 1700 },
233
+ { asset: "\uD604\uAE08", amount: 1e3 }
234
+ ],
235
+ label: "asset",
236
+ value: "amount",
237
+ colors: { \uD604\uAE08: "#B0B8C1" },
238
+ totalLabel: "\uCD1D \uC790\uC0B0",
239
+ format: { suffix: "\uB9CC" }
240
+ }
241
+ },
242
+ treemap: {
243
+ type: "treemap",
244
+ ko: "\uD2B8\uB9AC\uB9F5. \uC2DC\uAC00\uCD1D\uC561\uC73C\uB85C \uD06C\uAE30, \uB4F1\uB77D\uB960\uB85C \uC0C9\uC744 \uC815\uD558\uB294 \uB9C8\uCF13\uB9F5",
245
+ en: "Treemap: market map sized by value, colored by change",
246
+ component: "TreemapChart",
247
+ fields: {
248
+ ...COMMON,
249
+ label: { kind: "string", required: true, column: "any", ko: "\uC774\uB984 \uC5F4", en: "Label column" },
250
+ value: { kind: "string", required: true, column: "numeric", ko: "\uD06C\uAE30 \uC5F4 (\uC2DC\uAC00\uCD1D\uC561 \uB4F1)", en: "Size column (market cap, \u2026)" },
251
+ change: { kind: "string", column: "numeric", ko: "\uB4F1\uB77D\uB960 \uC5F4 (%). \uC624\uB974\uBA74 \uBE68\uAC15, \uB0B4\uB9AC\uBA74 \uD30C\uB791", en: "Change column (%): red up, blue down" },
252
+ maxChange: { kind: "number", default: 5, ko: "\uAC00\uC7A5 \uC9C4\uD55C \uC0C9\uC774 \uB418\uB294 \uB4F1\uB77D\uB960", en: "Change for the full color" },
253
+ colors: { kind: "record", ko: "\uC774\uB984 \u2192 \uC0C9 (change \uB300\uC2E0)", en: "Label \u2192 color (instead of change)" },
254
+ format: FORMAT
255
+ },
256
+ example: {
257
+ title: "\uC624\uB298\uC758 \uC2DC\uC7A5",
258
+ data: [
259
+ { name: "\uC0BC\uC131\uC804\uC790", cap: 420, pct: 1.84 },
260
+ { name: "SK\uD558\uC774\uB2C9\uC2A4", cap: 130, pct: 4.12 },
261
+ { name: "LG\uC5D0\uB108\uC9C0\uC194\uB8E8\uC158", cap: 85, pct: -2.31 },
262
+ { name: "\uC0BC\uC131\uBC14\uC774\uC624", cap: 55, pct: -0.4 },
263
+ { name: "\uD604\uB300\uCC28", cap: 48, pct: 0.62 },
264
+ { name: "\uC140\uD2B8\uB9AC\uC628", cap: 40, pct: 2.95 },
265
+ { name: "\uAE30\uC544", cap: 38, pct: 1.05 },
266
+ { name: "NAVER", cap: 32, pct: -0.88 },
267
+ { name: "KB\uAE08\uC735", cap: 30, pct: 0.21 },
268
+ { name: "\uCE74\uCE74\uC624", cap: 18, pct: -3.4 }
269
+ ],
270
+ label: "name",
271
+ value: "cap",
272
+ change: "pct",
273
+ format: { suffix: "\uC870" }
274
+ }
275
+ },
174
276
  "percentage-bar": {
175
277
  type: "percentage-bar",
176
278
  ko: "\uBE44\uC911 \uB9C9\uB300. \uD3EC\uD2B8\uD3F4\uB9AC\uC624 \uBE44\uC911, \uB9E4\uC218\xB7\uB9E4\uB3C4 \uCCB4\uACB0 \uAC15\uB3C4",
@@ -359,7 +461,7 @@ function chartSchema(type, locale = "ko") {
359
461
  var exampleSpec = (type) => ({ type, ...structuredClone(CHARTS[type].example) });
360
462
 
361
463
  // src/render.tsx
362
- import { AreaChart, BarChart, LineChart, Point, ReferenceLine } from "@funnest/charts-react";
464
+ import { AreaChart, BarChart, CandlestickChart, DonutChart, LineChart, Point, ReferenceLine, TreemapChart } from "@funnest/charts-react";
363
465
  import { percentages, seriesColor } from "@funnest/charts-viz";
364
466
  import { renderToStaticMarkup } from "react-dom/server";
365
467
  import { jsx } from "react/jsx-runtime";
@@ -439,6 +541,59 @@ function element(spec, width, height) {
439
541
  ))
440
542
  ];
441
543
  switch (spec.type) {
544
+ case "candlestick": {
545
+ const candles = spec.data.map((r) => {
546
+ const o = num(r[spec.open]);
547
+ const h = num(r[spec.high]);
548
+ const l = num(r[spec.low]);
549
+ const c = num(r[spec.close]);
550
+ if (o === null || h === null || l === null || c === null) return null;
551
+ const v = spec.volume ? num(r[spec.volume]) : null;
552
+ return { open: o, high: h, low: l, close: c, ...v !== null ? { volume: v } : {} };
553
+ });
554
+ return /* @__PURE__ */ jsx(
555
+ CandlestickChart,
556
+ {
557
+ data: candles,
558
+ width,
559
+ height,
560
+ animate: false,
561
+ tooltip: false,
562
+ valueFormatter: fmt,
563
+ ...xAxis ? { xAxis } : {},
564
+ showXAxis: spec.showXAxis ?? !!spec.x,
565
+ showYAxis: spec.showYAxis === false ? false : { tickLabelFormatter: fmt, width: 64 },
566
+ children: (spec.referenceLines ?? []).map((r, i) => /* @__PURE__ */ jsx(
567
+ ReferenceLine,
568
+ {
569
+ yAxisId: "price",
570
+ ...r.y !== void 0 ? { dataY: r.y } : {},
571
+ ...r.x !== void 0 ? { dataX: indexOf(spec, r.x) } : {},
572
+ ...r.label !== void 0 ? { label: r.label } : {},
573
+ ...r.color ? { color: r.color } : {}
574
+ },
575
+ `r${i}`
576
+ ))
577
+ }
578
+ );
579
+ }
580
+ case "treemap":
581
+ return /* @__PURE__ */ jsx(
582
+ TreemapChart,
583
+ {
584
+ width,
585
+ height,
586
+ enableTooltip: false,
587
+ valueFormatter: fmt,
588
+ ...spec.maxChange !== void 0 ? { maxChange: spec.maxChange } : {},
589
+ data: spec.data.map((r, i) => {
590
+ const label = String(r[spec.label] ?? i);
591
+ const change = spec.change ? num(r[spec.change]) : null;
592
+ const color = spec.colors?.[label];
593
+ return { id: `${i}`, label, value: num(r[spec.value]) ?? 0, ...change !== null ? { change } : {}, ...color ? { color } : {} };
594
+ })
595
+ }
596
+ );
442
597
  case "area":
443
598
  return /* @__PURE__ */ jsx(AreaChart, { ...common, stacked: spec.stacked ?? false, ...spec.curve ? { curve: spec.curve } : {}, children: extras });
444
599
  case "bar":
@@ -512,11 +667,43 @@ var percentBarSvg = (spec, width, top, p) => {
512
667
  return bars + labels;
513
668
  };
514
669
  var textWidth = (t, size) => [...t].reduce((w, ch) => w + (ch.charCodeAt(0) > 11904 ? size : size * 0.6), 0);
670
+ var donutSvg = (spec, width, height, p) => {
671
+ const fmt = valueFormatter(spec);
672
+ const items = spec.data.map((r, i) => {
673
+ const label = String(r[spec.label] ?? i);
674
+ return { id: `${i}`, label, value: num(r[spec.value]) ?? 0, color: spec.colors?.[label] ?? seriesColor(i) };
675
+ });
676
+ const size = Math.max(60, Math.min(height, width * 0.5));
677
+ const markup = renderToStaticMarkup(
678
+ /* @__PURE__ */ jsx(
679
+ DonutChart,
680
+ {
681
+ data: items,
682
+ size,
683
+ showLegend: false,
684
+ valueFormatter: fmt,
685
+ ...spec.totalLabel ? { totalLabel: spec.totalLabel } : {},
686
+ ...spec.innerRadius !== void 0 ? { innerRadius: spec.innerRadius } : {}
687
+ }
688
+ )
689
+ );
690
+ const svg = markup.slice(markup.indexOf("<svg"), markup.indexOf("</svg>") + 6).replace("<svg", `<svg x="0" y="${(height - size) / 2}"`);
691
+ const segs = percentages(items);
692
+ const lx = size + 28;
693
+ const rowH = 24;
694
+ const top = height / 2 - items.length * rowH / 2 + rowH / 2;
695
+ const labels = items.map((it, i) => {
696
+ const y = top + i * rowH;
697
+ const pct = `${(Math.round(segs[i].fraction * 1e3) / 10).toString()}%`;
698
+ return `<circle cx="${lx + 4}" cy="${y}" r="4" fill="${it.color}"/><text x="${lx + 14}" y="${y}" dominant-baseline="central" font-size="13" fill="${p.muted}">${esc(it.label)} <tspan fill="${p.text}" font-weight="700">${pct}</tspan> <tspan fill="${p.muted}">${esc(fmt(it.value))}</tspan></text>`;
699
+ }).join("");
700
+ return svg + labels;
701
+ };
515
702
  function renderSvg(spec, options = {}) {
516
703
  const theme = THEMES[options.theme ?? spec.theme ?? "light"];
517
704
  const isSpark = spec.type === "sparkline";
518
705
  const width = spec.width ?? (isSpark ? 120 : 640);
519
- const height = spec.height ?? (isSpark ? 36 : spec.type === "percentage-bar" ? 96 : 320);
706
+ const height = spec.height ?? (isSpark ? 36 : spec.type === "percentage-bar" ? 96 : spec.type === "donut" ? 220 : 320);
520
707
  const pad = isSpark ? 0 : 16;
521
708
  const innerW = width - pad * 2;
522
709
  let y = pad;
@@ -529,6 +716,8 @@ function renderSvg(spec, options = {}) {
529
716
  if (spec.type === "percentage-bar") {
530
717
  body = percentBarSvg(spec, innerW, 0, theme);
531
718
  body = `<g transform="translate(${pad},${y})">${body}</g>`;
719
+ } else if (spec.type === "donut") {
720
+ body = `<g transform="translate(${pad},${y})">${donutSvg(spec, innerW, Math.max(60, height - y - pad), theme)}</g>`;
532
721
  } else {
533
722
  const series = seriesOf(spec);
534
723
  const legend = !isSpark && (spec.legend ?? series.length > 1);
@@ -608,6 +797,57 @@ export function Chart() {
608
797
  />
609
798
  );
610
799
  }
800
+ `;
801
+ }
802
+ if (spec.type === "candlestick") {
803
+ const vol = spec.volume ? `, volume: d[${lit(spec.volume)}]` : "";
804
+ return `import { CandlestickChart${spec.referenceLines?.length ? ", ReferenceLine" : ""} } from '@funnest/charts-react';
805
+
806
+ ${dataLine}
807
+ ${fmt}
808
+ export function Chart() {
809
+ return (
810
+ <CandlestickChart
811
+ data={data.map((d) => ({ open: d[${lit(spec.open)}], high: d[${lit(spec.high)}], low: d[${lit(spec.low)}], close: d[${lit(spec.close)}]${vol} }))}
812
+ height={${spec.height ?? 320}}${spec.x ? `
813
+ xAxis={{ data: data.map((d) => d[${lit(spec.x)}]) }}` : ""}${fmt ? "\n valueFormatter={fmt}" : ""}
814
+ >${(spec.referenceLines ?? []).map((r) => `
815
+ <ReferenceLine yAxisId="price"${r.y !== void 0 ? ` dataY={${r.y}}` : ""}${r.label ? ` label=${lit(r.label)}` : ""} />`).join("")}
816
+ </CandlestickChart>
817
+ );
818
+ }
819
+ `;
820
+ }
821
+ if (spec.type === "donut") {
822
+ return `import { DonutChart } from '@funnest/charts-react';
823
+
824
+ ${dataLine}
825
+ ${fmt}
826
+ export function Chart() {
827
+ return (
828
+ <DonutChart
829
+ 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)}])]` : ""} }))}${spec.totalLabel ? `
830
+ totalLabel=${lit(spec.totalLabel)}` : ""}${spec.innerRadius !== void 0 ? `
831
+ innerRadius={${spec.innerRadius}}` : ""}${fmt ? "\n valueFormatter={fmt}" : ""}
832
+ />
833
+ );
834
+ }
835
+ `;
836
+ }
837
+ if (spec.type === "treemap") {
838
+ return `import { TreemapChart } from '@funnest/charts-react';
839
+
840
+ ${dataLine}
841
+ ${fmt}
842
+ export function Chart() {
843
+ return (
844
+ <TreemapChart
845
+ height={${spec.height ?? 320}}
846
+ data={data.map((d, i) => ({ id: String(i), label: String(d[${lit(spec.label)}]), value: d[${lit(spec.value)}]${spec.change ? `, change: d[${lit(spec.change)}]` : ""} }))}${spec.maxChange !== void 0 ? `
847
+ maxChange={${spec.maxChange}}` : ""}${fmt ? "\n valueFormatter={fmt}" : ""}
848
+ />
849
+ );
850
+ }
611
851
  `;
612
852
  }
613
853
  const component = spec.type === "area" ? "AreaChart" : spec.type === "bar" ? "BarChart" : "LineChart";
@@ -719,6 +959,15 @@ function suggestCharts(rows) {
719
959
  const out = [];
720
960
  const allPositive = (c) => rows.every((r) => typeof r[c] !== "number" || r[c] >= 0);
721
961
  const hasNegative = (c) => rows.some((r) => typeof r[c] === "number" && r[c] < 0);
962
+ const find = (...names) => numeric.find((c) => names.includes(c.toLowerCase()));
963
+ const ohlc = { open: find("open", "o", "\uC2DC\uAC00"), high: find("high", "h", "\uACE0\uAC00"), low: find("low", "l", "\uC800\uAC00"), close: find("close", "c", "\uC885\uAC00") };
964
+ if (ohlc.open && ohlc.high && ohlc.low && ohlc.close) {
965
+ const volume = find("volume", "vol", "v", "\uAC70\uB798\uB7C9");
966
+ out.push({
967
+ reason: { ko: "\uC2DC\uAC00\xB7\uACE0\uAC00\xB7\uC800\uAC00\xB7\uC885\uAC00\uAC00 \uC788\uC5B4 \uCE94\uB4E4 \uCC28\uD2B8\uAC00 \uC798 \uB9DE\uC544\uC694", en: "Open/high/low/close columns: candles" },
968
+ spec: { type: "candlestick", data: rows, ...time ? { x: time } : {}, open: ohlc.open, high: ohlc.high, low: ohlc.low, close: ohlc.close, ...volume ? { volume } : {} }
969
+ });
970
+ }
722
971
  if (time && numeric.length) {
723
972
  const magnitude = (c) => Math.max(...rows.map((r) => Math.abs(Number(r[c]) || 0)));
724
973
  const base = numeric.find(allPositive) ?? numeric[0];
@@ -750,11 +999,23 @@ function suggestCharts(rows) {
750
999
  }
751
1000
  if (category && numeric.length) {
752
1001
  const v = numeric[0];
1002
+ const size = numeric.find(allPositive);
1003
+ const change = numeric.find(hasNegative);
1004
+ if (size && change && rows.length >= 4) {
1005
+ out.push({
1006
+ reason: { ko: `${size}\uB85C \uD06C\uAE30, ${change}\uB85C \uC0C9\uC744 \uC815\uD55C \uB9C8\uCF13\uB9F5`, en: `Market map: size by ${size}, color by ${change}` },
1007
+ spec: { type: "treemap", data: rows, label: category, value: size, change }
1008
+ });
1009
+ }
753
1010
  if (rows.length <= 8 && allPositive(v)) {
754
1011
  out.push({
755
1012
  reason: { ko: `${category}\uBCC4 ${v} \uBE44\uC911`, en: `Share of ${v} by ${category}` },
756
1013
  spec: { type: "percentage-bar", data: rows, label: category, value: v }
757
1014
  });
1015
+ out.push({
1016
+ reason: { ko: `${category}\uBCC4 ${v} \uBE44\uC911\uC744 \uB3C4\uB11B\uC73C\uB85C`, en: `Share of ${v} by ${category} as a donut` },
1017
+ spec: { type: "donut", data: rows, label: category, value: v }
1018
+ });
758
1019
  }
759
1020
  out.push({
760
1021
  reason: { ko: `${category}\uBCC4 ${v} \uBE44\uAD50`, en: `Compare ${v} by ${category}` },
package/dist/spec.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { type CliLocale } from './i18n';
2
2
  /** A row of tabular data: column name → value. */
3
3
  export type Row = Record<string, unknown>;
4
- export type ChartType = 'line' | 'area' | 'bar' | 'percentage-bar' | 'sparkline';
4
+ export type ChartType = 'line' | 'area' | 'bar' | 'candlestick' | 'donut' | 'treemap' | 'percentage-bar' | 'sparkline';
5
5
  export interface ReferenceLineSpec {
6
6
  y?: number;
7
7
  /** Row index, or a value of the x column. */
@@ -36,9 +36,23 @@ export interface ChartSpec {
36
36
  labels?: Record<string, string>;
37
37
  /** Colors per y column (or per row label for percentage-bar). */
38
38
  colors?: Record<string, string>;
39
- /** percentage-bar: the label and value columns. */
39
+ /** percentage-bar / donut / treemap: the label and value columns. */
40
40
  label?: string;
41
41
  value?: string;
42
+ /** candlestick: OHLC (+ volume) columns. */
43
+ open?: string;
44
+ high?: string;
45
+ low?: string;
46
+ close?: string;
47
+ volume?: string;
48
+ /** treemap: change column (%) for the tile color. */
49
+ change?: string;
50
+ /** treemap: |change| that gets the full color. Default 5. */
51
+ maxChange?: number;
52
+ /** donut: text under the total. */
53
+ totalLabel?: string;
54
+ /** donut: hole size 0–0.95 (0 = pie). Default 0.68. */
55
+ innerRadius?: number;
42
56
  curve?: 'monotone' | 'linear' | 'step';
43
57
  area?: 'none' | 'gradient' | 'solid';
44
58
  stacked?: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@funnest/charts-cli",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Funnest Charts command line: list charts, validate JSON specs, render SVG/PNG/HTML, suggest charts for your data, generate React code",
5
5
  "keywords": [
6
6
  "chart",
@@ -48,8 +48,8 @@
48
48
  "dependencies": {
49
49
  "react": "^19.3.1",
50
50
  "react-dom": "^19.3.1",
51
- "@funnest/charts-react": "0.3.0",
52
- "@funnest/charts-viz": "0.3.0"
51
+ "@funnest/charts-react": "0.5.0",
52
+ "@funnest/charts-viz": "0.5.0"
53
53
  },
54
54
  "optionalDependencies": {
55
55
  "@resvg/resvg-js": "^2.6.2"