@funnest/charts-cli 0.2.1 → 0.4.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,10 +286,63 @@ 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":
271
- return /* @__PURE__ */ jsx(BarChart, { ...common, stacked: spec.stacked ?? false, ...spec.colorBySign ? { getBarColor: (v) => v >= 0 ? UP : DOWN } : {}, children: extras });
345
+ return /* @__PURE__ */ jsx(BarChart, { ...common, stacked: spec.stacked ?? false, ...spec.layout === "horizontal" ? { layout: "horizontal" } : {}, ...spec.colorBySign ? { getBarColor: (v) => v >= 0 ? UP : DOWN } : {}, children: extras });
272
346
  case "sparkline": {
273
347
  const s = series[0];
274
348
  const vals = (s?.data ?? []).filter((v) => v !== null);
@@ -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";
@@ -455,6 +614,7 @@ export function Chart() {
455
614
  if (spec.stacked) props.push("stacked");
456
615
  if (spec.area && spec.area !== "none") props.push(`area=${lit(spec.area)}`);
457
616
  if (spec.curve) props.push(`curve=${lit(spec.curve)}`);
617
+ if (spec.type === "bar" && spec.layout === "horizontal") props.push('layout="horizontal"');
458
618
  if (spec.type === "bar" && spec.colorBySign) props.push("getBarColor={(v) => (v >= 0 ? '#F04452' : '#3182F6')}");
459
619
  if (ys.length > 1 || spec.legend) props.push("legend");
460
620
  const children = [
@@ -572,7 +732,8 @@ var CHARTS = {
572
732
  ...COMMON,
573
733
  ...SERIES,
574
734
  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" }
735
+ colorBySign: { kind: "boolean", default: false, ko: "0 \uC774\uC0C1 \uBE68\uAC15, \uBBF8\uB9CC \uD30C\uB791", en: "Red for \u2265 0, blue for < 0" },
736
+ layout: { kind: "string", enum: ["vertical", "horizontal"], default: "vertical", ko: "'horizontal'\uC774\uBA74 \uAC00\uB85C \uB9C9\uB300 (\uC21C\uC704, \uC5C5\uC885\uBCC4 \uC218\uC775\uB960)", en: "'horizontal' for left-to-right bars (rankings, sector returns)" }
576
737
  },
577
738
  example: {
578
739
  title: "\uC77C\uBCC4 \uC190\uC775",
@@ -584,6 +745,108 @@ var CHARTS = {
584
745
  referenceLines: [{ y: 0 }]
585
746
  }
586
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
+ },
587
850
  "percentage-bar": {
588
851
  type: "percentage-bar",
589
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
@@ -158,7 +158,8 @@ var CHARTS = {
158
158
  ...COMMON,
159
159
  ...SERIES,
160
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" }
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
+ layout: { kind: "string", enum: ["vertical", "horizontal"], default: "vertical", ko: "'horizontal'\uC774\uBA74 \uAC00\uB85C \uB9C9\uB300 (\uC21C\uC704, \uC5C5\uC885\uBCC4 \uC218\uC775\uB960)", en: "'horizontal' for left-to-right bars (rankings, sector returns)" }
162
163
  },
163
164
  example: {
164
165
  title: "\uC77C\uBCC4 \uC190\uC775",
@@ -170,6 +171,108 @@ var CHARTS = {
170
171
  referenceLines: [{ y: 0 }]
171
172
  }
172
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
+ },
173
276
  "percentage-bar": {
174
277
  type: "percentage-bar",
175
278
  ko: "\uBE44\uC911 \uB9C9\uB300. \uD3EC\uD2B8\uD3F4\uB9AC\uC624 \uBE44\uC911, \uB9E4\uC218\xB7\uB9E4\uB3C4 \uCCB4\uACB0 \uAC15\uB3C4",
@@ -358,7 +461,7 @@ function chartSchema(type, locale = "ko") {
358
461
  var exampleSpec = (type) => ({ type, ...structuredClone(CHARTS[type].example) });
359
462
 
360
463
  // src/render.tsx
361
- import { AreaChart, BarChart, LineChart, Point, ReferenceLine } from "@funnest/charts-react";
464
+ import { AreaChart, BarChart, CandlestickChart, DonutChart, LineChart, Point, ReferenceLine, TreemapChart } from "@funnest/charts-react";
362
465
  import { percentages, seriesColor } from "@funnest/charts-viz";
363
466
  import { renderToStaticMarkup } from "react-dom/server";
364
467
  import { jsx } from "react/jsx-runtime";
@@ -438,10 +541,63 @@ function element(spec, width, height) {
438
541
  ))
439
542
  ];
440
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
+ );
441
597
  case "area":
442
598
  return /* @__PURE__ */ jsx(AreaChart, { ...common, stacked: spec.stacked ?? false, ...spec.curve ? { curve: spec.curve } : {}, children: extras });
443
599
  case "bar":
444
- return /* @__PURE__ */ jsx(BarChart, { ...common, stacked: spec.stacked ?? false, ...spec.colorBySign ? { getBarColor: (v) => v >= 0 ? UP : DOWN } : {}, children: extras });
600
+ return /* @__PURE__ */ jsx(BarChart, { ...common, stacked: spec.stacked ?? false, ...spec.layout === "horizontal" ? { layout: "horizontal" } : {}, ...spec.colorBySign ? { getBarColor: (v) => v >= 0 ? UP : DOWN } : {}, children: extras });
445
601
  case "sparkline": {
446
602
  const s = series[0];
447
603
  const vals = (s?.data ?? []).filter((v) => v !== null);
@@ -511,11 +667,43 @@ var percentBarSvg = (spec, width, top, p) => {
511
667
  return bars + labels;
512
668
  };
513
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
+ };
514
702
  function renderSvg(spec, options = {}) {
515
703
  const theme = THEMES[options.theme ?? spec.theme ?? "light"];
516
704
  const isSpark = spec.type === "sparkline";
517
705
  const width = spec.width ?? (isSpark ? 120 : 640);
518
- 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);
519
707
  const pad = isSpark ? 0 : 16;
520
708
  const innerW = width - pad * 2;
521
709
  let y = pad;
@@ -528,6 +716,8 @@ function renderSvg(spec, options = {}) {
528
716
  if (spec.type === "percentage-bar") {
529
717
  body = percentBarSvg(spec, innerW, 0, theme);
530
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>`;
531
721
  } else {
532
722
  const series = seriesOf(spec);
533
723
  const legend = !isSpark && (spec.legend ?? series.length > 1);
@@ -607,6 +797,57 @@ export function Chart() {
607
797
  />
608
798
  );
609
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
+ }
610
851
  `;
611
852
  }
612
853
  const component = spec.type === "area" ? "AreaChart" : spec.type === "bar" ? "BarChart" : "LineChart";
@@ -628,6 +869,7 @@ export function Chart() {
628
869
  if (spec.stacked) props.push("stacked");
629
870
  if (spec.area && spec.area !== "none") props.push(`area=${lit(spec.area)}`);
630
871
  if (spec.curve) props.push(`curve=${lit(spec.curve)}`);
872
+ if (spec.type === "bar" && spec.layout === "horizontal") props.push('layout="horizontal"');
631
873
  if (spec.type === "bar" && spec.colorBySign) props.push("getBarColor={(v) => (v >= 0 ? '#F04452' : '#3182F6')}");
632
874
  if (ys.length > 1 || spec.legend) props.push("legend");
633
875
  const children = [
@@ -717,6 +959,15 @@ function suggestCharts(rows) {
717
959
  const out = [];
718
960
  const allPositive = (c) => rows.every((r) => typeof r[c] !== "number" || r[c] >= 0);
719
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
+ }
720
971
  if (time && numeric.length) {
721
972
  const magnitude = (c) => Math.max(...rows.map((r) => Math.abs(Number(r[c]) || 0)));
722
973
  const base = numeric.find(allPositive) ?? numeric[0];
@@ -748,11 +999,23 @@ function suggestCharts(rows) {
748
999
  }
749
1000
  if (category && numeric.length) {
750
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
+ }
751
1010
  if (rows.length <= 8 && allPositive(v)) {
752
1011
  out.push({
753
1012
  reason: { ko: `${category}\uBCC4 ${v} \uBE44\uC911`, en: `Share of ${v} by ${category}` },
754
1013
  spec: { type: "percentage-bar", data: rows, label: category, value: v }
755
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
+ });
756
1019
  }
757
1020
  out.push({
758
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,14 +36,30 @@ 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;
45
59
  /** bar: red for ≥ 0, blue for < 0 (Korean convention). */
46
60
  colorBySign?: boolean;
61
+ /** bar: 'horizontal' draws one row per category (rankings, sector returns). */
62
+ layout?: 'vertical' | 'horizontal';
47
63
  showXAxis?: boolean;
48
64
  showYAxis?: boolean;
49
65
  legend?: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@funnest/charts-cli",
3
- "version": "0.2.1",
3
+ "version": "0.4.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.2.1",
52
- "@funnest/charts-viz": "0.2.1"
51
+ "@funnest/charts-react": "0.4.0",
52
+ "@funnest/charts-viz": "0.4.0"
53
53
  },
54
54
  "optionalDependencies": {
55
55
  "@resvg/resvg-js": "^2.6.2"