From 6c26773c46690489185607fbb2a09c398671e0dd Mon Sep 17 00:00:00 2001 From: Max Date: Wed, 22 Apr 2026 08:07:22 +0200 Subject: [PATCH 1/2] Added plotext backend --- .gitignore | 8 + README.md | 47 +- README.qmd | 37 +- pyproject.toml | 1 + src/maxplotlib/backends/plotext/__init__.py | 3 + src/maxplotlib/backends/plotext/figure.py | 51 ++ src/maxplotlib/canvas/canvas.py | 69 ++ src/maxplotlib/subfigure/line_plot.py | 592 +++++++++++++++++ src/maxplotlib/tests/test_plotext.py | 131 ++++ src/maxplotlib/utils/options.py | 2 +- tutorials/tutorial_09_plotext.ipynb | 670 ++++++++++++++++++++ 11 files changed, 1597 insertions(+), 14 deletions(-) create mode 100644 src/maxplotlib/backends/plotext/__init__.py create mode 100644 src/maxplotlib/backends/plotext/figure.py create mode 100644 src/maxplotlib/tests/test_plotext.py create mode 100644 tutorials/tutorial_09_plotext.ipynb diff --git a/.gitignore b/.gitignore index b5b8334..515f6e9 100644 --- a/.gitignore +++ b/.gitignore @@ -189,3 +189,11 @@ env* # VS code .vscode/* + +# Outputs +docs/.astro/ +docs/node_modules/ +docs/superpowers/ +docs/tutorials/ +tutorials/*.txt +tutorials/*.png diff --git a/README.md b/README.md index 95baae7..0e1215f 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,11 @@ -# Maxlotlib - - # Maxplotlib -A clean, expressive wrapper around **Matplotlib** **tikzfigure** for -producing publication-quality figures with minimal boilerplate. Swap -backends without rewriting your data — render the same canvas as a crisp -PNG, an interactive Plotly chart, or camera-ready **TikZ** code for -LaTeX. +A clean, expressive wrapper around **Matplotlib**, **Plotly**, +**plotext**, and **tikzfigure** for producing publication-quality +figures with minimal boilerplate. Swap backends without rewriting your +data — render the same canvas as a crisp PNG, an interactive Plotly +chart, a terminal-native plotext figure, or camera-ready **TikZ** code +for LaTeX. ## Install @@ -38,7 +36,15 @@ canvas.show() ![](README_files/figure-markdown_strict/cell-3-output-1.png) -Alternatively, plot with the TikZ backend (not done yet): +Render the same line graph directly in the terminal with the `plotext` +backend: + +``` python +terminal_fig = canvas.plot(backend="plotext") +print(terminal_fig.build(keep_colors=False)) +``` + +Or plot with the TikZ backend: ``` python canvas.show(backend="tikzfigure") @@ -46,6 +52,29 @@ canvas.show(backend="tikzfigure") ![](README_files/figure-markdown_strict/cell-4-output-1.png) +### Terminal backend + +The `plotext` backend is designed for terminal-first workflows. It +currently supports line plots, scatter plots, bars, filled regions, +error bars, reference lines, text/annotations, labels/titles, log +axes, layers, matrix-style `imshow()` rendering, common patches, and +multi-subplot canvases. + +``` python +x = np.linspace(1, 10, 40) + +canvas, ax = Canvas.subplots() +ax.plot(x, np.sqrt(x), color="cyan", label="sqrt(x)") +ax.errorbar(x[::8], np.sqrt(x[::8]), yerr=0.15, color="yellow", label="samples") +ax.set_title("Terminal plot") +ax.set_xlabel("x") +ax.set_ylabel("y") +ax.set_xscale("log") +ax.set_legend(True) + +canvas.show(backend="plotext") +``` + ### Layers ``` python diff --git a/README.qmd b/README.qmd index 5b82cad..320ef78 100644 --- a/README.qmd +++ b/README.qmd @@ -6,9 +6,10 @@ fig-dpi: 150 # Maxplotlib -A clean, expressive wrapper around **Matplotlib** **tikzfigure** for producing publication-quality figures -with minimal boilerplate. Swap backends without rewriting your data — render the same canvas -as a crisp PNG, an interactive Plotly chart, or camera-ready **TikZ** code for LaTeX. +A clean, expressive wrapper around **Matplotlib**, **Plotly**, **plotext**, and **tikzfigure** +for producing publication-quality figures with minimal boilerplate. Swap backends without +rewriting your data — render the same canvas as a crisp PNG, an interactive Plotly chart, a +terminal-native plotext figure, or camera-ready **TikZ** code for LaTeX. ## Install @@ -41,7 +42,14 @@ Plot the figure with the default (matplotlib) backend: canvas.show() ``` -Alternatively, plot with the TikZ backend: +Render the same line graph directly in the terminal with the `plotext` backend: + +```{python} +terminal_fig = canvas.plot(backend="plotext") +print(terminal_fig.build(keep_colors=False)) +``` + +Or plot with the TikZ backend: ```{python} canvas.show(backend="tikzfigure") @@ -71,6 +79,27 @@ canvas.show(backend="tikzfigure") # Generates LaTeX subfigures **Note:** Only horizontal layouts (1×n) are currently supported with the tikzfigure backend. Vertical/grid layouts will raise `NotImplementedError`. See the tutorials for more examples. +### Terminal Backend with plotext + +The `plotext` backend is designed for terminal-first workflows. It currently supports line plots, +scatter plots, bars, filled regions, error bars, reference lines, text/annotations, labels/titles, +log axes, layers, matrix-style `imshow()` rendering, common patches, and multi-subplot canvases. + +```{python} +x = np.linspace(1, 10, 40) + +canvas, ax = Canvas.subplots() +ax.plot(x, np.sqrt(x), color="cyan", label="sqrt(x)") +ax.errorbar(x[::8], np.sqrt(x[::8]), yerr=0.15, color="yellow", label="samples") +ax.set_title("Terminal plot") +ax.set_xlabel("x") +ax.set_ylabel("y") +ax.set_xscale("log") +ax.set_legend(True) + +canvas.show(backend="plotext") +``` + ### Layers ```{python} diff --git a/pyproject.toml b/pyproject.toml index aef8f35..ed7639c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ "matplotlib", "pint", "plotly", + "plotext", "tikzfigure[vis]>=0.2.1", ] [project.optional-dependencies] diff --git a/src/maxplotlib/backends/plotext/__init__.py b/src/maxplotlib/backends/plotext/__init__.py new file mode 100644 index 0000000..d0ce577 --- /dev/null +++ b/src/maxplotlib/backends/plotext/__init__.py @@ -0,0 +1,3 @@ +from maxplotlib.backends.plotext.figure import PlotextFigure, create_plotext_figure + +__all__ = ["PlotextFigure", "create_plotext_figure"] diff --git a/src/maxplotlib/backends/plotext/figure.py b/src/maxplotlib/backends/plotext/figure.py new file mode 100644 index 0000000..be07159 --- /dev/null +++ b/src/maxplotlib/backends/plotext/figure.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import re +from pathlib import Path + +from plotext._figure import _figure_class + +_ANSI_ESCAPE_RE = re.compile(r"\x1B\[[0-?]*[ -/]*[@-~]") + + +def strip_ansi(text: str) -> str: + return _ANSI_ESCAPE_RE.sub("", text) + + +def create_plotext_figure(nrows: int = 1, ncols: int = 1) -> _figure_class: + figure = _figure_class() + if nrows > 1 or ncols > 1: + figure.subplots(nrows, ncols) + return figure + + +class PlotextFigure: + def __init__(self, figure: _figure_class, suptitle: str | None = None): + self.figure = figure + self.suptitle = suptitle + + def build(self, keep_colors: bool = True) -> str: + output = self.figure.build() + if self.suptitle: + output = f"{self.suptitle}\n{output}" + return output if keep_colors else strip_ansi(output) + + def show(self) -> str: + output = self.build() + print(output) + return output + + def savefig(self, path, append: bool = False, keep_colors: bool = False) -> None: + destination = Path(path) + mode = "a" if append else "w" + with destination.open(mode, encoding="utf-8") as handle: + handle.write(self.build(keep_colors=keep_colors)) + handle.write("\n") + + save_fig = savefig + + def __getattr__(self, name): + return getattr(self.figure, name) + + def __str__(self) -> str: + return self.build(keep_colors=False) diff --git a/src/maxplotlib/canvas/canvas.py b/src/maxplotlib/canvas/canvas.py index bc61b29..397be5e 100644 --- a/src/maxplotlib/canvas/canvas.py +++ b/src/maxplotlib/canvas/canvas.py @@ -12,6 +12,7 @@ setup_plotstyle, setup_tex_fonts, ) +from maxplotlib.backends.plotext import PlotextFigure, create_plotext_figure from maxplotlib.colors.colors import Color from maxplotlib.linestyle.linestyle import Linestyle from maxplotlib.subfigure.line_plot import LinePlot @@ -199,6 +200,7 @@ def __init__( self._plotted = False self._matplotlib_fig = None self._matplotlib_axes = None + self._plotext_figure = None self._suptitle: str | None = None self._suptitle_kwargs: dict = {} @@ -690,6 +692,33 @@ def savefig( fig.savefig(full_filepath) if verbose: print(f"Saved {full_filepath}") + elif backend == "plotext": + if layer_by_layer: + layers = [] + for layer in self.layers: + layers.append(layer) + figure = self.plot( + backend="plotext", + savefig=False, + layers=layers, + ) + _fn = f"{filename_no_extension}_{layers}.{extension}" + figure.savefig(_fn) + print(f"Saved {_fn}") + else: + if layers is None: + layers = self.layers + full_filepath = filename + else: + full_filepath = f"{filename_no_extension}_{layers}.{extension}" + figure = self.plot( + backend="plotext", + savefig=False, + layers=layers, + ) + figure.savefig(full_filepath) + if verbose: + print(f"Saved {full_filepath}") def plot( self, @@ -709,6 +738,12 @@ def plot( ) elif backend == "plotly": return self.plot_plotly(savefig=savefig) + elif backend == "plotext": + return self.plot_plotext( + savefig=savefig, + layers=layers, + verbose=verbose, + ) elif backend == "tikzfigure": return self.plot_tikzfigure(savefig=savefig) else: @@ -733,6 +768,14 @@ def show( # self._matplotlib_fig.show() elif backend == "plotly": self.plot_plotly(savefig=False) + elif backend == "plotext": + figure = self.plot_plotext( + savefig=False, + layers=layers, + verbose=verbose, + ) + figure.show() + return figure elif backend == "tikzfigure": fig = self.plot_tikzfigure(savefig=False, verbose=verbose) # TikzFigure handles all rendering (single or multi-subplot) @@ -890,6 +933,32 @@ def plot_tikzfigure( return fig + def plot_plotext( + self, + savefig: bool = False, + layers: list | None = None, + verbose: bool = False, + ) -> PlotextFigure: + if verbose: + print("Generating plotext figure...") + + figure = create_plotext_figure(self.nrows, self.ncols) + + for row, col, subplot in self.iter_subplots(): + ax = figure if (self.nrows, self.ncols) == (1, 1) else figure.subplot(row + 1, col + 1) + if isinstance(subplot, TikzFigure): + raise NotImplementedError( + "tikzfigure subplots cannot be rendered with the plotext backend." + ) + subplot.plot_plotext(ax, layers=layers) + + wrapped = PlotextFigure(figure=figure, suptitle=self._suptitle) + if savefig and isinstance(savefig, str): + wrapped.savefig(savefig) + + self._plotext_figure = wrapped + return wrapped + def plot_plotly(self, show=True, savefig=None, usetex=False): """ Generate and optionally display the subplots using Plotly. diff --git a/src/maxplotlib/subfigure/line_plot.py b/src/maxplotlib/subfigure/line_plot.py index 923bd67..f46869c 100644 --- a/src/maxplotlib/subfigure/line_plot.py +++ b/src/maxplotlib/subfigure/line_plot.py @@ -67,6 +67,7 @@ def __init__( """ self._title = title + self._caption = None self._grid = grid self._legend = legend self._xmin = xmin @@ -615,6 +616,597 @@ def plot_plotly(self): return traces + def _iter_layer_lines(self, layers=None): + for layer_name, layer_lines in self.layered_line_data.items(): + if layers and layer_name not in layers: + continue + for line in layer_lines: + yield line + + def _symlog_transform(self, values): + array = np.asarray(values, dtype=float) + return np.sign(array) * np.log10(1.0 + np.abs(array)) + + def _symlog_inverse(self, values): + array = np.asarray(values, dtype=float) + return np.sign(array) * (10 ** np.abs(array) - 1.0) + + def _plotext_axis_scale(self, axis: str): + return self._xaxis_scale if axis == "x" else self._yaxis_scale + + def _plotext_axis_transform(self, values, axis: str): + array = np.asarray(values) + if axis == "x": + transformed = (array + self._xshift) * self._xscale + else: + transformed = (array + self._yshift) * self._yscale + if self._plotext_axis_scale(axis) == "symlog": + return self._symlog_transform(transformed) + return transformed + + def _transform_x(self, values): + return self._plotext_axis_transform(values, "x") + + def _transform_y(self, values): + return self._plotext_axis_transform(values, "y") + + def _transform_scalar_x(self, value): + return float(np.asarray(self._transform_x([value]))[0]) + + def _transform_scalar_y(self, value): + return float(np.asarray(self._transform_y([value]))[0]) + + def _plotext_plot_kwargs(self, kwargs): + return { + key: kwargs[key] + for key in ("marker", "color", "label") + if kwargs.get(key) is not None + } + + def _plotext_scatter_kwargs(self, kwargs): + filtered = self._plotext_plot_kwargs(kwargs) + if kwargs.get("style") is not None: + filtered["style"] = kwargs["style"] + return filtered + + def _plotext_bar_kwargs(self, kwargs): + return { + key: kwargs[key] + for key in ("marker", "color", "fill", "width", "label") + if kwargs.get(key) is not None + } + + def _plotext_text_kwargs(self, kwargs): + return { + key: kwargs[key] + for key in ("color", "background", "style", "orientation", "alignment") + if kwargs.get(key) is not None + } + + def _plotext_native(self, value): + return value.item() if isinstance(value, np.generic) else value + + def _plotext_color(self, *candidates): + for color in candidates: + if color is None: + continue + if isinstance(color, tuple) and len(color) >= 4 and color[3] == 0: + continue + return color + return None + + def _plotext_patch_style(self, patch, kwargs): + edgecolor = kwargs.get( + "edgecolor", + kwargs.get("color", patch.get_edgecolor() if hasattr(patch, "get_edgecolor") else None), + ) + facecolor = kwargs.get( + "facecolor", + patch.get_facecolor() if hasattr(patch, "get_facecolor") else None, + ) + fill = kwargs.get("fill") + if fill is None and hasattr(patch, "get_fill"): + fill = bool(patch.get_fill()) + fill = bool(fill) + color = self._plotext_color(facecolor if fill else None, edgecolor, facecolor) + label = kwargs.get("label") + if label is None and hasattr(patch, "get_label"): + patch_label = patch.get_label() + if patch_label and not str(patch_label).startswith("_"): + label = patch_label + return color, fill, label + + def _plotext_patch_vertices(self, patch): + if hasattr(patch, "get_path") and hasattr(patch, "get_patch_transform"): + vertices = np.asarray( + patch.get_path().transformed(patch.get_patch_transform()).vertices + ) + elif hasattr(patch, "get_xy"): + vertices = np.asarray(patch.get_xy()) + else: + raise NotImplementedError( + f"plotext backend does not support patch type: {type(patch).__name__}" + ) + if vertices.size == 0: + return vertices.reshape(0, 2) + if vertices.shape[1] != 2: + raise NotImplementedError( + f"plotext backend does not support patch type: {type(patch).__name__}" + ) + return vertices + + def _plotext_patch_bounds(self, patch): + vertices = self._plotext_patch_vertices(patch) + if vertices.size == 0: + return [], [] + return vertices[:, 0].tolist(), vertices[:, 1].tolist() + + def _plotext_apply_patch_transform(self, vertices): + if vertices.size == 0: + return vertices + transformed = np.empty_like(vertices, dtype=float) + transformed[:, 0] = self._transform_x(vertices[:, 0]) + transformed[:, 1] = self._transform_y(vertices[:, 1]) + return transformed + + def _plotext_draw_patch(self, ax, patch, kwargs): + color, fill, label = self._plotext_patch_style(patch, kwargs) + vertices = self._plotext_apply_patch_transform( + self._plotext_patch_vertices(patch) + ) + if vertices.size == 0: + return color + if not np.array_equal(vertices[0], vertices[-1]): + vertices = np.vstack([vertices, vertices[0]]) + plot_kwargs = {"color": color, "label": label} + if fill: + plot_kwargs["fillx"] = "internal" + ax.plot(vertices[:, 0].tolist(), vertices[:, 1].tolist(), **plot_kwargs) + return color + + def _coerce_numeric_array(self, values): + if values is None: + return None + array = np.asarray(values) + if array.ndim == 0: + array = array.reshape(1) + try: + return array.astype(float) + except (TypeError, ValueError): + return None + + def _plotext_bounds(self, layers=None): + xs = [] + ys = [] + + def extend_x(values): + array = self._coerce_numeric_array(values) + if array is not None: + xs.extend(array.tolist()) + + def extend_y(values): + array = self._coerce_numeric_array(values) + if array is not None: + ys.extend(array.tolist()) + + for line in self._iter_layer_lines(layers=layers): + plot_type = line["plot_type"] + if plot_type in {"plot", "scatter", "errorbar"}: + x = self._transform_x(line["x"]) + y = self._transform_y(line["y"]) + extend_x(x) + extend_y(y) + xerr = self._coerce_numeric_array(line.get("xerr")) + yerr = self._coerce_numeric_array(line.get("yerr")) + if xerr is not None: + extend_x(x - xerr) + extend_x(x + xerr) + if yerr is not None: + extend_y(y - yerr) + extend_y(y + yerr) + elif plot_type == "bar": + extend_x(self._transform_x(line["x"])) + extend_y(np.asarray(line["height"]) * self._yscale) + extend_y([self._transform_scalar_y(0)]) + elif plot_type == "fill_between": + extend_x(self._transform_x(line["x"])) + y1 = line["y1"] if np.isscalar(line["y1"]) else self._transform_y(line["y1"]) + y2 = ( + self._transform_scalar_y(line["y2"]) + if np.isscalar(line["y2"]) + else self._transform_y(line["y2"]) + ) + extend_y(y1) + extend_y(y2) + elif plot_type == "hlines": + extend_y(self._transform_y(line["y"])) + extend_x(self._transform_x(line["xmin"])) + extend_x(self._transform_x(line["xmax"])) + elif plot_type == "vlines": + extend_x(self._transform_x(line["x"])) + extend_y(self._transform_y(line["ymin"])) + extend_y(self._transform_y(line["ymax"])) + elif plot_type == "axhline": + extend_y([self._transform_scalar_y(line["y"])]) + elif plot_type == "axvline": + extend_x([self._transform_scalar_x(line["x"])]) + elif plot_type == "annotate": + extend_x([self._transform_scalar_x(line["xy"][0])]) + extend_y([self._transform_scalar_y(line["xy"][1])]) + if line["xytext"] is not None: + extend_x([self._transform_scalar_x(line["xytext"][0])]) + extend_y([self._transform_scalar_y(line["xytext"][1])]) + elif plot_type == "text": + extend_x([self._transform_scalar_x(line["x"])]) + extend_y([self._transform_scalar_y(line["y"])]) + elif plot_type == "imshow": + data = np.asarray(line["data"]) + if data.ndim >= 2 and data.shape[0] and data.shape[1]: + extend_x([0, data.shape[1] - 1]) + extend_y([0, data.shape[0] - 1]) + elif plot_type == "patch": + patch_xs, patch_ys = self._plotext_patch_bounds(line["patch"]) + extend_x(self._transform_x(patch_xs)) + extend_y(self._transform_y(patch_ys)) + return xs, ys + + def _plotext_error_values(self, error, count): + if error is None: + return None + if np.isscalar(error): + return [float(error)] * count + return np.asarray(error).tolist() + + def _plotext_ranges(self, layers=None): + xs, ys = self._plotext_bounds(layers=layers) + xmin = self._xmin if self._xmin is not None else (min(xs) if xs else None) + xmax = self._xmax if self._xmax is not None else (max(xs) if xs else None) + ymin = self._ymin if self._ymin is not None else (min(ys) if ys else None) + ymax = self._ymax if self._ymax is not None else (max(ys) if ys else None) + return xmin, xmax, ymin, ymax + + def _plotext_format_tick(self, value): + value = float(value) + if abs(value) >= 1000 or (0 < abs(value) < 0.01): + return f"{value:.1e}" + if value.is_integer(): + return str(int(value)) + return f"{value:.3g}" + + def _plotext_axis_limit(self, value, axis: str): + if value is None: + return None + if axis == "x": + return self._transform_scalar_x(value) + return self._transform_scalar_y(value) + + def _plotext_symlog_ticks(self, axis: str, lower, upper, ticks=None, labels=None): + if ticks is not None: + positions = self._plotext_axis_transform(ticks, axis).tolist() + if labels is None: + labels = [self._plotext_format_tick(tick) for tick in np.asarray(ticks)] + return positions, list(labels) + + if lower is None or upper is None: + return None, None + + positions = np.linspace(lower, upper, 5) + labels = [ + self._plotext_format_tick(value) + for value in self._symlog_inverse(positions) + ] + return positions.tolist(), labels + + def _plotext_apply_aspect(self, ax, layers=None): + if self._aspect in (None, "auto"): + return + if self._aspect == "equal": + aspect = 1.0 + elif isinstance(self._aspect, (int, float)): + aspect = float(self._aspect) + else: + raise NotImplementedError( + f"plotext backend does not support aspect={self._aspect!r}" + ) + if aspect <= 0: + raise ValueError("Aspect ratio must be positive.") + + xmin, xmax, ymin, ymax = self._plotext_ranges(layers=layers) + if None in (xmin, xmax, ymin, ymax): + return + x_span = abs(xmax - xmin) or 1.0 + y_span = abs(ymax - ymin) or 1.0 + height = 16 + width = int(round(height * (x_span / (y_span * aspect)) * 2.0)) + title_hint = len( + " | ".join( + [part for part in [self._title, getattr(self, "_caption", None)] if part] + ) + ) + width = max(40, title_hint + 6, min(width, 80)) + ax.plotsize(width, height) + + def _plotext_colorbar_note(self, image_data, label): + data = np.asarray(image_data) + if data.size == 0: + return label or "colorbar" + finite = data[np.isfinite(data)] + if finite.size == 0: + return label or "colorbar" + vmin = float(np.min(finite)) + vmax = float(np.max(finite)) + prefix = f"{label}: " if label else "" + return f"{prefix}{self._plotext_format_tick(vmin)}..{self._plotext_format_tick(vmax)}" + + def _plotext_add_legend(self, ax, entries, layers=None): + if self._xaxis_scale in {"log", "symlog"} or self._yaxis_scale in {"log", "symlog"}: + return + + unique_entries = [] + seen = set() + for label, color in entries: + if not label or label in seen: + continue + unique_entries.append((label, color)) + seen.add(label) + if not unique_entries: + return + + xmin, xmax, ymin, ymax = self._plotext_ranges(layers=layers) + if None in (xmin, xmax, ymin, ymax): + return + + x_span = xmax - xmin or 1.0 + y_span = ymax - ymin or 1.0 + x_pos = xmin + 0.7 * x_span + y_pos = ymax - 0.08 * y_span + y_step = 0.08 * y_span + + for index, (label, color) in enumerate(unique_entries): + ax.text( + label, + x_pos, + y_pos - index * y_step, + color=color, + alignment="left", + ) + + def plot_plotext(self, ax, layers=None): + legend_entries = [] + colorbar_notes = [] + last_image_data = None + for line in self._iter_layer_lines(layers=layers): + plot_type = line["plot_type"] + kwargs = line["kwargs"] + if plot_type == "plot": + x = self._transform_x(line["x"]).tolist() + y = self._transform_y(line["y"]).tolist() + plot_kwargs = self._plotext_plot_kwargs(kwargs) + ax.plot(x, y, **plot_kwargs) + legend_entries.append((kwargs.get("label"), kwargs.get("color"))) + elif plot_type == "scatter": + x = self._transform_x(line["x"]).tolist() + y = self._transform_y(line["y"]).tolist() + scatter_kwargs = self._plotext_scatter_kwargs(kwargs) + ax.scatter(x, y, **scatter_kwargs) + legend_entries.append((kwargs.get("label"), kwargs.get("color"))) + elif plot_type == "bar": + transformed_heights = np.asarray(line["height"]) * self._yscale + bar_kwargs = self._plotext_bar_kwargs(kwargs) + if self._plotext_axis_scale("y") == "symlog": + transformed_heights = self._symlog_transform(transformed_heights) + bar_kwargs["minimum"] = 0.0 + ax.bar( + self._transform_x(line["x"]).tolist(), + transformed_heights.tolist(), + **bar_kwargs, + ) + legend_entries.append((kwargs.get("label"), kwargs.get("color"))) + elif plot_type == "fill_between": + x = self._transform_x(line["x"]).tolist() + y1 = self._transform_y(line["y1"]).tolist() + plot_kwargs = self._plotext_plot_kwargs(kwargs) + if np.isscalar(line["y2"]): + plot_kwargs["filly"] = line["y2"] + ax.plot(x, y1, **plot_kwargs) + else: + y2 = self._transform_y(line["y2"]).tolist() + polygon_x = x + x[::-1] + polygon_y = y1 + y2[::-1] + plot_kwargs["fillx"] = "internal" + ax.plot(polygon_x, polygon_y, **plot_kwargs) + legend_entries.append((kwargs.get("label"), kwargs.get("color"))) + elif plot_type == "errorbar": + x = self._transform_x(line["x"]).tolist() + y = self._transform_y(line["y"]).tolist() + ax.error( + x, + y, + xerr=self._plotext_error_values(line["xerr"], len(x)), + yerr=self._plotext_error_values(line["yerr"], len(y)), + color=kwargs.get("color"), + label=kwargs.get("label"), + ) + legend_entries.append((kwargs.get("label"), kwargs.get("color"))) + elif plot_type == "hlines": + ys = np.atleast_1d(line["y"]) + xmins = np.atleast_1d(line["xmin"]) + xmaxs = np.atleast_1d(line["xmax"]) + count = max(len(ys), len(xmins), len(xmaxs)) + ys = np.resize(ys, count) + xmins = np.resize(xmins, count) + xmaxs = np.resize(xmaxs, count) + for y, xmin, xmax in zip(ys, xmins, xmaxs): + ax.plot( + self._transform_x([xmin, xmax]).tolist(), + [self._transform_scalar_y(y), self._transform_scalar_y(y)], + color=kwargs.get("color"), + ) + elif plot_type == "vlines": + xs = np.atleast_1d(line["x"]) + ymins = np.atleast_1d(line["ymin"]) + ymaxs = np.atleast_1d(line["ymax"]) + count = max(len(xs), len(ymins), len(ymaxs)) + xs = np.resize(xs, count) + ymins = np.resize(ymins, count) + ymaxs = np.resize(ymaxs, count) + for x, ymin, ymax in zip(xs, ymins, ymaxs): + ax.plot( + [self._transform_scalar_x(x), self._transform_scalar_x(x)], + self._transform_y([ymin, ymax]).tolist(), + color=kwargs.get("color"), + ) + elif plot_type == "annotate": + text_x, text_y = line["xytext"] if line["xytext"] is not None else line["xy"] + arrowprops = kwargs.get("arrowprops") + text_x = self._plotext_native(self._transform_scalar_x(text_x)) + text_y = self._plotext_native(self._transform_scalar_y(text_y)) + xy_x = self._plotext_native(self._transform_scalar_x(line["xy"][0])) + xy_y = self._plotext_native(self._transform_scalar_y(line["xy"][1])) + if arrowprops: + arrow_color = ( + arrowprops.get("color") + if isinstance(arrowprops, dict) + else kwargs.get("color") + ) + ax.plot( + [text_x, xy_x], + [text_y, xy_y], + color=arrow_color, + ) + ax.text( + line["text"], + text_x, + text_y, + **self._plotext_text_kwargs(kwargs), + ) + elif plot_type == "text": + ax.text( + line["s"], + self._transform_scalar_x(line["x"]), + self._transform_scalar_y(line["y"]), + **self._plotext_text_kwargs(kwargs), + ) + elif plot_type == "imshow": + unsupported = set(kwargs) - {"marker", "style", "fast"} + if unsupported: + unsupported_str = ", ".join(sorted(unsupported)) + raise NotImplementedError( + f"plotext backend does not support imshow kwargs: {unsupported_str}" + ) + ax.matrix_plot( + np.asarray(line["data"]).tolist(), + marker=kwargs.get("marker"), + style=kwargs.get("style"), + fast=kwargs.get("fast", False), + ) + last_image_data = line["data"] + elif plot_type == "patch": + patch_color = self._plotext_draw_patch(ax, line["patch"], kwargs) + patch_label = kwargs.get("label") + if patch_label is None and hasattr(line["patch"], "get_label"): + candidate = line["patch"].get_label() + if candidate and not str(candidate).startswith("_"): + patch_label = candidate + legend_entries.append((patch_label, patch_color)) + elif plot_type == "colorbar": + colorbar_notes.append( + self._plotext_colorbar_note(last_image_data, line.get("label")) + ) + elif plot_type == "axhline": + ax.horizontal_line( + self._transform_scalar_y(line["y"]), + color=kwargs.get("color"), + ) + elif plot_type == "axvline": + ax.vertical_line( + self._transform_scalar_x(line["x"]), + color=kwargs.get("color"), + ) + else: + raise NotImplementedError( + f"plotext backend does not support plot type: {plot_type}" + ) + + self._plotext_apply_aspect(ax, layers=layers) + title_parts = [part for part in [self._title, getattr(self, "_caption", None)] if part] + if colorbar_notes: + title_parts.extend(colorbar_notes) + if title_parts: + ax.title(" | ".join(title_parts)) + if self._xlabel: + ax.xlabel(self._xlabel) + if self._ylabel: + ax.ylabel(self._ylabel) + if self._grid: + ax.grid(True, True) + if self.xmin is not None or self.xmax is not None: + ax.xlim( + self._plotext_axis_limit(self.xmin, "x"), + self._plotext_axis_limit(self.xmax, "x"), + ) + if self.ymin is not None or self.ymax is not None: + ax.ylim( + self._plotext_axis_limit(self.ymin, "y"), + self._plotext_axis_limit(self.ymax, "y"), + ) + if self._xaxis_scale is not None: + if self._xaxis_scale not in {"linear", "log", "symlog"}: + raise NotImplementedError( + f"plotext backend does not support xscale={self._xaxis_scale!r}" + ) + if self._xaxis_scale == "log": + ax.xscale("log") + if self._yaxis_scale is not None: + if self._yaxis_scale not in {"linear", "log", "symlog"}: + raise NotImplementedError( + f"plotext backend does not support yscale={self._yaxis_scale!r}" + ) + if self._yaxis_scale == "log": + ax.yscale("log") + if self._xticks is not None: + if self._xaxis_scale == "symlog": + positions, labels = self._plotext_symlog_ticks( + "x", + self._plotext_axis_limit(self.xmin, "x"), + self._plotext_axis_limit(self.xmax, "x"), + ticks=self._xticks, + labels=self._xticklabels, + ) + ax.xticks(positions, labels) + else: + ax.xticks(self._transform_x(self._xticks).tolist(), self._xticklabels) + elif self._xaxis_scale == "symlog": + positions, labels = self._plotext_symlog_ticks( + "x", + self._plotext_axis_limit(self.xmin, "x"), + self._plotext_axis_limit(self.xmax, "x"), + ) + if positions is not None: + ax.xticks(positions, labels) + if self._yticks is not None: + if self._yaxis_scale == "symlog": + positions, labels = self._plotext_symlog_ticks( + "y", + self._plotext_axis_limit(self.ymin, "y"), + self._plotext_axis_limit(self.ymax, "y"), + ticks=self._yticks, + labels=self._yticklabels, + ) + ax.yticks(positions, labels) + else: + ax.yticks(self._transform_y(self._yticks).tolist(), self._yticklabels) + elif self._yaxis_scale == "symlog": + positions, labels = self._plotext_symlog_ticks( + "y", + self._plotext_axis_limit(self.ymin, "y"), + self._plotext_axis_limit(self.ymax, "y"), + ) + if positions is not None: + ax.yticks(positions, labels) + if self._legend: + self._plotext_add_legend(ax, legend_entries, layers=layers) + @property def xmin(self): return self._xmin diff --git a/src/maxplotlib/tests/test_plotext.py b/src/maxplotlib/tests/test_plotext.py new file mode 100644 index 0000000..8add81d --- /dev/null +++ b/src/maxplotlib/tests/test_plotext.py @@ -0,0 +1,131 @@ +import re + +import matplotlib.patches as mpatches +import numpy as np + +from maxplotlib import Canvas +from maxplotlib.backends.plotext import PlotextFigure + +ANSI_ESCAPE_RE = re.compile(r"\x1B\[[0-?]*[ -/]*[@-~]") + + +def strip_ansi(text: str) -> str: + return ANSI_ESCAPE_RE.sub("", text) + + +def test_canvas_plot_plotext_builds_terminal_output(): + x = np.linspace(0, 2 * np.pi, 40) + canvas, ax = Canvas.subplots(width="10cm", ratio=0.5) + + ax.plot(x, np.sin(x), color="blue", label="sin(x)") + ax.scatter(x[::8], np.cos(x[::8]), color="red", label="samples") + ax.axhline(0, color="white") + ax.set_title("Terminal sine") + ax.set_xlabel("x") + ax.set_ylabel("y") + ax.set_grid(True) + ax.set_legend(True) + canvas.suptitle("Plotext demo") + + figure = canvas.plot(backend="plotext") + output = strip_ansi(figure.build()) + + assert isinstance(figure, PlotextFigure) + assert "Plotext demo" in output + assert "Terminal sine" in output + assert "sin(x)" in output + assert "samples" in output + + +def test_canvas_show_plotext_prints_output(capsys): + x = np.linspace(0, 1, 12) + canvas, ax = Canvas.subplots() + ax.plot(x, x**2, color="green") + ax.set_title("Quadratic") + + figure = canvas.show(backend="plotext") + captured = strip_ansi(capsys.readouterr().out) + + assert isinstance(figure, PlotextFigure) + assert "Quadratic" in captured + + +def test_canvas_plot_plotext_supports_scalar_errorbars(): + x = np.linspace(1, 10, 10) + canvas, ax = Canvas.subplots() + ax.errorbar(x, np.sqrt(x), yerr=0.2, color="yellow", label="samples") + ax.set_xscale("log") + ax.set_title("Log errors") + + output = strip_ansi(canvas.plot(backend="plotext").build()) + + assert "Log errors" in output + + +def test_canvas_plot_plotext_supports_fill_between_curves_and_annotations(): + x = np.linspace(0, 4, 25) + canvas, ax = Canvas.subplots() + ax.fill_between(x, np.sin(x) + 1.5, np.cos(x) + 0.5, color="cyan", label="band") + ax.annotate("crossing", xy=(1.5, 1.0), xytext=(2.5, 2.1), arrowprops={"color": "yellow"}) + ax.set_title("Filled band") + ax.set_legend(True) + + output = strip_ansi(canvas.plot(backend="plotext").build()) + + assert "Filled band" in output + assert "band" in output + assert "crossing" in output + + +def test_canvas_plot_plotext_supports_matrix_plots_and_patches(): + canvas, ax = Canvas.subplots() + ax.add_imshow(np.arange(9).reshape(3, 3)) + ax.add_patch(mpatches.Rectangle((0.2, 0.2), 1.2, 0.8, fill=False, edgecolor="yellow")) + ax.add_patch(mpatches.Circle((1.8, 1.8), 0.4, fill=False, edgecolor="cyan")) + ax.set_title("Matrix plot") + + output = strip_ansi(canvas.plot(backend="plotext").build()) + + assert "Matrix plot" in output + + +def test_canvas_plot_plotext_supports_colorbar_notes_symlog_aspect_and_generic_patches(): + canvas, ax = Canvas.subplots() + ax.add_imshow(np.eye(3)) + ax.add_colorbar(label="scale") + ax.set_title("Heatmap") + output = strip_ansi(canvas.plot(backend="plotext").build()) + + assert "Heatmap" in output + assert "scale:" in output + + x = np.linspace(-20, 20, 81) + canvas, ax = Canvas.subplots() + ax.plot(x, x**3, color="cyan") + ax.set_xscale("symlog") + ax.set_yscale("symlog") + ax.set_aspect("equal") + ax.add_caption("caption text") + ax.set_title("Symlog view") + output = strip_ansi(canvas.plot(backend="plotext").build()) + + assert "Symlog view" in output + assert "caption text" in output + + canvas, ax = Canvas.subplots() + ax.add_patch( + mpatches.Ellipse( + (1.5, 1.0), + 2.0, + 1.0, + fill=False, + edgecolor="yellow", + label="ellipse", + ) + ) + ax.set_title("Generic patch") + ax.set_legend(True) + output = strip_ansi(canvas.plot(backend="plotext").build()) + + assert "Generic patch" in output + assert "ellipse" in output diff --git a/src/maxplotlib/utils/options.py b/src/maxplotlib/utils/options.py index 074514e..0282f92 100644 --- a/src/maxplotlib/utils/options.py +++ b/src/maxplotlib/utils/options.py @@ -1,3 +1,3 @@ from typing import Literal -Backends = Literal["matplotlib", "plotly", "tikzfigure"] +Backends = Literal["matplotlib", "plotly", "plotext", "tikzfigure"] diff --git a/tutorials/tutorial_09_plotext.ipynb b/tutorials/tutorial_09_plotext.ipynb new file mode 100644 index 0000000..271309f --- /dev/null +++ b/tutorials/tutorial_09_plotext.ipynb @@ -0,0 +1,670 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Tutorial 09 - plotext Backend\n", + "\n", + "The **plotext backend** renders a `maxplotlib` canvas directly in the terminal. That makes it a good fit for SSH sessions, CLI workflows, quick diagnostics, and situations where you want the same `Canvas` API without a GUI browser or desktop window.\n", + "\n", + "## What this tutorial covers\n", + "\n", + "| Area | Status |\n", + "| --- | --- |\n", + "| `plot()` / line graphs | ✅ |\n", + "| `scatter()` | ✅ |\n", + "| `bar()` | ✅ |\n", + "| `fill_between()` to a baseline | ✅ |\n", + "| `fill_between()` between two curves | ✅ |\n", + "| `errorbar()` | ✅ |\n", + "| `axhline()` / `axvline()` / `hlines()` / `vlines()` | ✅ |\n", + "| `text()` / `annotate()` | ✅ |\n", + "| Titles, labels, captions, limits, ticks, grid, log/symlog scales | ✅ |\n", + "| `set_aspect()` | ✅ |\n", + "| Layers | ✅ |\n", + "| Multi-subplot canvases | ✅ |\n", + "| `add_imshow()` matrix-style rendering | ✅ |\n", + "| `add_colorbar()` note-style summary | ✅ |\n", + "| Generic matplotlib patch geometry | ✅ (best effort) |\n", + "| Terminal animation redraw loop | ✅ |\n", + "\n", + "The goal is not pixel-identical matplotlib output. The goal is a **faithful terminal representation** of the same plot intent." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "from pathlib import Path\n", + "\n", + "import matplotlib.patches as mpatches\n", + "import numpy as np\n", + "\n", + "from maxplotlib import Canvas" + ] + }, + { + "cell_type": "markdown", + "id": "2", + "metadata": {}, + "source": [ + "## 1 · Figure lifecycle: `plot()`, `build()`, `show()`, and `savefig()`\n", + "\n", + "Use `canvas.plot(backend=\"plotext\")` when you want a reusable terminal figure object. The returned object supports:\n", + "\n", + "- `.build()` to get the rendered terminal plot as a string\n", + "- `.show()` to print it immediately\n", + "- `.savefig(path)` to write the terminal output to a text file\n", + "\n", + "Use `canvas.show(backend=\"plotext\")` when you just want direct terminal output." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "x = np.linspace(0, 2 * np.pi, 100)\n", + "\n", + "canvas, ax = Canvas.subplots()\n", + "ax.plot(x, np.sin(x), color=\"cyan\", label=\"sin(x)\")\n", + "ax.set_title(\"Demo\")\n", + "ax.set_xlabel(\"x\")\n", + "ax.set_ylabel(\"y\")\n", + "\n", + "terminal_fig = canvas.plot(backend=\"plotext\")\n", + "preview = terminal_fig.build(keep_colors=False)\n", + "print(preview)\n", + "\n", + "# terminal_fig.show()\n", + "# canvas.show(backend=\"plotext\")" + ] + }, + { + "cell_type": "markdown", + "id": "4", + "metadata": {}, + "source": [ + "## 2 · Line plots\n", + "\n", + "Line plots are the natural fit for `plotext`. Labels, markers, grid lines, axis titles, and legends all carry over cleanly." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5", + "metadata": {}, + "outputs": [], + "source": [ + "x = np.linspace(0, 2 * np.pi, 120)\n", + "\n", + "canvas, ax = Canvas.subplots()\n", + "ax.plot(x, np.sin(x), color=\"cyan\", label=\"sin(x)\")\n", + "ax.plot(x, np.cos(x), color=\"yellow\", label=\"cos(x)\", marker=\"dot\")\n", + "ax.plot(x, np.sin(2 * x), color=\"green\", label=\"sin(2x)\")\n", + "ax.set_title(\"Multiple terminal lines\")\n", + "ax.set_xlabel(\"x\")\n", + "ax.set_ylabel(\"value\")\n", + "ax.set_grid(True)\n", + "ax.set_legend(True)\n", + "\n", + "print(canvas.plot(backend=\"plotext\").build(keep_colors=False))" + ] + }, + { + "cell_type": "markdown", + "id": "6", + "metadata": {}, + "source": [ + "## 3 · Scatter plots\n", + "\n", + "Scatter traces use `plotext.scatter(...)` under the hood. They combine well with a line on the same axes." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7", + "metadata": {}, + "outputs": [], + "source": [ + "rng = np.random.default_rng(42)\n", + "x = np.linspace(0, 8, 60)\n", + "samples_x = np.linspace(0, 8, 15)\n", + "samples_y = np.sin(samples_x) + rng.normal(0, 0.15, len(samples_x))\n", + "\n", + "canvas, ax = Canvas.subplots()\n", + "ax.plot(x, np.sin(x), color=\"white\", label=\"sin(x)\")\n", + "ax.scatter(samples_x, samples_y, color=\"red\", marker=\"x\", label=\"samples\")\n", + "ax.set_title(\"Scatter + line\")\n", + "ax.set_xlabel(\"x\")\n", + "ax.set_ylabel(\"y\")\n", + "ax.set_legend(True)\n", + "\n", + "print(canvas.plot(backend=\"plotext\").build(keep_colors=False))" + ] + }, + { + "cell_type": "markdown", + "id": "8", + "metadata": {}, + "source": [ + "## 4 · Bar charts\n", + "\n", + "Bars are supported too, so summary views and simple dashboards work well in the terminal." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9", + "metadata": {}, + "outputs": [], + "source": [ + "bins = np.arange(5)\n", + "values = np.array([4.0, 6.5, 3.2, 7.4, 5.8])\n", + "\n", + "canvas, ax = Canvas.subplots()\n", + "ax.bar(bins, values, color=\"green\", label=\"count\")\n", + "ax.scatter(bins, values, color=\"yellow\", label=\"sample mean\")\n", + "ax.set_title(\"Bar + scatter overlay\")\n", + "ax.set_xlabel(\"bin\")\n", + "ax.set_ylabel(\"value\")\n", + "ax.set_legend(True)\n", + "\n", + "print(canvas.plot(backend=\"plotext\").build(keep_colors=False))" + ] + }, + { + "cell_type": "markdown", + "id": "10", + "metadata": {}, + "source": [ + "## 5 · Filled regions\n", + "\n", + "The backend supports both common `fill_between(...)` shapes:\n", + "\n", + "1. Filling down to a scalar baseline\n", + "2. Filling the region between two full curves" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "11", + "metadata": {}, + "outputs": [], + "source": [ + "x = np.linspace(0, 5, 100)\n", + "\n", + "canvas, ax = Canvas.subplots()\n", + "ax.fill_between(x, np.exp(-0.5 * x) * np.sin(3 * x) + 1.0, 0.0, color=\"cyan\", label=\"signal envelope\")\n", + "ax.set_title(\"fill_between() to a scalar baseline\")\n", + "ax.set_xlabel(\"x\")\n", + "ax.set_ylabel(\"amplitude\")\n", + "ax.set_legend(True)\n", + "\n", + "print(canvas.plot(backend=\"plotext\").build(keep_colors=False))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "12", + "metadata": {}, + "outputs": [], + "source": [ + "x = np.linspace(0, 4, 100)\n", + "upper = np.sin(x) + 1.8\n", + "lower = np.cos(x) + 0.8\n", + "\n", + "canvas, ax = Canvas.subplots()\n", + "ax.fill_between(x, upper, lower, color=\"blue\", label=\"between curves\")\n", + "ax.plot(x, upper, color=\"white\", label=\"upper\")\n", + "ax.plot(x, lower, color=\"yellow\", label=\"lower\")\n", + "ax.set_title(\"fill_between() between two curves\")\n", + "ax.set_legend(True)\n", + "\n", + "print(canvas.plot(backend=\"plotext\").build(keep_colors=False))" + ] + }, + { + "cell_type": "markdown", + "id": "13", + "metadata": {}, + "source": [ + "## 6 · Error bars and reference lines\n", + "\n", + "Reference-line helpers (`axhline`, `axvline`, `hlines`, `vlines`) are all available, and `errorbar()` works for scalar or array-shaped errors." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "14", + "metadata": {}, + "outputs": [], + "source": [ + "x = np.linspace(1, 10, 9)\n", + "y = np.sqrt(x)\n", + "\n", + "canvas, ax = Canvas.subplots()\n", + "ax.errorbar(x, y, yerr=0.15, color=\"cyan\", label=\"sqrt(x)\")\n", + "ax.axhline(2.0, color=\"white\")\n", + "ax.axvline(4.0, color=\"yellow\")\n", + "ax.hlines([1.2, 2.7], xmin=[1, 5], xmax=[3, 9], color=\"green\")\n", + "ax.vlines([2.0, 8.0], ymin=[1.0, 2.0], ymax=[1.8, 3.0], color=\"red\")\n", + "ax.set_title(\"Error bars + reference lines\")\n", + "ax.set_xlabel(\"x\")\n", + "ax.set_ylabel(\"y\")\n", + "ax.set_legend(True)\n", + "\n", + "print(canvas.plot(backend=\"plotext\").build(keep_colors=False))" + ] + }, + { + "cell_type": "markdown", + "id": "15", + "metadata": {}, + "source": [ + "## 7 · Text and annotations\n", + "\n", + "`text()` is mapped directly. `annotate()` works too, and when you pass `arrowprops`, the backend draws a connector line toward the annotated point." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "16", + "metadata": {}, + "outputs": [], + "source": [ + "x = np.linspace(0, 6, 120)\n", + "y = np.sin(x)\n", + "peak_x = x[np.argmax(y)]\n", + "peak_y = y.max()\n", + "\n", + "canvas, ax = Canvas.subplots()\n", + "ax.plot(x, y, color=\"cyan\")\n", + "ax.text(0.8, -0.8, \"terminal note\", color=\"yellow\")\n", + "ax.annotate(\n", + " \"peak\",\n", + " xy=(peak_x, peak_y),\n", + " xytext=(4.4, 0.4),\n", + " color=\"white\",\n", + " arrowprops={\"color\": \"green\"},\n", + ")\n", + "ax.set_title(\"Text and annotations\")\n", + "\n", + "print(canvas.plot(backend=\"plotext\").build(keep_colors=False))" + ] + }, + { + "cell_type": "markdown", + "id": "17", + "metadata": {}, + "source": [ + "## 8 · Limits, ticks, grid, and log scales\n", + "\n", + "Axis metadata is one of the nice parts of keeping the same `LinePlot` API across backends." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "18", + "metadata": {}, + "outputs": [], + "source": [ + "x = np.linspace(1, 20, 120)\n", + "\n", + "canvas, ax = Canvas.subplots()\n", + "ax.plot(x, x**0.5, color=\"cyan\", label=\"sqrt(x)\")\n", + "ax.plot(x, np.log(x + 1), color=\"yellow\", label=\"log(x + 1)\")\n", + "ax.set_title(\"Axis controls\")\n", + "ax.set_xlabel(\"x\")\n", + "ax.set_ylabel(\"value\")\n", + "ax.set_xlim(1, 20)\n", + "ax.set_ylim(0, 5)\n", + "ax.set_xticks([1, 2, 5, 10, 20], [\"1\", \"2\", \"5\", \"10\", \"20\"])\n", + "ax.set_yticks([0, 1, 2, 3, 4, 5])\n", + "ax.set_xscale(\"log\")\n", + "ax.set_grid(True)\n", + "ax.set_legend(True)\n", + "\n", + "print(canvas.plot(backend=\"plotext\").build(keep_colors=False))" + ] + }, + { + "cell_type": "markdown", + "id": "19", + "metadata": {}, + "source": [ + "## 9 · Layers\n", + "\n", + "Layer filtering works with the terminal backend too. This is useful when you want progressive reveals or to inspect subsets of a figure." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "20", + "metadata": {}, + "outputs": [], + "source": [ + "x = np.linspace(0, 2 * np.pi, 100)\n", + "\n", + "canvas, ax = Canvas.subplots()\n", + "ax.plot(x, np.sin(x), color=\"cyan\", label=\"layer 0\", layer=0)\n", + "ax.plot(x, np.cos(x), color=\"yellow\", label=\"layer 1\", layer=1)\n", + "ax.fill_between(x, np.sin(x) + 1.5, 0.0, color=\"green\", label=\"layer 2\", layer=2)\n", + "ax.set_title(\"Layers 0 and 1 only\")\n", + "ax.set_legend(True)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "21", + "metadata": {}, + "outputs": [], + "source": [ + "print(canvas.plot(backend=\"plotext\", layers=[0]).build(keep_colors=False))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "22", + "metadata": {}, + "outputs": [], + "source": [ + "print(canvas.plot(backend=\"plotext\", layers=[1]).build(keep_colors=False))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "23", + "metadata": {}, + "outputs": [], + "source": [ + "print(canvas.plot(backend=\"plotext\", layers=[0,1]).build(keep_colors=False))" + ] + }, + { + "cell_type": "markdown", + "id": "24", + "metadata": {}, + "source": [ + "## 10 · Multi-subplot canvases\n", + "\n", + "Subplots are fully supported, including figure-level titles via `canvas.suptitle(...)`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "25", + "metadata": {}, + "outputs": [], + "source": [ + "x = np.linspace(0, 2 * np.pi, 80)\n", + "rng = np.random.default_rng(5)\n", + "\n", + "canvas, (ax1, ax2) = Canvas.subplots(ncols=2)\n", + "\n", + "ax1.plot(x, np.sin(x), color=\"cyan\", label=\"sin(x)\")\n", + "ax1.plot(x, np.cos(x), color=\"yellow\", label=\"cos(x)\")\n", + "ax1.set_title(\"Signals\")\n", + "ax1.set_xlabel(\"x\")\n", + "ax1.set_ylabel(\"value\")\n", + "ax1.set_legend(True)\n", + "\n", + "cats = np.arange(6)\n", + "vals = rng.integers(2, 9, size=6)\n", + "ax2.bar(cats, vals, color=\"green\", label=\"count\")\n", + "ax2.scatter(cats, vals, color=\"red\", label=\"points\")\n", + "ax2.set_title(\"Counts\")\n", + "ax2.set_xlabel(\"bin\")\n", + "ax2.set_ylabel(\"value\")\n", + "ax2.set_legend(True)\n", + "\n", + "canvas.suptitle(\"Terminal dashboard\")\n", + "print(canvas.plot(backend=\"plotext\").build(keep_colors=False))" + ] + }, + { + "cell_type": "markdown", + "id": "26", + "metadata": {}, + "source": [ + "## 11 · Matrix-style `imshow()` output\n", + "\n", + "`add_imshow()` is rendered as a terminal matrix plot. This is the terminal approximation of image-like numeric data, not a full matplotlib colormap implementation." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "27", + "metadata": {}, + "outputs": [], + "source": [ + "data = np.arange(1, 26).reshape(5, 5)\n", + "\n", + "canvas, ax = Canvas.subplots()\n", + "ax.add_imshow(data)\n", + "ax.set_title(\"Matrix-style imshow\")\n", + "ax.set_xlabel(\"column\")\n", + "ax.set_ylabel(\"row\")\n", + "\n", + "print(canvas.plot(backend=\"plotext\").build(keep_colors=False))" + ] + }, + { + "cell_type": "markdown", + "id": "28", + "metadata": {}, + "source": [ + "## 12 · Patches and arbitrary patch geometry\n", + "\n", + "The backend supports common matplotlib patch types directly and also uses matplotlib patch geometry as a best-effort fallback for many other patch subclasses.\n", + "\n", + "- `matplotlib.patches.Rectangle`\n", + "- `matplotlib.patches.Circle`\n", + "- `matplotlib.patches.Polygon`\n", + "- `matplotlib.patches.Ellipse`\n", + "- many other patch types that expose a usable matplotlib path\n", + "\n", + "That is enough for many annotations, regions of interest, and geometric callouts." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "29", + "metadata": {}, + "outputs": [], + "source": [ + "canvas, ax = Canvas.subplots()\n", + "ax.add_patch(mpatches.Rectangle((0.2, 0.2), 1.3, 0.7, fill=False, edgecolor=\"yellow\", label=\"window\"))\n", + "ax.add_patch(mpatches.Circle((2.2, 1.6), 0.45, fill=False, edgecolor=\"cyan\", label=\"sensor\"))\n", + "ax.add_patch(mpatches.Polygon([[3.0, 0.5], [3.8, 1.2], [3.4, 2.0]], fill=True, facecolor=\"green\", label=\"region\"))\n", + "ax.add_patch(mpatches.Ellipse((2.8, 1.0), 0.8, 0.5, fill=False, edgecolor=\"white\", label=\"ellipse\"))\n", + "ax.set_xlim(0, 4.5)\n", + "ax.set_ylim(0, 2.5)\n", + "ax.set_title(\"Supported patch types\")\n", + "ax.set_legend(True)\n", + "\n", + "print(canvas.plot(backend=\"plotext\").build(keep_colors=False))" + ] + }, + { + "cell_type": "markdown", + "id": "30", + "metadata": {}, + "source": [ + "## 13 · Captions, symlog scales, aspect, and colorbar notes\n", + "\n", + "The last backend-specific pieces of the `LinePlot` surface also work in the terminal backend:\n", + "\n", + "- `add_caption(...)`\n", + "- `set_xscale('symlog')` and `set_yscale('symlog')`\n", + "- `set_aspect(...)` with a best-effort terminal plotsize approximation\n", + "- `add_colorbar(...)` as a note-style value summary for matrix/image output" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "31", + "metadata": {}, + "outputs": [], + "source": [ + "x = np.linspace(-20, 20, 161)\n", + "\n", + "canvas, ax = Canvas.subplots()\n", + "ax.plot(x, x**3, color=\"cyan\", label=\"x^3\")\n", + "ax.set_title(\"Symlog example\")\n", + "ax.add_caption(\"caption text\")\n", + "ax.set_xscale(\"symlog\")\n", + "ax.set_yscale(\"symlog\")\n", + "ax.set_aspect(\"equal\")\n", + "ax.set_legend(True)\n", + "\n", + "print(canvas.plot(backend=\"plotext\").build(keep_colors=False))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "32", + "metadata": {}, + "outputs": [], + "source": [ + "heat = np.arange(1, 26).reshape(5, 5)\n", + "\n", + "canvas, ax = Canvas.subplots()\n", + "ax.add_imshow(heat)\n", + "ax.add_colorbar(label=\"intensity\")\n", + "ax.set_title(\"Matrix + colorbar note\")\n", + "\n", + "print(canvas.plot(backend=\"plotext\").build(keep_colors=False))" + ] + }, + { + "cell_type": "markdown", + "id": "33", + "metadata": {}, + "source": [ + "## 14 · Saving terminal output to a file\n", + "\n", + "Saving with the plotext backend writes the rendered terminal figure to a text file. By default the saved text is plain and easy to inspect in editors, CI logs, or generated artifacts." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "34", + "metadata": {}, + "outputs": [], + "source": [ + "x = np.linspace(0, 2 * np.pi, 60)\n", + "\n", + "canvas, ax = Canvas.subplots()\n", + "ax.plot(x, np.sin(x), color=\"cyan\", label=\"sin(x)\")\n", + "ax.set_title(\"Saved terminal figure\")\n", + "terminal_fig = canvas.plot(backend=\"plotext\")\n", + "\n", + "output_path = Path(\"plotext_output.txt\")\n", + "terminal_fig.savefig(output_path)\n", + "print(output_path.read_text().splitlines()[0])" + ] + }, + { + "cell_type": "markdown", + "id": "35", + "metadata": {}, + "source": [ + "## 15 · A simple terminal animation\n", + "\n", + "`plotext` does not provide browser-widget animation like Plotly or Matplotlib's GUI animation stack, but terminal animation is still practical: render a frame, clear the output, sleep briefly, and repeat.\n", + "\n", + "The example below works well in a notebook cell or a terminal Python session. In notebooks, `clear_output(wait=True)` keeps the cell output updating in place." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "36", + "metadata": {}, + "outputs": [], + "source": [ + "import time\n", + "\n", + "from IPython.display import clear_output\n", + "\n", + "x = np.linspace(0, 2 * np.pi, 120)\n", + "\n", + "for phase in np.linspace(0, 2 * np.pi, 24):\n", + " canvas, ax = Canvas.subplots()\n", + " ax.plot(x, np.sin(x + phase), color=\"cyan\", label=\"sin(x + phase)\")\n", + " ax.plot(x, np.cos(x + phase), color=\"yellow\", label=\"cos(x + phase)\")\n", + " ax.set_ylim(-1.2, 1.2)\n", + " ax.set_title(f\"Animated phase = {phase:.2f}\")\n", + " ax.set_legend(True)\n", + "\n", + " clear_output(wait=True)\n", + " print(canvas.plot(backend=\"plotext\").build(keep_colors=False))\n", + " time.sleep(0.08)" + ] + }, + { + "cell_type": "markdown", + "id": "37", + "metadata": {}, + "source": [ + "## 16 · Current limitations\n", + "\n", + "| Feature | Status | Notes |\n", + "| --- | --- | --- |\n", + "| `add_colorbar()` | ⚠️ | rendered as a compact note-style min/max summary rather than a true continuous side bar |\n", + "| Arbitrary matplotlib patch subclasses | ⚠️ | best-effort path extraction works for many patch types, but not every custom patch will map perfectly |\n", + "| Exact matplotlib styling parity | ❌ | Terminal rendering is approximate by design |\n", + "| Notebook-style high-frame-rate animation | ⚠️ | possible via redraw loops, but not a substitute for GUI animation widgets |\n", + "\n", + "For terminal work, this backend now covers a large and practical portion of the `LinePlot` API. When you need richer styling, publication export, or interactive browser behavior, use the matplotlib, tikzfigure, or plotly backends instead." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} From 1c17064816d9f1f8dddec6e67b3439762e91e363 Mon Sep 17 00:00:00 2001 From: Max Date: Wed, 22 Apr 2026 08:39:46 +0200 Subject: [PATCH 2/2] Formatting --- src/maxplotlib/canvas/canvas.py | 9 ++++--- src/maxplotlib/colors/colors.py | 1 - src/maxplotlib/linestyle/linestyle.py | 1 - src/maxplotlib/subfigure/line_plot.py | 30 ++++++++++++++++++----- src/maxplotlib/tests/test_plotext.py | 8 ++++-- tutorials/tutorial_07_tikz.ipynb | 4 +-- tutorials/tutorial_09_plotext.ipynb | 35 ++++++++++++++++++++++----- 7 files changed, 67 insertions(+), 21 deletions(-) diff --git a/src/maxplotlib/canvas/canvas.py b/src/maxplotlib/canvas/canvas.py index 397be5e..ba1027c 100644 --- a/src/maxplotlib/canvas/canvas.py +++ b/src/maxplotlib/canvas/canvas.py @@ -683,7 +683,6 @@ def savefig( if self._plotted: self._matplotlib_fig.savefig(full_filepath) else: - fig, axs = self.plot( backend="matplotlib", savefig=True, @@ -905,7 +904,7 @@ def plot_tikzfigure( else None ), grid=line_plot._grid, - caption=line_plot._title or f"Subplot {col+1}", + caption=line_plot._title or f"Subplot {col + 1}", width=0.45, ) @@ -945,7 +944,11 @@ def plot_plotext( figure = create_plotext_figure(self.nrows, self.ncols) for row, col, subplot in self.iter_subplots(): - ax = figure if (self.nrows, self.ncols) == (1, 1) else figure.subplot(row + 1, col + 1) + ax = ( + figure + if (self.nrows, self.ncols) == (1, 1) + else figure.subplot(row + 1, col + 1) + ) if isinstance(subplot, TikzFigure): raise NotImplementedError( "tikzfigure subplots cannot be rendered with the plotext backend." diff --git a/src/maxplotlib/colors/colors.py b/src/maxplotlib/colors/colors.py index 4d04287..965e54c 100644 --- a/src/maxplotlib/colors/colors.py +++ b/src/maxplotlib/colors/colors.py @@ -5,7 +5,6 @@ class Color: - def _parse_color(self, color_spec): """ Internal method to parse the color specification and convert it to an RGB tuple. diff --git a/src/maxplotlib/linestyle/linestyle.py b/src/maxplotlib/linestyle/linestyle.py index 27e0758..c5a0625 100644 --- a/src/maxplotlib/linestyle/linestyle.py +++ b/src/maxplotlib/linestyle/linestyle.py @@ -2,7 +2,6 @@ class Linestyle: - def _parse_style(self, style_spec): """ Internal method to parse the style specification and convert it to a Matplotlib linestyle. diff --git a/src/maxplotlib/subfigure/line_plot.py b/src/maxplotlib/subfigure/line_plot.py index f46869c..daf1383 100644 --- a/src/maxplotlib/subfigure/line_plot.py +++ b/src/maxplotlib/subfigure/line_plot.py @@ -698,7 +698,10 @@ def _plotext_color(self, *candidates): def _plotext_patch_style(self, patch, kwargs): edgecolor = kwargs.get( "edgecolor", - kwargs.get("color", patch.get_edgecolor() if hasattr(patch, "get_edgecolor") else None), + kwargs.get( + "color", + patch.get_edgecolor() if hasattr(patch, "get_edgecolor") else None, + ), ) facecolor = kwargs.get( "facecolor", @@ -810,7 +813,11 @@ def extend_y(values): extend_y([self._transform_scalar_y(0)]) elif plot_type == "fill_between": extend_x(self._transform_x(line["x"])) - y1 = line["y1"] if np.isscalar(line["y1"]) else self._transform_y(line["y1"]) + y1 = ( + line["y1"] + if np.isscalar(line["y1"]) + else self._transform_y(line["y1"]) + ) y2 = ( self._transform_scalar_y(line["y2"]) if np.isscalar(line["y2"]) @@ -920,7 +927,11 @@ def _plotext_apply_aspect(self, ax, layers=None): width = int(round(height * (x_span / (y_span * aspect)) * 2.0)) title_hint = len( " | ".join( - [part for part in [self._title, getattr(self, "_caption", None)] if part] + [ + part + for part in [self._title, getattr(self, "_caption", None)] + if part + ] ) ) width = max(40, title_hint + 6, min(width, 80)) @@ -939,7 +950,10 @@ def _plotext_colorbar_note(self, image_data, label): return f"{prefix}{self._plotext_format_tick(vmin)}..{self._plotext_format_tick(vmax)}" def _plotext_add_legend(self, ax, entries, layers=None): - if self._xaxis_scale in {"log", "symlog"} or self._yaxis_scale in {"log", "symlog"}: + if self._xaxis_scale in {"log", "symlog"} or self._yaxis_scale in { + "log", + "symlog", + }: return unique_entries = [] @@ -1057,7 +1071,9 @@ def plot_plotext(self, ax, layers=None): color=kwargs.get("color"), ) elif plot_type == "annotate": - text_x, text_y = line["xytext"] if line["xytext"] is not None else line["xy"] + text_x, text_y = ( + line["xytext"] if line["xytext"] is not None else line["xy"] + ) arrowprops = kwargs.get("arrowprops") text_x = self._plotext_native(self._transform_scalar_x(text_x)) text_y = self._plotext_native(self._transform_scalar_y(text_y)) @@ -1129,7 +1145,9 @@ def plot_plotext(self, ax, layers=None): ) self._plotext_apply_aspect(ax, layers=layers) - title_parts = [part for part in [self._title, getattr(self, "_caption", None)] if part] + title_parts = [ + part for part in [self._title, getattr(self, "_caption", None)] if part + ] if colorbar_notes: title_parts.extend(colorbar_notes) if title_parts: diff --git a/src/maxplotlib/tests/test_plotext.py b/src/maxplotlib/tests/test_plotext.py index 8add81d..ee34a63 100644 --- a/src/maxplotlib/tests/test_plotext.py +++ b/src/maxplotlib/tests/test_plotext.py @@ -66,7 +66,9 @@ def test_canvas_plot_plotext_supports_fill_between_curves_and_annotations(): x = np.linspace(0, 4, 25) canvas, ax = Canvas.subplots() ax.fill_between(x, np.sin(x) + 1.5, np.cos(x) + 0.5, color="cyan", label="band") - ax.annotate("crossing", xy=(1.5, 1.0), xytext=(2.5, 2.1), arrowprops={"color": "yellow"}) + ax.annotate( + "crossing", xy=(1.5, 1.0), xytext=(2.5, 2.1), arrowprops={"color": "yellow"} + ) ax.set_title("Filled band") ax.set_legend(True) @@ -80,7 +82,9 @@ def test_canvas_plot_plotext_supports_fill_between_curves_and_annotations(): def test_canvas_plot_plotext_supports_matrix_plots_and_patches(): canvas, ax = Canvas.subplots() ax.add_imshow(np.arange(9).reshape(3, 3)) - ax.add_patch(mpatches.Rectangle((0.2, 0.2), 1.2, 0.8, fill=False, edgecolor="yellow")) + ax.add_patch( + mpatches.Rectangle((0.2, 0.2), 1.2, 0.8, fill=False, edgecolor="yellow") + ) ax.add_patch(mpatches.Circle((1.8, 1.8), 0.4, fill=False, edgecolor="cyan")) ax.set_title("Matrix plot") diff --git a/tutorials/tutorial_07_tikz.ipynb b/tutorials/tutorial_07_tikz.ipynb index 4596e17..816c63c 100644 --- a/tutorials/tutorial_07_tikz.ipynb +++ b/tutorials/tutorial_07_tikz.ipynb @@ -161,12 +161,12 @@ "# Render only layer 0 — one \\draw command\n", "tikz_l0 = canvas3.plot(backend=\"tikzfigure\", layers=[0])\n", "print(\"\\n--- Layer 0 only ---\")\n", - "print(f'\\\\draw count: {tikz_l0.generate_tikz().count(chr(92)+\"draw\")}')\n", + "print(f\"\\\\draw count: {tikz_l0.generate_tikz().count(chr(92) + 'draw')}\")\n", "\n", "# Render layers 0 and 1\n", "tikz_l01 = canvas3.plot(backend=\"tikzfigure\", layers=[0, 1])\n", "print(\"\\n--- Layers 0 & 1 ---\")\n", - "print(f'\\\\draw count: {tikz_l01.generate_tikz().count(chr(92)+\"draw\")}')" + "print(f\"\\\\draw count: {tikz_l01.generate_tikz().count(chr(92) + 'draw')}\")" ] }, { diff --git a/tutorials/tutorial_09_plotext.ipynb b/tutorials/tutorial_09_plotext.ipynb index 271309f..a1a02b9 100644 --- a/tutorials/tutorial_09_plotext.ipynb +++ b/tutorials/tutorial_09_plotext.ipynb @@ -206,7 +206,13 @@ "x = np.linspace(0, 5, 100)\n", "\n", "canvas, ax = Canvas.subplots()\n", - "ax.fill_between(x, np.exp(-0.5 * x) * np.sin(3 * x) + 1.0, 0.0, color=\"cyan\", label=\"signal envelope\")\n", + "ax.fill_between(\n", + " x,\n", + " np.exp(-0.5 * x) * np.sin(3 * x) + 1.0,\n", + " 0.0,\n", + " color=\"cyan\",\n", + " label=\"signal envelope\",\n", + ")\n", "ax.set_title(\"fill_between() to a scalar baseline\")\n", "ax.set_xlabel(\"x\")\n", "ax.set_ylabel(\"amplitude\")\n", @@ -397,7 +403,7 @@ "metadata": {}, "outputs": [], "source": [ - "print(canvas.plot(backend=\"plotext\", layers=[0,1]).build(keep_colors=False))" + "print(canvas.plot(backend=\"plotext\", layers=[0, 1]).build(keep_colors=False))" ] }, { @@ -496,10 +502,27 @@ "outputs": [], "source": [ "canvas, ax = Canvas.subplots()\n", - "ax.add_patch(mpatches.Rectangle((0.2, 0.2), 1.3, 0.7, fill=False, edgecolor=\"yellow\", label=\"window\"))\n", - "ax.add_patch(mpatches.Circle((2.2, 1.6), 0.45, fill=False, edgecolor=\"cyan\", label=\"sensor\"))\n", - "ax.add_patch(mpatches.Polygon([[3.0, 0.5], [3.8, 1.2], [3.4, 2.0]], fill=True, facecolor=\"green\", label=\"region\"))\n", - "ax.add_patch(mpatches.Ellipse((2.8, 1.0), 0.8, 0.5, fill=False, edgecolor=\"white\", label=\"ellipse\"))\n", + "ax.add_patch(\n", + " mpatches.Rectangle(\n", + " (0.2, 0.2), 1.3, 0.7, fill=False, edgecolor=\"yellow\", label=\"window\"\n", + " )\n", + ")\n", + "ax.add_patch(\n", + " mpatches.Circle((2.2, 1.6), 0.45, fill=False, edgecolor=\"cyan\", label=\"sensor\")\n", + ")\n", + "ax.add_patch(\n", + " mpatches.Polygon(\n", + " [[3.0, 0.5], [3.8, 1.2], [3.4, 2.0]],\n", + " fill=True,\n", + " facecolor=\"green\",\n", + " label=\"region\",\n", + " )\n", + ")\n", + "ax.add_patch(\n", + " mpatches.Ellipse(\n", + " (2.8, 1.0), 0.8, 0.5, fill=False, edgecolor=\"white\", label=\"ellipse\"\n", + " )\n", + ")\n", "ax.set_xlim(0, 4.5)\n", "ax.set_ylim(0, 2.5)\n", "ax.set_title(\"Supported patch types\")\n",