diff --git a/README.md b/README.md index 1eac2d2..f554af5 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,6 @@ +# Maxlotlib + + # Maxplotlib A clean, expressive wrapper around **Matplotlib**, **Plotly**, @@ -17,6 +20,8 @@ pip install maxplotlibx ### Quickstart +
+ ``` python import numpy as np from maxplotlib import Canvas @@ -28,13 +33,124 @@ canvas, ax = Canvas.subplots() ax.plot(x, y) ``` +Figure 1 + +
+ Plot the figure with the default (matplotlib) backend: ``` python canvas.show() ``` -![](README_files/figure-markdown_strict/cell-3-output-1.png) +![](README_files/figure-commonmark/cell-3-output-1.png) + +For Matplotlib-specific customization, pass method calls declaratively. +Figure methods run once and axes methods run for every subplot, +providing access to any Matplotlib API without requiring a maxplotlib +wrapper: + +``` python +canvas.plot(matplotlib_customizations={ + "figure": { + "suptitle": "My figure", + }, + "axes": { + "tick_params": { + "axis": "both", + "which": "major", + "length": 6, + }, + }, +}) +``` + +For dynamic customization, the same option also accepts a function: + +``` python +def customize(fig, axes): + fig.suptitle("My figure") + for ax in axes.flat: + ax.tick_params(axis="both", which="major", length=6) + +canvas.plot(matplotlib_customizations=customize) +``` + +### Axis Label and Tick Styling + +Axis labels, titles, and tick appearance accept Matplotlib-style keyword +arguments: + +``` python +canvas.set_xlabel("Time", fontsize=12, fontweight="bold", labelpad=10) +canvas.set_ylabel("Duration", color="darkblue") +canvas.set_title("Runtime", fontsize=14, color="navy") +canvas.tick_params( + axis="both", + which="major", + labelsize=10, + colors="darkgreen", + length=6, +) +``` + +Common axis controls and figure-level layout settings are also +available: + +``` python +canvas.set_facecolor("whitesmoke") +canvas.set_axisbelow(True) +canvas.margins(x=0.05, y=0.1) +canvas.minorticks_on() +canvas.invert_yaxis() +canvas.supxlabel("Shared x label") +canvas.supylabel("Shared y label") +canvas.subplots_adjust(left=0.15, bottom=0.15) +canvas.tight_layout() +``` + +### Secondary Y-Axis + +Use `Canvas.twinx()` to add a second y-axis that shares the primary +x-axis: + +``` python +twin_canvas, primary = Canvas.subplots() +secondary = twin_canvas.twinx() + +primary.plot(x, np.sin(x), color="tab:blue") +secondary.plot(x, 100 * np.cos(x), color="tab:red") +primary.set_ylabel("sin(x)", color="tab:blue") +secondary.set_ylabel("100 cos(x)", color="tab:red") + +twin_canvas.show() +``` + +Secondary y-axes are currently supported by the Matplotlib and Plotly +backends. + +### Plotly field plots and tables + +Several Matplotlib field and annotation APIs map directly to interactive +Plotly traces, including pseudocolor plots, sparsity patterns, +triangular grids, and tables: + +``` python +plotly_canvas, plotly_ax = Canvas.subplots() +plotly_ax.pcolor(x, x, np.outer(np.sin(x), np.cos(x))) +plotly_ax.spy([[1, 0, 1], [0, 1, 0], [1, 0, 1]]) +plotly_ax.table(cellText=[["A", "B"], ["1", "2"]]) +plotly_canvas.show(backend="plotly") +``` + +Plotly raises `NotImplementedError` for primitives without a faithful +equivalent instead of silently dropping them. To render the supported +parts of a mixed canvas, explicitly opt into skipping unsupported +primitives: + +``` python +plotly_canvas.plot(backend="plotly", allow_unsupported=True) +``` Render the same line graph directly in the terminal with the `plotext` backend: @@ -44,20 +160,76 @@ terminal_fig = canvas.plot(backend="plotext") print(terminal_fig.build(keep_colors=False)) ``` + Runtime + ┌─────────────────────────────────────────────────────────────────────────┐ + 1.00┤ ▗▄▞▀▀▀▀▀▙▄▖ │ + │ ▗▄▀▘ ▝▀▄ │ + │ ▗▞▘ ▀▄ │ + 0.67┤ ▟▀ ▀▄ │ + │ ▄▛ ▚▖ │ + 0.33┤ ▗▞ ▝▄ │ + │ ▄▀ ▚▖ │ + │▗▞▘ ▀▄ │ + 0.00┤▀ ▝▚▖ ▞│ + │ ▀▄ ▗▞▘│ + │ ▝▚ ▄▀ │ + -0.33┤ ▀▖ ▞▘ │ + │ ▝▚ ▟▀ │ + -0.67┤ ▀▄ ▄▛ │ + │ ▀▄ ▗▞▘ │ + │ ▀▄▖ ▗▄▀▘ │ + -1.00┤ ▝▀▜▄▄▄▄▄▞▀▘ │ + └┬─────────────────┬─────────────────┬─────────────────┬─────────────────┬┘ + 0.0 1.6 3.1 4.7 6.3 + Duration Time + Or plot with the TikZ backend: ``` python canvas.show(backend="tikzfigure") ``` -![](README_files/figure-markdown_strict/cell-4-output-1.png) +![](README_files/figure-commonmark/cell-12-output-1.png) + +### Horizontal Subplots with TikZ Backend + +The tikzfigure backend supports creating side-by-side subplots (1×n +layouts): + +``` python +x = np.linspace(0, 2 * np.pi, 200) +canvas, (ax1, ax2) = Canvas.subplots(ncols=2, width="10cm", ratio=0.3) + +ax1.plot(x, np.sin(x), color="royalblue") +ax1.set_title("sin(x)") + +ax2.plot(x, np.cos(x), color="tomato") +ax2.set_title("cos(x)") + +canvas.suptitle("Trigonometric Functions") +canvas.show(backend="tikzfigure") # Generates LaTeX subfigures +``` + +
+ + -### Terminal backend +Figure 2 + +
+ +**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 +error bars, reference lines, text/annotations, labels/titles, log axes, +layers, matrix-style `imshow()` rendering, common patches, and multi-subplot canvases. ``` python @@ -75,17 +247,35 @@ ax.set_legend(True) canvas.show(backend="plotext") ``` -## Examples - -Runnable example scripts live in `examples/`: - -``` bash -python examples/plotly_backend_basic.py -python examples/plotly_backend_parity.py -``` + Terminal plot + ┌──────────────────────────────────────────────────────────────────────────┐ + 3.16┤ ▞▞ sqrt(x) ▄▞│ + │ │▗▄▞▀ │ + │ ▄┼▘ │ + 2.79┤ ▄▀▀ │ + │ ┼▀▀ │ + 2.42┤ ▗▞▀▀│ │ + │ ▗▞▀▀▘ │ + │ ▗▄┼▄▀▘ │ + 2.04┤ ▗▄▀▘ │ │ + │ ▗▄▞▀▘ │ + │ │ ▄▄▀▀▘ │ + 1.67┤ ▗▄▄┼▀▀ │ + │ ▗▄▄▞▀▀▘ │ + 1.30┤ ▄▄▄▄▀▀▀▘ │ + │ ▄▄▞▀▀ │ + │┼ ▗▄▄▞▀▀▀▀▀ │ + 0.93┤│▀▀▘ │ + └┬─────────────────┬──────────────────┬─────────────────┬─────────────────┬┘ + 1.0 1.8 3.2 5.6 10.0 + y x + + ### Layers +
+ ``` python x = np.linspace(0, 2 * np.pi, 200) @@ -106,13 +296,20 @@ ax.set_xlabel("x") ax.set_legend(True) ``` +Figure 3 + +
+ Show layer 0 only, then layers 0 and 1, then everything: ``` python canvas.show(layers=[0]) ``` -![](README_files/figure-markdown_strict/cell-6-output-1.png) +![](README_files/figure-commonmark/cell-16-output-1.png) + + (
, + array([[]], dtype=object)) Show all layers: @@ -120,4 +317,7 @@ Show all layers: canvas.show() ``` -![](README_files/figure-markdown_strict/cell-7-output-1.png) +![](README_files/figure-commonmark/cell-17-output-1.png) + + (
, + array([[]], dtype=object)) diff --git a/README.qmd b/README.qmd index 320ef78..45cbd13 100644 --- a/README.qmd +++ b/README.qmd @@ -42,6 +42,116 @@ Plot the figure with the default (matplotlib) backend: canvas.show() ``` +For Matplotlib-specific customization, pass method calls declaratively. Figure +methods run once and axes methods run for every subplot, providing access to +any Matplotlib API without requiring a maxplotlib wrapper: + +```{python} +#| output: false +canvas.plot(matplotlib_customizations={ + "figure": { + "suptitle": "My figure", + }, + "axes": { + "tick_params": { + "axis": "both", + "which": "major", + "length": 6, + }, + }, +}) +``` + +For dynamic customization, the same option also accepts a function: + +```{python} +#| output: false +def customize(fig, axes): + fig.suptitle("My figure") + for ax in axes.flat: + ax.tick_params(axis="both", which="major", length=6) + +canvas.plot(matplotlib_customizations=customize) +``` + +### Axis Label and Tick Styling + +Axis labels, titles, and tick appearance accept Matplotlib-style keyword +arguments: + +```{python} +#| output: false +canvas.set_xlabel("Time", fontsize=12, fontweight="bold", labelpad=10) +canvas.set_ylabel("Duration", color="darkblue") +canvas.set_title("Runtime", fontsize=14, color="navy") +canvas.tick_params( + axis="both", + which="major", + labelsize=10, + colors="darkgreen", + length=6, +) +``` + +Common axis controls and figure-level layout settings are also available: + +```{python} +#| output: false +canvas.set_facecolor("whitesmoke") +canvas.set_axisbelow(True) +canvas.margins(x=0.05, y=0.1) +canvas.minorticks_on() +canvas.invert_yaxis() +canvas.supxlabel("Shared x label") +canvas.supylabel("Shared y label") +canvas.subplots_adjust(left=0.15, bottom=0.15) +canvas.tight_layout() +``` + +### Secondary Y-Axis + +Use `Canvas.twinx()` to add a second y-axis that shares the primary x-axis: + +```{python} +#| output: false +twin_canvas, primary = Canvas.subplots() +secondary = twin_canvas.twinx() + +primary.plot(x, np.sin(x), color="tab:blue") +secondary.plot(x, 100 * np.cos(x), color="tab:red") +primary.set_ylabel("sin(x)", color="tab:blue") +secondary.set_ylabel("100 cos(x)", color="tab:red") + +twin_canvas.show() +``` + +Secondary y-axes are currently supported by the Matplotlib and Plotly +backends. + +### Plotly field plots and tables + +Several Matplotlib field and annotation APIs map directly to interactive +Plotly traces, including pseudocolor plots, sparsity patterns, triangular +grids, and tables: + +```{python} +#| output: false +plotly_canvas, plotly_ax = Canvas.subplots() +plotly_ax.pcolor(x, x, np.outer(np.sin(x), np.cos(x))) +plotly_ax.spy([[1, 0, 1], [0, 1, 0], [1, 0, 1]]) +plotly_ax.table(cellText=[["A", "B"], ["1", "2"]]) +plotly_canvas.show(backend="plotly") +``` + +Plotly raises `NotImplementedError` for primitives without a faithful +equivalent instead of silently dropping them. To render the supported parts +of a mixed canvas, explicitly opt into skipping unsupported primitives: + +```{python} +#| output: false +plotly_canvas.plot(backend="plotly", allow_unsupported=True) +``` + Render the same line graph directly in the terminal with the `plotext` backend: ```{python} diff --git a/README_files/figure-commonmark/cell-10-output-1.png b/README_files/figure-commonmark/cell-10-output-1.png new file mode 100644 index 0000000..aad94a7 Binary files /dev/null and b/README_files/figure-commonmark/cell-10-output-1.png differ diff --git a/README_files/figure-commonmark/cell-11-output-1.png b/README_files/figure-commonmark/cell-11-output-1.png new file mode 100644 index 0000000..4b0e063 Binary files /dev/null and b/README_files/figure-commonmark/cell-11-output-1.png differ diff --git a/README_files/figure-commonmark/cell-12-output-1.png b/README_files/figure-commonmark/cell-12-output-1.png new file mode 100644 index 0000000..aad94a7 Binary files /dev/null and b/README_files/figure-commonmark/cell-12-output-1.png differ diff --git a/README_files/figure-commonmark/cell-13-output-1.png b/README_files/figure-commonmark/cell-13-output-1.png new file mode 100644 index 0000000..ed52dae Binary files /dev/null and b/README_files/figure-commonmark/cell-13-output-1.png differ diff --git a/README_files/figure-commonmark/cell-14-output-1.png b/README_files/figure-commonmark/cell-14-output-1.png new file mode 100644 index 0000000..ed52dae Binary files /dev/null and b/README_files/figure-commonmark/cell-14-output-1.png differ diff --git a/README_files/figure-commonmark/cell-15-output-1.png b/README_files/figure-commonmark/cell-15-output-1.png new file mode 100644 index 0000000..2c1df4c Binary files /dev/null and b/README_files/figure-commonmark/cell-15-output-1.png differ diff --git a/README_files/figure-commonmark/cell-16-output-1.png b/README_files/figure-commonmark/cell-16-output-1.png new file mode 100644 index 0000000..ed52dae Binary files /dev/null and b/README_files/figure-commonmark/cell-16-output-1.png differ diff --git a/README_files/figure-commonmark/cell-17-output-1.png b/README_files/figure-commonmark/cell-17-output-1.png new file mode 100644 index 0000000..2c1df4c Binary files /dev/null and b/README_files/figure-commonmark/cell-17-output-1.png differ diff --git a/README_files/figure-commonmark/cell-3-output-1.png b/README_files/figure-commonmark/cell-3-output-1.png new file mode 100644 index 0000000..7b43e83 Binary files /dev/null and b/README_files/figure-commonmark/cell-3-output-1.png differ diff --git a/README_files/figure-commonmark/cell-4-output-1.png b/README_files/figure-commonmark/cell-4-output-1.png new file mode 100644 index 0000000..6537ee5 Binary files /dev/null and b/README_files/figure-commonmark/cell-4-output-1.png differ diff --git a/README_files/figure-commonmark/cell-6-output-1.png b/README_files/figure-commonmark/cell-6-output-1.png new file mode 100644 index 0000000..ec50ece Binary files /dev/null and b/README_files/figure-commonmark/cell-6-output-1.png differ diff --git a/README_files/figure-commonmark/cell-7-output-1.png b/README_files/figure-commonmark/cell-7-output-1.png new file mode 100644 index 0000000..ec50ece Binary files /dev/null and b/README_files/figure-commonmark/cell-7-output-1.png differ diff --git a/README_files/figure-commonmark/cell-8-output-1.png b/README_files/figure-commonmark/cell-8-output-1.png new file mode 100644 index 0000000..aad94a7 Binary files /dev/null and b/README_files/figure-commonmark/cell-8-output-1.png differ diff --git a/README_files/figure-commonmark/cell-9-output-1.png b/README_files/figure-commonmark/cell-9-output-1.png new file mode 100644 index 0000000..aad94a7 Binary files /dev/null and b/README_files/figure-commonmark/cell-9-output-1.png differ diff --git a/README_files/figure-commonmark/fig-showcase-subplots-output-1.png b/README_files/figure-commonmark/fig-showcase-subplots-output-1.png new file mode 100644 index 0000000..e943f76 Binary files /dev/null and b/README_files/figure-commonmark/fig-showcase-subplots-output-1.png differ diff --git a/docs/source/index.rst b/docs/source/index.rst index 3b75b14..2924abb 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -24,4 +24,6 @@ documentation for details. tutorials/tutorial_07_tikz tutorials/tutorial_08_plotly tutorials/tutorial_09_plotext + tutorials/tutorial_13_advanced_matplotlib + tutorials/tutorial_14_axis_and_layout_controls tutorials/tutorial_tikzfigure_subplots diff --git a/examples/plotly_field_primitives.py b/examples/plotly_field_primitives.py new file mode 100644 index 0000000..4cbeda6 --- /dev/null +++ b/examples/plotly_field_primitives.py @@ -0,0 +1,32 @@ +"""Plotly examples for scalar fields, triangular meshes, and tables.""" + +import numpy as np + +from maxplotlib import Canvas + + +def main() -> None: + coordinates = np.linspace(-2, 2, 30) + xx, yy = np.meshgrid(coordinates, coordinates) + values = np.exp(-(xx**2 + yy**2)) + + canvas, (field_axis, table_axis) = Canvas.subplots(ncols=2, width="14cm") + field_axis.pcolor(coordinates, coordinates, values, cmap="Viridis") + field_axis.contour(coordinates, coordinates, values, levels=6, color="white") + field_axis.set_title("pcolor + contour") + field_axis.set_xlabel("x") + field_axis.set_ylabel("y") + + table_axis.table( + cellText=[["Viridis", "30×30"], ["Peak", f"{values.max():.2f}"]], + colLabels=["Field", "Value"], + rowLabels=["Map", "Statistic"], + ) + table_axis.set_axis_off() + table_axis.set_title("table") + + canvas.savefig("plotly_field_primitives.html", backend="plotly") + + +if __name__ == "__main__": + main() diff --git a/examples/plotly_vector_fields.py b/examples/plotly_vector_fields.py new file mode 100644 index 0000000..1b938d7 --- /dev/null +++ b/examples/plotly_vector_fields.py @@ -0,0 +1,48 @@ +"""Plotly examples for quiver, streamplot, and triangular plotting.""" + +import numpy as np + +from maxplotlib import Canvas + + +def main() -> None: + coordinates = np.linspace(-2, 2, 12) + xx, yy = np.meshgrid(coordinates, coordinates) + + canvas, (vector_axis, triangle_axis) = Canvas.subplots(ncols=2, width="14cm") + vector_axis.quiver( + xx, + yy, + -yy, + xx, + color="darkgreen", + alpha=0.7, + linewidth=1.2, + ) + vector_axis.streamplot( + coordinates, + coordinates, + -yy, + xx, + color="royalblue", + density=1.0, + ) + vector_axis.set_title("quiver + streamplot") + + points_x = np.array([0.0, 1.0, 0.0, 1.0]) + points_y = np.array([0.0, 0.0, 1.0, 1.0]) + triangles = [[0, 1, 2], [1, 3, 2]] + triangle_axis.tripcolor( + points_x, + points_y, + [0.0, 1.0, 2.0, 3.0], + triangles=triangles, + ) + triangle_axis.triplot(points_x, points_y, triangles=triangles, color="black") + triangle_axis.set_title("tripcolor + triplot") + + canvas.savefig("plotly_vector_fields.html", backend="plotly") + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index 78d2e24..e8d4b05 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "maxplotlibx" -version = "0.1.6" +version = "0.1.7" description = "A reproducible plotting module with various backends and export options." readme = "README.md" requires-python = ">=3.8" diff --git a/scripts/generate_readme.py b/scripts/generate_readme.py new file mode 100644 index 0000000..58cced8 --- /dev/null +++ b/scripts/generate_readme.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +"""Generate README.md from README.qmd using Quarto.""" + +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + + +def main() -> int: + repository_root = Path(__file__).resolve().parent.parent + quarto = shutil.which("quarto") + if quarto is None: + raise SystemExit( + "Quarto is required to generate README.md. " + "Install it from https://quarto.org/docs/get-started/" + ) + + subprocess.run( + [quarto, "render", "README.qmd", "--output", "README.md"], + cwd=repository_root, + check=True, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/maxplotlib/canvas/canvas.py b/src/maxplotlib/canvas/canvas.py index cc7918c..248f4b5 100644 --- a/src/maxplotlib/canvas/canvas.py +++ b/src/maxplotlib/canvas/canvas.py @@ -39,6 +39,71 @@ def _parse_bool_env_var(name: str, default: bool = False) -> bool: return value.strip().lower() in {"1", "true", "yes", "on"} +def _display_matplotlib_figure_in_notebook(fig) -> bool: + """Display a Matplotlib figure through IPython when running in Jupyter.""" + try: + from IPython.display import display + except ImportError: + return False + + if not _running_in_jupyter(): + return False + + display(fig) + return True + + +def _running_in_jupyter() -> bool: + """Return whether the current process is running in a Jupyter kernel.""" + try: + from IPython import get_ipython + except ImportError: + return False + + shell = get_ipython() + return shell is not None and "IPKernelApp" in getattr(shell, "config", {}) + + +def _apply_matplotlib_customizations(fig, axes, customizations) -> None: + """Apply declarative method calls to a Matplotlib figure and its axes.""" + if callable(customizations): + customizations(fig, axes) + return + if not isinstance(customizations, Mapping): + raise TypeError("matplotlib_customizations must be a mapping or callable") + + unknown_targets = set(customizations) - {"figure", "axes"} + if unknown_targets: + raise ValueError( + "matplotlib_customizations only supports 'figure' and 'axes' targets; " + f"got {sorted(unknown_targets)!r}" + ) + + for target, objects in ( + ("figure", (fig,)), + ("axes", tuple(axes.flat)), + ): + for method_name, spec in customizations.get(target, {}).items(): + if not isinstance(method_name, str): + raise TypeError("Matplotlib customization method names must be strings") + if isinstance(spec, Mapping) and ("args" in spec or "kwargs" in spec): + args = tuple(spec.get("args", ())) + kwargs = dict(spec.get("kwargs", {})) + elif isinstance(spec, Mapping): + args = () + kwargs = dict(spec) + elif spec is None: + args = () + kwargs = {} + else: + args = (spec,) + kwargs = {} + + for obj in objects: + method = getattr(obj, method_name) + method(*args, **kwargs) + + def plot_matplotlib(tikzfigure: TikzFigure, ax, layers=None): """ Plot all nodes and paths on the provided axis using Matplotlib. @@ -242,10 +307,18 @@ def __init__( self._plotext_figure = None self._suptitle: str | None = None self._suptitle_kwargs: dict = {} + self._supxlabel: str | None = None + self._supxlabel_kwargs: dict = {} + self._supylabel: str | None = None + self._supylabel_kwargs: dict = {} + self._subplots_adjust_kwargs: dict = {} + self._tight_layout_kwargs: dict | None = None # Dictionary to store lines for each subplot # Key: (row, col), Value: list of lines with their data and kwargs self._subplots = {} + self._twinx_subplots = {} + self._matplotlib_twin_axes = {} self._num_subplots = 0 self._subplot_matrix = [[None] * self.ncols for _ in range(self.nrows)] @@ -322,6 +395,9 @@ def layers(self): layers = [] for (row, col), subplot in self._subplot_dict.items(): layers.extend(subplot.layers) + twin_subplot = self._twinx_subplots.get((row, col)) + if twin_subplot is not None: + layers.extend(twin_subplot.layers) return list(set(layers)) def generate_new_rowcol(self, row, col): @@ -430,6 +506,377 @@ def bar( sp = self._get_or_create_subplot(row, col) sp.bar(x, height, layer=layer, **kwargs) + def barh( + self, + y, + width, + layer=0, + row: int | None = None, + col: int | None = None, + **kwargs, + ): + """Add a horizontal bar chart to a subplot.""" + self._get_or_create_subplot(row, col).barh(y, width, layer=layer, **kwargs) + + def hist( + self, + x, + bins=10, + layer=0, + row: int | None = None, + col: int | None = None, + **kwargs, + ): + """Add a histogram to a subplot.""" + self._get_or_create_subplot(row, col).hist(x, bins=bins, layer=layer, **kwargs) + + def step( + self, x, y, layer=0, row: int | None = None, col: int | None = None, **kwargs + ): + """Add a step plot to a subplot.""" + self._get_or_create_subplot(row, col).step(x, y, layer=layer, **kwargs) + + def stairs( + self, + values, + edges=None, + baseline=0, + layer=0, + row: int | None = None, + col: int | None = None, + **kwargs, + ): + """Add a stairs plot to a subplot.""" + self._get_or_create_subplot(row, col).stairs( + values, edges=edges, baseline=baseline, layer=layer, **kwargs + ) + + def broken_barh( + self, + xranges, + yrange, + layer=0, + row: int | None = None, + col: int | None = None, + **kwargs, + ): + """Add broken horizontal bars to a subplot.""" + self._get_or_create_subplot(row, col).broken_barh( + xranges, yrange, layer=layer, **kwargs + ) + + def pie(self, x, layer=0, row: int | None = None, col: int | None = None, **kwargs): + """Add a pie chart to a subplot.""" + self._get_or_create_subplot(row, col).pie(x, layer=layer, **kwargs) + + def bar_label(self, row: int | None = None, col: int | None = None, **kwargs): + """Add labels to bar containers in the Matplotlib backend.""" + self._get_or_create_subplot(row, col).bar_label(**kwargs) + + def fill(self, *args, layer=0, row=None, col=None, **kwargs): + """Fill one or more polygonal regions on a subplot.""" + self._get_or_create_subplot(row, col).fill(*args, layer=layer, **kwargs) + + def semilogx(self, x, y, layer=0, row=None, col=None, **kwargs): + """Add a line with a logarithmic x-axis.""" + self._get_or_create_subplot(row, col).semilogx(x, y, layer=layer, **kwargs) + + def semilogy(self, x, y, layer=0, row=None, col=None, **kwargs): + """Add a line with a logarithmic y-axis.""" + self._get_or_create_subplot(row, col).semilogy(x, y, layer=layer, **kwargs) + + def loglog(self, x, y, layer=0, row=None, col=None, **kwargs): + """Add a line with logarithmic x- and y-axes.""" + self._get_or_create_subplot(row, col).loglog(x, y, layer=layer, **kwargs) + + def axis(self, *args, row=None, col=None, **kwargs): + """Set Matplotlib-style axis limits or modes.""" + self._get_or_create_subplot(row, col).axis(*args, **kwargs) + + def autoscale(self, enable=True, axis="both", tight=None, row=None, col=None): + """Configure autoscaling for a subplot.""" + self._get_or_create_subplot(row, col).autoscale(enable, axis, tight) + + def autoscale_view(self, tight=None, scalex=True, scaley=True, row=None, col=None): + """Configure view-limit autoscaling for a subplot.""" + self._get_or_create_subplot(row, col).autoscale_view(tight, scalex, scaley) + + def relim(self, visible_only=False, row=None, col=None): + """Recompute a subplot's data limits.""" + self._get_or_create_subplot(row, col).relim(visible_only) + + def set_box_aspect(self, aspect, row=None, col=None): + """Set the physical height-to-width ratio of a subplot.""" + self._get_or_create_subplot(row, col).set_box_aspect(aspect) + + def secondary_xaxis( + self, location="top", functions=None, row=None, col=None, **kwargs + ): + """Configure a secondary x-axis for a subplot.""" + self._get_or_create_subplot(row, col).secondary_xaxis( + location, functions, **kwargs + ) + + def secondary_yaxis( + self, location="right", functions=None, row=None, col=None, **kwargs + ): + """Configure a secondary y-axis for a subplot.""" + self._get_or_create_subplot(row, col).secondary_yaxis( + location, functions, **kwargs + ) + + def clabel(self, row: int | None = None, col: int | None = None, **kwargs): + """Label contour levels in a subplot.""" + self._get_or_create_subplot(row, col).clabel(**kwargs) + + def set_rasterization_zorder( + self, z, row: int | None = None, col: int | None = None + ): + """Rasterize Matplotlib artists below the given z-order.""" + self._get_or_create_subplot(row, col).set_rasterization_zorder(z) + + def stem( + self, x, y, layer=0, row: int | None = None, col: int | None = None, **kwargs + ): + """Add a stem plot to a subplot.""" + self._get_or_create_subplot(row, col).stem(x, y, layer=layer, **kwargs) + + def stackplot( + self, x, *ys, layer=0, row: int | None = None, col: int | None = None, **kwargs + ): + """Add a stacked area plot to a subplot.""" + self._get_or_create_subplot(row, col).stackplot(x, *ys, layer=layer, **kwargs) + + def boxplot( + self, x, layer=0, row: int | None = None, col: int | None = None, **kwargs + ): + """Add a box-and-whisker plot to a subplot.""" + self._get_or_create_subplot(row, col).boxplot(x, layer=layer, **kwargs) + + def violinplot( + self, + dataset, + layer=0, + row: int | None = None, + col: int | None = None, + **kwargs, + ): + """Add a violin plot to a subplot.""" + self._get_or_create_subplot(row, col).violinplot(dataset, layer=layer, **kwargs) + + def eventplot( + self, + positions, + layer=0, + row: int | None = None, + col: int | None = None, + **kwargs, + ): + """Add an event/rug plot to a subplot.""" + self._get_or_create_subplot(row, col).eventplot( + positions, layer=layer, **kwargs + ) + + def contour( + self, + x, + y, + z, + layer=0, + row: int | None = None, + col: int | None = None, + **kwargs, + ): + """Add contour lines to a subplot.""" + self._get_or_create_subplot(row, col).contour(x, y, z, layer=layer, **kwargs) + + def contourf( + self, + x, + y, + z, + layer=0, + row: int | None = None, + col: int | None = None, + **kwargs, + ): + """Add filled contours to a subplot.""" + self._get_or_create_subplot(row, col).contourf(x, y, z, layer=layer, **kwargs) + + def pcolormesh( + self, + x, + y, + z, + layer=0, + row: int | None = None, + col: int | None = None, + **kwargs, + ): + """Add a pseudocolor mesh to a subplot.""" + self._get_or_create_subplot(row, col).pcolormesh(x, y, z, layer=layer, **kwargs) + + def hexbin( + self, + x, + y, + layer=0, + row: int | None = None, + col: int | None = None, + **kwargs, + ): + """Add a hexagonal density plot to a subplot.""" + self._get_or_create_subplot(row, col).hexbin(x, y, layer=layer, **kwargs) + + def matshow( + self, data, layer=0, row: int | None = None, col: int | None = None, **kwargs + ): + """Display a matrix with matrix-oriented axes.""" + self._get_or_create_subplot(row, col).matshow(data, layer=layer, **kwargs) + + def quiver( + self, + x, + y, + u, + v, + layer=0, + row: int | None = None, + col: int | None = None, + **kwargs, + ): + """Add a vector field to a subplot.""" + self._get_or_create_subplot(row, col).quiver(x, y, u, v, layer=layer, **kwargs) + + def triplot( + self, + x, + y, + triangles=None, + layer=0, + row: int | None = None, + col: int | None = None, + **kwargs, + ): + """Add an unstructured triangular grid to a subplot.""" + self._get_or_create_subplot(row, col).triplot( + x, y, triangles=triangles, layer=layer, **kwargs + ) + + def tripcolor( + self, + x, + y, + c, + triangles=None, + layer=0, + row: int | None = None, + col: int | None = None, + **kwargs, + ): + """Add colored unstructured triangles to a subplot.""" + self._get_or_create_subplot(row, col).tripcolor( + x, y, c, triangles=triangles, layer=layer, **kwargs + ) + + def tricontour( + self, + x, + y, + z, + triangles=None, + layer=0, + row: int | None = None, + col: int | None = None, + **kwargs, + ): + """Add unstructured contour lines to a subplot.""" + self._get_or_create_subplot(row, col).tricontour( + x, y, z, triangles=triangles, layer=layer, **kwargs + ) + + def tricontourf( + self, + x, + y, + z, + triangles=None, + layer=0, + row: int | None = None, + col: int | None = None, + **kwargs, + ): + """Add filled unstructured contours to a subplot.""" + self._get_or_create_subplot(row, col).tricontourf( + x, y, z, triangles=triangles, layer=layer, **kwargs + ) + + def streamplot( + self, + x, + y, + u, + v, + layer=0, + row: int | None = None, + col: int | None = None, + **kwargs, + ): + """Add streamlines for a vector field to a subplot.""" + self._get_or_create_subplot(row, col).streamplot( + x, y, u, v, layer=layer, **kwargs + ) + + def pcolor( + self, + x, + y, + z, + layer=0, + row: int | None = None, + col: int | None = None, + **kwargs, + ): + """Add a pseudocolor plot to a subplot.""" + self._get_or_create_subplot(row, col).pcolor(x, y, z, layer=layer, **kwargs) + + def pcolorfast( + self, + x, + y, + z, + layer=0, + row: int | None = None, + col: int | None = None, + **kwargs, + ): + """Add a fast pseudocolor plot to a subplot.""" + self._get_or_create_subplot(row, col).pcolorfast(x, y, z, layer=layer, **kwargs) + + def spy( + self, + matrix, + layer=0, + row: int | None = None, + col: int | None = None, + **kwargs, + ): + """Visualize a matrix sparsity pattern in a subplot.""" + self._get_or_create_subplot(row, col).spy(matrix, layer=layer, **kwargs) + + def table( + self, + cellText=None, + layer=0, + row: int | None = None, + col: int | None = None, + **kwargs, + ): + """Add a table annotation to a subplot.""" + self._get_or_create_subplot(row, col).table( + cellText=cellText, layer=layer, **kwargs + ) + def gantt( self, tasks, @@ -482,17 +929,35 @@ def flame_chart( labels, parents, values, start_times=start_times, layer=layer, **kwargs ) - def set_xlabel(self, label: str, row: int | None = None, col: int | None = None): - """Set the x-axis label for a subplot (default top-left).""" - self._get_or_create_subplot(row, col).set_xlabel(label) + def set_xlabel( + self, + label: str, + row: int | None = None, + col: int | None = None, + **kwargs, + ): + """Set the x-axis label and text properties for a subplot.""" + self._get_or_create_subplot(row, col).set_xlabel(label, **kwargs) - def set_ylabel(self, label: str, row: int | None = None, col: int | None = None): - """Set the y-axis label for a subplot (default top-left).""" - self._get_or_create_subplot(row, col).set_ylabel(label) + def set_ylabel( + self, + label: str, + row: int | None = None, + col: int | None = None, + **kwargs, + ): + """Set the y-axis label and text properties for a subplot.""" + self._get_or_create_subplot(row, col).set_ylabel(label, **kwargs) - def set_title(self, title: str, row: int | None = None, col: int | None = None): - """Set the title for a subplot (default top-left).""" - self._get_or_create_subplot(row, col).set_title(title) + def set_title( + self, + title: str, + row: int | None = None, + col: int | None = None, + **kwargs, + ): + """Set the title and text properties for a subplot.""" + self._get_or_create_subplot(row, col).set_title(title, **kwargs) def set_xlim( self, left=None, right=None, row: int | None = None, col: int | None = None @@ -518,6 +983,10 @@ def set_legend( """Show or hide the legend for a subplot (default top-left).""" self._get_or_create_subplot(row, col).set_legend(visible) + def tick_params(self, row: int | None = None, col: int | None = None, **kwargs): + """Configure major/minor tick appearance for a subplot.""" + self._get_or_create_subplot(row, col).tick_params(**kwargs) + def set_xscale(self, scale: str, row: int | None = None, col: int | None = None): """Set x-axis scale ('linear', 'log', 'symlog') for a subplot.""" self._get_or_create_subplot(row, col).set_xscale(scale) @@ -526,17 +995,81 @@ def set_yscale(self, scale: str, row: int | None = None, col: int | None = None) """Set y-axis scale ('linear', 'log', 'symlog') for a subplot.""" self._get_or_create_subplot(row, col).set_yscale(scale) + def set_axis_off(self, row: int | None = None, col: int | None = None): + """Hide the axis frame, ticks, and labels for a subplot.""" + self._get_or_create_subplot(row, col).set_axis_off() + + def set_axis_on(self, row: int | None = None, col: int | None = None): + """Show the axis frame, ticks, and labels for a subplot.""" + self._get_or_create_subplot(row, col).set_axis_on() + + def set_axisbelow(self, state=True, row: int | None = None, col: int | None = None): + """Set whether gridlines and ticks are drawn below plot data.""" + self._get_or_create_subplot(row, col).set_axisbelow(state) + + def set_facecolor(self, color, row: int | None = None, col: int | None = None): + """Set a subplot's background color.""" + self._get_or_create_subplot(row, col).set_facecolor(color) + + def margins(self, *args, row: int | None = None, col: int | None = None, **kwargs): + """Set x/y data margins for a subplot.""" + self._get_or_create_subplot(row, col).margins(*args, **kwargs) + + def invert_xaxis(self, row: int | None = None, col: int | None = None): + """Invert a subplot's x-axis.""" + self._get_or_create_subplot(row, col).invert_xaxis() + + def invert_yaxis(self, row: int | None = None, col: int | None = None): + """Invert a subplot's y-axis.""" + self._get_or_create_subplot(row, col).invert_yaxis() + + def minorticks_on(self, row: int | None = None, col: int | None = None): + """Enable minor ticks for a subplot.""" + self._get_or_create_subplot(row, col).minorticks_on() + + def minorticks_off(self, row: int | None = None, col: int | None = None): + """Disable minor ticks for a subplot.""" + self._get_or_create_subplot(row, col).minorticks_off() + + def locator_params(self, row: int | None = None, col: int | None = None, **kwargs): + """Set axis locator parameters for a subplot.""" + self._get_or_create_subplot(row, col).locator_params(**kwargs) + + def ticklabel_format( + self, row: int | None = None, col: int | None = None, **kwargs + ): + """Configure numeric tick-label formatting for a subplot.""" + self._get_or_create_subplot(row, col).ticklabel_format(**kwargs) + def set_xticks( - self, ticks, labels=None, row: int | None = None, col: int | None = None + self, + ticks, + labels=None, + row: int | None = None, + col: int | None = None, + **kwargs, ): - """Set x-axis tick positions (and optional labels) for a subplot.""" - self._get_or_create_subplot(row, col).set_xticks(ticks, labels) + """Set x-axis ticks and optional label properties for a subplot.""" + self._get_or_create_subplot(row, col).set_xticks(ticks, labels, **kwargs) def set_yticks( - self, ticks, labels=None, row: int | None = None, col: int | None = None + self, + ticks, + labels=None, + row: int | None = None, + col: int | None = None, + **kwargs, ): - """Set y-axis tick positions (and optional labels) for a subplot.""" - self._get_or_create_subplot(row, col).set_yticks(ticks, labels) + """Set y-axis ticks and optional label properties for a subplot.""" + self._get_or_create_subplot(row, col).set_yticks(ticks, labels, **kwargs) + + def set_xticklabels(self, labels, row=None, col=None, **kwargs): + """Set x-axis tick labels and text properties.""" + self._get_or_create_subplot(row, col).set_xticklabels(labels, **kwargs) + + def set_yticklabels(self, labels, row=None, col=None, **kwargs): + """Set y-axis tick labels and text properties.""" + self._get_or_create_subplot(row, col).set_yticklabels(labels, **kwargs) def fill_between( self, @@ -553,6 +1086,21 @@ def fill_between( x, y1, y2, layer=layer, **kwargs ) + def fill_betweenx( + self, + y, + x1, + x2=0, + layer=0, + row: int | None = None, + col: int | None = None, + **kwargs, + ): + """Fill the area between two x-boundaries along y.""" + self._get_or_create_subplot(row, col).fill_betweenx( + y, x1, x2, layer=layer, **kwargs + ) + def errorbar( self, x, @@ -611,6 +1159,59 @@ def vlines( x, ymin, ymax, layer=layer, **kwargs ) + def axvspan( + self, + xmin, + xmax, + layer=0, + row: int | None = None, + col: int | None = None, + **kwargs, + ): + """Add a vertical shaded span across a subplot.""" + self._get_or_create_subplot(row, col).axvspan(xmin, xmax, layer=layer, **kwargs) + + def axhspan( + self, + ymin, + ymax, + layer=0, + row: int | None = None, + col: int | None = None, + **kwargs, + ): + """Add a horizontal shaded span across a subplot.""" + self._get_or_create_subplot(row, col).axhspan(ymin, ymax, layer=layer, **kwargs) + + def arrow( + self, + x, + y, + dx, + dy, + layer=0, + row: int | None = None, + col: int | None = None, + **kwargs, + ): + """Add an arrow to a subplot.""" + self._get_or_create_subplot(row, col).arrow(x, y, dx, dy, layer=layer, **kwargs) + + def axline( + self, + xy1, + xy2=None, + slope=None, + layer=0, + row: int | None = None, + col: int | None = None, + **kwargs, + ): + """Add an infinitely extending line to a subplot.""" + self._get_or_create_subplot(row, col).axline( + xy1, xy2=xy2, slope=slope, layer=layer, **kwargs + ) + def annotate( self, text, @@ -685,6 +1286,26 @@ def subplot(self, row: int = 0, col: int = 0) -> LinePlot: ) return sp + def twinx(self, row: int | None = None, col: int | None = None) -> LinePlot: + """Create or return a secondary y-axis sharing a subplot's x-axis. + + The returned subplot accepts the same plotting methods as a regular + subplot. With the Matplotlib backend it is rendered on ``ax.twinx()``. + Only one secondary y-axis is supported per subplot. + """ + self._get_or_create_subplot(row, col) + if row is None: + row, col = 0, 0 + key = (row, col) + if key not in self._twinx_subplots: + self._twinx_subplots[key] = LinePlot() + return self._twinx_subplots[key] + + @property + def twinx_axes(self): + """Return materialized Matplotlib secondary axes by ``(row, col)``.""" + return dict(self._matplotlib_twin_axes) + def iter_subplots(self): """Yield (row, col, subplot) for every initialized subplot, row-major.""" for r in range(self.nrows): @@ -704,6 +1325,24 @@ def suptitle(self, title: str, **kwargs): self._suptitle = title self._suptitle_kwargs = kwargs + def supxlabel(self, label: str, **kwargs): + """Set a figure-level x-axis label.""" + self._supxlabel = label + self._supxlabel_kwargs = dict(kwargs) + + def supylabel(self, label: str, **kwargs): + """Set a figure-level y-axis label.""" + self._supylabel = label + self._supylabel_kwargs = dict(kwargs) + + def subplots_adjust(self, **kwargs): + """Adjust subplot spacing after the figure is created.""" + self._subplots_adjust_kwargs = dict(kwargs) + + def tight_layout(self, **kwargs): + """Apply Matplotlib's automatic tight layout after plotting.""" + self._tight_layout_kwargs = dict(kwargs) + def add_tikzfigure( self, col=None, @@ -909,7 +1548,23 @@ def plot( layers: list | None = None, usetex: bool | None = None, verbose: bool = False, + matplotlib_postprocess=None, + matplotlib_customizations=None, + allow_unsupported: bool = False, ): + """Render the canvas. + + ``matplotlib_customizations`` accepts either a callable receiving + ``(figure, axes)`` or a mapping whose ``figure`` methods run once and + whose ``axes`` methods run on every axes. Values are keyword arguments, + or use ``{"args": [...], "kwargs": {...}}`` for positional and keyword + arguments. A scalar value is passed as one positional argument. + + ``matplotlib_postprocess`` is an optional callable receiving + ``(figure, axes)`` after a Matplotlib figure has been created. It can + call any Matplotlib API, including APIs not wrapped by maxplotlib. + Both options are only valid with the Matplotlib backend. + """ resolved_usetex = self._usetex if usetex is None else usetex if verbose: @@ -921,6 +1576,14 @@ def plot( layers=layers, usetex=resolved_usetex, verbose=verbose, + matplotlib_postprocess=matplotlib_postprocess, + matplotlib_customizations=matplotlib_customizations, + ) + elif ( + matplotlib_postprocess is not None or matplotlib_customizations is not None + ): + raise ValueError( + "Matplotlib customizations are only supported with the matplotlib backend" ) elif backend == "plotly": return self.plot_plotly( @@ -928,6 +1591,7 @@ def plot( layers=layers, usetex=resolved_usetex, verbose=verbose, + allow_unsupported=allow_unsupported, ) elif backend == "plotext": return self.plot_plotext( @@ -947,6 +1611,9 @@ def show( usetex: bool | None = None, verbose: bool = False, block: bool = True, + matplotlib_postprocess=None, + matplotlib_customizations=None, + allow_unsupported: bool = False, ): """ Render and display the canvas. @@ -966,6 +1633,12 @@ def show( """ if verbose: print(f"Showing canvas using backend: {backend}") + if backend != "matplotlib" and ( + matplotlib_postprocess is not None or matplotlib_customizations is not None + ): + raise ValueError( + "Matplotlib customizations are only supported with the matplotlib backend" + ) if backend == "matplotlib": if verbose: @@ -976,15 +1649,26 @@ def show( layers=layers, usetex=usetex, verbose=verbose, + matplotlib_postprocess=matplotlib_postprocess, + matplotlib_customizations=matplotlib_customizations, ) if verbose: print("Displaying Matplotlib figure...") - plt.show(block=block) + if _display_matplotlib_figure_in_notebook(fig): + # IPython has rendered the figure already. Closing it prevents + # a later implicit pyplot display and releases its resources. + plt.close(fig) + else: + plt.show(block=block) return fig, axes elif backend == "plotly": resolved_usetex = self._usetex if usetex is None else usetex fig = self.plot_plotly( - savefig=False, layers=layers, usetex=resolved_usetex, verbose=verbose + savefig=False, + layers=layers, + usetex=resolved_usetex, + verbose=verbose, + allow_unsupported=allow_unsupported, ) fig.show() return fig @@ -1000,7 +1684,10 @@ def show( fig = self.plot_tikzfigure(savefig=False, verbose=verbose) # TikzFigure handles all rendering (single or multi-subplot) fig.show(transparent=False) - return fig + # TikzFigure.__repr__ returns the generated TikZ source. Returning + # it from a notebook cell would therefore print the source after + # TikzFigure has already displayed the rendered image. + return None if _running_in_jupyter() else fig else: raise ValueError("Invalid backend") @@ -1010,6 +1697,8 @@ def plot_matplotlib( layers: list | None = None, usetex: bool | None = None, verbose: bool = False, + matplotlib_postprocess=None, + matplotlib_customizations=None, ): """ Generate and optionally display the subplots. @@ -1085,6 +1774,15 @@ def plot_matplotlib( suptitle_kwargs.setdefault("fontsize", self.fontsize) fig.suptitle(self._suptitle, **suptitle_kwargs) + if self._supxlabel: + fig.supxlabel(self._supxlabel, **self._supxlabel_kwargs) + if self._supylabel: + fig.supylabel(self._supylabel, **self._supylabel_kwargs) + if self._subplots_adjust_kwargs: + fig.subplots_adjust(**self._subplots_adjust_kwargs) + if self._tight_layout_kwargs is not None: + fig.tight_layout(**self._tight_layout_kwargs) + if verbose: print("Set suptitle.") @@ -1092,6 +1790,17 @@ def plot_matplotlib( self._plotted = True self._matplotlib_fig = fig self._matplotlib_axes = axes + self._matplotlib_twin_axes = {} + for (row, col), twin_subplot in self._twinx_subplots.items(): + twin_axis = axes[row][col].twinx() + twin_subplot.plot_matplotlib(twin_axis, layers=layers) + self._matplotlib_twin_axes[(row, col)] = twin_axis + if matplotlib_customizations is not None: + _apply_matplotlib_customizations(fig, axes, matplotlib_customizations) + if matplotlib_postprocess is not None: + if not callable(matplotlib_postprocess): + raise TypeError("matplotlib_postprocess must be callable") + matplotlib_postprocess(fig, axes) return fig, axes def plot_tikzfigure( @@ -1114,6 +1823,11 @@ def plot_tikzfigure( if verbose: print(f"Plotting tikzfigure with {len(self._subplot_dict)} subplot(s)") + if self._twinx_subplots: + raise NotImplementedError( + "twinx plots are currently supported only by the matplotlib and plotly backends" + ) + # Check for unsupported layouts if self.nrows > 1: raise NotImplementedError( @@ -1249,6 +1963,10 @@ def plot_plotext( layers: list | None = None, verbose: bool = False, ) -> PlotextFigure: + if self._twinx_subplots: + raise NotImplementedError( + "twinx plots are not supported by the plotext backend" + ) if verbose: print("Generating plotext figure...") @@ -1280,6 +1998,7 @@ def plot_plotly( layers: list | None = None, usetex: bool | None = None, verbose: bool = False, + allow_unsupported: bool = False, ): """ Generate and optionally display the subplots using Plotly. @@ -1293,6 +2012,16 @@ def plot_plotly( resolved_usetex = self._usetex if usetex is None else usetex + for subplot in self._subplot_dict.values(): + if ( + subplot._secondary_xaxis_settings is not None + or subplot._secondary_yaxis_settings is not None + ): + raise NotImplementedError( + "secondary_xaxis and secondary_yaxis are currently supported " + "only by the matplotlib backend" + ) + setup_tex_fonts( fontsize=self.fontsize, usetex=resolved_usetex, @@ -1304,23 +2033,55 @@ def plot_plotly( index = row * self.ncols + col subplot_titles[index] = sp._title or f"({row}, {col})" + specs = [ + [{"secondary_y": (r, c) in self._twinx_subplots} for c in range(self.ncols)] + for r in range(self.nrows) + ] fig = make_subplots( rows=self.nrows, cols=self.ncols, subplot_titles=subplot_titles, + specs=specs, ) # Plot each subplot and propagate axis labels/scale for (row, col), line_plot in self._subplot_dict.items(): - traces, shapes, annotations = line_plot.plot_plotly(layers=layers) + traces, shapes, annotations = line_plot.plot_plotly( + layers=layers, allow_unsupported=allow_unsupported + ) for trace in traces: - fig.add_trace(trace, row=row + 1, col=col + 1) + if trace.type in ("pie", "table"): + fig.add_trace(trace) + else: + fig.add_trace(trace, row=row + 1, col=col + 1) # Axis indices are row-major: (row*ncols + col + 1) axis_index = row * self.ncols + col + 1 xref = "x" if axis_index == 1 else f"x{axis_index}" yref = "y" if axis_index == 1 else f"y{axis_index}" + twin_subplot = self._twinx_subplots.get((row, col)) + if twin_subplot is not None: + twin_traces, twin_shapes, twin_annotations = twin_subplot.plot_plotly( + layers=layers, allow_unsupported=allow_unsupported + ) + for trace in twin_traces: + if trace.type in ("pie", "table"): + fig.add_trace(trace) + else: + fig.add_trace( + trace, + row=row + 1, + col=col + 1, + secondary_y=True, + ) + for shape in twin_shapes: + shape = dict(shape) + shape["yref"] = yref + fig.add_shape(shape) + for annotation in twin_annotations: + fig.add_annotation(dict(annotation)) + for shape in shapes: shape = dict(shape) if shape.get("xref") not in {"paper"}: @@ -1356,6 +2117,14 @@ def plot_plotly( yaxis_kwargs["type"] = "log" fig.update_yaxes(**yaxis_kwargs) + if twin_subplot is not None: + fig.update_yaxes( + title_text=twin_subplot._ylabel or None, + secondary_y=True, + row=row + 1, + col=col + 1, + ) + # Axis limits if line_plot._xmin is not None or line_plot._xmax is not None: x_range = [line_plot._xmin, line_plot._xmax] @@ -1403,6 +2172,7 @@ def plot_plotly( tickmode="array", tickvals=tickvals, ticktext=line_plot._xticklabels, + tickangle=line_plot._xtick_kwargs.get("rotation"), row=row + 1, col=col + 1, ) @@ -1412,10 +2182,48 @@ def plot_plotly( tickmode="array", tickvals=tickvals, ticktext=line_plot._yticklabels, + tickangle=line_plot._ytick_kwargs.get("rotation"), + row=row + 1, + col=col + 1, + ) + + if line_plot._xticklabels is not None and line_plot._xticks is None: + fig.update_xaxes( + tickmode="array", + ticktext=line_plot._xticklabels, + tickfont={ + key: line_plot._xticklabel_kwargs[key] + for key in ("size", "color", "family") + if key in line_plot._xticklabel_kwargs + }, + row=row + 1, + col=col + 1, + ) + if line_plot._yticklabels is not None and line_plot._yticks is None: + fig.update_yaxes( + tickmode="array", + ticktext=line_plot._yticklabels, + tickfont={ + key: line_plot._yticklabel_kwargs[key] + for key in ("size", "color", "family") + if key in line_plot._yticklabel_kwargs + }, row=row + 1, col=col + 1, ) + if line_plot._axis_settings: + axis_args = line_plot._axis_settings.get("args", ()) + if axis_args and axis_args[0] == "off": + fig.update_xaxes(visible=False, row=row + 1, col=col + 1) + fig.update_yaxes(visible=False, row=row + 1, col=col + 1) + elif axis_args and axis_args[0] == "equal": + fig.update_yaxes(scaleanchor=xref, row=row + 1, col=col + 1) + elif axis_args and len(axis_args[0]) == 4: + xmin, xmax, ymin, ymax = axis_args[0] + fig.update_xaxes(range=[xmin, xmax], row=row + 1, col=col + 1) + fig.update_yaxes(range=[ymin, ymax], row=row + 1, col=col + 1) + # Aspect ratio if line_plot._aspect == "equal": fig.update_yaxes(scaleanchor=xref, row=row + 1, col=col + 1) diff --git a/src/maxplotlib/subfigure/line_plot.py b/src/maxplotlib/subfigure/line_plot.py index 4a5a49d..6e7c78a 100644 --- a/src/maxplotlib/subfigure/line_plot.py +++ b/src/maxplotlib/subfigure/line_plot.py @@ -76,6 +76,10 @@ def __init__( self._ymax = ymax self._xlabel = xlabel self._ylabel = ylabel + self._xlabel_kwargs: dict = {} + self._ylabel_kwargs: dict = {} + self._title_kwargs: dict = {} + self._tick_params: dict = {} self._xscale = xscale self._yscale = yscale self._xshift = xshift @@ -84,12 +88,35 @@ def __init__( # Axis scale type ('linear', 'log', 'symlog') self._xaxis_scale: str | None = None self._yaxis_scale: str | None = None + self._axis_off = False + self._axisbelow = None + self._facecolor = None + self._margins: dict = {} + self._invert_xaxis = False + self._invert_yaxis = False + self._minorticks = None + self._locator_params: dict = {} + self._ticklabel_format: dict = {} + self._bar_label_kwargs: dict | None = None + self._clabel_kwargs: dict | None = None + self._rasterization_zorder = None + self._axis_settings: dict = {} + self._autoscale_settings: dict | None = None + self._autoscale_view_settings: dict | None = None + self._relim_settings: dict | None = None + self._box_aspect = None + self._secondary_xaxis_settings: dict | None = None + self._secondary_yaxis_settings: dict | None = None # Custom tick positions and labels self._xticks: list | None = None self._xticklabels: list | None = None + self._xtick_kwargs: dict = {} + self._xticklabel_kwargs: dict = {} self._yticks: list | None = None self._yticklabels: list | None = None + self._ytick_kwargs: dict = {} + self._yticklabel_kwargs: dict = {} # Aspect ratio self._aspect = None @@ -185,6 +212,351 @@ def bar(self, x, height, layer=0, **kwargs): } self._add(ld, layer) + def barh(self, y, width, layer=0, **kwargs): + """Add a horizontal bar chart.""" + self._add( + { + "y": np.array(y), + "width": np.array(width), + "layer": layer, + "plot_type": "barh", + "kwargs": kwargs, + }, + layer, + ) + + def hist(self, x, bins=10, layer=0, **kwargs): + """Add a histogram.""" + self._add( + { + "x": np.array(x), + "bins": bins, + "layer": layer, + "plot_type": "hist", + "kwargs": kwargs, + }, + layer, + ) + + def step(self, x, y, layer=0, **kwargs): + """Add a step plot.""" + self._add( + { + "x": np.array(x), + "y": np.array(y), + "layer": layer, + "plot_type": "step", + "kwargs": kwargs, + }, + layer, + ) + + def stairs(self, values, edges=None, baseline=0, layer=0, **kwargs): + """Add a stairs plot.""" + self._add( + { + "values": np.array(values), + "edges": edges, + "baseline": baseline, + "layer": layer, + "plot_type": "stairs", + "kwargs": kwargs, + }, + layer, + ) + + def broken_barh(self, xranges, yrange, layer=0, **kwargs): + """Add horizontal bars with gaps between x ranges.""" + self._add( + { + "xranges": list(xranges), + "yrange": yrange, + "layer": layer, + "plot_type": "broken_barh", + "kwargs": kwargs, + }, + layer, + ) + + def pie(self, x, layer=0, **kwargs): + """Add a pie chart.""" + self._add( + {"x": np.array(x), "layer": layer, "plot_type": "pie", "kwargs": kwargs}, + layer, + ) + + def bar_label(self, **kwargs): + """Add labels to bar containers in the Matplotlib backend.""" + self._bar_label_kwargs = dict(kwargs) + + def clabel(self, **kwargs): + """Label contour lines and filled contour levels.""" + self._clabel_kwargs = dict(kwargs) + + def set_rasterization_zorder(self, z): + """Rasterize artists below the given z-order when exporting.""" + self._rasterization_zorder = z + + def stem(self, x, y, layer=0, **kwargs): + """Add a stem plot.""" + self._add( + { + "x": np.array(x), + "y": np.array(y), + "layer": layer, + "plot_type": "stem", + "kwargs": kwargs, + }, + layer, + ) + + def stackplot(self, x, *ys, layer=0, **kwargs): + """Add a stacked area plot.""" + self._add( + { + "x": np.array(x), + "ys": [np.array(y) for y in ys], + "layer": layer, + "plot_type": "stackplot", + "kwargs": kwargs, + }, + layer, + ) + + def boxplot(self, x, layer=0, **kwargs): + """Add a box-and-whisker plot.""" + self._add( + {"x": x, "layer": layer, "plot_type": "boxplot", "kwargs": kwargs}, + layer, + ) + + def violinplot(self, dataset, layer=0, **kwargs): + """Add a violin plot.""" + self._add( + { + "dataset": dataset, + "layer": layer, + "plot_type": "violinplot", + "kwargs": kwargs, + }, + layer, + ) + + def eventplot(self, positions, layer=0, **kwargs): + """Add an event/rug plot.""" + self._add( + { + "positions": positions, + "layer": layer, + "plot_type": "eventplot", + "kwargs": kwargs, + }, + layer, + ) + + def contour(self, x, y, z, layer=0, **kwargs): + """Add contour lines for a 2D scalar field.""" + self._add( + { + "x": x, + "y": y, + "z": np.asarray(z), + "layer": layer, + "plot_type": "contour", + "kwargs": kwargs, + }, + layer, + ) + + def contourf(self, x, y, z, layer=0, **kwargs): + """Add filled contours for a 2D scalar field.""" + self._add( + { + "x": x, + "y": y, + "z": np.asarray(z), + "layer": layer, + "plot_type": "contourf", + "kwargs": kwargs, + }, + layer, + ) + + def pcolormesh(self, x, y, z, layer=0, **kwargs): + """Add a pseudocolor mesh.""" + self._add( + { + "x": x, + "y": y, + "z": np.asarray(z), + "layer": layer, + "plot_type": "pcolormesh", + "kwargs": kwargs, + }, + layer, + ) + + def hexbin(self, x, y, layer=0, **kwargs): + """Add a hexagonal bin density plot.""" + self._add( + { + "x": np.asarray(x), + "y": np.asarray(y), + "layer": layer, + "plot_type": "hexbin", + "kwargs": kwargs, + }, + layer, + ) + + def matshow(self, data, layer=0, **kwargs): + """Display a matrix with matrix-oriented axes.""" + self._add( + { + "data": np.asarray(data), + "layer": layer, + "plot_type": "matshow", + "kwargs": kwargs, + }, + layer, + ) + + def quiver(self, x, y, u, v, layer=0, **kwargs): + """Add a vector field.""" + self._add( + { + "x": x, + "y": y, + "u": u, + "v": v, + "layer": layer, + "plot_type": "quiver", + "kwargs": kwargs, + }, + layer, + ) + + def triplot(self, x, y, triangles=None, layer=0, **kwargs): + """Add an unstructured triangular grid.""" + self._add( + { + "x": x, + "y": y, + "triangles": triangles, + "layer": layer, + "plot_type": "triplot", + "kwargs": kwargs, + }, + layer, + ) + + def tripcolor(self, x, y, c, triangles=None, layer=0, **kwargs): + """Add a colored unstructured triangular grid.""" + self._add( + { + "x": x, + "y": y, + "c": c, + "triangles": triangles, + "layer": layer, + "plot_type": "tripcolor", + "kwargs": kwargs, + }, + layer, + ) + + def tricontour(self, x, y, z, triangles=None, layer=0, **kwargs): + """Add contour lines on an unstructured triangular grid.""" + self._add( + { + "x": x, + "y": y, + "z": z, + "triangles": triangles, + "layer": layer, + "plot_type": "tricontour", + "kwargs": kwargs, + }, + layer, + ) + + def tricontourf(self, x, y, z, triangles=None, layer=0, **kwargs): + """Add filled contours on an unstructured triangular grid.""" + self._add( + { + "x": x, + "y": y, + "z": z, + "triangles": triangles, + "layer": layer, + "plot_type": "tricontourf", + "kwargs": kwargs, + }, + layer, + ) + + def streamplot(self, x, y, u, v, layer=0, **kwargs): + """Add streamlines for a 2D vector field.""" + self._add( + { + "x": x, + "y": y, + "u": u, + "v": v, + "layer": layer, + "plot_type": "streamplot", + "kwargs": kwargs, + }, + layer, + ) + + def pcolor(self, x, y, z, layer=0, **kwargs): + """Add a pseudocolor plot.""" + self._add( + { + "x": x, + "y": y, + "z": np.asarray(z), + "layer": layer, + "plot_type": "pcolor", + "kwargs": kwargs, + }, + layer, + ) + + def pcolorfast(self, x, y, z, layer=0, **kwargs): + """Add a fast pseudocolor plot.""" + self._add( + { + "x": x, + "y": y, + "z": np.asarray(z), + "layer": layer, + "plot_type": "pcolorfast", + "kwargs": kwargs, + }, + layer, + ) + + def spy(self, matrix, layer=0, **kwargs): + """Visualize the sparsity pattern of a matrix.""" + self._add( + {"matrix": matrix, "layer": layer, "plot_type": "spy", "kwargs": kwargs}, + layer, + ) + + def table(self, cellText=None, layer=0, **kwargs): + """Add a table annotation to the axes.""" + self._add( + { + "cellText": cellText, + "layer": layer, + "plot_type": "table", + "kwargs": kwargs, + }, + layer, + ) + def gantt(self, tasks, start_times, durations, layer=0, **kwargs): """ Add a Gantt chart to the subplot. @@ -263,17 +635,20 @@ def axvline(self, x=0, layer=0, **kwargs): } self._add(ld, layer) - def set_xlabel(self, label: str): - """Set the x-axis label.""" + def set_xlabel(self, label: str, **kwargs): + """Set the x-axis label and its text properties.""" self._xlabel = label + self._xlabel_kwargs = dict(kwargs) - def set_ylabel(self, label: str): - """Set the y-axis label.""" + def set_ylabel(self, label: str, **kwargs): + """Set the y-axis label and its text properties.""" self._ylabel = label + self._ylabel_kwargs = dict(kwargs) - def set_title(self, title: str): - """Set the subplot title.""" + def set_title(self, title: str, **kwargs): + """Set the subplot title and its text properties.""" self._title = title + self._title_kwargs = dict(kwargs) def set_xlim(self, left=None, right=None): """Set the x-axis limits.""" @@ -293,6 +668,10 @@ def set_grid(self, visible: bool = True): """Show or hide the grid.""" self._grid = visible + def tick_params(self, **kwargs): + """Configure tick appearance using Matplotlib-style keyword arguments.""" + self._tick_params = dict(kwargs) + def set_legend(self, visible: bool = True): """Show or hide the legend.""" self._legend = visible @@ -305,20 +684,141 @@ def set_yscale(self, scale: str): """Set the y-axis scale type: 'linear', 'log', or 'symlog'.""" self._yaxis_scale = scale - def set_xticks(self, ticks, labels=None): - """Set x-axis tick positions and optional labels.""" + def set_axis_off(self): + """Hide the axis frame, ticks, and labels.""" + self._axis_off = True + + def set_axis_on(self): + """Show the axis frame, ticks, and labels.""" + self._axis_off = False + + def set_axisbelow(self, state=True): + """Set whether axis gridlines and ticks are drawn below plot data.""" + self._axisbelow = state + + def set_facecolor(self, color): + """Set the subplot background color.""" + self._facecolor = color + + def margins(self, *args, **kwargs): + """Set x/y data margins using Matplotlib-style arguments.""" + self._margins = {"args": args, **kwargs} + + def invert_xaxis(self): + """Invert the x-axis direction.""" + self._invert_xaxis = True + + def invert_yaxis(self): + """Invert the y-axis direction.""" + self._invert_yaxis = True + + def minorticks_on(self): + """Enable minor ticks.""" + self._minorticks = True + + def minorticks_off(self): + """Disable minor ticks.""" + self._minorticks = False + + def locator_params(self, **kwargs): + """Set axis locator parameters.""" + self._locator_params = dict(kwargs) + + def ticklabel_format(self, **kwargs): + """Configure tick-label numeric formatting.""" + self._ticklabel_format = dict(kwargs) + + def set_xticks(self, ticks, labels=None, **kwargs): + """Set x-axis tick positions, labels, and label properties. + + Keyword arguments are forwarded to the plotting backend. For example, + ``rotation=45`` rotates the tick labels in the Matplotlib and Plotly + backends. + """ self._xticks = list(ticks) self._xticklabels = list(labels) if labels is not None else None + self._xtick_kwargs = dict(kwargs) - def set_yticks(self, ticks, labels=None): - """Set y-axis tick positions and optional labels.""" + def set_yticks(self, ticks, labels=None, **kwargs): + """Set y-axis tick positions, labels, and label properties.""" self._yticks = list(ticks) self._yticklabels = list(labels) if labels is not None else None + self._ytick_kwargs = dict(kwargs) def set_aspect(self, aspect): """Set the axes aspect ratio: 'equal', 'auto', or a float.""" self._aspect = aspect + def axis(self, *args, **kwargs): + """Set Matplotlib-style axis limits or modes.""" + self._axis_settings = {"args": args, **kwargs} + + def autoscale(self, enable=True, axis="both", tight=None): + """Configure autoscaling of one or both axes.""" + self._autoscale_settings = { + "enable": enable, + "axis": axis, + "tight": tight, + } + + def autoscale_view(self, tight=None, scalex=True, scaley=True): + """Configure autoscaling using the current data limits.""" + self._autoscale_view_settings = { + "tight": tight, + "scalex": scalex, + "scaley": scaley, + } + + def relim(self, visible_only=False): + """Recompute data limits before autoscaling.""" + self._relim_settings = {"visible_only": visible_only} + + def set_box_aspect(self, aspect): + """Set the physical height-to-width ratio of the axes box.""" + self._box_aspect = aspect + + def secondary_xaxis(self, location="top", functions=None, **kwargs): + """Add a Matplotlib secondary x-axis using forward/inverse functions.""" + self._secondary_xaxis_settings = { + "location": location, + "functions": functions, + "kwargs": kwargs, + } + + def secondary_yaxis(self, location="right", functions=None, **kwargs): + """Add a Matplotlib secondary y-axis using forward/inverse functions.""" + self._secondary_yaxis_settings = { + "location": location, + "functions": functions, + "kwargs": kwargs, + } + + def semilogx(self, x, y, layer=0, **kwargs): + """Add a line while using a logarithmic x-axis.""" + self.set_xscale("log") + self.plot(x, y, layer=layer, **kwargs) + + def semilogy(self, x, y, layer=0, **kwargs): + """Add a line while using a logarithmic y-axis.""" + self.set_yscale("log") + self.plot(x, y, layer=layer, **kwargs) + + def loglog(self, x, y, layer=0, **kwargs): + """Add a line while using logarithmic x- and y-axes.""" + self.set_xscale("log") + self.set_yscale("log") + self.plot(x, y, layer=layer, **kwargs) + + def set_xticklabels(self, labels, **kwargs): + """Set x tick labels and their text properties.""" + self._xticklabels = list(labels) + self._xticklabel_kwargs = dict(kwargs) + + def set_yticklabels(self, labels, **kwargs): + """Set y tick labels and their text properties.""" + self._yticklabels = list(labels) + self._yticklabel_kwargs = dict(kwargs) + def fill_between(self, x, y1, y2=0, layer=0, **kwargs): """ Fill the region between two curves. @@ -340,6 +840,27 @@ def fill_between(self, x, y1, y2=0, layer=0, **kwargs): } self._add(ld, layer) + def fill(self, *args, layer=0, **kwargs): + """Fill one or more polygonal regions.""" + self._add( + {"args": args, "layer": layer, "plot_type": "fill", "kwargs": kwargs}, + layer, + ) + + def fill_betweenx(self, y, x1, x2=0, layer=0, **kwargs): + """Fill the area between two x-boundaries along y.""" + self._add( + { + "y": np.array(y), + "x1": np.array(x1) if not np.isscalar(x1) else x1, + "x2": np.array(x2) if not np.isscalar(x2) else x2, + "layer": layer, + "plot_type": "fill_betweenx", + "kwargs": kwargs, + }, + layer, + ) + def errorbar(self, x, y, yerr=None, xerr=None, layer=0, **kwargs): """ Add a line plot with error bars. @@ -401,6 +922,61 @@ def vlines(self, x, ymin, ymax, layer=0, **kwargs): } self._add(ld, layer) + def axvspan(self, xmin, xmax, layer=0, **kwargs): + """Add a vertical shaded span across the axes.""" + self._add( + { + "xmin": xmin, + "xmax": xmax, + "layer": layer, + "plot_type": "axvspan", + "kwargs": kwargs, + }, + layer, + ) + + def axhspan(self, ymin, ymax, layer=0, **kwargs): + """Add a horizontal shaded span across the axes.""" + self._add( + { + "ymin": ymin, + "ymax": ymax, + "layer": layer, + "plot_type": "axhspan", + "kwargs": kwargs, + }, + layer, + ) + + def arrow(self, x, y, dx, dy, layer=0, **kwargs): + """Add an arrow to the axes.""" + self._add( + { + "x": x, + "y": y, + "dx": dx, + "dy": dy, + "layer": layer, + "plot_type": "arrow", + "kwargs": kwargs, + }, + layer, + ) + + def axline(self, xy1, xy2=None, slope=None, layer=0, **kwargs): + """Add an infinitely extending line through one or two points.""" + self._add( + { + "xy1": xy1, + "xy2": xy2, + "slope": slope, + "layer": layer, + "plot_type": "axline", + "kwargs": kwargs, + }, + layer, + ) + def annotate(self, text, xy, xytext=None, layer=0, **kwargs): """ Add a text annotation, optionally with an arrow. @@ -487,6 +1063,7 @@ def plot_matplotlib( ax (matplotlib.axes.Axes): Axis on which to plot the lines. """ im = None + contour_sets = [] for layer_name, layer_lines in self.layered_line_data.items(): if layers and layer_name not in layers: continue @@ -509,6 +1086,99 @@ def plot_matplotlib( line["height"] * self._yscale, **line["kwargs"], ) + elif line["plot_type"] == "barh": + ax.barh( + line["y"], + line["width"] * self._xscale, + **line["kwargs"], + ) + elif line["plot_type"] == "hist": + ax.hist(line["x"], bins=line["bins"], **line["kwargs"]) + elif line["plot_type"] == "step": + ax.step( + (line["x"] + self._xshift) * self._xscale, + (line["y"] + self._yshift) * self._yscale, + **line["kwargs"], + ) + elif line["plot_type"] == "stairs": + ax.stairs( + line["values"], + edges=line["edges"], + baseline=line["baseline"], + **line["kwargs"], + ) + elif line["plot_type"] == "broken_barh": + ax.broken_barh(line["xranges"], line["yrange"], **line["kwargs"]) + elif line["plot_type"] == "pie": + ax.pie(line["x"], **line["kwargs"]) + elif line["plot_type"] == "stem": + ax.stem(line["x"], line["y"], **line["kwargs"]) + elif line["plot_type"] == "stackplot": + ax.stackplot(line["x"], *line["ys"], **line["kwargs"]) + elif line["plot_type"] == "boxplot": + ax.boxplot(line["x"], **line["kwargs"]) + elif line["plot_type"] == "violinplot": + ax.violinplot(line["dataset"], **line["kwargs"]) + elif line["plot_type"] == "eventplot": + ax.eventplot(line["positions"], **line["kwargs"]) + elif line["plot_type"] == "contour": + contour_sets.append( + ax.contour(line["x"], line["y"], line["z"], **line["kwargs"]) + ) + elif line["plot_type"] == "contourf": + contour_sets.append( + ax.contourf(line["x"], line["y"], line["z"], **line["kwargs"]) + ) + elif line["plot_type"] == "pcolormesh": + ax.pcolormesh(line["x"], line["y"], line["z"], **line["kwargs"]) + elif line["plot_type"] == "hexbin": + ax.hexbin(line["x"], line["y"], **line["kwargs"]) + elif line["plot_type"] == "matshow": + ax.matshow(line["data"], **line["kwargs"]) + elif line["plot_type"] == "quiver": + ax.quiver( + line["x"], line["y"], line["u"], line["v"], **line["kwargs"] + ) + elif line["plot_type"] == "triplot": + import matplotlib.tri as mtri + + triangulation = mtri.Triangulation( + line["x"], line["y"], triangles=line["triangles"] + ) + ax.triplot(triangulation, **line["kwargs"]) + elif line["plot_type"] == "tripcolor": + import matplotlib.tri as mtri + + triangulation = mtri.Triangulation( + line["x"], line["y"], triangles=line["triangles"] + ) + ax.tripcolor(triangulation, line["c"], **line["kwargs"]) + elif line["plot_type"] == "tricontour": + import matplotlib.tri as mtri + + triangulation = mtri.Triangulation( + line["x"], line["y"], triangles=line["triangles"] + ) + ax.tricontour(triangulation, line["z"], **line["kwargs"]) + elif line["plot_type"] == "tricontourf": + import matplotlib.tri as mtri + + triangulation = mtri.Triangulation( + line["x"], line["y"], triangles=line["triangles"] + ) + ax.tricontourf(triangulation, line["z"], **line["kwargs"]) + elif line["plot_type"] == "streamplot": + ax.streamplot( + line["x"], line["y"], line["u"], line["v"], **line["kwargs"] + ) + elif line["plot_type"] == "pcolor": + ax.pcolor(line["x"], line["y"], line["z"], **line["kwargs"]) + elif line["plot_type"] == "pcolorfast": + ax.pcolorfast(line["x"], line["y"], line["z"], **line["kwargs"]) + elif line["plot_type"] == "spy": + ax.spy(line["matrix"], **line["kwargs"]) + elif line["plot_type"] == "table": + ax.table(cellText=line["cellText"], **line["kwargs"]) elif line["plot_type"] == "gantt": tasks = line["tasks"] start_times = (line["start_times"] + self._xshift) * self._xscale @@ -595,6 +1265,22 @@ def plot_matplotlib( ), **line["kwargs"], ) + elif line["plot_type"] == "fill_betweenx": + y = (line["y"] + self._yshift) * self._yscale + x1 = line["x1"] + x2 = line["x2"] + if np.isscalar(x1): + x1 = np.full_like(y, x1, dtype=float) + if np.isscalar(x2): + x2 = np.full_like(y, x2, dtype=float) + ax.fill_betweenx( + y, + (np.asarray(x1) + self._xshift) * self._xscale, + (np.asarray(x2) + self._xshift) * self._xscale, + **line["kwargs"], + ) + elif line["plot_type"] == "fill": + ax.fill(*line["args"], **line["kwargs"]) elif line["plot_type"] == "errorbar": ax.errorbar( (line["x"] + self._xshift) * self._xscale, @@ -607,6 +1293,25 @@ def plot_matplotlib( ax.hlines(line["y"], line["xmin"], line["xmax"], **line["kwargs"]) elif line["plot_type"] == "vlines": ax.vlines(line["x"], line["ymin"], line["ymax"], **line["kwargs"]) + elif line["plot_type"] == "axvspan": + ax.axvspan(line["xmin"], line["xmax"], **line["kwargs"]) + elif line["plot_type"] == "axhspan": + ax.axhspan(line["ymin"], line["ymax"], **line["kwargs"]) + elif line["plot_type"] == "arrow": + ax.arrow( + line["x"], + line["y"], + line["dx"], + line["dy"], + **line["kwargs"], + ) + elif line["plot_type"] == "axline": + ax.axline( + line["xy1"], + xy2=line["xy2"], + slope=line["slope"], + **line["kwargs"], + ) elif line["plot_type"] == "annotate": ann_kwargs = dict(line["kwargs"]) if line["xytext"] is not None: @@ -632,15 +1337,19 @@ def plot_matplotlib( plt.colorbar(im, cax=cax, label="Potential (V)") if self._title: - ax.set_title(self._title) + ax.set_title(self._title, **self._title_kwargs) if self._xlabel: - ax.set_xlabel(self._xlabel) + ax.set_xlabel(self._xlabel, **self._xlabel_kwargs) if self._ylabel: - ax.set_ylabel(self._ylabel) + ax.set_ylabel(self._ylabel, **self._ylabel_kwargs) if self._legend and len(self.line_data) > 0: ax.legend() if self._grid: ax.grid() + if self._axis_settings: + axis_settings = dict(self._axis_settings) + axis_args = axis_settings.pop("args", ()) + ax.axis(*axis_args, **axis_settings) if self.xmin is not None: ax.axis(xmin=self.xmin) if self.xmax is not None: @@ -654,15 +1363,85 @@ def plot_matplotlib( if self._yaxis_scale is not None: ax.set_yscale(self._yaxis_scale) if self._xticks is not None: - ax.set_xticks(self._xticks) - if self._xticklabels is not None: - ax.set_xticklabels(self._xticklabels) + ax.set_xticks( + self._xticks, + labels=self._xticklabels, + **self._xtick_kwargs, + ) if self._yticks is not None: - ax.set_yticks(self._yticks) - if self._yticklabels is not None: - ax.set_yticklabels(self._yticklabels) + ax.set_yticks( + self._yticks, + labels=self._yticklabels, + **self._ytick_kwargs, + ) + if self._xticklabels is not None and self._xticks is None: + ax.set_xticklabels(self._xticklabels, **self._xticklabel_kwargs) + if self._yticklabels is not None and self._yticks is None: + ax.set_yticklabels(self._yticklabels, **self._yticklabel_kwargs) + if self._tick_params: + ax.tick_params(**self._tick_params) if self._aspect is not None: ax.set_aspect(self._aspect) + if self._box_aspect is not None: + ax.set_box_aspect(self._box_aspect) + if self._axisbelow is not None: + ax.set_axisbelow(self._axisbelow) + if self._facecolor is not None: + ax.set_facecolor(self._facecolor) + if self._margins: + margin_settings = dict(self._margins) + margin_args = margin_settings.pop("args", ()) + ax.margins(*margin_args, **margin_settings) + if self._invert_xaxis: + ax.invert_xaxis() + if self._invert_yaxis: + ax.invert_yaxis() + if self._minorticks is True: + ax.minorticks_on() + elif self._minorticks is False: + ax.minorticks_off() + if self._locator_params: + ax.locator_params(**self._locator_params) + if self._ticklabel_format: + ax.ticklabel_format(**self._ticklabel_format) + if self._axis_off: + ax.set_axis_off() + if self._relim_settings is not None: + ax.relim(**self._relim_settings) + if self._autoscale_settings is not None: + ax.autoscale(**self._autoscale_settings) + if self._autoscale_view_settings is not None: + ax.autoscale_view(**self._autoscale_view_settings) + if self._secondary_xaxis_settings is not None: + settings = self._secondary_xaxis_settings + secondary_kwargs = dict(settings["kwargs"]) + label = secondary_kwargs.pop("label", None) + secondary = ax.secondary_xaxis( + settings["location"], + functions=settings["functions"], + **secondary_kwargs, + ) + if label: + secondary.set_xlabel(label) + if self._secondary_yaxis_settings is not None: + settings = self._secondary_yaxis_settings + secondary_kwargs = dict(settings["kwargs"]) + label = secondary_kwargs.pop("label", None) + secondary = ax.secondary_yaxis( + settings["location"], + functions=settings["functions"], + **secondary_kwargs, + ) + if label: + secondary.set_ylabel(label) + if self._bar_label_kwargs: + for container in ax.containers: + ax.bar_label(container, **self._bar_label_kwargs) + if self._clabel_kwargs: + for contour_set in contour_sets: + ax.clabel(contour_set, **self._clabel_kwargs) + if self._rasterization_zorder is not None: + ax.set_rasterization_zorder(self._rasterization_zorder) def plot_tikzfigure(self, layers=None, verbose: bool = False) -> TikzFigure: @@ -762,7 +1541,7 @@ def plot_tikzfigure(self, layers=None, verbose: bool = False) -> TikzFigure: print(tikz_figure.generate_tikz()) return tikz_figure - def plot_plotly(self, layers=None): + def plot_plotly(self, layers=None, allow_unsupported=False): """ Plot all lines using Plotly. @@ -803,6 +1582,12 @@ def plot_plotly(self, layers=None): patch_bounds_x: list[float] = [] patch_bounds_y: list[float] = [] + # These primitives have no faithful 2-D Plotly equivalent in the + # current backend. Keep the default strict so a mixed plot cannot + # silently lose data, while allowing callers to deliberately render + # the Plotly-compatible portions of a canvas. + unsupported_plot_types = set() + def tx(values): return self._transform_x(values) @@ -835,6 +1620,13 @@ def plotly_color(value): for line in self._iter_layer_lines(layers=layers): plot_type = line["plot_type"] + if plot_type in unsupported_plot_types: + if allow_unsupported: + continue + raise NotImplementedError( + f"{plot_type} is currently supported only by the matplotlib " + "backend; pass allow_unsupported=True to skip it for Plotly" + ) if plot_type == "plot": kwargs = line["kwargs"] marker = kwargs.get("marker") @@ -890,6 +1682,486 @@ def plotly_color(value): marker_color=plotly_color(kwargs.get("color", None)), ) traces.append(trace) + elif plot_type == "barh": + kwargs = line["kwargs"] + traces.append( + go.Bar( + x=np.asarray(line["width"]) * self._xscale, + y=ty(line["y"]), + orientation="h", + name=kwargs.get("label", ""), + showlegend=bool(kwargs.get("label")) and bool(self._legend), + marker_color=plotly_color(kwargs.get("color", None)), + ) + ) + elif plot_type == "hist": + kwargs = line["kwargs"] + traces.append( + go.Histogram( + x=tx(line["x"]), + nbinsx=line["bins"] if np.isscalar(line["bins"]) else None, + name=kwargs.get("label", ""), + showlegend=bool(kwargs.get("label")) and bool(self._legend), + marker_color=plotly_color(kwargs.get("color", None)), + opacity=kwargs.get("alpha", None), + ) + ) + elif plot_type == "step": + kwargs = line["kwargs"] + traces.append( + go.Scatter( + x=tx(line["x"]), + y=ty(line["y"]), + mode="lines", + line=dict( + shape=kwargs.get("where", "hv"), + color=plotly_color(kwargs.get("color", None)), + ), + name=kwargs.get("label", ""), + showlegend=bool(kwargs.get("label")) and bool(self._legend), + ) + ) + elif plot_type == "stairs": + kwargs = line["kwargs"] + values = np.asarray(line["values"]) + edges = line["edges"] + if edges is None: + edges = np.arange(values.size + 1) + edges = np.asarray(edges) + x_values = np.repeat(tx(edges), 2)[1:-1] + y_values = np.repeat(ty(values), 2) + traces.append( + go.Scatter( + x=x_values, + y=y_values, + mode="lines", + line=dict(color=plotly_color(kwargs.get("color", None))), + name=kwargs.get("label", ""), + showlegend=bool(kwargs.get("label")) and bool(self._legend), + ) + ) + elif plot_type == "broken_barh": + kwargs = line["kwargs"] + for start, width in line["xranges"]: + traces.append( + go.Bar( + x=[width * self._xscale], + y=[line["yrange"][0] + line["yrange"][1] / 2], + base=[txs(start)], + orientation="h", + width=line["yrange"][1], + marker_color=plotly_color(kwargs.get("color", None)), + showlegend=False, + ) + ) + elif plot_type == "pie": + kwargs = line["kwargs"] + labels = kwargs.get("labels", None) + traces.append( + go.Pie( + values=line["x"], + labels=labels, + name=kwargs.get("label", ""), + showlegend=bool(self._legend), + ) + ) + elif plot_type == "stem": + kwargs = line["kwargs"] + traces.append( + go.Scatter( + x=tx(line["x"]), + y=ty(line["y"]), + mode="markers+lines", + line=dict(color=plotly_color(kwargs.get("color", None))), + name=kwargs.get("label", ""), + showlegend=bool(kwargs.get("label")) and bool(self._legend), + ) + ) + elif plot_type == "stackplot": + kwargs = line["kwargs"] + x_values = tx(line["x"]) + cumulative = np.zeros(len(x_values)) + for index, values in enumerate(line["ys"]): + next_cumulative = cumulative + np.asarray(values) + traces.append( + go.Scatter( + x=x_values, + y=ty(next_cumulative), + mode="lines", + stackgroup="one", + name=( + kwargs.get("labels", [])[index] + if index < len(kwargs.get("labels", [])) + else "" + ), + showlegend=bool(self._legend), + ) + ) + cumulative = next_cumulative + elif plot_type == "boxplot": + kwargs = line["kwargs"] + datasets = ( + line["x"] if isinstance(line["x"], (list, tuple)) else [line["x"]] + ) + for index, values in enumerate(datasets): + showfliers = kwargs.get("showfliers", True) + boxpoints = "outliers" if showfliers else False + traces.append( + go.Box( + y=values, + name=str(index), + boxpoints=boxpoints, + showlegend=False, + ) + ) + elif plot_type == "violinplot": + dataset = line["dataset"] + datasets = dataset if isinstance(dataset, (list, tuple)) else [dataset] + for index, values in enumerate(datasets): + traces.append( + go.Violin(y=values, name=str(index), showlegend=False) + ) + elif plot_type == "eventplot": + positions = line["positions"] + for row_index, row_positions in enumerate(np.atleast_1d(positions)): + for position in np.atleast_1d(row_positions): + shapes.append( + dict( + type="line", + x0=txs(position), + x1=txs(position), + y0=row_index, + y1=row_index + 0.8, + line=dict(color="black"), + ) + ) + elif plot_type == "contour": + kwargs = line["kwargs"] + contours = dict(coloring="lines") + if self._clabel_kwargs: + contours["showlabels"] = True + contours["labelfont"] = { + "size": self._clabel_kwargs.get( + "size", self._clabel_kwargs.get("fontsize") + ), + "color": self._clabel_kwargs.get("color"), + "family": self._clabel_kwargs.get("family"), + } + traces.append( + go.Contour( + x=line["x"], + y=line["y"], + z=line["z"], + contours=contours, + colorscale=kwargs.get("cmap", "Viridis"), + showscale=kwargs.get("colorbar", True), + ) + ) + elif plot_type == "contourf": + kwargs = line["kwargs"] + contours = {} + if self._clabel_kwargs: + contours["showlabels"] = True + contours["labelfont"] = { + "size": self._clabel_kwargs.get( + "size", self._clabel_kwargs.get("fontsize") + ), + "color": self._clabel_kwargs.get("color"), + "family": self._clabel_kwargs.get("family"), + } + traces.append( + go.Contour( + x=line["x"], + y=line["y"], + z=line["z"], + colorscale=kwargs.get("cmap", "Viridis"), + showscale=kwargs.get("colorbar", True), + contours=contours, + ) + ) + elif plot_type == "pcolormesh": + kwargs = line["kwargs"] + traces.append( + go.Heatmap( + x=line["x"], + y=line["y"], + z=line["z"], + colorscale=kwargs.get("cmap", "Viridis"), + showscale=kwargs.get("colorbar", True), + ) + ) + elif plot_type in ("pcolor", "pcolorfast"): + # Plotly's heatmap is the closest equivalent to Matplotlib's + # pseudocolor artists. The cell-centered rendering differs + # slightly from pcolor, but preserves the data and color map. + kwargs = line["kwargs"] + traces.append( + go.Heatmap( + x=line["x"], + y=line["y"], + z=line["z"], + colorscale=kwargs.get("cmap", "Viridis"), + showscale=kwargs.get("colorbar", True), + opacity=kwargs.get("alpha", None), + ) + ) + elif plot_type == "hexbin": + kwargs = line["kwargs"] + traces.append( + go.Histogram2d( + x=tx(line["x"]), + y=ty(line["y"]), + nbinsx=( + kwargs.get("gridsize", 30) + if np.isscalar(kwargs.get("gridsize", 30)) + else 30 + ), + nbinsy=( + kwargs.get("gridsize", 30) + if np.isscalar(kwargs.get("gridsize", 30)) + else 30 + ), + colorscale=kwargs.get("cmap", "Viridis"), + ) + ) + elif plot_type == "matshow": + kwargs = line["kwargs"] + traces.append( + go.Heatmap( + z=line["data"], + colorscale=kwargs.get("cmap", "Viridis"), + showscale=kwargs.get("colorbar", True), + ) + ) + elif plot_type == "quiver": + kwargs = line["kwargs"] + x_values = np.asarray(line["x"]) + y_values = np.asarray(line["y"]) + u_values = np.asarray(line["u"]) + v_values = np.asarray(line["v"]) + if u_values.ndim == 2 and x_values.ndim == 1 and y_values.ndim == 1: + x_values, y_values = np.meshgrid(x_values, y_values) + x_values, y_values, u_values, v_values = np.broadcast_arrays( + x_values, y_values, u_values, v_values + ) + color = plotly_color(kwargs.get("color", "black")) + arrow_width = kwargs.get("linewidth", kwargs.get("width", 1)) + for x_start, y_start, u_value, v_value in zip( + x_values.flat, y_values.flat, u_values.flat, v_values.flat + ): + raw_x_start = x_start + raw_y_start = y_start + x_start = txs(raw_x_start) + y_start = tys(raw_y_start) + x_end = txs(raw_x_start + u_value) + y_end = tys(raw_y_start + v_value) + annotations.append( + dict( + x=x_end, + y=y_end, + ax=x_start, + ay=y_start, + showarrow=True, + arrowhead=2, + arrowsize=kwargs.get("headlength", 1), + arrowwidth=arrow_width, + arrowcolor=color, + opacity=kwargs.get("alpha", 1), + ) + ) + elif plot_type == "spy": + kwargs = line["kwargs"] + matrix = np.asarray(line["matrix"]) + traces.append( + go.Heatmap( + z=(matrix != 0).astype(int), + colorscale=kwargs.get( + "cmap", [[0, "rgba(0,0,0,0)"], [1, "black"]] + ), + showscale=False, + xgap=kwargs.get("markersize", 0), + ygap=kwargs.get("markersize", 0), + ) + ) + elif plot_type == "triplot": + import matplotlib.tri as mtri + + kwargs = line["kwargs"] + triangulation = mtri.Triangulation( + line["x"], line["y"], triangles=line["triangles"] + ) + x_values = [] + y_values = [] + x_data = np.asarray(line["x"]) + y_data = np.asarray(line["y"]) + for triangle in triangulation.triangles: + indices = [*triangle, triangle[0]] + x_values.extend(x_data[indices].tolist() + [None]) + y_values.extend(y_data[indices].tolist() + [None]) + transformed_x = [ + None if value is None else txs(value) for value in x_values + ] + transformed_y = [ + None if value is None else tys(value) for value in y_values + ] + traces.append( + go.Scatter( + x=transformed_x, + y=transformed_y, + mode="lines", + line=dict( + color=plotly_color(kwargs.get("color", None)), + dash=linestyle_map.get( + kwargs.get("linestyle", "solid"), "solid" + ), + width=kwargs.get("linewidth", None), + ), + name=kwargs.get("label", ""), + showlegend=bool(kwargs.get("label")) and bool(self._legend), + ) + ) + elif plot_type == "tripcolor": + import matplotlib.tri as mtri + from plotly.colors import sample_colorscale + + kwargs = line["kwargs"] + triangulation = mtri.Triangulation( + line["x"], line["y"], triangles=line["triangles"] + ) + x_data = np.asarray(line["x"]) + y_data = np.asarray(line["y"]) + c_data = np.asarray(line["c"]) + if c_data.ndim == 0: + c_data = np.full(len(x_data), c_data.item()) + triangle_values = ( + c_data + if c_data.size == len(triangulation.triangles) + else np.asarray( + [ + np.mean(c_data[triangle]) + for triangle in triangulation.triangles + ] + ) + ) + finite_values = triangle_values[np.isfinite(triangle_values)] + minimum = finite_values.min() if finite_values.size else 0.0 + maximum = finite_values.max() if finite_values.size else 1.0 + scale = maximum - minimum or 1.0 + colorscale = kwargs.get("cmap", "Viridis") + edge_color = plotly_color(kwargs.get("edgecolors", "black")) + for triangle, value in zip( + triangulation.triangles, triangle_values, strict=False + ): + fraction = float(np.clip((value - minimum) / scale, 0, 1)) + try: + fill_color = sample_colorscale(colorscale, [fraction])[0] + except (KeyError, ValueError): + fill_color = plotly_color(kwargs.get("color", "blue")) + indices = [*triangle, triangle[0]] + traces.append( + go.Scatter( + x=tx(x_data[indices]), + y=ty(y_data[indices]), + mode="lines", + fill="toself", + fillcolor=fill_color, + line=dict(color=edge_color), + showlegend=False, + ) + ) + elif plot_type == "streamplot": + import matplotlib.pyplot as mpl_plt + + kwargs = dict(line["kwargs"]) + stream_figure, stream_axis = mpl_plt.subplots() + try: + stream_set = stream_axis.streamplot( + line["x"], line["y"], line["u"], line["v"], **kwargs + ) + segments = stream_set.lines.get_segments() + colors = stream_set.lines.get_colors() + widths = stream_set.lines.get_linewidths() + for index, segment in enumerate(segments): + if len(segment) < 2: + continue + color = plotly_color(colors[min(index, len(colors) - 1)]) + width = widths[min(index, len(widths) - 1)] + traces.append( + go.Scatter( + x=tx(segment[:, 0]), + y=ty(segment[:, 1]), + mode="lines", + line=dict(color=color, width=width), + showlegend=False, + ) + ) + finally: + mpl_plt.close(stream_figure) + elif plot_type in ("tricontour", "tricontourf"): + import matplotlib.pyplot as mpl_plt + import matplotlib.tri as mtri + + kwargs = dict(line["kwargs"]) + kwargs.pop("colorbar", None) + kwargs.pop("label", None) + triangulation = mtri.Triangulation( + line["x"], line["y"], triangles=line["triangles"] + ) + contour_figure, contour_axis = mpl_plt.subplots() + try: + if plot_type == "tricontourf": + contour_set = contour_axis.tricontourf( + triangulation, line["z"], **kwargs + ) + colors = contour_set.get_facecolors() + else: + contour_set = contour_axis.tricontour( + triangulation, line["z"], **kwargs + ) + colors = contour_set.get_edgecolors() + for index, path in enumerate(contour_set.get_paths()): + vertices = path.vertices + if len(vertices) < 2: + continue + color = plotly_color(colors[min(index, len(colors) - 1)]) + traces.append( + go.Scatter( + x=tx(vertices[:, 0]), + y=ty(vertices[:, 1]), + mode="lines", + fill="toself" if plot_type == "tricontourf" else None, + fillcolor=color if plot_type == "tricontourf" else None, + line=dict(color=color), + showlegend=False, + ) + ) + finally: + mpl_plt.close(contour_figure) + elif plot_type == "table": + kwargs = line["kwargs"] + cell_text = line["cellText"] or [] + col_labels = kwargs.get("colLabels") + row_labels = kwargs.get("rowLabels") + if col_labels is not None: + header_values = list(col_labels) + rows = cell_text + elif cell_text: + header_values = [] + rows = cell_text + else: + header_values = [] + rows = [] + columns = list(map(list, zip(*rows))) if rows else [] + if row_labels is not None: + columns.insert(0, list(row_labels)) + if header_values: + header_values.insert(0, "") + traces.append( + go.Table( + header=dict(values=header_values), + cells=dict(values=columns), + ) + ) elif plot_type == "gantt": kwargs = line["kwargs"] tasks = line["tasks"] @@ -997,6 +2269,58 @@ def plotly_color(value): showlegend=bool(kwargs.get("label")) and bool(self._legend), ) traces.append(fill_trace) + elif plot_type == "fill_betweenx": + kwargs = line["kwargs"] + y = ty(line["y"]) + x1 = line["x1"] + x2 = line["x2"] + if np.isscalar(x1): + x1 = np.full_like(y, float(txs(x1)), dtype=float) + else: + x1 = tx(x1) + if np.isscalar(x2): + x2 = np.full_like(y, float(txs(x2)), dtype=float) + else: + x2 = tx(x2) + color = plotly_color(kwargs.get("color", kwargs.get("facecolor", None))) + traces.append( + go.Scatter( + x=np.concatenate([x1, x2[::-1]]), + y=np.concatenate([y, y[::-1]]), + fill="toself", + fillcolor=color, + opacity=kwargs.get("alpha", 0.3), + line=dict(color="rgba(0,0,0,0)"), + name=kwargs.get("label", ""), + showlegend=bool(kwargs.get("label")) and bool(self._legend), + ) + ) + elif plot_type == "fill": + kwargs = line["kwargs"] + polygon_args = line["args"] + for index in range(0, len(polygon_args), 2): + x_values = tx(polygon_args[index]) + y_values = ty(polygon_args[index + 1]) + traces.append( + go.Scatter( + x=x_values, + y=y_values, + fill="toself", + fillcolor=plotly_color( + kwargs.get("color", kwargs.get("facecolor", None)) + ), + opacity=kwargs.get("alpha", None), + line=dict( + color=plotly_color( + kwargs.get("edgecolor", kwargs.get("color", None)) + ) + ), + name=kwargs.get("label", ""), + showlegend=bool(kwargs.get("label")) + and bool(self._legend) + and index == 0, + ) + ) elif plot_type == "errorbar": kwargs = line["kwargs"] marker = kwargs.get("marker") @@ -1102,6 +2426,72 @@ def plotly_color(value): line=dict(color=color, dash=dash, width=width), ) ) + elif plot_type in ("axvspan", "axhspan"): + kwargs = line["kwargs"] + color = plotly_color(kwargs.get("color", kwargs.get("facecolor", None))) + span_shape = dict( + type="rect", + fillcolor=color, + opacity=kwargs.get("alpha", 0.3), + line=dict(width=0), + ) + if plot_type == "axvspan": + span_shape.update( + x0=txs(line["xmin"]), + x1=txs(line["xmax"]), + y0=0, + y1=1, + yref="paper", + ) + else: + span_shape.update( + x0=0, + x1=1, + xref="paper", + y0=tys(line["ymin"]), + y1=tys(line["ymax"]), + ) + shapes.append(span_shape) + elif plot_type == "arrow": + kwargs = line["kwargs"] + annotations.append( + dict( + x=txs(line["x"] + line["dx"]), + y=tys(line["y"] + line["dy"]), + ax=txs(line["x"]), + ay=tys(line["y"]), + xref="x", + yref="y", + axref="x", + ayref="y", + text="", + showarrow=True, + arrowhead=kwargs.get("arrowhead", 2), + arrowcolor=plotly_color(kwargs.get("color", "black")), + ) + ) + elif plot_type == "axline": + kwargs = line["kwargs"] + if line["xy2"] is None and line["slope"] is None: + raise ValueError("axline requires xy2 or slope") + if line["xy2"] is not None: + x0, y0 = line["xy1"] + x1, y1 = line["xy2"] + slope = (y1 - y0) / (x1 - x0) + else: + x0, y0 = line["xy1"] + slope = line["slope"] + shapes.append( + dict( + type="line", + x0=0, + x1=1, + xref="paper", + y0=tys(y0 - slope * x0), + y1=tys(y0 + slope * (1 - x0)), + line=dict(color=plotly_color(kwargs.get("color", "black"))), + ) + ) elif plot_type in ("text", "annotate"): kwargs = line["kwargs"] if plot_type == "text": @@ -1331,6 +2721,59 @@ def _add_hover_trace(x_pts, y_pts, hovertext=hovertext): ) ) + if self._bar_label_kwargs: + label_kwargs = self._bar_label_kwargs + fmt = label_kwargs.get("fmt", "{x}") + + def format_bar_label(value): + if callable(fmt): + return str(fmt(value)) + if "%" in str(fmt): + return str(fmt) % value + return str(fmt).format(x=value) + + for line in self._iter_layer_lines(layers=layers): + if line["plot_type"] == "bar": + x_values = np.asarray(line["x"]) + heights = np.asarray(line["height"]) + bottom = np.asarray(line["kwargs"].get("bottom", 0)) + for x_value, height, base in zip( + x_values, + heights, + np.broadcast_to(bottom, heights.shape), + strict=False, + ): + edge = base + height + annotations.append( + dict( + x=txs(x_value), + y=tys(edge), + text=format_bar_label(height), + showarrow=False, + yshift=label_kwargs.get("padding", 0), + ) + ) + elif line["plot_type"] == "barh": + y_values = np.asarray(line["y"]) + widths = np.asarray(line["width"]) + left = np.asarray(line["kwargs"].get("left", 0)) + for y_value, width, start in zip( + y_values, + widths, + np.broadcast_to(left, widths.shape), + strict=False, + ): + edge = start + width + annotations.append( + dict( + x=txs(edge), + y=tys(y_value), + text=format_bar_label(width), + showarrow=False, + xshift=label_kwargs.get("padding", 0), + ) + ) + return traces, shapes, annotations def _iter_layer_lines(self, layers=None): diff --git a/src/maxplotlib/tests/test_canvas.py b/src/maxplotlib/tests/test_canvas.py index 7bd8f9a..32d3f7d 100644 --- a/src/maxplotlib/tests/test_canvas.py +++ b/src/maxplotlib/tests/test_canvas.py @@ -355,6 +355,64 @@ def test_canvas_show_uses_matplotlib_show(monkeypatch): assert axes is not None +def test_canvas_show_uses_ipython_display_in_jupyter(monkeypatch): + import sys + import types + + import matplotlib.pyplot as plt + import pytest + + from maxplotlib import Canvas + + displayed = [] + closed = [] + fig = object() + + ipython = types.ModuleType("IPython") + ipython.get_ipython = lambda: types.SimpleNamespace(config={"IPKernelApp": {}}) + ipython_display = types.ModuleType("IPython.display") + ipython_display.display = displayed.append + + monkeypatch.setitem(sys.modules, "IPython", ipython) + monkeypatch.setitem(sys.modules, "IPython.display", ipython_display) + monkeypatch.setattr(plt, "close", lambda value: closed.append(value)) + monkeypatch.setattr(Canvas, "plot", lambda *args, **kwargs: (fig, object())) + monkeypatch.setattr(plt, "show", lambda: pytest.fail("pyplot.show was called")) + + canvas = Canvas() + result = canvas.show() + assert result[0] is fig + assert result[1] is not None + assert displayed == [fig] + assert closed == [fig] + + +def test_canvas_show_falls_back_to_pyplot_outside_jupyter(monkeypatch): + import sys + import types + + import matplotlib.pyplot as plt + import pytest + + from maxplotlib import Canvas + + calls = [] + ipython = types.ModuleType("IPython") + ipython.get_ipython = lambda: types.SimpleNamespace(config={}) + ipython_display = types.ModuleType("IPython.display") + ipython_display.display = lambda fig: pytest.fail("IPython display was called") + + monkeypatch.setitem(sys.modules, "IPython", ipython) + monkeypatch.setitem(sys.modules, "IPython.display", ipython_display) + monkeypatch.setattr(plt, "show", lambda *args, **kwargs: calls.append(kwargs)) + + canvas = Canvas() + canvas.add_subplot().plot([0, 1], [0, 1]) + canvas.show(block=False) + + assert calls == [{"block": False}] + + def test_canvas_show_block_false_is_forwarded(monkeypatch): import matplotlib.pyplot as plt @@ -375,6 +433,384 @@ def test_canvas_show_block_false_is_forwarded(monkeypatch): assert calls == [((), {"block": False})] +def test_canvas_tick_label_rotation_is_forwarded_to_matplotlib(): + import matplotlib.pyplot as plt + + from maxplotlib import Canvas + + canvas, axis = Canvas.subplots() + axis.plot([0, 1], [0, 1]) + canvas.set_xticks([0, 1], labels=["zero", "one"], rotation=45) + + fig, axes = canvas.plot() + assert [label.get_rotation() for label in axes[0][0].get_xticklabels()] == [ + 45.0, + 45.0, + ] + plt.close(fig) + + +def test_axis_label_and_tick_settings_are_forwarded_to_matplotlib(): + import matplotlib.pyplot as plt + + from maxplotlib import Canvas + + canvas, axis = Canvas.subplots() + axis.plot([0, 1], [0, 1]) + canvas.set_xlabel("Time", fontsize=14, fontweight="bold", labelpad=12) + canvas.set_ylabel("Value", color="crimson") + canvas.set_title("Results", fontsize=16, color="navy") + canvas.tick_params(axis="both", labelsize=12, colors="darkgreen", length=7) + + fig, axes = canvas.plot() + matplotlib_axis = axes[0][0] + assert matplotlib_axis.xaxis.label.get_fontsize() == 14 + assert matplotlib_axis.xaxis.label.get_fontweight() == "bold" + assert matplotlib_axis.xaxis.labelpad == 12 + assert matplotlib_axis.yaxis.label.get_color() == "crimson" + assert matplotlib_axis.title.get_fontsize() == 16 + assert matplotlib_axis.xaxis.majorTicks[0].tick1line.get_markersize() == 7 + assert matplotlib_axis.xaxis.get_ticklabels()[0].get_fontsize() == 12 + plt.close(fig) + + +def test_common_axis_and_figure_controls_are_forwarded_to_matplotlib(): + import matplotlib.pyplot as plt + + from maxplotlib import Canvas + + canvas, axis = Canvas.subplots() + axis.plot([1, 2, 3], [1, 4, 9]) + canvas.set_facecolor("whitesmoke") + canvas.set_axisbelow(True) + canvas.margins(x=0.2, y=0.1) + canvas.invert_yaxis() + canvas.minorticks_on() + canvas.set_axis_on() + canvas.supxlabel("Shared time", fontsize=11) + canvas.supylabel("Shared value", fontsize=11) + canvas.subplots_adjust(left=0.2) + + fig, axes = canvas.plot() + matplotlib_axis = axes[0][0] + assert matplotlib_axis.get_facecolor() == (0.9607843137254902,) * 3 + (1.0,) + assert matplotlib_axis.get_axisbelow() is True + assert matplotlib_axis.yaxis_inverted() + assert matplotlib_axis.xaxis.get_minorticklocs().size > 0 + assert fig._supxlabel.get_text() == "Shared time" + assert fig._supylabel.get_text() == "Shared value" + plt.close(fig) + + +def test_twinx_renders_a_secondary_matplotlib_y_axis(): + import matplotlib.pyplot as plt + + from maxplotlib import Canvas + + canvas, primary = Canvas.subplots() + secondary = canvas.twinx() + primary.plot([0, 1, 2], [0, 1, 2], color="tab:blue", label="temperature") + secondary.plot([0, 1, 2], [10, 20, 30], color="tab:red", label="pressure") + primary.set_ylabel("Temperature", color="tab:blue") + secondary.set_ylabel("Pressure", color="tab:red") + + fig, axes = canvas.plot() + twin_axis = canvas.twinx_axes[(0, 0)] + + assert twin_axis is not axes[0][0] + assert twin_axis.get_ylabel() == "Pressure" + assert axes[0][0].get_ylabel() == "Temperature" + assert len(twin_axis.lines) == 1 + plt.close(fig) + + +def test_common_matplotlib_plot_primitives_are_supported(): + import matplotlib.pyplot as plt + + from maxplotlib import Canvas + + canvas, axis = Canvas.subplots() + axis.barh([0, 1], [2, 3], color="steelblue") + axis.hist([0, 1, 1, 2, 2, 2], bins=3, alpha=0.5) + axis.fill_betweenx([0, 1, 2], 0.5, [1, 1.5, 2], alpha=0.2) + axis.axvspan(0.25, 0.75, alpha=0.1) + axis.axhspan(0.5, 1.5, alpha=0.1) + axis.arrow(0, 0, 1, 1, length_includes_head=True) + axis.axline((0, 0), slope=1, linestyle="--") + + fig, axes = canvas.plot() + matplotlib_axis = axes[0][0] + assert len(matplotlib_axis.patches) > 0 + assert len(matplotlib_axis.lines) > 0 + plt.close(fig) + + +def test_additional_matplotlib_plot_primitives_are_supported(): + import matplotlib.pyplot as plt + + from maxplotlib import Canvas + + canvas, axis = Canvas.subplots() + axis.step([0, 1, 2], [1, 3, 2], color="black") + axis.stairs([1, 2, 1], edges=[0, 1, 2, 3], color="purple") + axis.broken_barh([(0, 1), (1.5, 0.5)], (0, 0.4), color="orange") + axis.bar([0, 1], [2, 3]) + axis.bar_label(fmt="%d") + + fig, axes = canvas.plot() + matplotlib_axis = axes[0][0] + assert len(matplotlib_axis.containers) >= 1 + assert len(matplotlib_axis.lines) >= 1 + assert len(matplotlib_axis.patches) >= 2 + assert len(matplotlib_axis.texts) >= 2 + plt.close(fig) + + +def test_statistical_and_event_plot_primitives_are_supported(): + import matplotlib.pyplot as plt + + from maxplotlib import Canvas + + canvas, axis = Canvas.subplots() + axis.stem([0, 1, 2], [1, 3, 2]) + axis.stackplot([0, 1, 2], [1, 2, 1], [2, 1, 2]) + axis.boxplot([[1, 2, 3], [2, 4, 5]]) + axis.violinplot([[1, 2, 3], [2, 4, 5]]) + axis.eventplot([[0.2, 0.5], [1.0, 1.5]]) + + fig, axes = canvas.plot() + matplotlib_axis = axes[0][0] + assert len(matplotlib_axis.lines) > 0 + assert len(matplotlib_axis.collections) > 0 + plt.close(fig) + + +def test_scientific_field_plot_primitives_are_supported(): + import matplotlib.pyplot as plt + import numpy as np + + from maxplotlib import Canvas + + x = np.linspace(-1, 1, 8) + y = np.linspace(-1, 1, 8) + xx, yy = np.meshgrid(x, y) + z = xx**2 + yy**2 + + canvas, axis = Canvas.subplots() + axis.contour(x, y, z) + axis.contourf(x, y, z, alpha=0.4) + axis.pcolormesh(x, y, z) + axis.hexbin(np.ravel(xx), np.ravel(yy), gridsize=8) + axis.matshow(z) + + fig, axes = canvas.plot() + matplotlib_axis = axes[0][0] + assert len(matplotlib_axis.collections) > 0 + assert len(matplotlib_axis.images) > 0 + plt.close(fig) + + +def test_vector_and_triangulated_plot_primitives_are_supported(): + import matplotlib.pyplot as plt + import numpy as np + + from maxplotlib import Canvas + + x = np.array([0.0, 1.0, 0.0, 1.0]) + y = np.array([0.0, 0.0, 1.0, 1.0]) + triangles = [[0, 1, 2], [1, 3, 2]] + z = x + y + + canvas, axis = Canvas.subplots() + axis.quiver(x, y, np.ones(4), np.ones(4)) + axis.triplot(x, y, triangles=triangles) + axis.tripcolor(x, y, z, triangles=triangles, alpha=0.3) + axis.tricontour(x, y, z, triangles=triangles) + axis.tricontourf(x, y, z, triangles=triangles, alpha=0.2) + + fig, axes = canvas.plot() + matplotlib_axis = axes[0][0] + assert len(matplotlib_axis.collections) > 0 + assert len(matplotlib_axis.lines) > 0 + plt.close(fig) + + +def test_stream_matrix_and_table_primitives_are_supported(): + import matplotlib.pyplot as plt + import numpy as np + + from maxplotlib import Canvas + + x = np.linspace(-1, 1, 8) + y = np.linspace(-1, 1, 8) + xx, yy = np.meshgrid(x, y) + z = xx**2 + yy**2 + matrix = np.eye(5) + + canvas, axis = Canvas.subplots() + axis.streamplot(x, y, -yy, xx) + axis.pcolor(x, y, z, alpha=0.2) + axis.pcolorfast(np.linspace(-1, 1, 9), np.linspace(-1, 1, 9), z, alpha=0.2) + axis.spy(matrix) + axis.table(cellText=[["A", "B"], ["1", "2"]], loc="upper right") + + fig, axes = canvas.plot() + matplotlib_axis = axes[0][0] + assert len(matplotlib_axis.collections) > 0 + assert len(matplotlib_axis.tables) == 1 + + +def test_contour_labels_and_rasterization_zorder_are_supported(): + import matplotlib.pyplot as plt + import numpy as np + + from maxplotlib import Canvas + + x = np.linspace(-1, 1, 5) + xx, yy = np.meshgrid(x, x) + canvas, axis = Canvas.subplots() + axis.contour(x, x, xx**2 + yy**2) + axis.clabel(inline=True, fontsize=8) + axis.set_rasterization_zorder(2) + + fig, axes = canvas.plot(backend="matplotlib") + + assert len(axes[0, 0].texts) > 0 + assert axes[0, 0].get_rasterization_zorder() == 2 + plt.close(fig) + + +def test_axis_layout_and_log_shortcuts_are_supported(): + import matplotlib.pyplot as plt + import numpy as np + + from maxplotlib import Canvas + + canvas, axis = Canvas.subplots() + axis.fill([1, 2, 3], [1, 4, 1], alpha=0.2) + axis.loglog([1, 2, 4], [1, 4, 16]) + axis.axis([1, 4, 1, 16]) + axis.autoscale_view(tight=True) + axis.relim() + axis.set_box_aspect(1) + axis.set_xticklabels(["one", "two", "four"], rotation=30) + axis.set_yticklabels(["low", "high"], color="navy") + + fig, axes = canvas.plot(backend="matplotlib") + matplotlib_axis = axes[0, 0] + + assert matplotlib_axis.get_xscale() == "log" + assert matplotlib_axis.get_yscale() == "log" + assert matplotlib_axis.get_box_aspect() == 1 + assert len(matplotlib_axis.patches) == 1 + plt.close(fig) + + +def test_secondary_axes_are_supported_by_matplotlib(): + import matplotlib.pyplot as plt + + from maxplotlib import Canvas + + canvas, axis = Canvas.subplots() + axis.plot([0, 1], [0, 1]) + axis.secondary_xaxis( + "top", functions=(lambda x: x * 2, lambda x: x / 2), label="double" + ) + axis.secondary_yaxis( + "right", functions=(lambda y: y + 1, lambda y: y - 1), label="offset" + ) + + fig, axes = canvas.plot(backend="matplotlib") + + assert len(axes[0, 0].child_axes) == 2 + plt.close(fig) + + +def test_matplotlib_postprocess_can_customize_figure_and_axes(): + import matplotlib.pyplot as plt + from matplotlib.colors import to_rgba + + from maxplotlib import Canvas + + canvas, (axis0, axis1) = Canvas.subplots(nrows=1, ncols=2) + axis0.plot([0, 1], [0, 1]) + axis1.plot([0, 1], [1, 0]) + received = [] + + def customize(fig, axes): + received.append((fig, axes)) + fig.suptitle("Customized") + for matplotlib_axis in axes.flat: + matplotlib_axis.set_facecolor("lightgray") + + fig, axes = canvas.plot(matplotlib_postprocess=customize) + + assert received == [(fig, axes)] + assert fig._suptitle.get_text() == "Customized" + assert all(axis.get_facecolor() == to_rgba("lightgray") for axis in axes.flat) + plt.close(fig) + + +def test_matplotlib_customizations_apply_declarative_figure_and_axes_methods(): + import matplotlib.pyplot as plt + + from maxplotlib import Canvas + + canvas, (axis0, axis1) = Canvas.subplots(ncols=2) + axis0.plot([0, 1], [0, 1]) + axis1.plot([0, 1], [1, 0]) + + fig, axes = canvas.plot( + matplotlib_customizations={ + "figure": {"suptitle": "Customized"}, + "axes": { + "set_facecolor": "lightgray", + "tick_params": { + "kwargs": {"axis": "both", "length": 6}, + }, + }, + } + ) + + from matplotlib.colors import to_rgba + + assert fig._suptitle.get_text() == "Customized" + assert all(axis.get_facecolor() == to_rgba("lightgray") for axis in axes.flat) + assert all( + axis.xaxis.majorTicks[0].tick1line.get_markersize() == 6 for axis in axes.flat + ) + plt.close(fig) + + +def test_matplotlib_customizations_accept_a_callable(): + import matplotlib.pyplot as plt + + from maxplotlib import Canvas + + canvas, axis = Canvas.subplots() + axis.plot([0, 1], [0, 1]) + received = [] + + def customize(fig, axes): + received.append((fig, axes)) + fig.suptitle("Customized") + + fig, axes = canvas.plot(matplotlib_customizations=customize) + + assert received == [(fig, axes)] + assert fig._suptitle.get_text() == "Customized" + plt.close(fig) + + +def test_matplotlib_postprocess_rejects_non_matplotlib_backends(): + import pytest + + from maxplotlib import Canvas + + with pytest.raises(ValueError, match="only supported with the matplotlib backend"): + Canvas().plot(backend="plotly", matplotlib_postprocess=lambda fig, axes: None) + + def test_show_canvas_script_invokes_canvas_show(monkeypatch): import maxplotlib @@ -393,6 +829,27 @@ def fake_show(self, *args, **kwargs): assert calls == [((), {"backend": "tikzfigure", "verbose": True})] +def test_tikzfigure_show_does_not_return_repr_in_jupyter(monkeypatch): + import sys + import types + + from maxplotlib import Canvas + + ipython = types.ModuleType("IPython") + ipython.get_ipython = lambda: types.SimpleNamespace(config={"IPKernelApp": {}}) + monkeypatch.setitem(sys.modules, "IPython", ipython) + + class FakeTikzFigure: + def show(self, **kwargs): + self.show_kwargs = kwargs + + figure = FakeTikzFigure() + monkeypatch.setattr(Canvas, "plot_tikzfigure", lambda *args, **kwargs: figure) + + assert Canvas().show(backend="tikzfigure") is None + assert figure.show_kwargs == {"transparent": False} + + def test_canvas_plot_uses_screen_dpi_when_not_saving(): import matplotlib.pyplot as plt import pytest diff --git a/src/maxplotlib/tests/test_plotly_backend.py b/src/maxplotlib/tests/test_plotly_backend.py index 2184972..9fde583 100644 --- a/src/maxplotlib/tests/test_plotly_backend.py +++ b/src/maxplotlib/tests/test_plotly_backend.py @@ -28,6 +28,138 @@ def test_plotly_backend_supports_common_primitives(): ) # subplot title + text/annotate +def test_plotly_backend_supports_tick_label_rotation(): + from maxplotlib import Canvas + + canvas, axis = Canvas.subplots() + axis.plot([0, 1], [0, 1]) + axis.set_xticks([0, 1], labels=["zero", "one"], rotation=45) + + fig = canvas.plot(backend="plotly") + + assert fig.layout.xaxis.tickangle == 45 + + +def test_plotly_backend_supports_twinx(): + from maxplotlib import Canvas + + canvas, primary = Canvas.subplots() + secondary = canvas.twinx() + primary.plot([0, 1], [0, 1], color="blue") + secondary.plot([0, 1], [10, 20], color="red") + secondary.set_ylabel("Secondary") + + fig = canvas.plot(backend="plotly") + + assert len(fig.data) == 2 + assert fig.layout.yaxis2.title.text == "Secondary" + + +def test_plotly_backend_supports_common_added_primitives(): + from maxplotlib import Canvas + + canvas, axis = Canvas.subplots() + axis.barh([0, 1], [2, 3]) + axis.hist([0, 1, 1, 2], bins=3) + axis.fill_betweenx([0, 1], 0, 1, alpha=0.2) + axis.axvspan(0.25, 0.75, alpha=0.1) + axis.axhspan(0.25, 0.75, alpha=0.1) + axis.arrow(0, 0, 1, 1) + + fig = canvas.plot(backend="plotly") + + assert len(fig.data) >= 3 + assert len(fig.layout.shapes) >= 2 + assert any(annotation.showarrow for annotation in fig.layout.annotations) + + +def test_plotly_backend_supports_step_stairs_broken_barh_and_pie(): + from maxplotlib import Canvas + + canvas, axis = Canvas.subplots() + axis.step([0, 1, 2], [1, 3, 2]) + axis.stairs([1, 2], edges=[0, 1, 2]) + axis.broken_barh([(0, 1), (2, 0.5)], (0, 0.5)) + axis.pie([2, 3, 4], labels=["A", "B", "C"]) + + fig = canvas.plot(backend="plotly") + + assert any(trace.type == "pie" for trace in fig.data) + assert len(fig.data) >= 5 + + +def test_plotly_backend_supports_statistical_and_event_plots(): + from maxplotlib import Canvas + + canvas, axis = Canvas.subplots() + axis.stem([0, 1, 2], [1, 3, 2]) + axis.stackplot([0, 1, 2], [1, 2, 1], [2, 1, 2]) + axis.boxplot([[1, 2, 3], [2, 4, 5]]) + axis.violinplot([[1, 2, 3], [2, 4, 5]]) + axis.eventplot([[0.2, 0.5], [1.0, 1.5]]) + + fig = canvas.plot(backend="plotly") + + assert any(trace.type == "box" for trace in fig.data) + assert any(trace.type == "violin" for trace in fig.data) + assert len(fig.layout.shapes) >= 4 + + +def test_plotly_backend_supports_scientific_field_plots(): + import numpy as np + + from maxplotlib import Canvas + + x = np.linspace(-1, 1, 5) + y = np.linspace(-1, 1, 5) + xx, yy = np.meshgrid(x, y) + z = xx**2 + yy**2 + + canvas, axis = Canvas.subplots() + axis.contour(x, y, z) + axis.contourf(x, y, z) + axis.pcolormesh(x, y, z) + axis.hexbin(xx.ravel(), yy.ravel(), gridsize=5) + axis.matshow(z) + + fig = canvas.plot(backend="plotly") + + assert any(trace.type == "contour" for trace in fig.data) + assert any(trace.type == "heatmap" for trace in fig.data) + assert any(trace.type == "histogram2d" for trace in fig.data) + + +def test_plotly_backend_supports_contour_labels(): + from maxplotlib import Canvas + + x = np.linspace(-1, 1, 5) + xx, yy = np.meshgrid(x, x) + canvas, axis = Canvas.subplots() + axis.contour(x, x, xx**2 + yy**2) + axis.clabel(fontsize=10, color="black") + + fig = canvas.plot(backend="plotly") + + assert fig.data[0].contours.showlabels is True + assert fig.data[0].contours.labelfont.size == 10 + + +def test_plotly_backend_supports_fill_log_scales_and_ticklabels(): + from maxplotlib import Canvas + + canvas, axis = Canvas.subplots() + axis.fill([1, 2, 4], [1, 4, 1], color="orange", alpha=0.3) + axis.loglog([1, 2, 4], [1, 4, 16]) + axis.set_xticklabels(["one", "two", "four"], color="navy") + + fig = canvas.plot(backend="plotly") + + assert any(trace.fill == "toself" for trace in fig.data) + assert fig.layout.xaxis.type == "log" + assert fig.layout.yaxis.type == "log" + assert fig.layout.xaxis.ticktext == ("one", "two", "four") + + def test_plotly_backend_respects_layers(): from maxplotlib import Canvas @@ -84,3 +216,123 @@ def test_plotly_backend_supports_common_patches_and_symlog(): ax2.set_yscale("symlog") fig2 = canvas2.plot(backend="plotly") assert fig2 is not None + + +def test_plotly_backend_renders_mixed_vector_primitives(): + from maxplotlib import Canvas + + canvas, axis = Canvas.subplots() + axis.plot([0, 1], [0, 1]) + axis.streamplot( + np.linspace(0, 1, 3), + np.linspace(0, 1, 3), + np.ones((3, 3)), + np.ones((3, 3)), + ) + + fig = canvas.plot(backend="plotly") + assert len(fig.data) > 1 + + +def test_plotly_backend_supports_bar_labels(): + from maxplotlib import Canvas + + canvas, axis = Canvas.subplots() + axis.bar([0, 1], [2, 3]) + axis.bar_label(fmt="%d") + + fig = canvas.plot(backend="plotly") + labels = [annotation.text for annotation in fig.layout.annotations] + assert labels[-2:] == ["2", "3"] + + +def test_plotly_backend_supports_pseudocolor_spy_table_and_triplot(): + from maxplotlib import Canvas + + x = np.linspace(-1, 1, 4) + y = np.linspace(-1, 1, 4) + xx, yy = np.meshgrid(x, y) + z = xx**2 + yy**2 + points_x = np.array([0.0, 1.0, 0.0, 1.0]) + points_y = np.array([0.0, 0.0, 1.0, 1.0]) + triangles = np.array([[0, 1, 2], [1, 3, 2]]) + + canvas, axis = Canvas.subplots() + axis.pcolor(x, y, z) + axis.pcolorfast(x, y, z) + axis.spy([[1, 0], [0, 2]]) + axis.table(cellText=[["A", "B"], ["1", "2"]]) + axis.triplot(points_x, points_y, triangles=triangles) + + fig = canvas.plot(backend="plotly") + + assert sum(trace.type == "heatmap" for trace in fig.data) >= 3 + assert any(trace.type == "table" for trace in fig.data) + assert any(trace.type == "scatter" for trace in fig.data) + + +def test_plotly_backend_supports_quiver(): + from maxplotlib import Canvas + + canvas, axis = Canvas.subplots() + axis.quiver([0, 1], [0, 1], [1, -1], [1, 1], color="purple", alpha=0.5) + + fig = canvas.plot(backend="plotly") + + arrows = [ + annotation for annotation in fig.layout.annotations if annotation.showarrow + ] + assert len(arrows) == 2 + assert arrows[0].arrowcolor == "purple" + + +def test_plotly_backend_supports_tripcolor(): + from maxplotlib import Canvas + + canvas, axis = Canvas.subplots() + axis.tripcolor( + [0, 1, 0, 1], + [0, 0, 1, 1], + [0, 1, 2, 3], + triangles=[[0, 1, 2], [1, 3, 2]], + ) + + fig = canvas.plot(backend="plotly") + + assert sum(trace.fill == "toself" for trace in fig.data) == 2 + + +def test_plotly_backend_supports_triangulated_contours(): + from maxplotlib import Canvas + + canvas, axis = Canvas.subplots() + x = [0, 1, 0, 1] + y = [0, 0, 1, 1] + triangles = [[0, 1, 2], [1, 3, 2]] + values = [0, 1, 2, 3] + axis.tricontour(x, y, values, triangles=triangles, levels=3) + axis.tricontourf(x, y, values, triangles=triangles, levels=3) + + fig = canvas.plot(backend="plotly") + + assert any(trace.fill is None for trace in fig.data) + assert any(trace.fill == "toself" for trace in fig.data) + + +def test_plotly_backend_supports_streamplot(): + from maxplotlib import Canvas + + coordinates = np.linspace(0, 1, 5) + canvas, axis = Canvas.subplots() + axis.streamplot( + coordinates, + coordinates, + np.ones((5, 5)), + np.ones((5, 5)), + color="darkgreen", + ) + + fig = canvas.plot(backend="plotly") + + assert len(fig.data) > 0 + assert all(trace.type == "scatter" for trace in fig.data) diff --git a/src/maxplotlib/tests/test_styles_and_colors.py b/src/maxplotlib/tests/test_styles_and_colors.py new file mode 100644 index 0000000..f8df991 --- /dev/null +++ b/src/maxplotlib/tests/test_styles_and_colors.py @@ -0,0 +1,61 @@ +import pytest + +from maxplotlib.colors.colors import Color +from maxplotlib.linestyle.linestyle import Linestyle + + +@pytest.mark.parametrize( + ("style", "expected"), + [ + ("solid", "solid"), + ("dashed", "dashed"), + ("dotted", "dotted"), + ("dashdot", "dashdot"), + ], +) +def test_named_linestyles_are_preserved(style, expected): + assert Linestyle(style).to_matplotlib() == expected + + +def test_custom_linestyle_pattern_is_converted_to_matplotlib_dash_tuple(): + linestyle = Linestyle("dash pattern=on 5pt off 2.5pt") + + assert linestyle.to_matplotlib() == (0, (5.0, 2.5)) + + +def test_unknown_linestyle_defaults_to_solid(capsys): + linestyle = Linestyle("long-dash") + + assert linestyle.to_matplotlib() == "solid" + assert "Unknown line style: 'long-dash'" in capsys.readouterr().out + + +@pytest.mark.parametrize( + ("specification", "expected"), + [ + ((255, 128, 0), (1.0, 128 / 255, 0.0)), + ([0.25, 0.5, 0.75], (0.25, 0.5, 0.75)), + ("#336699", (0.2, 0.4, 0.6)), + ("navy", (0.0, 0.0, 0.5019607843137255)), + ], +) +def test_color_specifications_are_converted_to_rgb(specification, expected): + assert Color(specification).to_rgb() == pytest.approx(expected) + + +def test_tikz_color_mix_is_interpolated_with_white(): + color = Color("red!20") + + assert color.to_rgb() == pytest.approx((1.0, 0.8, 0.8)) + + +def test_color_output_helpers_include_alpha_and_hex_values(): + color = Color("#336699") + + assert color.to_hex() == "#336699" + assert color.to_rgba(alpha=0.35) == pytest.approx((0.2, 0.4, 0.6, 0.35)) + + +def test_invalid_color_specification_raises_value_error(): + with pytest.raises(ValueError, match="Invalid color specification"): + Color("not-a-real-color") diff --git a/tutorials/tutorial_13_advanced_matplotlib.ipynb b/tutorials/tutorial_13_advanced_matplotlib.ipynb new file mode 100644 index 0000000..580a319 --- /dev/null +++ b/tutorials/tutorial_13_advanced_matplotlib.ipynb @@ -0,0 +1,298 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Tutorial 13 – Advanced Matplotlib Plotting\n", + "\n", + "# Advanced Matplotlib plotting\n", + "\n", + "This tutorial covers horizontal bars, histograms, filled regions,\n", + "reference spans, arrows, infinitely extending lines, and secondary\n", + "y-axes." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "from maxplotlib import Canvas\n", + "\n", + "x = np.linspace(0, 2 * np.pi, 200)" + ] + }, + { + "cell_type": "markdown", + "id": "2", + "metadata": {}, + "source": [ + "## Common plotting primitives" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "canvas, ax = Canvas.subplots(width=\"12cm\", ratio=0.65)\n", + "ax.barh([0, 1, 2], [2, 4, 3], color=\"steelblue\", alpha=0.8)\n", + "ax.set_yticks([0, 1, 2], labels=[\"A\", \"B\", \"C\"])\n", + "ax.set_xlabel(\"Amount\")\n", + "ax.set_title(\"Horizontal bars\")\n", + "canvas.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4", + "metadata": {}, + "outputs": [], + "source": [ + "samples = np.random.default_rng(4).normal(size=1000)\n", + "canvas, ax = Canvas.subplots()\n", + "ax.hist(samples, bins=30, color=\"slateblue\", alpha=0.75)\n", + "ax.set_xlabel(\"Value\")\n", + "ax.set_ylabel(\"Count\")\n", + "ax.set_title(\"Distribution\")\n", + "canvas.show()" + ] + }, + { + "cell_type": "markdown", + "id": "5", + "metadata": {}, + "source": [ + "## Steps, stairs, broken bars, and pie charts" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6", + "metadata": {}, + "outputs": [], + "source": [ + "canvas, ax = Canvas.subplots()\n", + "ax.step([0, 1, 2, 3], [1, 3, 2, 4], where=\"mid\", label=\"step\")\n", + "ax.stairs([1, 2, 1], edges=[0, 1, 2, 3], color=\"purple\", label=\"stairs\")\n", + "ax.set_title(\"Discrete data\")\n", + "ax.set_legend(True)\n", + "canvas.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7", + "metadata": {}, + "outputs": [], + "source": [ + "canvas, ax = Canvas.subplots()\n", + "ax.broken_barh([(0, 1), (1.5, 0.75), (2.75, 1.0)], (0, 0.6), color=\"orange\")\n", + "ax.set_xlabel(\"Intervals\")\n", + "canvas.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8", + "metadata": {}, + "outputs": [], + "source": [ + "canvas, ax = Canvas.subplots()\n", + "ax.pie([30, 45, 25], labels=[\"A\", \"B\", \"C\"], autopct=\"%1.0f%%\")\n", + "ax.set_title(\"Shares\")\n", + "canvas.show()" + ] + }, + { + "cell_type": "markdown", + "id": "9", + "metadata": {}, + "source": [ + "## Regions, arrows, and reference lines" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "10", + "metadata": {}, + "outputs": [], + "source": [ + "canvas, ax = Canvas.subplots()\n", + "ax.plot(x, np.sin(x), color=\"black\")\n", + "ax.fill_betweenx([-1, 0, 1], 0.5, [1.0, 1.5, 2.0], alpha=0.2)\n", + "ax.axvspan(1.0, 2.0, color=\"orange\", alpha=0.2)\n", + "ax.axhspan(-0.25, 0.25, color=\"steelblue\", alpha=0.15)\n", + "ax.arrow(2.0, np.sin(2.0), 0.5, 0.3, length_includes_head=True)\n", + "ax.axline((0, 0), slope=0.2, linestyle=\"--\", color=\"crimson\")\n", + "canvas.show()" + ] + }, + { + "cell_type": "markdown", + "id": "11", + "metadata": {}, + "source": [ + "## Secondary y-axis" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "12", + "metadata": {}, + "outputs": [], + "source": [ + "twin_canvas, primary = Canvas.subplots()\n", + "secondary = twin_canvas.twinx()\n", + "\n", + "primary.plot(x, np.sin(x), color=\"tab:blue\")\n", + "secondary.plot(x, 100 * np.cos(x), color=\"tab:red\")\n", + "primary.set_xlabel(\"Time\")\n", + "primary.set_ylabel(\"sin(x)\", color=\"tab:blue\")\n", + "secondary.set_ylabel(\"100 cos(x)\", color=\"tab:red\")\n", + "twin_canvas.set_title(\"Two scales sharing one x-axis\")\n", + "twin_canvas.show()" + ] + }, + { + "cell_type": "markdown", + "id": "13", + "metadata": {}, + "source": [ + "Secondary y-axes are supported by the Matplotlib and Plotly backends.\n", + "The plotext and TikZ backends currently reject a canvas containing\n", + "`twinx()` plots instead of silently producing an incorrect figure.\n", + "\n", + "## Scientific field plots" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "14", + "metadata": {}, + "outputs": [], + "source": [ + "x = np.linspace(-1, 1, 40)\n", + "y = np.linspace(-1, 1, 40)\n", + "xx, yy = np.meshgrid(x, y)\n", + "z = xx**2 + yy**2\n", + "\n", + "canvas, ax = Canvas.subplots()\n", + "ax.contour(x, y, z, colors=\"black\")\n", + "ax.contourf(x, y, z, alpha=0.5)\n", + "ax.pcolormesh(x, y, z, alpha=0.25)\n", + "ax.set_title(\"Scalar field\")\n", + "canvas.show()" + ] + }, + { + "cell_type": "markdown", + "id": "15", + "metadata": {}, + "source": [ + "For point-density data and matrix-oriented displays:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "16", + "metadata": {}, + "outputs": [], + "source": [ + "canvas, ax = Canvas.subplots()\n", + "ax.hexbin(xx.ravel(), yy.ravel(), gridsize=12)\n", + "ax.matshow(z)\n", + "canvas.show()" + ] + }, + { + "cell_type": "markdown", + "id": "17", + "metadata": {}, + "source": [ + "Unstructured triangular data and vector fields are also supported:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "18", + "metadata": {}, + "outputs": [], + "source": [ + "points_x = np.array([0.0, 1.0, 0.0, 1.0])\n", + "points_y = np.array([0.0, 0.0, 1.0, 1.0])\n", + "triangles = [[0, 1, 2], [1, 3, 2]]\n", + "values = points_x + points_y\n", + "\n", + "canvas, ax = Canvas.subplots()\n", + "ax.quiver(points_x, points_y, np.ones(4), np.ones(4))\n", + "ax.triplot(points_x, points_y, triangles=triangles)\n", + "ax.tripcolor(points_x, points_y, values, triangles=triangles, alpha=0.3)\n", + "ax.tricontour(points_x, points_y, values, triangles=triangles)\n", + "canvas.show()" + ] + }, + { + "cell_type": "markdown", + "id": "19", + "metadata": {}, + "source": [ + "## Statistical and event plots" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "20", + "metadata": {}, + "outputs": [], + "source": [ + "canvas, ax = Canvas.subplots()\n", + "ax.stem([0, 1, 2], [1, 3, 2])\n", + "ax.stackplot([0, 1, 2], [1, 2, 1], [2, 1, 2], alpha=0.4)\n", + "ax.set_title(\"Discrete and stacked data\")\n", + "canvas.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "21", + "metadata": {}, + "outputs": [], + "source": [ + "canvas, ax = Canvas.subplots()\n", + "ax.boxplot([[1, 2, 3], [2, 4, 5]])\n", + "ax.violinplot([[1, 2, 3], [2, 4, 5]])\n", + "ax.eventplot([[0.2, 0.5], [1.0, 1.5]])\n", + "canvas.show()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3", + "path": "/Library/Frameworks/Python.framework/Versions/3.13/share/jupyter/kernels/python3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/tutorial_14_axis_and_layout_controls.ipynb b/tutorials/tutorial_14_axis_and_layout_controls.ipynb new file mode 100644 index 0000000..ac43ba3 --- /dev/null +++ b/tutorials/tutorial_14_axis_and_layout_controls.ipynb @@ -0,0 +1,196 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Tutorial 14 – Axis and layout controls\n", + "\n", + "This tutorial demonstrates the latest Matplotlib-style wrappers: filled polygons, logarithmic shortcuts, axis limits and autoscaling, secondary axes, box aspect, and explicit tick labels." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "from maxplotlib import Canvas\n", + "\n", + "x = np.linspace(0.1, 10, 200)" + ] + }, + { + "cell_type": "markdown", + "id": "2", + "metadata": {}, + "source": [ + "## Filled polygons\n", + "\n", + "`fill()` forwards polygon coordinates to Matplotlib and becomes a filled Plotly scatter trace." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "canvas, ax = Canvas.subplots()\n", + "ax.fill([0, 1, 2, 1], [0, 2, 0, -1], color=\"tab:orange\", alpha=0.5)\n", + "ax.set_title(\"A filled polygon\")\n", + "canvas.show()" + ] + }, + { + "cell_type": "markdown", + "id": "4", + "metadata": {}, + "source": [ + "## Logarithmic plotting shortcuts\n", + "\n", + "Use `semilogx()`, `semilogy()`, or `loglog()` when the data and axis scale should be configured together." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5", + "metadata": {}, + "outputs": [], + "source": [ + "canvas, (ax1, ax2, ax3) = Canvas.subplots(nrows=1, ncols=3)\n", + "ax1.semilogx(x, np.sin(x) + 2)\n", + "ax2.semilogy(x, np.exp(x / 4))\n", + "ax3.loglog(x, x**2)\n", + "ax1.set_title(\"semilogx\")\n", + "ax2.set_title(\"semilogy\")\n", + "ax3.set_title(\"loglog\")\n", + "canvas.show()" + ] + }, + { + "cell_type": "markdown", + "id": "6", + "metadata": {}, + "source": [ + "## Axis limits, modes, and autoscaling\n", + "\n", + "`axis()` accepts Matplotlib-style modes such as `\"equal\"`, `\"tight\"`, and `\"off\"`, or a four-value `[xmin, xmax, ymin, ymax]` limit list. `relim()` and `autoscale_view()` recompute the visible limits from plotted data." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7", + "metadata": {}, + "outputs": [], + "source": [ + "canvas, ax = Canvas.subplots()\n", + "ax.plot([0, 1, 2], [0, 4, 1])\n", + "ax.axis([0, 2, -1, 5])\n", + "ax.relim()\n", + "ax.autoscale_view(tight=True)\n", + "canvas.show()" + ] + }, + { + "cell_type": "markdown", + "id": "8", + "metadata": {}, + "source": [ + "## Secondary x- and y-axes\n", + "\n", + "Secondary axes use forward and inverse functions. They are currently rendered by the Matplotlib backend." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9", + "metadata": {}, + "outputs": [], + "source": [ + "canvas, ax = Canvas.subplots()\n", + "ax.plot(x, x**2)\n", + "ax.set_xlabel(\"distance (m)\")\n", + "ax.set_ylabel(\"area (m²)\")\n", + "ax.secondary_xaxis(\n", + " \"top\",\n", + " functions=(lambda value: value / 1000, lambda value: value * 1000),\n", + " label=\"distance (km)\",\n", + ")\n", + "ax.secondary_yaxis(\n", + " \"right\", functions=(np.sqrt, lambda value: value**2), label=\"length (m)\"\n", + ")\n", + "canvas.show()" + ] + }, + { + "cell_type": "markdown", + "id": "10", + "metadata": {}, + "source": [ + "## Box aspect and explicit tick labels" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "11", + "metadata": {}, + "outputs": [], + "source": [ + "canvas, ax = Canvas.subplots()\n", + "ax.plot([0, 1, 2], [0, 1, 0], marker=\"o\")\n", + "ax.set_box_aspect(1)\n", + "ax.set_xticks([0, 1, 2])\n", + "ax.set_xticklabels([\"start\", \"middle\", \"end\"], rotation=25, color=\"navy\")\n", + "ax.set_yticks([0, 1])\n", + "ax.set_yticklabels([\"low\", \"high\"], fontweight=\"bold\")\n", + "canvas.show()" + ] + }, + { + "cell_type": "markdown", + "id": "12", + "metadata": {}, + "source": [ + "## Plotly rendering\n", + "\n", + "The filled polygon, logarithmic scales, axis modes, box aspect, and tick labels also work with Plotly. Secondary axes currently require Matplotlib." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "13", + "metadata": {}, + "outputs": [], + "source": [ + "plotly_canvas, plotly_ax = Canvas.subplots()\n", + "plotly_ax.fill(x, np.sin(x) + 2, color=\"purple\", alpha=0.25)\n", + "plotly_ax.loglog(x, x**2, color=\"black\")\n", + "plotly_ax.set_xticklabels([\"small\", \"medium\", \"large\"], color=\"darkgreen\")\n", + "plotly_canvas.show(backend=\"plotly\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +}