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.
- CLAUDE.md +51 -0
- LICENSE +21 -0
- PKG-INFO +358 -0
- README.md +340 -0
- main.py +6 -0
- plotcli-original/.Rbuildignore +18 -0
- plotcli-original/.github/workflows/deploy_docs.yml +43 -0
- plotcli-original/.gitignore +46 -0
- plotcli-original/DESCRIPTION +25 -0
- plotcli-original/NAMESPACE +60 -0
- plotcli-original/NEWS.md +112 -0
- plotcli-original/R/ascii_escape.r +13 -0
- plotcli-original/R/canvas.r +586 -0
- plotcli-original/R/class_functions.r +114 -0
- plotcli-original/R/geom_registry.r +1376 -0
- plotcli-original/R/ggplotcli.r +234 -0
- plotcli-original/R/ggplotcli_helpers.r +1099 -0
- plotcli-original/R/helper_functions.r +351 -0
- plotcli-original/R/plotcli.r +963 -0
- plotcli-original/R/plotcli_grid.r +1 -0
- plotcli-original/R/plotcli_wrappers.r +416 -0
- plotcli-original/R/zzz.r +15 -0
- plotcli-original/README.md +192 -0
- plotcli-original/docs/ascii.png +0 -0
- plotcli-original/docs/bar.png +0 -0
- plotcli-original/docs/block.png +0 -0
- plotcli-original/docs/boxplot.png +0 -0
- plotcli-original/docs/density.png +0 -0
- plotcli-original/docs/facet.png +0 -0
- plotcli-original/docs/facet_grid.png +0 -0
- plotcli-original/docs/generate_png.sh +137 -0
- plotcli-original/docs/heatmap.png +0 -0
- plotcli-original/docs/histogram.png +0 -0
- plotcli-original/docs/line.png +0 -0
- plotcli-original/docs/noborder.png +0 -0
- plotcli-original/docs/scatter.png +0 -0
- plotcli-original/docs/showcase.R +182 -0
- plotcli-original/inst/doc/ggplotcli.R +231 -0
- plotcli-original/inst/doc/ggplotcli.Rmd +329 -0
- plotcli-original/inst/doc/ggplotcli.html +1078 -0
- plotcli-original/inst/doc/plotcli_class.R +98 -0
- plotcli-original/inst/doc/plotcli_class.Rmd +121 -0
- plotcli-original/inst/doc/plotcli_class.html +564 -0
- plotcli-original/inst/doc/plotcli_wrappers.R +35 -0
- plotcli-original/inst/doc/plotcli_wrappers.Rmd +62 -0
- plotcli-original/inst/doc/plotcli_wrappers.html +546 -0
- plotcli-original/man/AsciiCanvas.Rd +116 -0
- plotcli-original/man/BlockCanvas.Rd +132 -0
- plotcli-original/man/BrailleCanvas.Rd +146 -0
- plotcli-original/man/Canvas.Rd +492 -0
- plotcli-original/man/GeomRegistry.Rd +9 -0
- plotcli-original/man/add_legend_to_output.Rd +12 -0
- plotcli-original/man/braille_dot_bit.Rd +29 -0
- plotcli-original/man/braille_set_dot.Rd +21 -0
- plotcli-original/man/bresenham.Rd +27 -0
- plotcli-original/man/build_plot_output.Rd +28 -0
- plotcli-original/man/build_plot_output_v2.Rd +41 -0
- plotcli-original/man/cat_plot_matrix.Rd +17 -0
- plotcli-original/man/cbind.plotcli.Rd +19 -0
- plotcli-original/man/cbind_plots.Rd +17 -0
- plotcli-original/man/color_to_term.Rd +18 -0
- plotcli-original/man/create_canvas.Rd +21 -0
- plotcli-original/man/create_panel_scales.Rd +29 -0
- plotcli-original/man/create_scales.Rd +27 -0
- plotcli-original/man/dot-geom_registry.Rd +16 -0
- plotcli-original/man/draw_border.Rd +12 -0
- plotcli-original/man/draw_grid.Rd +12 -0
- plotcli-original/man/extract_legend_info.Rd +12 -0
- plotcli-original/man/extract_plot_labels.Rd +12 -0
- plotcli-original/man/extract_plot_style.Rd +12 -0
- plotcli-original/man/format_axis_label.Rd +18 -0
- plotcli-original/man/format_four_chars.Rd +21 -0
- plotcli-original/man/geom_area_handler.Rd +12 -0
- plotcli-original/man/geom_bar_handler.Rd +12 -0
- plotcli-original/man/geom_boxplot_handler.Rd +13 -0
- plotcli-original/man/geom_density_handler.Rd +12 -0
- plotcli-original/man/geom_histogram_handler.Rd +12 -0
- plotcli-original/man/geom_hline_handler.Rd +12 -0
- plotcli-original/man/geom_line_handler.Rd +12 -0
- plotcli-original/man/geom_path_handler.Rd +12 -0
- plotcli-original/man/geom_point_handler.Rd +12 -0
- plotcli-original/man/geom_rect_handler.Rd +12 -0
- plotcli-original/man/geom_segment_handler.Rd +12 -0
- plotcli-original/man/geom_smooth_handler.Rd +12 -0
- plotcli-original/man/geom_text_handler.Rd +12 -0
- plotcli-original/man/geom_vline_handler.Rd +12 -0
- plotcli-original/man/get_color_hue.Rd +18 -0
- plotcli-original/man/get_data_subset.Rd +23 -0
- plotcli-original/man/get_facet_info.Rd +18 -0
- plotcli-original/man/get_geom_handler.Rd +17 -0
- plotcli-original/man/get_term_colors.Rd +21 -0
- plotcli-original/man/ggplotcli.Rd +83 -0
- plotcli-original/man/init_color_mapping.Rd +15 -0
- plotcli-original/man/is_braille.Rd +20 -0
- plotcli-original/man/is_geom_registered.Rd +17 -0
- plotcli-original/man/list_registered_geoms.Rd +14 -0
- plotcli-original/man/make_colored.Rd +23 -0
- plotcli-original/man/make_unique_names.Rd +20 -0
- plotcli-original/man/normalize_data.Rd +27 -0
- plotcli-original/man/pclib.Rd +48 -0
- plotcli-original/man/pclibx.Rd +46 -0
- plotcli-original/man/pclid.Rd +44 -0
- plotcli-original/man/pclih.Rd +50 -0
- plotcli-original/man/pclil.Rd +48 -0
- plotcli-original/man/pclis.Rd +48 -0
- plotcli-original/man/pixel_to_braille.Rd +23 -0
- plotcli-original/man/plotcli.Rd +598 -0
- plotcli-original/man/plotcli_bar.Rd +48 -0
- plotcli-original/man/plotcli_box.Rd +46 -0
- plotcli-original/man/plotcli_density.Rd +44 -0
- plotcli-original/man/plotcli_histogram.Rd +50 -0
- plotcli-original/man/plotcli_line.Rd +48 -0
- plotcli-original/man/plotcli_options.Rd +18 -0
- plotcli-original/man/plotcli_scatter.Rd +48 -0
- plotcli-original/man/plus-.plotcli.Rd +19 -0
- plotcli-original/man/rbind.plotcli.Rd +19 -0
- plotcli-original/man/rbind_plots.Rd +17 -0
- plotcli-original/man/register_geom.Rd +21 -0
- plotcli-original/man/remove_color_codes.Rd +21 -0
- plotcli-original/man/render_faceted_plot.Rd +25 -0
- plotcli-original/man/render_single_panel.Rd +12 -0
- plotcli-original/man/safe_aes_name.Rd +18 -0
- plotcli-original/tests/testthat/test-new-geoms.R +136 -0
- plotcli-original/tests/testthat/test-plotcli.R +69 -0
- plotcli-original/tests/testthat.R +4 -0
- plotcli-original/vignettes/ggplotcli.Rmd +329 -0
- plotcli-original/vignettes/plotcli_class.R +98 -0
- plotcli-original/vignettes/plotcli_class.Rmd +121 -0
- plotcli-original/vignettes/plotcli_wrappers.R +35 -0
- plotcli-original/vignettes/plotcli_wrappers.Rmd +62 -0
- plotcli.egg-info/PKG-INFO +11 -0
- plotcli.egg-info/SOURCES.txt +7 -0
- plotcli.egg-info/dependency_links.txt +1 -0
- plotcli.egg-info/entry_points.txt +3 -0
- plotcli.egg-info/top_level.txt +1 -0
- plotcli.py +978 -0
- plotcli_py-0.1.0.dist-info/METADATA +358 -0
- plotcli_py-0.1.0.dist-info/RECORD +143 -0
- plotcli_py-0.1.0.dist-info/WHEEL +4 -0
- plotcli_py-0.1.0.dist-info/entry_points.txt +2 -0
- plotcli_py-0.1.0.dist-info/licenses/LICENSE +21 -0
- pyproject.toml +31 -0
- 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()
|