plotcli-py 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. CLAUDE.md +51 -0
  2. LICENSE +21 -0
  3. PKG-INFO +358 -0
  4. README.md +340 -0
  5. main.py +6 -0
  6. plotcli-original/.Rbuildignore +18 -0
  7. plotcli-original/.github/workflows/deploy_docs.yml +43 -0
  8. plotcli-original/.gitignore +46 -0
  9. plotcli-original/DESCRIPTION +25 -0
  10. plotcli-original/NAMESPACE +60 -0
  11. plotcli-original/NEWS.md +112 -0
  12. plotcli-original/R/ascii_escape.r +13 -0
  13. plotcli-original/R/canvas.r +586 -0
  14. plotcli-original/R/class_functions.r +114 -0
  15. plotcli-original/R/geom_registry.r +1376 -0
  16. plotcli-original/R/ggplotcli.r +234 -0
  17. plotcli-original/R/ggplotcli_helpers.r +1099 -0
  18. plotcli-original/R/helper_functions.r +351 -0
  19. plotcli-original/R/plotcli.r +963 -0
  20. plotcli-original/R/plotcli_grid.r +1 -0
  21. plotcli-original/R/plotcli_wrappers.r +416 -0
  22. plotcli-original/R/zzz.r +15 -0
  23. plotcli-original/README.md +192 -0
  24. plotcli-original/docs/ascii.png +0 -0
  25. plotcli-original/docs/bar.png +0 -0
  26. plotcli-original/docs/block.png +0 -0
  27. plotcli-original/docs/boxplot.png +0 -0
  28. plotcli-original/docs/density.png +0 -0
  29. plotcli-original/docs/facet.png +0 -0
  30. plotcli-original/docs/facet_grid.png +0 -0
  31. plotcli-original/docs/generate_png.sh +137 -0
  32. plotcli-original/docs/heatmap.png +0 -0
  33. plotcli-original/docs/histogram.png +0 -0
  34. plotcli-original/docs/line.png +0 -0
  35. plotcli-original/docs/noborder.png +0 -0
  36. plotcli-original/docs/scatter.png +0 -0
  37. plotcli-original/docs/showcase.R +182 -0
  38. plotcli-original/inst/doc/ggplotcli.R +231 -0
  39. plotcli-original/inst/doc/ggplotcli.Rmd +329 -0
  40. plotcli-original/inst/doc/ggplotcli.html +1078 -0
  41. plotcli-original/inst/doc/plotcli_class.R +98 -0
  42. plotcli-original/inst/doc/plotcli_class.Rmd +121 -0
  43. plotcli-original/inst/doc/plotcli_class.html +564 -0
  44. plotcli-original/inst/doc/plotcli_wrappers.R +35 -0
  45. plotcli-original/inst/doc/plotcli_wrappers.Rmd +62 -0
  46. plotcli-original/inst/doc/plotcli_wrappers.html +546 -0
  47. plotcli-original/man/AsciiCanvas.Rd +116 -0
  48. plotcli-original/man/BlockCanvas.Rd +132 -0
  49. plotcli-original/man/BrailleCanvas.Rd +146 -0
  50. plotcli-original/man/Canvas.Rd +492 -0
  51. plotcli-original/man/GeomRegistry.Rd +9 -0
  52. plotcli-original/man/add_legend_to_output.Rd +12 -0
  53. plotcli-original/man/braille_dot_bit.Rd +29 -0
  54. plotcli-original/man/braille_set_dot.Rd +21 -0
  55. plotcli-original/man/bresenham.Rd +27 -0
  56. plotcli-original/man/build_plot_output.Rd +28 -0
  57. plotcli-original/man/build_plot_output_v2.Rd +41 -0
  58. plotcli-original/man/cat_plot_matrix.Rd +17 -0
  59. plotcli-original/man/cbind.plotcli.Rd +19 -0
  60. plotcli-original/man/cbind_plots.Rd +17 -0
  61. plotcli-original/man/color_to_term.Rd +18 -0
  62. plotcli-original/man/create_canvas.Rd +21 -0
  63. plotcli-original/man/create_panel_scales.Rd +29 -0
  64. plotcli-original/man/create_scales.Rd +27 -0
  65. plotcli-original/man/dot-geom_registry.Rd +16 -0
  66. plotcli-original/man/draw_border.Rd +12 -0
  67. plotcli-original/man/draw_grid.Rd +12 -0
  68. plotcli-original/man/extract_legend_info.Rd +12 -0
  69. plotcli-original/man/extract_plot_labels.Rd +12 -0
  70. plotcli-original/man/extract_plot_style.Rd +12 -0
  71. plotcli-original/man/format_axis_label.Rd +18 -0
  72. plotcli-original/man/format_four_chars.Rd +21 -0
  73. plotcli-original/man/geom_area_handler.Rd +12 -0
  74. plotcli-original/man/geom_bar_handler.Rd +12 -0
  75. plotcli-original/man/geom_boxplot_handler.Rd +13 -0
  76. plotcli-original/man/geom_density_handler.Rd +12 -0
  77. plotcli-original/man/geom_histogram_handler.Rd +12 -0
  78. plotcli-original/man/geom_hline_handler.Rd +12 -0
  79. plotcli-original/man/geom_line_handler.Rd +12 -0
  80. plotcli-original/man/geom_path_handler.Rd +12 -0
  81. plotcli-original/man/geom_point_handler.Rd +12 -0
  82. plotcli-original/man/geom_rect_handler.Rd +12 -0
  83. plotcli-original/man/geom_segment_handler.Rd +12 -0
  84. plotcli-original/man/geom_smooth_handler.Rd +12 -0
  85. plotcli-original/man/geom_text_handler.Rd +12 -0
  86. plotcli-original/man/geom_vline_handler.Rd +12 -0
  87. plotcli-original/man/get_color_hue.Rd +18 -0
  88. plotcli-original/man/get_data_subset.Rd +23 -0
  89. plotcli-original/man/get_facet_info.Rd +18 -0
  90. plotcli-original/man/get_geom_handler.Rd +17 -0
  91. plotcli-original/man/get_term_colors.Rd +21 -0
  92. plotcli-original/man/ggplotcli.Rd +83 -0
  93. plotcli-original/man/init_color_mapping.Rd +15 -0
  94. plotcli-original/man/is_braille.Rd +20 -0
  95. plotcli-original/man/is_geom_registered.Rd +17 -0
  96. plotcli-original/man/list_registered_geoms.Rd +14 -0
  97. plotcli-original/man/make_colored.Rd +23 -0
  98. plotcli-original/man/make_unique_names.Rd +20 -0
  99. plotcli-original/man/normalize_data.Rd +27 -0
  100. plotcli-original/man/pclib.Rd +48 -0
  101. plotcli-original/man/pclibx.Rd +46 -0
  102. plotcli-original/man/pclid.Rd +44 -0
  103. plotcli-original/man/pclih.Rd +50 -0
  104. plotcli-original/man/pclil.Rd +48 -0
  105. plotcli-original/man/pclis.Rd +48 -0
  106. plotcli-original/man/pixel_to_braille.Rd +23 -0
  107. plotcli-original/man/plotcli.Rd +598 -0
  108. plotcli-original/man/plotcli_bar.Rd +48 -0
  109. plotcli-original/man/plotcli_box.Rd +46 -0
  110. plotcli-original/man/plotcli_density.Rd +44 -0
  111. plotcli-original/man/plotcli_histogram.Rd +50 -0
  112. plotcli-original/man/plotcli_line.Rd +48 -0
  113. plotcli-original/man/plotcli_options.Rd +18 -0
  114. plotcli-original/man/plotcli_scatter.Rd +48 -0
  115. plotcli-original/man/plus-.plotcli.Rd +19 -0
  116. plotcli-original/man/rbind.plotcli.Rd +19 -0
  117. plotcli-original/man/rbind_plots.Rd +17 -0
  118. plotcli-original/man/register_geom.Rd +21 -0
  119. plotcli-original/man/remove_color_codes.Rd +21 -0
  120. plotcli-original/man/render_faceted_plot.Rd +25 -0
  121. plotcli-original/man/render_single_panel.Rd +12 -0
  122. plotcli-original/man/safe_aes_name.Rd +18 -0
  123. plotcli-original/tests/testthat/test-new-geoms.R +136 -0
  124. plotcli-original/tests/testthat/test-plotcli.R +69 -0
  125. plotcli-original/tests/testthat.R +4 -0
  126. plotcli-original/vignettes/ggplotcli.Rmd +329 -0
  127. plotcli-original/vignettes/plotcli_class.R +98 -0
  128. plotcli-original/vignettes/plotcli_class.Rmd +121 -0
  129. plotcli-original/vignettes/plotcli_wrappers.R +35 -0
  130. plotcli-original/vignettes/plotcli_wrappers.Rmd +62 -0
  131. plotcli.egg-info/PKG-INFO +11 -0
  132. plotcli.egg-info/SOURCES.txt +7 -0
  133. plotcli.egg-info/dependency_links.txt +1 -0
  134. plotcli.egg-info/entry_points.txt +3 -0
  135. plotcli.egg-info/top_level.txt +1 -0
  136. plotcli.py +978 -0
  137. plotcli_py-0.1.0.dist-info/METADATA +358 -0
  138. plotcli_py-0.1.0.dist-info/RECORD +143 -0
  139. plotcli_py-0.1.0.dist-info/WHEEL +4 -0
  140. plotcli_py-0.1.0.dist-info/entry_points.txt +2 -0
  141. plotcli_py-0.1.0.dist-info/licenses/LICENSE +21 -0
  142. pyproject.toml +31 -0
  143. uv.lock +8 -0
plotcli.py ADDED
@@ -0,0 +1,978 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ plotcli - Terminal plotting for CLI interfaces like Claude Code.
4
+
5
+ Renders plots using Unicode Braille characters, block elements, or ASCII.
6
+ Supports: scatter, line, bar, histogram, boxplot, density, heatmap.
7
+
8
+ Usage:
9
+ # Pipe data
10
+ echo "1 2 3 4 5" | plotcli line
11
+ echo -e "1,2\\n3,4\\n5,6" | plotcli scatter -d ","
12
+
13
+ # Expressions
14
+ plotcli line -e "import math; [(x/10, math.sin(x/10)) for x in range(100)]"
15
+
16
+ # CSV files
17
+ plotcli scatter -f data.csv -x col1 -y col2
18
+
19
+ # Quick functions
20
+ plotcli fn "math.sin(x)" 0 6.28
21
+ plotcli hist -e "[random.gauss(0,1) for _ in range(1000)]"
22
+ """
23
+
24
+ import sys
25
+ import math
26
+ import argparse
27
+ import os
28
+ import json
29
+ import re
30
+ from typing import Optional
31
+
32
+ # ─── ANSI Colors ─────────────────────────────────────────────────────────────
33
+
34
+ COLORS = {
35
+ "red": "\033[31m",
36
+ "green": "\033[32m",
37
+ "yellow": "\033[33m",
38
+ "blue": "\033[34m",
39
+ "magenta": "\033[35m",
40
+ "cyan": "\033[36m",
41
+ "white": "\033[37m",
42
+ "bright_red": "\033[91m",
43
+ "bright_green": "\033[92m",
44
+ "bright_yellow": "\033[93m",
45
+ "bright_blue": "\033[94m",
46
+ "bright_magenta": "\033[95m",
47
+ "bright_cyan": "\033[96m",
48
+ }
49
+ RESET = "\033[0m"
50
+
51
+ COLOR_CYCLE = ["blue", "red", "green", "yellow", "magenta", "cyan",
52
+ "bright_red", "bright_green", "bright_blue", "bright_magenta"]
53
+
54
+
55
+ def colorize(text: str, color: Optional[str]) -> str:
56
+ if color and color in COLORS:
57
+ return f"{COLORS[color]}{text}{RESET}"
58
+ return text
59
+
60
+
61
+ # ─── Bresenham's Line Algorithm ──────────────────────────────────────────────
62
+
63
+ def bresenham(x0, y0, x1, y1):
64
+ points = []
65
+ dx = abs(x1 - x0)
66
+ dy = abs(y1 - y0)
67
+ sx = 1 if x0 < x1 else -1
68
+ sy = 1 if y0 < y1 else -1
69
+ err = dx - dy
70
+ while True:
71
+ points.append((x0, y0))
72
+ if x0 == x1 and y0 == y1:
73
+ break
74
+ e2 = 2 * err
75
+ if e2 > -dy:
76
+ err -= dy
77
+ x0 += sx
78
+ if e2 < dx:
79
+ err += dx
80
+ y0 += sy
81
+ return points
82
+
83
+
84
+ # ─── Canvas Classes ──────────────────────────────────────────────────────────
85
+
86
+ class BrailleCanvas:
87
+ """High-resolution canvas using Unicode Braille patterns (2x4 dots per cell)."""
88
+
89
+ BRAILLE_BASE = 0x2800
90
+ # Bit positions for each dot in a braille cell
91
+ # Layout: col0 col1
92
+ # row0: 0x01 0x08
93
+ # row1: 0x02 0x10
94
+ # row2: 0x04 0x20
95
+ # row3: 0x40 0x80
96
+ DOT_BITS = [
97
+ [0x01, 0x08],
98
+ [0x02, 0x10],
99
+ [0x04, 0x20],
100
+ [0x40, 0x80],
101
+ ]
102
+
103
+ def __init__(self, width, height):
104
+ self.width = width
105
+ self.height = height
106
+ self.pixel_width = width * 2
107
+ self.pixel_height = height * 4
108
+ self.codes = [[0] * width for _ in range(height)]
109
+ self.color_matrix = [[None] * width for _ in range(height)]
110
+
111
+ def set_pixel(self, px, py, color=None):
112
+ px = max(0, min(px, self.pixel_width - 1))
113
+ py = max(0, min(py, self.pixel_height - 1))
114
+ cell_col = px // 2
115
+ cell_row = py // 4
116
+ dot_col = px % 2
117
+ dot_row = py % 4
118
+ self.codes[cell_row][cell_col] |= self.DOT_BITS[dot_row][dot_col]
119
+ if color:
120
+ self.color_matrix[cell_row][cell_col] = color
121
+
122
+ def draw_line(self, x0, y0, x1, y1, color=None):
123
+ for px, py in bresenham(round(x0), round(y0), round(x1), round(y1)):
124
+ self.set_pixel(px, py, color)
125
+
126
+ def render(self):
127
+ lines = []
128
+ for r in range(self.height):
129
+ row = ""
130
+ for c in range(self.width):
131
+ ch = chr(self.BRAILLE_BASE + self.codes[r][c]) if self.codes[r][c] else " "
132
+ row += colorize(ch, self.color_matrix[r][c])
133
+ lines.append(row)
134
+ return lines
135
+
136
+
137
+ class BlockCanvas:
138
+ """Medium-resolution canvas using half-block characters (1x2 per cell)."""
139
+
140
+ UPPER = "\u2580"
141
+ LOWER = "\u2584"
142
+ FULL = "\u2588"
143
+
144
+ def __init__(self, width, height):
145
+ self.width = width
146
+ self.height = height
147
+ self.pixel_width = width
148
+ self.pixel_height = height * 2
149
+ self.state = [[0] * width for _ in range(height)] # 0=none 1=upper 2=lower 3=both
150
+ self.color_matrix = [[None] * width for _ in range(height)]
151
+
152
+ def set_pixel(self, px, py, color=None):
153
+ px = max(0, min(px, self.pixel_width - 1))
154
+ py = max(0, min(py, self.pixel_height - 1))
155
+ cell_col = px
156
+ cell_row = py // 2
157
+ is_lower = (py % 2) == 1
158
+ if is_lower:
159
+ self.state[cell_row][cell_col] |= 2
160
+ else:
161
+ self.state[cell_row][cell_col] |= 1
162
+ if color:
163
+ self.color_matrix[cell_row][cell_col] = color
164
+
165
+ def draw_line(self, x0, y0, x1, y1, color=None):
166
+ for px, py in bresenham(round(x0), round(y0), round(x1), round(y1)):
167
+ self.set_pixel(px, py, color)
168
+
169
+ def render(self):
170
+ chars = {0: " ", 1: self.UPPER, 2: self.LOWER, 3: self.FULL}
171
+ lines = []
172
+ for r in range(self.height):
173
+ row = ""
174
+ for c in range(self.width):
175
+ ch = chars.get(self.state[r][c], " ")
176
+ row += colorize(ch, self.color_matrix[r][c])
177
+ lines.append(row)
178
+ return lines
179
+
180
+
181
+ class AsciiCanvas:
182
+ """Basic ASCII canvas (1x1 per cell)."""
183
+
184
+ def __init__(self, width, height, char="*"):
185
+ self.width = width
186
+ self.height = height
187
+ self.pixel_width = width
188
+ self.pixel_height = height
189
+ self.char = char
190
+ self.matrix = [[" "] * width for _ in range(height)]
191
+ self.color_matrix = [[None] * width for _ in range(height)]
192
+
193
+ def set_pixel(self, px, py, color=None):
194
+ px = max(0, min(px, self.pixel_width - 1))
195
+ py = max(0, min(py, self.pixel_height - 1))
196
+ self.matrix[py][px] = self.char
197
+ if color:
198
+ self.color_matrix[py][px] = color
199
+
200
+ def draw_line(self, x0, y0, x1, y1, color=None):
201
+ for px, py in bresenham(round(x0), round(y0), round(x1), round(y1)):
202
+ self.set_pixel(px, py, color)
203
+
204
+ def render(self):
205
+ lines = []
206
+ for r in range(self.height):
207
+ row = ""
208
+ for c in range(self.width):
209
+ row += colorize(self.matrix[r][c], self.color_matrix[r][c])
210
+ lines.append(row)
211
+ return lines
212
+
213
+
214
+ def create_canvas(width, height, canvas_type="braille"):
215
+ if canvas_type == "braille":
216
+ return BrailleCanvas(width, height)
217
+ elif canvas_type == "block":
218
+ return BlockCanvas(width, height)
219
+ elif canvas_type == "ascii":
220
+ return AsciiCanvas(width, height)
221
+ raise ValueError(f"Unknown canvas type: {canvas_type}")
222
+
223
+
224
+ # ─── Normalization & Helpers ─────────────────────────────────────────────────
225
+
226
+ def normalize(data, data_min, data_max, plot_range):
227
+ if data_max == data_min:
228
+ return [plot_range // 2] * len(data)
229
+ return [
230
+ max(0, min(plot_range - 1, round((v - data_min) / (data_max - data_min) * (plot_range - 1))))
231
+ for v in data
232
+ ]
233
+
234
+
235
+ def format_tick(num, width=6):
236
+ if abs(num) < 0.001 and num != 0:
237
+ s = f"{num:.1e}"
238
+ elif abs(num) >= 10000:
239
+ s = f"{num:.1e}"
240
+ elif num == int(num):
241
+ s = str(int(num))
242
+ else:
243
+ s = f"{num:.2f}".rstrip("0").rstrip(".")
244
+ return s.rjust(width)
245
+
246
+
247
+ def auto_size():
248
+ """Detect terminal size, return (width, height) for the plot canvas."""
249
+ try:
250
+ cols, rows = os.get_terminal_size()
251
+ except (AttributeError, ValueError, OSError):
252
+ cols, rows = 80, 24
253
+ # Leave room for borders, labels, title
254
+ w = max(20, cols - 18)
255
+ h = max(8, rows - 8)
256
+ return w, h
257
+
258
+
259
+ def compute_boxplot_stats(data):
260
+ data = sorted(data)
261
+ n = len(data)
262
+ def percentile(d, p):
263
+ k = (len(d) - 1) * p / 100
264
+ f = math.floor(k)
265
+ c = math.ceil(k)
266
+ if f == c:
267
+ return d[int(k)]
268
+ return d[f] * (c - k) + d[c] * (k - f)
269
+ q1 = percentile(data, 25)
270
+ median = percentile(data, 50)
271
+ q3 = percentile(data, 75)
272
+ iqr = q3 - q1
273
+ lower_fence = q1 - 1.5 * iqr
274
+ upper_fence = q3 + 1.5 * iqr
275
+ whisker_low = min(v for v in data if v >= lower_fence)
276
+ whisker_high = max(v for v in data if v <= upper_fence)
277
+ outliers = [v for v in data if v < lower_fence or v > upper_fence]
278
+ return whisker_low, q1, median, q3, whisker_high, outliers
279
+
280
+
281
+ # ─── Plot Class ──────────────────────────────────────────────────────────────
282
+
283
+ class Plot:
284
+ """Terminal plot with support for multiple series and plot types."""
285
+
286
+ # Box-drawing characters
287
+ H_BORDER = "\u2500"
288
+ V_BORDER = "\u2502"
289
+ TL = "\u250c"
290
+ TR = "\u2510"
291
+ BL = "\u2514"
292
+ BR = "\u2518"
293
+
294
+ def __init__(self, width=None, height=None, title=None,
295
+ x_label="x", y_label="y", canvas_type="braille",
296
+ xlim=None, ylim=None, legend=True):
297
+ if width is None or height is None:
298
+ aw, ah = auto_size()
299
+ width = width or aw
300
+ height = height or ah
301
+ self.width = width
302
+ self.height = height
303
+ self.title = title
304
+ self.x_label = x_label
305
+ self.y_label = y_label
306
+ self.canvas_type = canvas_type
307
+ self.xlim = xlim
308
+ self.ylim = ylim
309
+ self.show_legend = legend
310
+ self.series = [] # list of dicts: {x, y, type, color, name}
311
+
312
+ def add(self, x, y, plot_type="line", color=None, name=None):
313
+ if color is None:
314
+ color = COLOR_CYCLE[len(self.series) % len(COLOR_CYCLE)]
315
+ if name is None:
316
+ name = f"data_{len(self.series) + 1}"
317
+ self.series.append({"x": x, "y": y, "type": plot_type, "color": color, "name": name})
318
+ return self
319
+
320
+ def _get_bounds(self):
321
+ all_x, all_y = [], []
322
+ for s in self.series:
323
+ all_x.extend(s["x"])
324
+ all_y.extend(s["y"])
325
+ x_min = self.xlim[0] if self.xlim else min(all_x)
326
+ x_max = self.xlim[1] if self.xlim else max(all_x)
327
+ y_min = self.ylim[0] if self.ylim else min(all_y)
328
+ y_max = self.ylim[1] if self.ylim else max(all_y)
329
+ # Expand if range is zero
330
+ if x_max == x_min:
331
+ expand = abs(x_min) * 0.05 if x_min != 0 else 1
332
+ x_min -= expand
333
+ x_max += expand
334
+ if y_max == y_min:
335
+ expand = abs(y_min) * 0.05 if y_min != 0 else 1
336
+ y_min -= expand
337
+ y_max += expand
338
+ return x_min, x_max, y_min, y_max
339
+
340
+ def _draw_scatter(self, canvas, s, x_min, x_max, y_min, y_max):
341
+ xs_norm = normalize(s["x"], x_min, x_max, canvas.pixel_width)
342
+ ys_norm = normalize(s["y"], y_min, y_max, canvas.pixel_height)
343
+ for px, py in zip(xs_norm, ys_norm):
344
+ canvas.set_pixel(px, canvas.pixel_height - 1 - py, s["color"])
345
+
346
+ def _draw_line(self, canvas, s, x_min, x_max, y_min, y_max):
347
+ xs_norm = normalize(s["x"], x_min, x_max, canvas.pixel_width)
348
+ ys_norm = normalize(s["y"], y_min, y_max, canvas.pixel_height)
349
+ # Invert y axis (top=0 in canvas)
350
+ ys_norm = [canvas.pixel_height - 1 - y for y in ys_norm]
351
+ for i in range(len(xs_norm) - 1):
352
+ canvas.draw_line(xs_norm[i], ys_norm[i], xs_norm[i + 1], ys_norm[i + 1], s["color"])
353
+
354
+ def _draw_bar(self, canvas, s, x_min, x_max, y_min, y_max):
355
+ xs_norm = normalize(s["x"], x_min, x_max, canvas.pixel_width)
356
+ ys_norm = normalize(s["y"], y_min, y_max, canvas.pixel_height)
357
+ y_zero = normalize([max(y_min, 0)], y_min, y_max, canvas.pixel_height)[0]
358
+ for px, py in zip(xs_norm, ys_norm):
359
+ top = canvas.pixel_height - 1 - py
360
+ bottom = canvas.pixel_height - 1 - y_zero
361
+ if top > bottom:
362
+ top, bottom = bottom, top
363
+ for row in range(top, bottom + 1):
364
+ # Fill a few columns for width
365
+ for dx in range(-1, 2):
366
+ canvas.set_pixel(max(0, px + dx), row, s["color"])
367
+
368
+ def _draw_boxplot(self, canvas, s, x_min, x_max, y_min, y_max, box_center_px):
369
+ stats = compute_boxplot_stats(s["y"])
370
+ whisker_low, q1, median, q3, whisker_high, outliers = stats
371
+ vals = [whisker_low, q1, median, q3, whisker_high]
372
+ norms = normalize(vals, y_min, y_max, canvas.pixel_height)
373
+ norms = [canvas.pixel_height - 1 - v for v in norms]
374
+ # whisker_low_py, q1_py, median_py, q3_py, whisker_high_py
375
+ wl, q1p, mp, q3p, wh = norms
376
+ color = s["color"]
377
+ # Whisker lines (vertical)
378
+ canvas.draw_line(box_center_px, wh, box_center_px, q3p, color)
379
+ canvas.draw_line(box_center_px, q1p, box_center_px, wl, color)
380
+ # Box
381
+ box_half = max(2, canvas.pixel_width // 12)
382
+ left = box_center_px - box_half
383
+ right = box_center_px + box_half
384
+ canvas.draw_line(left, q1p, right, q1p, color)
385
+ canvas.draw_line(left, q3p, right, q3p, color)
386
+ canvas.draw_line(left, q1p, left, q3p, color)
387
+ canvas.draw_line(right, q1p, right, q3p, color)
388
+ # Median line
389
+ canvas.draw_line(left, mp, right, mp, color)
390
+ # Whisker caps
391
+ cap = max(1, box_half // 2)
392
+ canvas.draw_line(box_center_px - cap, wl, box_center_px + cap, wl, color)
393
+ canvas.draw_line(box_center_px - cap, wh, box_center_px + cap, wh, color)
394
+ # Outliers
395
+ for o in outliers:
396
+ oy = normalize([o], y_min, y_max, canvas.pixel_height)[0]
397
+ oy = canvas.pixel_height - 1 - oy
398
+ canvas.set_pixel(box_center_px, oy, color)
399
+
400
+ def render(self):
401
+ """Render the plot and return as a string."""
402
+ x_min, x_max, y_min, y_max = self._get_bounds()
403
+ canvas = create_canvas(self.width, self.height, self.canvas_type)
404
+
405
+ is_boxplot = any(s["type"] == "boxplot" for s in self.series)
406
+ boxplot_series = [s for s in self.series if s["type"] == "boxplot"]
407
+
408
+ for i, s in enumerate(self.series):
409
+ t = s["type"]
410
+ if t == "scatter":
411
+ self._draw_scatter(canvas, s, x_min, x_max, y_min, y_max)
412
+ elif t == "line":
413
+ self._draw_line(canvas, s, x_min, x_max, y_min, y_max)
414
+ elif t in ("bar", "barplot"):
415
+ self._draw_bar(canvas, s, x_min, x_max, y_min, y_max)
416
+ elif t == "boxplot":
417
+ idx = boxplot_series.index(s)
418
+ n = len(boxplot_series)
419
+ center = canvas.pixel_width * (idx * 2 + 1) // (n * 2)
420
+ self._draw_boxplot(canvas, s, x_min, x_max, y_min, y_max, center)
421
+
422
+ canvas_lines = canvas.render()
423
+ return self._assemble(canvas_lines, x_min, x_max, y_min, y_max, is_boxplot)
424
+
425
+ def _assemble(self, canvas_lines, x_min, x_max, y_min, y_max, is_boxplot=False):
426
+ """Add borders, axes, title, legend around the canvas."""
427
+ lines = []
428
+ tick_width = 8
429
+
430
+ # Y-axis tick values (top to bottom)
431
+ n_y_ticks = min(self.height, 6)
432
+ y_tick_vals = [y_max - i * (y_max - y_min) / (n_y_ticks - 1) for i in range(n_y_ticks)]
433
+ y_tick_positions = [round(i * (self.height - 1) / (n_y_ticks - 1)) for i in range(n_y_ticks)]
434
+
435
+ # Build y tick labels
436
+ y_labels = {}
437
+ for pos, val in zip(y_tick_positions, y_tick_vals):
438
+ y_labels[pos] = format_tick(val, tick_width)
439
+
440
+ # Title
441
+ if self.title:
442
+ title_line = self.title.center(self.width + tick_width + 3)
443
+ lines.append(title_line)
444
+
445
+ # Top border
446
+ top = " " * tick_width + " " + self.TL + self.H_BORDER * self.width + self.TR
447
+ lines.append(top)
448
+
449
+ # Canvas rows with y-axis ticks
450
+ for i, row in enumerate(canvas_lines):
451
+ label = y_labels.get(i, " " * tick_width)
452
+ tick_mark = "\u2524" if i in y_labels else self.V_BORDER
453
+ lines.append(f"{label} {tick_mark}{row}{self.V_BORDER}")
454
+
455
+ # Bottom border
456
+ bot = " " * tick_width + " " + self.BL + self.H_BORDER * self.width + self.BR
457
+ lines.append(bot)
458
+
459
+ # X-axis ticks
460
+ if not is_boxplot:
461
+ n_x_ticks = min(self.width // 10, 6)
462
+ n_x_ticks = max(2, n_x_ticks)
463
+ x_tick_vals = [x_min + i * (x_max - x_min) / (n_x_ticks - 1) for i in range(n_x_ticks)]
464
+ x_tick_positions = [round(i * (self.width - 1) / (n_x_ticks - 1)) for i in range(n_x_ticks)]
465
+
466
+ tick_line = [" "] * (self.width + tick_width + 3)
467
+ for pos, val in zip(x_tick_positions, x_tick_vals):
468
+ s = format_tick(val, 6).strip()
469
+ start = tick_width + 2 + pos - len(s) // 2
470
+ for j, ch in enumerate(s):
471
+ if 0 <= start + j < len(tick_line):
472
+ tick_line[start + j] = ch
473
+ lines.append("".join(tick_line))
474
+ else:
475
+ # Label boxplots by name
476
+ boxplot_series = [s for s in self.series if s["type"] == "boxplot"]
477
+ tick_line = [" "] * (self.width + tick_width + 3)
478
+ for idx, s in enumerate(boxplot_series):
479
+ n = len(boxplot_series)
480
+ center = self.width * (idx * 2 + 1) // (n * 2)
481
+ name = s["name"]
482
+ start = tick_width + 2 + center - len(name) // 2
483
+ for j, ch in enumerate(name):
484
+ if 0 <= start + j < len(tick_line):
485
+ tick_line[start + j] = ch
486
+ lines.append("".join(tick_line))
487
+
488
+ # X-axis label
489
+ if self.x_label:
490
+ x_label_line = " " * tick_width + " " + self.x_label.center(self.width + 2)
491
+ lines.append(x_label_line)
492
+
493
+ # Y-axis label (vertically centered, prepend to existing lines)
494
+ if self.y_label and len(self.y_label) <= self.height:
495
+ y_start = (len(lines) - len(self.y_label)) // 2
496
+ new_lines = []
497
+ for i, line in enumerate(lines):
498
+ if y_start <= i < y_start + len(self.y_label):
499
+ ch = self.y_label[i - y_start]
500
+ else:
501
+ ch = " "
502
+ new_lines.append(ch + " " + line)
503
+ lines = new_lines
504
+ else:
505
+ lines = [" " + line for line in lines]
506
+
507
+ # Legend
508
+ if self.show_legend and len(self.series) > 1:
509
+ legend_start = 2 if self.title else 1
510
+ for i, s in enumerate(self.series):
511
+ row_idx = legend_start + 1 + i
512
+ if row_idx < len(lines):
513
+ legend_entry = " " + colorize("\u2588\u2588", s["color"]) + " " + s["name"]
514
+ lines[row_idx] = lines[row_idx] + legend_entry
515
+
516
+ return "\n".join(lines)
517
+
518
+ def show(self):
519
+ print(self.render())
520
+ return self
521
+
522
+
523
+ # ─── Convenience Functions ───────────────────────────────────────────────────
524
+
525
+ def scatter(x, y, **kwargs):
526
+ return Plot(**kwargs).add(x, y, "scatter")
527
+
528
+ def line(x, y, **kwargs):
529
+ return Plot(**kwargs).add(x, y, "line")
530
+
531
+ def bar(x, y, **kwargs):
532
+ return Plot(**kwargs).add(x, y, "bar")
533
+
534
+ def histogram(data, bins=None, **kwargs):
535
+ if bins is None:
536
+ bins = max(5, min(int(math.sqrt(len(data))), 50))
537
+ d_min, d_max = min(data), max(data)
538
+ if d_max == d_min:
539
+ d_max = d_min + 1
540
+ bin_width = (d_max - d_min) / bins
541
+ counts = [0] * bins
542
+ for v in data:
543
+ idx = min(int((v - d_min) / bin_width), bins - 1)
544
+ counts[idx] += 1
545
+ mids = [d_min + (i + 0.5) * bin_width for i in range(bins)]
546
+ kwargs.setdefault("ylim", (0, max(counts) * 1.05))
547
+ kwargs.setdefault("y_label", "count")
548
+ return Plot(**kwargs).add(mids, counts, "bar")
549
+
550
+ def density(data, n_points=100, **kwargs):
551
+ """Simple KDE using Gaussian kernel."""
552
+ d = sorted(data)
553
+ n = len(d)
554
+ # Silverman's rule of thumb for bandwidth
555
+ std = (sum((x - sum(d) / n) ** 2 for x in d) / n) ** 0.5
556
+ bw = 1.06 * std * n ** (-1 / 5) if std > 0 else 1
557
+ x_min, x_max = min(d) - 3 * bw, max(d) + 3 * bw
558
+ xs = [x_min + i * (x_max - x_min) / (n_points - 1) for i in range(n_points)]
559
+ ys = []
560
+ for x in xs:
561
+ s = sum(math.exp(-0.5 * ((x - xi) / bw) ** 2) for xi in d)
562
+ ys.append(s / (n * bw * math.sqrt(2 * math.pi)))
563
+ kwargs.setdefault("y_label", "density")
564
+ return Plot(**kwargs).add(xs, ys, "line")
565
+
566
+ def boxplot(datasets, names=None, **kwargs):
567
+ """Create boxplots from a list of datasets."""
568
+ if not isinstance(datasets[0], (list, tuple)):
569
+ datasets = [datasets]
570
+ if names is None:
571
+ names = [f"box_{i+1}" for i in range(len(datasets))]
572
+ all_vals = [v for d in datasets for v in d]
573
+ kwargs.setdefault("ylim", (min(all_vals) - 0.05 * (max(all_vals) - min(all_vals)),
574
+ max(all_vals) + 0.05 * (max(all_vals) - min(all_vals))))
575
+ p = Plot(**kwargs)
576
+ for i, (d, name) in enumerate(zip(datasets, names)):
577
+ p.add([0] * len(d), d, "boxplot", name=name)
578
+ return p
579
+
580
+ def function_plot(expr_str, x_min=0, x_max=10, n_points=200, **kwargs):
581
+ """Plot a math function like 'math.sin(x)' or 'x**2'."""
582
+ xs = [x_min + i * (x_max - x_min) / (n_points - 1) for i in range(n_points)]
583
+ ys = []
584
+ for x in xs:
585
+ try:
586
+ y = eval(expr_str, {"x": x, "math": math, "pi": math.pi, "e": math.e,
587
+ "sin": math.sin, "cos": math.cos, "tan": math.tan,
588
+ "sqrt": math.sqrt, "log": math.log, "exp": math.exp,
589
+ "abs": abs})
590
+ ys.append(float(y))
591
+ except Exception:
592
+ ys.append(float("nan"))
593
+ # Filter NaN
594
+ valid = [(x, y) for x, y in zip(xs, ys) if not math.isnan(y) and not math.isinf(y)]
595
+ if not valid:
596
+ raise ValueError("No valid points to plot")
597
+ xs, ys = zip(*valid)
598
+ kwargs.setdefault("title", f"f(x) = {expr_str}")
599
+ return Plot(**kwargs).add(list(xs), list(ys), "line")
600
+
601
+
602
+ # ─── Heatmap ─────────────────────────────────────────────────────────────────
603
+
604
+ def heatmap(matrix, row_labels=None, col_labels=None, title=None, width=None, height=None):
605
+ """Render a simple heatmap using block shading."""
606
+ rows = len(matrix)
607
+ cols = len(matrix[0]) if rows > 0 else 0
608
+ all_vals = [v for row in matrix for v in row]
609
+ vmin, vmax = min(all_vals), max(all_vals)
610
+ if vmax == vmin:
611
+ vmax = vmin + 1
612
+
613
+ # Shade characters from light to dark
614
+ shades = " ░▒▓█"
615
+ # Color gradient: blue -> cyan -> green -> yellow -> red
616
+ heat_colors = ["blue", "cyan", "green", "yellow", "red"]
617
+
618
+ lines = []
619
+ if title:
620
+ lines.append(title.center(cols * 2 + 10))
621
+
622
+ label_w = 0
623
+ if row_labels:
624
+ label_w = max(len(str(l)) for l in row_labels) + 1
625
+
626
+ for r in range(rows):
627
+ row_str = ""
628
+ if row_labels:
629
+ row_str += str(row_labels[r]).rjust(label_w) + " "
630
+ for c in range(cols):
631
+ frac = (matrix[r][c] - vmin) / (vmax - vmin)
632
+ shade_idx = min(int(frac * len(shades)), len(shades) - 1)
633
+ color_idx = min(int(frac * len(heat_colors)), len(heat_colors) - 1)
634
+ ch = shades[shade_idx]
635
+ row_str += colorize(ch * 2, heat_colors[color_idx])
636
+ lines.append(row_str)
637
+
638
+ if col_labels:
639
+ label_line = " " * (label_w + 1) if row_labels else ""
640
+ for l in col_labels:
641
+ label_line += str(l)[:2].center(2)
642
+ lines.append(label_line)
643
+
644
+ return "\n".join(lines)
645
+
646
+
647
+ # ─── Data Parsing ────────────────────────────────────────────────────────────
648
+
649
+ def parse_input(text, delimiter=None):
650
+ """Parse text input into x, y arrays. Handles various formats:
651
+ - Single line of values: treated as y values
652
+ - One value per line: treated as y values
653
+ - Two columns per line: treated as x,y pairs
654
+ """
655
+ lines = [l.strip() for l in text.strip().split("\n") if l.strip() and not l.startswith("#")]
656
+ if not lines:
657
+ return [], []
658
+
659
+ # Try to detect delimiter
660
+ if delimiter is None:
661
+ first = lines[0]
662
+ if "," in first:
663
+ delimiter = ","
664
+ elif "\t" in first:
665
+ delimiter = "\t"
666
+ else:
667
+ delimiter = None # whitespace
668
+
669
+ # If only one line, split it into individual values (y values)
670
+ if len(lines) == 1:
671
+ parts = lines[0].split(delimiter) if delimiter else lines[0].split()
672
+ ys = []
673
+ for p in parts:
674
+ try:
675
+ ys.append(float(p.strip()))
676
+ except ValueError:
677
+ continue
678
+ if len(ys) > 2 or (len(ys) <= 2 and len(parts) <= 2):
679
+ # Treat as y-values when there are many values on one line
680
+ # or when there are exactly 1-2 values (ambiguous, default to y)
681
+ if len(ys) > 2:
682
+ return list(range(len(ys))), ys
683
+ # For exactly 2 values on one line, could be x,y pair or two y-values
684
+ # Default to y-values for consistency with piping
685
+ return list(range(len(ys))), ys
686
+
687
+ # Multi-line: try to detect if it's one column or two columns
688
+ xs, ys = [], []
689
+ two_column = False
690
+ for line in lines:
691
+ parts = line.split(delimiter) if delimiter else line.split()
692
+ parts = [p.strip() for p in parts if p.strip()]
693
+ if len(parts) >= 2:
694
+ try:
695
+ xs.append(float(parts[0]))
696
+ ys.append(float(parts[1]))
697
+ two_column = True
698
+ except ValueError:
699
+ continue
700
+ elif len(parts) == 1:
701
+ try:
702
+ ys.append(float(parts[0]))
703
+ except ValueError:
704
+ continue
705
+
706
+ if not two_column or not xs:
707
+ xs = list(range(len(ys)))
708
+
709
+ return xs, ys
710
+
711
+
712
+ def parse_csv(filepath, x_col=None, y_col=None, delimiter=","):
713
+ """Parse a CSV file into x, y arrays."""
714
+ with open(filepath) as f:
715
+ lines = [l.strip() for l in f if l.strip()]
716
+
717
+ if not lines:
718
+ return [], []
719
+
720
+ # Check if first line is a header
721
+ header = lines[0].split(delimiter)
722
+ has_header = False
723
+ try:
724
+ [float(h) for h in header]
725
+ except ValueError:
726
+ has_header = True
727
+
728
+ if has_header:
729
+ header = [h.strip().strip('"').strip("'") for h in header]
730
+ data_lines = lines[1:]
731
+ else:
732
+ header = [str(i) for i in range(len(header))]
733
+ data_lines = lines
734
+
735
+ # Determine columns
736
+ if x_col is None and y_col is None:
737
+ x_idx, y_idx = 0, 1 if len(header) > 1 else (None, 0)
738
+ else:
739
+ x_idx = header.index(x_col) if x_col and x_col in header else (int(x_col) if x_col and x_col.isdigit() else 0)
740
+ y_idx = header.index(y_col) if y_col and y_col in header else (int(y_col) if y_col and y_col.isdigit() else 1)
741
+
742
+ xs, ys = [], []
743
+ for line in data_lines:
744
+ parts = line.split(delimiter)
745
+ try:
746
+ y = float(parts[y_idx].strip())
747
+ ys.append(y)
748
+ if x_idx is not None:
749
+ xs.append(float(parts[x_idx].strip()))
750
+ except (ValueError, IndexError):
751
+ continue
752
+
753
+ if not xs:
754
+ xs = list(range(len(ys)))
755
+
756
+ return xs, ys
757
+
758
+
759
+ # ─── CLI ─────────────────────────────────────────────────────────────────────
760
+
761
+ def build_parser():
762
+ parser = argparse.ArgumentParser(
763
+ prog="plotcli",
764
+ description="Terminal plotting tool. Supports piped data, CSV files, expressions, and math functions.",
765
+ formatter_class=argparse.RawDescriptionHelpFormatter,
766
+ epilog="""
767
+ Examples:
768
+ echo "1 4 9 16 25" | plotcli line
769
+ echo -e "1,2\\n2,4\\n3,9" | plotcli scatter -d ","
770
+ plotcli fn "sin(x)" 0 6.28
771
+ plotcli hist -e "[random.gauss(0,1) for _ in range(500)]"
772
+ plotcli line -f data.csv -x time -y value
773
+ plotcli scatter -e "[(x, x**2 + random.gauss(0,5)) for x in range(50)]" --title "Quadratic"
774
+ seq 100 | awk '{print sin($1/10)}' | plotcli line
775
+ plotcli boxplot -e "[random.gauss(0,1) for _ in range(100)]" "[random.gauss(2,1.5) for _ in range(100)]"
776
+ plotcli heatmap -e "[[math.sin(r/3+c/3) for c in range(15)] for r in range(10)]"
777
+ """,
778
+ )
779
+
780
+ parser.add_argument("type", nargs="?", default="line",
781
+ choices=["line", "scatter", "bar", "hist", "histogram",
782
+ "density", "boxplot", "box", "fn", "function",
783
+ "heatmap"],
784
+ help="Plot type (default: line)")
785
+ parser.add_argument("args", nargs="*", help="Positional args (for fn: expression x_min x_max)")
786
+
787
+ # Data sources
788
+ parser.add_argument("-e", "--eval", dest="expr", help="Python expression that produces data")
789
+ parser.add_argument("-f", "--file", help="CSV/TSV file path")
790
+ parser.add_argument("-x", "--x-col", help="X column name or index")
791
+ parser.add_argument("-y", "--y-col", help="Y column name or index")
792
+ parser.add_argument("-d", "--delimiter", help="Input delimiter")
793
+ parser.add_argument("-j", "--json", action="store_true", help="Parse input as JSON array")
794
+
795
+ # Appearance
796
+ parser.add_argument("-W", "--width", type=int, help="Canvas width in characters")
797
+ parser.add_argument("-H", "--height", type=int, help="Canvas height in characters")
798
+ parser.add_argument("-t", "--title", help="Plot title")
799
+ parser.add_argument("--x-label", default=None, help="X-axis label")
800
+ parser.add_argument("--y-label", default=None, help="Y-axis label")
801
+ parser.add_argument("-c", "--color", help="Series color")
802
+ parser.add_argument("--canvas", default="braille", choices=["braille", "block", "ascii"],
803
+ help="Canvas type (default: braille)")
804
+ parser.add_argument("--no-legend", action="store_true", help="Hide legend")
805
+
806
+ # Limits
807
+ parser.add_argument("--xlim", nargs=2, type=float, metavar=("MIN", "MAX"))
808
+ parser.add_argument("--ylim", nargs=2, type=float, metavar=("MIN", "MAX"))
809
+
810
+ # Histogram
811
+ parser.add_argument("--bins", type=int, help="Number of histogram bins")
812
+
813
+ return parser
814
+
815
+
816
+ def eval_data(expr_str):
817
+ """Evaluate a Python expression to get data."""
818
+ import random
819
+ result = eval(expr_str, {"__builtins__": __builtins__, "math": math, "random": random,
820
+ "pi": math.pi, "e": math.e,
821
+ "sin": math.sin, "cos": math.cos, "tan": math.tan,
822
+ "sqrt": math.sqrt, "log": math.log, "exp": math.exp})
823
+ return result
824
+
825
+
826
+ def main():
827
+ parser = build_parser()
828
+ args = parser.parse_args()
829
+
830
+ plot_kwargs = {
831
+ "width": args.width,
832
+ "height": args.height,
833
+ "title": args.title,
834
+ "x_label": args.x_label if args.x_label else "x",
835
+ "y_label": args.y_label,
836
+ "canvas_type": args.canvas,
837
+ "xlim": tuple(args.xlim) if args.xlim else None,
838
+ "ylim": tuple(args.ylim) if args.ylim else None,
839
+ "legend": not args.no_legend,
840
+ }
841
+
842
+ plot_type = args.type
843
+ if plot_type in ("hist", "histogram"):
844
+ plot_type = "histogram"
845
+ elif plot_type in ("box",):
846
+ plot_type = "boxplot"
847
+ elif plot_type in ("fn", "function"):
848
+ plot_type = "function"
849
+
850
+ # ── Function plot ────────────────────────────────────────────────────
851
+ if plot_type == "function":
852
+ if args.expr:
853
+ expr = args.expr
854
+ elif args.args:
855
+ expr = args.args[0]
856
+ else:
857
+ parser.error("fn requires an expression, e.g.: plotcli fn 'sin(x)' 0 6.28")
858
+ x_min = float(args.args[1]) if len(args.args) > 1 else 0
859
+ x_max = float(args.args[2]) if len(args.args) > 2 else 10
860
+ if plot_kwargs["y_label"] is None:
861
+ plot_kwargs["y_label"] = "f(x)"
862
+ function_plot(expr, x_min, x_max, **plot_kwargs).show()
863
+ return
864
+
865
+ # ── Heatmap ──────────────────────────────────────────────────────────
866
+ if plot_type == "heatmap":
867
+ if args.expr:
868
+ matrix = eval_data(args.expr)
869
+ elif not sys.stdin.isatty():
870
+ text = sys.stdin.read()
871
+ if args.json:
872
+ matrix = json.loads(text)
873
+ else:
874
+ matrix = []
875
+ for line in text.strip().split("\n"):
876
+ parts = line.split(args.delimiter) if args.delimiter else line.split()
877
+ matrix.append([float(p) for p in parts if p.strip()])
878
+ else:
879
+ parser.error("heatmap needs data via -e or stdin")
880
+ print(heatmap(matrix, title=args.title, width=args.width, height=args.height))
881
+ return
882
+
883
+ # ── Get data ─────────────────────────────────────────────────────────
884
+ xs, ys = [], []
885
+
886
+ # For boxplot, support multiple expressions
887
+ if plot_type == "boxplot":
888
+ datasets = []
889
+ names = []
890
+ if args.expr:
891
+ data = eval_data(args.expr)
892
+ if isinstance(data, (list, tuple)):
893
+ if data and isinstance(data[0], (list, tuple)):
894
+ datasets = [list(d) for d in data]
895
+ else:
896
+ datasets = [list(data)]
897
+ names = [f"box_{i+1}" for i in range(len(datasets))]
898
+ elif args.args:
899
+ for i, a in enumerate(args.args):
900
+ d = eval_data(a)
901
+ datasets.append(list(d))
902
+ names.append(f"box_{i+1}")
903
+ elif not sys.stdin.isatty():
904
+ text = sys.stdin.read()
905
+ if args.json:
906
+ data = json.loads(text)
907
+ if isinstance(data[0], (list, tuple)):
908
+ datasets = data
909
+ else:
910
+ datasets = [data]
911
+ else:
912
+ _, ys = parse_input(text, args.delimiter)
913
+ datasets = [ys]
914
+ names = [f"box_{i+1}" for i in range(len(datasets))]
915
+ else:
916
+ parser.error("No data provided")
917
+
918
+ if plot_kwargs["y_label"] is None:
919
+ plot_kwargs["y_label"] = "value"
920
+ boxplot(datasets, names, **plot_kwargs).show()
921
+ return
922
+
923
+ if args.expr:
924
+ data = eval_data(args.expr)
925
+ if isinstance(data, (list, tuple)):
926
+ if data and isinstance(data[0], (list, tuple)):
927
+ xs = [p[0] for p in data]
928
+ ys = [p[1] for p in data]
929
+ else:
930
+ ys = list(data)
931
+ xs = list(range(len(ys)))
932
+ elif args.file:
933
+ xs, ys = parse_csv(args.file, args.x_col, args.y_col,
934
+ args.delimiter or ",")
935
+ elif not sys.stdin.isatty():
936
+ text = sys.stdin.read()
937
+ if args.json:
938
+ data = json.loads(text)
939
+ if isinstance(data, dict):
940
+ xs = data.get("x", list(range(len(data.get("y", [])))))
941
+ ys = data.get("y", [])
942
+ elif isinstance(data, list):
943
+ if data and isinstance(data[0], (list, tuple)):
944
+ xs = [p[0] for p in data]
945
+ ys = [p[1] for p in data]
946
+ else:
947
+ ys = data
948
+ xs = list(range(len(ys)))
949
+ else:
950
+ xs, ys = parse_input(text, args.delimiter)
951
+ else:
952
+ parser.error("No data provided. Pipe data, use -e, or -f. See --help.")
953
+
954
+ if not ys:
955
+ print("Error: No valid data points found.", file=sys.stderr)
956
+ sys.exit(1)
957
+
958
+ if plot_kwargs["y_label"] is None:
959
+ plot_kwargs["y_label"] = "y"
960
+
961
+ # ── Create plot ──────────────────────────────────────────────────────
962
+ if plot_type == "histogram" or plot_type == "density":
963
+ if plot_type == "histogram":
964
+ if args.y_label is None:
965
+ plot_kwargs["y_label"] = "count"
966
+ histogram(ys, bins=args.bins, **plot_kwargs).show()
967
+ else:
968
+ if args.y_label is None:
969
+ plot_kwargs["y_label"] = "density"
970
+ density(ys, **plot_kwargs).show()
971
+ elif plot_type in ("scatter", "line", "bar"):
972
+ p = Plot(**plot_kwargs)
973
+ p.add(xs, ys, plot_type, color=args.color)
974
+ p.show()
975
+
976
+
977
+ if __name__ == "__main__":
978
+ main()