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()
```
-
+
+
+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")
```
-
+
+
+### 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])
```
-
+
+
+ (,
+ array([[]], dtype=object))
Show all layers:
@@ -120,4 +317,7 @@ Show all layers:
canvas.show()
```
-
+
+
+ (,
+ 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
+}